From 2690c6620c7087a13f4eb153924605046e0852f3 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 15:02:28 -0300 Subject: [PATCH 01/63] Retire the main LDE after the Round 1 commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1's main commit is a phase-wide barrier: the shared LogUp challenges need every table's root absorbed first, so all N tables' main LDEs stay live at once, O(N x main_cols x lde_size) — the largest single term in the prover's peak heap. An instruments run on fib_iterative_1M puts it at 1854 MB of a 4284 MB peak. Under the opt-in LAMBDA_STREAM_LDE=1, drop each table's row-major main LDE right after its commit, keeping only the column count, and rebuild it inside the table's fused chain through the same production row-major coset LDE that built it. Values are identical by construction, so the proof is byte-identical; the trade is one extra LDE expansion per table for dropping the N-wide term to k. Off by default, and inert under cuda where the LDE is device-resident. Measured on fib_iterative_1M, 10 alternating runs per arm: peak heap 4276 -> 3490 MB (-18.4%), prove time 15.95 -> 17.67 s (+10.8%). It composes with what main gained since: continuations (4 epochs, -16.6% heap) and disk-spill (-27.4% heap for +1.3% time), both proving and verifying with the flag on. Port of milestone M1 from the closed PR #647, which implemented the spec's "Approach 1" prove-and-retire prover. That PR's own /bench never measured the mode: the workflow does not set the env var, so what it reported as a +14.1% regression was the flag-off path. --- crypto/stark/src/prover.rs | 80 +++++++++++++ .../src/tests/prove_verify_roundtrip_tests.rs | 108 ++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index faf512a72..d38b4eac2 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -617,6 +617,28 @@ fn host_cores() -> usize { /// pool. Worst case against the best measured `k`: `num_airs` +1.6 % (inside /// noise), the old `cores*2/3` +13.0 %. Bounding concurrency is memory /// admission's job (`VramGate`), not this count's. +/// Retire each table's main LDE right after the Round 1 commit and rebuild it +/// on demand inside the table's fused chain, instead of holding all N of them +/// across the Round 1 barrier. +/// +/// Round 1's main commit is a phase-wide barrier (the shared LogUp challenges +/// need every root absorbed first), so all N tables' main LDEs are live at once +/// — `O(N x main_cols x lde_size)`, the largest single term in the prover's +/// peak. Retiring trades one extra LDE expansion (iFFT + coset + FFT) per table +/// for dropping that term to `O(k x ...)`. +/// +/// Opt-in via `LAMBDA_STREAM_LDE=1` (or `true`). Off by default. Inert under +/// `cuda`, where the LDE lives on the device and the host buffer is already +/// empty on the device-only path. +/// +/// Port of Approach 1 milestone M1 (PR #647, commit 6562c5f4). +pub fn streaming_retire_lde() -> bool { + matches!( + std::env::var("LAMBDA_STREAM_LDE").as_deref(), + Ok("1") | Ok("true") + ) +} + pub fn table_parallelism(num_airs: usize) -> usize { #[cfg(feature = "parallel")] { @@ -1412,6 +1434,36 @@ pub trait IsStarkProver< Ok(()) } + /// Rebuild a table's row-major main LDE from its trace. + /// + /// Byte-identical to what the Round 1 main commit produced: it runs the same + /// production path (row-major copy + cache-blocked two-half coset LDE), not + /// the column-wise debug reconstruction. Used by the retire-LDE streaming + /// path, which drops this buffer after the commit and rebuilds it here. + fn rebuild_main_lde( + trace: &TraceTable, + domain: &Domain, + twiddles: &LdeTwiddles, + ) -> (Vec>, usize) { + let lde_size = domain.interpolation_domain_size * domain.blowup_factor; + let (trace_data, total_cols) = trace.main_data_row_major(); + + let mut main_data: Vec> = Vec::with_capacity(lde_size * total_cols); + main_data.extend_from_slice(trace_data); + + Polynomial::>::coset_lde_full_expand_row_major::( + &mut main_data, + total_cols, + domain.blowup_factor, + &twiddles.coset_weights, + &twiddles.two_half_inv, + &twiddles.two_half_fwd, + ) + .expect("row-major coset LDE expansion"); + + (main_data, total_cols) + } + /// Recompute Round1 from the trace, reusing the Merkle trees stored in commitments. /// /// Only used by `run_debug_checks` — the production path consumes the @@ -3410,6 +3462,10 @@ pub trait IsStarkProver< let mut main_commits: Vec> = Vec::with_capacity(num_airs); let mut main_ldes: Vec<(Vec>, usize)> = Vec::with_capacity(num_airs); + // Read once: the flag is process-global and must not change mid-proof, + // or a table would be rebuilt against a commitment it never produced. + #[cfg(not(feature = "cuda"))] + let retire_main_lde = streaming_retire_lde(); // Optional device-side LDE handle per table, populated only when the // R1 fused GPU pipeline produced one. Pairing is by index: this vector // is moved into the per-table `gpu_main_cells` mutex slots below, and @@ -3465,6 +3521,16 @@ pub trait IsStarkProver< } transcript.append_bytes(&commit.root); main_commits.push(commit); + // Retire-LDE: drop the row-major main LDE here, keeping only its + // column count, so the O(N x main_cols x lde_size) cache never + // forms across this barrier. `rounds_stage` rebuilds each table's + // LDE inside its own fused chain. + #[cfg(not(feature = "cuda"))] + let cached_main = if retire_main_lde { + (Vec::new(), cached_main.1) + } else { + cached_main + }; main_ldes.push(cached_main); #[cfg(feature = "cuda")] main_gpu_handles.push(gpu_main); @@ -3922,6 +3988,20 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let table_start = Instant::now(); + // Retire-LDE: the main LDE was dropped right after the Round 1 + // commit; rebuild it here so at most `k` of them are ever live. + #[cfg(not(feature = "cuda"))] + let lde = if retire_main_lde { + #[cfg(feature = "instruments")] + let __sp_rebuild = crate::instruments::span("r1_main_lde_rebuild"); + let main = Self::rebuild_main_lde(trace, domain, &twiddle_caches[idx]); + #[cfg(feature = "instruments")] + drop(__sp_rebuild); + Lde { main, ..lde } + } else { + lde + }; + let mut round_1_result = commitment.build_round1(lde, air.step_size(), domain.blowup_factor); diff --git a/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs b/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs index a387df476..4ef3f96be 100644 --- a/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs +++ b/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs @@ -245,3 +245,111 @@ fn create_mul_air( EmptyConstraints, ) } + +/// THE retire-LDE correctness invariant: a proof produced with +/// `LAMBDA_STREAM_LDE=1` must be byte-identical to one produced with the flag +/// off. Retiring changes only *when* the main LDE exists — dropped after the +/// Round 1 commit, rebuilt from the same trace through the same production +/// row-major coset LDE inside the table's fused chain — never its contents. +/// A mismatch means the rebuild diverged from what was committed. +/// +/// The env var is process-global, so the two proving runs are serialized under +/// a mutex and the prior value is restored. +#[test] +fn retire_lde_proof_is_byte_identical() { + use std::sync::Mutex; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn prove_once() -> Vec { + let add_column = vec![ + FE::one(), + FE::zero(), + FE::one(), + FE::zero(), + FE::one(), + FE::one(), + FE::zero(), + FE::zero(), + ]; + let mul_column = vec![ + FE::zero(), + FE::one(), + FE::zero(), + FE::one(), + FE::zero(), + FE::zero(), + FE::one(), + FE::one(), + ]; + let a_column = (1..=8u64).map(FE::from).collect::>(); + let b_column = (1..=8u64).map(|i| FE::from(i * 10)).collect::>(); + let c_column = vec![ + FE::from(11), + FE::from(40), + FE::from(33), + FE::from(160), + FE::from(55), + FE::from(66), + FE::from(490), + FE::from(640), + ]; + let mut cpu_trace = crate::trace::TraceTable::from_columns_main( + vec![add_column, mul_column, a_column, b_column, c_column], + 1, + ); + + let add_a = vec![FE::from(1), FE::from(3), FE::from(5), FE::from(6)]; + let add_b = vec![FE::from(10), FE::from(30), FE::from(50), FE::from(60)]; + let add_c = vec![FE::from(11), FE::from(33), FE::from(55), FE::from(66)]; + let add_m = vec![FE::one(), FE::one(), FE::one(), FE::one()]; + let mut add_trace = + crate::trace::TraceTable::from_columns_main(vec![add_a, add_b, add_c, add_m], 1); + + let mul_a = vec![FE::from(2), FE::from(4), FE::from(7), FE::from(8)]; + let mul_b = vec![FE::from(20), FE::from(40), FE::from(70), FE::from(80)]; + let mul_c = vec![FE::from(40), FE::from(160), FE::from(490), FE::from(640)]; + let mul_m = vec![FE::one(), FE::one(), FE::one(), FE::one()]; + let mut mul_trace = + crate::trace::TraceTable::from_columns_main(vec![mul_a, mul_b, mul_c, mul_m], 1); + + let proof_options = ProofOptions::default_test_options(); + let cpu_air = create_cpu_air(&proof_options); + let add_air = create_add_air(&proof_options); + let mul_air = create_mul_air(&proof_options); + + #[allow(clippy::type_complexity)] + let air_trace_pairs: Vec<( + &dyn AIR, + &mut crate::trace::TraceTable, + &(), + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&add_air, &mut add_trace, &()), + (&mul_air, &mut mul_trace, &()), + ]; + + let proofs = + multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + serde_cbor::to_vec(&proofs).expect("serialize proofs") + } + + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let prev = std::env::var("LAMBDA_STREAM_LDE").ok(); + + // SAFETY: single-threaded section guarded by ENV_LOCK; restored below. + unsafe { std::env::set_var("LAMBDA_STREAM_LDE", "0") }; + let resident = prove_once(); + + unsafe { std::env::set_var("LAMBDA_STREAM_LDE", "1") }; + let retired = prove_once(); + + match prev { + Some(v) => unsafe { std::env::set_var("LAMBDA_STREAM_LDE", v) }, + None => unsafe { std::env::remove_var("LAMBDA_STREAM_LDE") }, + } + + assert_eq!( + resident, retired, + "retire-LDE proof must be byte-identical to the resident-LDE proof" + ); +} From 89616b74b9e23f0fbe72a7cb82fb6be02ffb874d Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 15:22:35 -0300 Subject: [PATCH 02/63] Trigger the LDE retire on memory pressure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retire-LDE mode only paid for itself when a human remembered to export an env var, which is not how a memory lever gets used. The spec that described this prover asked for the opposite: retire "once the memory pressure becomes too large". Add LAMBDA_STREAM_LDE=auto, resolved before proving from the same analytical peak-RAM estimate that already picks the storage mode: retire on exactly the inputs that pick Disk, so the two memory levers share one trigger and one safety margin, and a change to that threshold can never silently desynchronize them (a test pins the agreement on both sides of it). Unknown available RAM retires, matching the storage mode's conservative default. An explicit 0 or 1 still wins — that is the operator overriding the estimate. Unset stays off and costs nothing: only `auto` pays for the estimate's log pre-pass, and on a build without `disk-spill`, where the estimate does not exist, `auto` warns instead of proving with the mode off and reading as "auto decided no". Measured on fib_iterative_1M on a 16 GB box: the estimate (6.55 GB) clears the threshold, so auto turns on both levers and peak heap lands at 2084 MB against 4276 MB resident. --- crypto/stark/src/prover.rs | 51 ++++++++++++++++++++++---- prover/src/auto_storage.rs | 23 ++++++++++++ prover/src/lib.rs | 20 ++++++++++ prover/src/tests/auto_storage_tests.rs | 36 ++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index d38b4eac2..f6913389c 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::marker::PhantomData; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; #[cfg(feature = "instruments")] use std::time::{Duration, Instant}; @@ -627,18 +628,54 @@ fn host_cores() -> usize { /// peak. Retiring trades one extra LDE expansion (iFFT + coset + FFT) per table /// for dropping that term to `O(k x ...)`. /// -/// Opt-in via `LAMBDA_STREAM_LDE=1` (or `true`). Off by default. Inert under -/// `cuda`, where the LDE lives on the device and the host buffer is already -/// empty on the device-only path. +/// Driven by `LAMBDA_STREAM_LDE`: `1`/`true` forces it on, `0`/unset off, and +/// `auto` lets the caller decide from an estimate of the proof's peak RAM (the +/// prover crate resolves `auto` through [`set_retire_lde`] before proving, so +/// the estimate never costs anything on the default path). Inert under `cuda`, +/// where the LDE lives on the device and the host buffer is already empty on +/// the device-only path. /// /// Port of Approach 1 milestone M1 (PR #647, commit 6562c5f4). pub fn streaming_retire_lde() -> bool { - matches!( - std::env::var("LAMBDA_STREAM_LDE").as_deref(), - Ok("1") | Ok("true") - ) + match RETIRE_LDE_OVERRIDE.load(Ordering::Relaxed) { + RETIRE_OVERRIDE_ON => true, + RETIRE_OVERRIDE_OFF => false, + _ => matches!( + std::env::var("LAMBDA_STREAM_LDE").as_deref(), + Ok("1") | Ok("true") + ), + } +} + +/// Whether `LAMBDA_STREAM_LDE=auto` asked for the decision to be made from a +/// peak-RAM estimate. Only then does the caller pay for that estimate. +pub fn streaming_retire_lde_is_auto() -> bool { + matches!(std::env::var("LAMBDA_STREAM_LDE").as_deref(), Ok("auto")) } +/// Resolve `LAMBDA_STREAM_LDE=auto` to a decision for the rest of the process. +/// +/// Process-global, like the env var it resolves, and read once per table inside +/// `multi_prove` — so it must be set before proving starts and must not change +/// mid-proof, or a table would be rebuilt against a commitment it never +/// produced. Set it only from the resolution of `auto`: an explicit `0`/`1` +/// is the operator overriding the estimate, and this must not silently undo it. +pub fn set_retire_lde(on: bool) { + RETIRE_LDE_OVERRIDE.store( + if on { + RETIRE_OVERRIDE_ON + } else { + RETIRE_OVERRIDE_OFF + }, + Ordering::Relaxed, + ); +} + +const RETIRE_OVERRIDE_UNSET: u8 = 0; +const RETIRE_OVERRIDE_OFF: u8 = 1; +const RETIRE_OVERRIDE_ON: u8 = 2; +static RETIRE_LDE_OVERRIDE: AtomicU8 = AtomicU8::new(RETIRE_OVERRIDE_UNSET); + pub fn table_parallelism(num_airs: usize) -> usize { #[cfg(feature = "parallel")] { diff --git a/prover/src/auto_storage.rs b/prover/src/auto_storage.rs index b4718974c..1984d7a0b 100644 --- a/prover/src/auto_storage.rs +++ b/prover/src/auto_storage.rs @@ -228,6 +228,29 @@ pub fn decide(lengths: &TableLengths, blowup_factor: u8) -> StorageMode { mode } +/// Whether to retire each table's main LDE after the Round 1 commit, from the +/// same analytical estimate that picks the storage mode. +/// +/// Policy: retire exactly when the estimate does not fit under the safety +/// threshold — the regime where the prover is about to swap or die, and where +/// trading ~11 % of prove time for the N-wide main-LDE term is the trade you +/// want. Below the threshold the term is affordable and the time is not worth +/// paying. Resolves `LAMBDA_STREAM_LDE=auto`; an explicit `0`/`1` overrides it. +pub fn decide_retire_lde(lengths: &TableLengths, blowup_factor: u8) -> bool { + let estimated = peak_bytes(lengths, blowup_factor, storage_estimate_parallelism()); + let retire = retire_lde_for(estimated, available_ram_bytes()); + log::info!("estimated_peak_bytes: {estimated}, retire_lde: {retire}"); + retire +} + +/// The policy itself, over an explicit estimate and available RAM: retire on +/// exactly the inputs that pick `Disk`, so the two memory levers share one +/// trigger and one safety margin. Unknown available RAM retires, matching the +/// storage mode's conservative default. +pub(crate) fn retire_lde_for(estimated: u64, available: Option) -> bool { + select_storage_mode(estimated, available) == StorageMode::Disk +} + /// Peak RAM estimate in bytes for a proof whose trace shape matches `lengths`. /// /// `table_parallelism` is how many tables' rounds 2-4 transients this assumes diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 79ef4c715..77394367e 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1138,12 +1138,32 @@ pub fn prove_with_options_and_inputs( #[cfg(feature = "instruments")] let __sp = stark::instruments::span("trace_build"); + // The storage mode and `LAMBDA_STREAM_LDE=auto` read the same analytical + // peak estimate, so the log pre-pass behind it is paid once, and only when + // something actually asks for it. #[cfg(feature = "disk-spill")] let storage_mode = { let lengths = count_table_lengths(&program, &result.logs, max_rows, private_inputs)?; + if stark::prover::streaming_retire_lde_is_auto() { + stark::prover::set_retire_lde(auto_storage::decide_retire_lde( + &lengths, + proof_options.blowup_factor, + )); + } auto_storage::decide(&lengths, proof_options.blowup_factor) }; + // The estimate lives behind `disk-spill` (so do `TableLengths` and the + // storage mode it feeds). Say so instead of silently proving with the mode + // off, which would read as "auto decided no". + #[cfg(not(feature = "disk-spill"))] + if stark::prover::streaming_retire_lde_is_auto() { + log::warn!( + "LAMBDA_STREAM_LDE=auto needs the `disk-spill` feature for the peak-RAM estimate; \ + proving with the main LDE resident. Pass LAMBDA_STREAM_LDE=1 to force it on." + ); + } + let mut traces = Traces::from_elf_and_logs( &program, &result.logs, diff --git a/prover/src/tests/auto_storage_tests.rs b/prover/src/tests/auto_storage_tests.rs index e26674d27..bf6cd36af 100644 --- a/prover/src/tests/auto_storage_tests.rs +++ b/prover/src/tests/auto_storage_tests.rs @@ -135,3 +135,39 @@ fn unbounded_k_inflates_peak_bytes_on_many_page_shapes() { "expected >20 % inflation, got {bounded} -> {unbounded}" ); } + +#[test] +fn retire_lde_off_when_estimate_below_threshold() { + // 10 GB estimated, 32 GB available → threshold 28.8 GB → the main LDE is + // affordable, so do not pay ~11% of prove time to retire it. + assert!(!crate::auto_storage::retire_lde_for(10 * GB, Some(32 * GB))); +} + +#[test] +fn retire_lde_on_when_estimate_exceeds_threshold() { + // 30 GB estimated, 32 GB available → threshold 28.8 GB → about to swap. + assert!(crate::auto_storage::retire_lde_for(30 * GB, Some(32 * GB))); +} + +#[test] +fn retire_lde_on_when_available_ram_is_unknown() { + assert!(crate::auto_storage::retire_lde_for(10 * GB, None)); +} + +/// The documented policy: retire-LDE and disk-spill share one trigger, so a +/// change to the storage threshold can never silently desynchronize them. +#[test] +fn retire_lde_agrees_with_disk_spill_on_every_side_of_the_threshold() { + for (estimated, available) in [ + (10 * GB, Some(32 * GB)), + (30 * GB, Some(32 * GB)), + (GB, Some(2 * GB)), + (10 * GB, None), + ] { + assert_eq!( + crate::auto_storage::retire_lde_for(estimated, available), + select_storage_mode(estimated, available) == StorageMode::Disk, + "policy drifted for estimated={estimated} available={available:?}" + ); + } +} From a0d182ef1c1b968b3ee729fdec6e347939ec9e96 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 16:49:46 -0300 Subject: [PATCH 03/63] Sort the dedup'd ops so trace builds repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LT, MUL, DVRM, BRANCH, EQ and BYTEWISE deduplicate their rows through a HashMap and emitted `op_map.into_iter()` order, which std randomizes per map instance. Two builds of the same logs therefore produced the same rows in a different order. That is harmless while a trace is built once, and fatal for rebuilding a retired one: the rebuild has to hash to the root the first build committed. Derive Ord on each operation and sort the dedup'd ops by that key. NOTE: this changes the proof OUTPUT — row order is now sorted where it used to be HashMap order. Still a valid proof, now a reproducible one. `trace_build_is_deterministic_across_builds` compares two builds of the same logs across every chunked table; it fails if any of the sorts is removed. --- prover/src/tables/branch.rs | 9 +- prover/src/tables/bytewise.rs | 9 +- prover/src/tables/dvrm.rs | 9 +- prover/src/tables/eq.rs | 9 +- prover/src/tables/lt.rs | 9 +- prover/src/tables/mul.rs | 9 +- prover/src/tests/trace_builder_tests.rs | 108 ++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 12 deletions(-) diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 0d3c2e206..24776caca 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -106,7 +106,7 @@ const MASK_254: u64 = 254; /// A single BRANCH operation to be added to the trace. /// /// Derives Hash and Eq so it can be used as a HashMap key for deduplication. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct BranchOperation { /// Current program counter (64-bit) pub pc: u64, @@ -163,7 +163,12 @@ pub fn generate_branch_trace( *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tables/bytewise.rs b/prover/src/tables/bytewise.rs index 2808365c6..a608a6a80 100644 --- a/prover/src/tables/bytewise.rs +++ b/prover/src/tables/bytewise.rs @@ -47,7 +47,7 @@ pub mod cols { // ========================================================================= /// A single BYTEWISE operation. `op` is an [`alu_op`] opcode in {AND, OR, XOR}. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct BytewiseOperation { pub a: u64, pub b: u64, @@ -104,7 +104,12 @@ pub fn generate_bytewise_trace( *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index c499a72bf..32175166f 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -152,7 +152,7 @@ const SIGN_FILL: u64 = 0xFFFF; /// A single DVRM operation to be added to the trace. /// /// Derives Hash and Eq for HashMap-based deduplication. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct DvrmOperation { /// Numerator (64-bit) pub n: u64, @@ -295,7 +295,12 @@ pub fn generate_dvrm_trace( } } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tables/eq.rs b/prover/src/tables/eq.rs index f967becf4..bc6046f6e 100644 --- a/prover/src/tables/eq.rs +++ b/prover/src/tables/eq.rs @@ -64,7 +64,7 @@ pub mod cols { // ========================================================================= /// A single EQ operation. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct EqOperation { /// First operand (64-bit) pub a: u64, @@ -125,7 +125,12 @@ pub fn generate_eq_trace( *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index fb7d34267..aad3e0796 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -104,7 +104,7 @@ pub mod cols { /// from the inverted form (`BGE[U]`). /// /// Derives Hash and Eq so it can be used as a HashMap key for deduplication. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct LtOperation { /// Left operand (64-bit value) pub lhs: u64, @@ -165,7 +165,12 @@ pub fn generate_lt_trace( *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index a615f74df..48e3a48c2 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -144,7 +144,7 @@ const SIGN_FILL: u64 = 0xFFFF; /// the sender's `flags` byte at lookup time. /// /// Derives Hash and Eq for HashMap-based deduplication. -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct MulOperation { /// Left operand (64-bit) pub lhs: u64, @@ -303,7 +303,12 @@ pub fn generate_mul_trace( } } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + // Sorted, not `HashMap` order: std randomizes iteration per instance, so two + // builds of the same logs produced the same rows in a different order. Harmless + // for a single build, fatal for rebuilding a retired trace — the rebuilt table + // must hash to the root its first build committed. + let mut unique_ops: Vec<_> = op_map.into_iter().collect(); + unique_ops.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 428fd4700..4c57b4bea 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1099,3 +1099,111 @@ fn test_local_to_global_traces_from_real_execution() { assert_eq!(trace.num_rows(), expected_rows); } } + +/// Two builds of the same logs must produce byte-identical traces. +/// +/// The dedup'd tables (LT, MUL, DVRM, BRANCH, EQ, BYTEWISE) collect their rows +/// out of a `HashMap`, whose iteration order std randomizes per instance — so +/// the rows were identical in content but arbitrary in order. Harmless while a +/// trace is built once, fatal for rebuilding a retired one: the rebuild has to +/// hash to the root the first build committed. +/// +/// Fails if any of the `sort_unstable_by` calls after those dedups is removed. +#[test] +fn trace_build_is_deterministic_across_builds() { + type TT = stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >; + + // Several DISTINCT ops per table, so each dedup'd `unique_ops` holds more + // than one element and its order can actually vary. + let mut logs = vec![ + make_slt_log(0x1000, 5, 10, 1), + make_slt_log(0x1004, 200, 7, 0), + make_slt_log(0x1008, 42, 42, 0), + make_slt_log(0x100c, 1, 999, 1), + make_blt_log(0x1010, 3, 4, true), + make_blt_log(0x1014, 50, 9, false), + make_blt_log(0x1018, 77, 77, false), + ]; + let mut instrs = vec![ + Instruction::Arith { + dst: 1, + src1: 2, + src2: 3, + op: ArithOp::SetLessThan, + }, + Instruction::Arith { + dst: 1, + src1: 2, + src2: 3, + op: ArithOp::SetLessThan, + }, + Instruction::Arith { + dst: 1, + src1: 2, + src2: 3, + op: ArithOp::SetLessThan, + }, + Instruction::Arith { + dst: 1, + src1: 2, + src2: 3, + op: ArithOp::SetLessThan, + }, + Instruction::Branch { + src1: 2, + src2: 3, + cond: Comparison::LessThan, + offset: 8, + }, + Instruction::Branch { + src1: 2, + src2: 3, + cond: Comparison::LessThan, + offset: 8, + }, + Instruction::Branch { + src1: 2, + src2: 3, + cond: Comparison::LessThan, + offset: 8, + }, + ]; + append_ecall(&mut logs, &mut instrs); + let instructions = make_instructions(&logs, &instrs); + let max_rows = Default::default(); + + let a = Traces::from_logs(&logs, instructions.clone(), &max_rows).unwrap(); + let b = Traces::from_logs(&logs, instructions, &max_rows).unwrap(); + + fn flat(t: &TT) -> Vec { + let (data, _cols) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect() + } + fn eq_chunks(x: &[TT], y: &[TT], name: &str) { + assert_eq!( + x.len(), + y.len(), + "{name}: chunk count differs across builds" + ); + for (i, (s, m)) in x.iter().zip(y.iter()).enumerate() { + assert_eq!( + flat(s), + flat(m), + "{name} chunk {i}: trace data differs across builds (non-deterministic order)" + ); + } + } + eq_chunks(&a.lts, &b.lts, "LT"); + eq_chunks(&a.muls, &b.muls, "MUL"); + eq_chunks(&a.dvrms, &b.dvrms, "DVRM"); + eq_chunks(&a.branches, &b.branches, "BRANCH"); + eq_chunks(&a.eqs, &b.eqs, "EQ"); + eq_chunks(&a.bytewises, &b.bytewises, "BYTEWISE"); + eq_chunks(&a.cpus, &b.cpus, "CPU"); + eq_chunks(&a.memws, &b.memws, "MEMW"); + eq_chunks(&a.shifts, &b.shifts, "SHIFT"); + eq_chunks(&a.loads, &b.loads, "LOAD"); +} From 9cbca77b6d340db0eaab8ee6fa7201dadb12411b Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 16:49:54 -0300 Subject: [PATCH 04/63] Split the trace build into route and per-table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 built all 14 chunked tables from closures over local op lists, so a table could only ever be built at that one point in the function: the lists died with the call. Move the lists into a `RoutedOps` intermediate at the phase 4/5 seam, once all the cross-table coupling is folded in, and turn the per-table fills into `RoutedOps::build_table(TableKind)`. `build_traces` keeps its rayon-scope and sequential dispatch and its output is unchanged — the closures now call `build_table` instead of `chunk_and_generate` directly. The point is what this enables: after routing, each of these tables is a pure function of one op list, and the ops are far smaller than the trace they produce. Holding `RoutedOps` instead of the built traces is what lets a retired trace be rebuilt on demand. --- prover/src/tables/trace_builder.rs | 293 ++++++++++++++++------------- 1 file changed, 162 insertions(+), 131 deletions(-) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index d3560826a..579e6ee3e 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2919,6 +2919,117 @@ struct CollectedOps { hint_ops: Vec, } +/// One log-derived, chunked table — the ones whose trace is a function of a +/// single routed op list, so it can be rebuilt on demand long after the routing +/// that produced it. +/// +/// The preprocessed tables (BITWISE, DECODE, REGISTER, HALT, COMMIT, KECCAK*) +/// and PAGE are deliberately absent: they are not driven by one op list and the +/// streaming prover keeps them resident. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TableKind { + Cpu, + Memw, + MemwAligned, + MemwRegister, + Load, + Lt, + Shift, + Mul, + Dvrm, + Branch, + Eq, + Bytewise, + Store, + Cpu32, +} + +/// The op lists after routing (phases 3-4), kept so any one table can be built +/// from them on demand. +/// +/// This is the compact intermediate the streaming prover holds instead of the +/// built traces: the ops of a table are far smaller than its trace, and the +/// trace is a pure function of them — `build_table` is deterministic, which +/// `trace_build_is_deterministic_across_builds` pins. +pub(crate) struct RoutedOps { + pub(crate) cpu_ops: Vec, + pub(crate) memw_ops: Vec, + pub(crate) memw_aligned_ops: Vec, + pub(crate) memw_register_rows: Vec, + pub(crate) load_ops: Vec, + pub(crate) lt_ops: Vec, + pub(crate) shift_ops: Vec, + pub(crate) branch_ops: Vec, + pub(crate) mul_ops: Vec<(MulOperation, bool)>, + pub(crate) dvrm_ops: Vec<(DvrmOperation, bool)>, + pub(crate) eq_ops: Vec, + pub(crate) bytewise_ops: Vec, + pub(crate) store_ops: Vec, + pub(crate) cpu32_ops: Vec, +} + +impl RoutedOps { + /// Build every chunk of one table. Byte-identical whenever it is called, + /// which is what lets a retired trace be rebuilt against the root its first + /// build committed. + pub(crate) fn build_table( + &self, + kind: TableKind, + max_rows: &super::MaxRowsConfig, + #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + ) -> Result>, Error> { + macro_rules! build { + ($ops:expr, $limit:expr, $f:path) => { + chunk_and_generate( + $ops, + $limit, + $f, + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; + } + match kind { + TableKind::Cpu => build!(&self.cpu_ops, max_rows.cpu, cpu::generate_cpu_trace), + TableKind::Memw => build!(&self.memw_ops, max_rows.memw, memw::generate_memw_trace), + TableKind::MemwAligned => build!( + &self.memw_aligned_ops, + max_rows.memw_aligned, + memw_aligned::generate_memw_aligned_trace + ), + TableKind::MemwRegister => build!( + &self.memw_register_rows, + max_rows.memw_register, + memw_register::generate_memw_register_trace_from_rows + ), + TableKind::Load => build!(&self.load_ops, max_rows.load, load::generate_load_trace), + TableKind::Lt => build!(&self.lt_ops, max_rows.lt, lt::generate_lt_trace), + TableKind::Shift => { + build!(&self.shift_ops, max_rows.shift, shift::generate_shift_trace) + } + TableKind::Mul => build!(&self.mul_ops, max_rows.mul, mul::generate_mul_trace), + TableKind::Dvrm => build!(&self.dvrm_ops, max_rows.dvrm, dvrm::generate_dvrm_trace), + TableKind::Branch => build!( + &self.branch_ops, + max_rows.branch, + branch::generate_branch_trace + ), + TableKind::Eq => build!(&self.eq_ops, max_rows.eq, eq::generate_eq_trace), + TableKind::Bytewise => build!( + &self.bytewise_ops, + max_rows.bytewise, + bytewise::generate_bytewise_trace + ), + TableKind::Store => { + build!(&self.store_ops, max_rows.store, store::generate_store_trace) + } + TableKind::Cpu32 => { + build!(&self.cpu32_ops, max_rows.cpu32, cpu32::generate_cpu32_trace) + } + } + } +} + /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` /// is `Disk`, each chunk's main table is spilled to mmap before the next chunk /// is built so peak heap usage stays bounded. @@ -3352,138 +3463,58 @@ fn build_traces( // Each build below reads disjoint op lists and writes its own table, so // they all run in one rayon scope. Disk-spill stays sequential: its // generate→spill order keeps trace memory bounded. - let cpu_ops_ref = &cpu_ops; - let gen_cpus = || { - chunk_and_generate( - cpu_ops_ref, - max_rows.cpu, - cpu::generate_cpu_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_memws = || { - chunk_and_generate( - &memw_ops, - max_rows.memw, - memw::generate_memw_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_memw_aligneds = || { - chunk_and_generate( - &memw_aligned_ops, - max_rows.memw_aligned, - memw_aligned::generate_memw_aligned_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_memw_registers = || { - // Direct-to-column fill from compact RegRows — the register fast path never - // materializes a `Vec`. - chunk_and_generate( - &memw_register_rows, - max_rows.memw_register, - memw_register::generate_memw_register_trace_from_rows, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_loads = || { - chunk_and_generate( - &load_ops, - max_rows.load, - load::generate_load_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_lts = || { - chunk_and_generate( - <_ops, - max_rows.lt, - lt::generate_lt_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_shifts = || { - chunk_and_generate( - &shift_ops, - max_rows.shift, - shift::generate_shift_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_muls = || { - chunk_and_generate( - &mul_ops, - max_rows.mul, - mul::generate_mul_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_dvrms = || { - chunk_and_generate( - &dvrm_ops, - max_rows.dvrm, - dvrm::generate_dvrm_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_branches = || { - chunk_and_generate( - &branch_ops, - max_rows.branch, - branch::generate_branch_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - // Auxiliary ALU / memory / CPU32 dispatch chips. Not yet driven by the CPU - // dispatch, so they are generated empty — one padded (μ=0) chunk each, which - // contributes nothing to any bus. - let gen_eqs = || { - chunk_and_generate::( - &eq_ops, - max_rows.eq, - eq::generate_eq_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_bytewises = || { - chunk_and_generate::( - &bytewise_ops, - max_rows.bytewise, - bytewise::generate_bytewise_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_stores = || { - chunk_and_generate::( - &store_ops, - max_rows.store, - store::generate_store_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; - let gen_cpu32s = || { - chunk_and_generate::( - &cpu32_ops, - max_rows.cpu32, - cpu32::generate_cpu32_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) + // Phases 3-4 are settled, so every cross-table coupling is already folded in + // and each of these tables is now a pure function of one routed op list. + // Pack them into `RoutedOps`: the same intermediate builds them here and can + // rebuild any one of them later, which is what a retired trace needs. + let routed = RoutedOps { + cpu_ops, + memw_ops, + memw_aligned_ops, + memw_register_rows, + load_ops, + lt_ops, + shift_ops, + branch_ops, + mul_ops, + dvrm_ops, + eq_ops, + bytewise_ops, + store_ops, + cpu32_ops, }; + let cpu_ops_ref = &routed.cpu_ops; + + // Each build below reads disjoint op lists and writes its own table, so + // they all run in one rayon scope. Disk-spill stays sequential: its + // generate→spill order keeps trace memory bounded. + macro_rules! gen_of { + ($kind:ident) => { + || { + routed.build_table( + TableKind::$kind, + max_rows, + #[cfg(feature = "disk-spill")] + storage_mode, + ) + } + }; + } + let gen_cpus = gen_of!(Cpu); + let gen_memws = gen_of!(Memw); + let gen_memw_aligneds = gen_of!(MemwAligned); + let gen_memw_registers = gen_of!(MemwRegister); + let gen_loads = gen_of!(Load); + let gen_lts = gen_of!(Lt); + let gen_shifts = gen_of!(Shift); + let gen_muls = gen_of!(Mul); + let gen_dvrms = gen_of!(Dvrm); + let gen_branches = gen_of!(Branch); + let gen_eqs = gen_of!(Eq); + let gen_bytewises = gen_of!(Bytewise); + let gen_stores = gen_of!(Store); + let gen_cpu32s = gen_of!(Cpu32); + let gen_bitwise = || { let mut bitwise = bitwise::generate_bitwise_trace(); // Fill the MU columns (11..=20) from the accumulated histogram. From f8daaa5cb470ea0cea332e646491780d62041686 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 18:04:01 -0300 Subject: [PATCH 05/63] Retire the traces too, rebuilding them per chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retiring the LDE left the traces resident, and they are the next term down: 675 MB of a 4284 MB peak on fib_iterative_1M, against the 1854 MB the LDE accounted for. Under the same LAMBDA_STREAM_LDE flag, build the chunked tables as empty placeholders and keep only the routed op lists they came from. `TraceProvider` hands the prover a trace at each of the two points that needs one — the Round 1 main commit, where it dies with the closure, and the table's fused chain, where it carries the aux columns and the LDE rebuild until the table is proved. The pre-pass and the memory estimates ask the provider for shapes instead of reading a trace that does not exist yet. Passing no provider is byte-for-byte the resident path. This rests on `build_chunk(kind, i)` equalling `build_table(kind)[i]`: Round 1 commits a trace built one way and the fused chain rebuilds it the other, so a divergence would hash to a root the verifier rejects. A test pins that equality across a multi-chunk table, and the sorted dedup from the previous commit is what makes both builds repeatable at all. A retired chunk's shape is derived, not built: every generator pads to `count.next_power_of_two().max(4)` over its op count — the number of DISTINCT ops for the six tables that deduplicate — and the width is a per-table constant. So the pre-pass and the memory estimates cost a counting pass rather than a trace generation each. `chunk_shape_matches_the_built_chunk` pins the derivation against real builds for all fourteen kinds, on a fixture where deduplication actually changes the answer. Measured on fib_iterative_1M: peak heap 4240 -> 3255 MB (-23.2%) for +14% prove time, against 3490 MB with the LDE alone. All 108 end-to-end ELF proofs pass with the flag on, and the proof verifies. Port of Approach 1 steps C.1/C.2b from the closed PR #647. --- crypto/crypto/src/merkle_tree/merkle.rs | 62 +++++ crypto/crypto/src/tests/merkle_tests.rs | 76 ++++++ crypto/stark/src/prover.rs | 262 ++++++++++++++++++--- prover/src/lib.rs | 40 +++- prover/src/streaming.rs | 130 +++++++++++ prover/src/tables/trace_builder.rs | 298 +++++++++++++++++++++++- prover/src/tests/trace_builder_tests.rs | 112 +++++++++ 7 files changed, 937 insertions(+), 43 deletions(-) create mode 100644 prover/src/streaming.rs diff --git a/crypto/crypto/src/merkle_tree/merkle.rs b/crypto/crypto/src/merkle_tree/merkle.rs index 447654907..54ef9a3d5 100644 --- a/crypto/crypto/src/merkle_tree/merkle.rs +++ b/crypto/crypto/src/merkle_tree/merkle.rs @@ -282,6 +282,68 @@ where self.create_proof(merkle_path) } + /// Free the leaf half of the node buffer, keeping the inner nodes + /// (`nodes[0..leaves_len - 1]`, root at index 0). Roughly halves the tree's + /// footprint. + /// + /// Every node an opening needs is retained except one: the leaf-level + /// sibling, which the caller regenerates and hands to + /// [`get_proof_by_pos_with_leaf_sibling`](Self::get_proof_by_pos_with_leaf_sibling). + /// + /// `leaves_len` is checked against the buffer rather than trusted, so this + /// is a no-op — returning `false` — on a tree that was already dropped, on a + /// single-leaf or root-only tree, on disk-spill mmap backing, and on a wrong + /// `leaves_len`. Truncating twice would silently eat inner nodes. + pub fn drop_leaves(&mut self, leaves_len: usize) -> bool { + if leaves_len <= 1 || self.is_root_only() { + return false; + } + #[cfg(feature = "disk-spill")] + if self.mmap_backing.is_some() { + return false; + } + // A full tree, and only a full tree, has exactly `2 * leaves_len - 1` + // nodes. Anything else means this is not the shape we were told. + if self.nodes.len() != 2 * leaves_len - 1 { + return false; + } + self.nodes.truncate(leaves_len - 1); + self.nodes.shrink_to_fit(); + true + } + + /// Leaf index whose hash must be regenerated to open position `pos`. + pub fn sibling_leaf_position(pos: usize) -> usize { + pos ^ 1 + } + + /// Opening for `pos` on a tree whose leaves were dropped, with the + /// leaf-level sibling supplied by the caller (see + /// [`sibling_leaf_position`](Self::sibling_leaf_position)). + /// + /// Byte-identical to what [`get_proof_by_pos`](Self::get_proof_by_pos) would + /// return on the full tree: same bottom node, and every node above it read + /// from the retained inner nodes at the same indices. + pub fn get_proof_by_pos_with_leaf_sibling( + &self, + pos: usize, + leaves_len: usize, + sibling_leaf: B::Node, + ) -> Option> { + if leaves_len <= 1 || pos >= leaves_len { + return None; + } + let mut merkle_path = Vec::with_capacity(leaves_len.trailing_zeros() as usize); + merkle_path.push(sibling_leaf); + + let mut node = parent_index(pos + leaves_len - 1); + while node != ROOT { + merkle_path.push(self.nodes.get(sibling_index(node))?.clone()); + node = parent_index(node); + } + self.create_proof(merkle_path) + } + /// Creates a proof from a Merkle pasth fn create_proof(&self, merkle_path: Vec) -> Option> { Some(Proof { merkle_path }) diff --git a/crypto/crypto/src/tests/merkle_tests.rs b/crypto/crypto/src/tests/merkle_tests.rs index a4be838b1..8b43a26f7 100644 --- a/crypto/crypto/src/tests/merkle_tests.rs +++ b/crypto/crypto/src/tests/merkle_tests.rs @@ -172,3 +172,79 @@ mod disk_spill_serde_tests { assert_eq!(restored.root, unspilled.root); } } + +/// A leaf-dropped opening must be byte-identical to the full-tree one. +/// +/// Dropping the leaves changes only *where* the bottom node of the path comes +/// from — regenerated by the caller instead of read out of the buffer — never +/// what it is. If these ever diverge, the streaming prover emits openings the +/// verifier rejects. +#[test] +fn leaf_dropped_opening_matches_the_full_tree() { + const MODULUS: u64 = 13; + type U64PF = U64Field; + type FE = FieldElement; + + let leaves_len = 8; + let values: Vec = (1..=leaves_len as u64).map(FE::new).collect(); + let full = MerkleTree::>::build(&values).unwrap(); + + // The leaf hashes, as the tree stores them: what the prover regenerates. + let leaf_hashes: Vec = full.nodes()[leaves_len - 1..].to_vec(); + + let mut dropped = MerkleTree::>::build(&values).unwrap(); + assert!( + dropped.drop_leaves(leaves_len), + "a full power-of-two tree must drop its leaves" + ); + assert_eq!(dropped.root, full.root, "dropping leaves moved the root"); + assert_eq!( + dropped.nodes().len(), + leaves_len - 1, + "only the inner nodes should remain" + ); + + for pos in 0..leaves_len { + let expected = full.get_proof_by_pos(pos).expect("full-tree opening"); + let sibling = MerkleTree::>::sibling_leaf_position(pos); + let actual = dropped + .get_proof_by_pos_with_leaf_sibling(pos, leaves_len, leaf_hashes[sibling]) + .expect("leaf-dropped opening"); + assert_eq!( + expected.merkle_path, actual.merkle_path, + "opening for leaf {pos} differs from the full-tree one" + ); + } +} + +/// Dropping twice, or with the wrong shape, must not eat inner nodes. +#[test] +fn drop_leaves_refuses_anything_but_a_full_tree() { + const MODULUS: u64 = 13; + type U64PF = U64Field; + type FE = FieldElement; + + let leaves_len = 8; + let values: Vec = (1..=leaves_len as u64).map(FE::new).collect(); + let mut tree = MerkleTree::>::build(&values).unwrap(); + + assert!(tree.drop_leaves(leaves_len)); + let after_first = tree.nodes().len(); + + assert!( + !tree.drop_leaves(leaves_len), + "a second drop must be refused, not applied" + ); + assert_eq!(tree.nodes().len(), after_first, "a refused drop truncated"); + + let mut other = MerkleTree::>::build(&values).unwrap(); + assert!( + !other.drop_leaves(leaves_len * 2), + "a wrong leaves_len must be refused" + ); + assert_eq!( + other.nodes().len(), + 2 * leaves_len - 1, + "a refused drop truncated" + ); +} diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index f6913389c..2c0123a93 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -121,6 +121,10 @@ where pub(crate) precomputed_root: Option, /// Preprocessed tables only: number of precomputed columns. Zero otherwise. pub(crate) num_precomputed_cols: usize, + /// `Some(leaves_len)` when `tree`'s leaf half was freed after committing, so + /// the opening path knows to regenerate the one leaf-level sibling it needs. + /// `None` on a full tree. + pub(crate) leaves_dropped: Option, } impl TableCommit @@ -128,13 +132,38 @@ where FieldElement: AsBytes, { /// Build a `TableCommit` for a plain (non-preprocessed) table. - fn plain(tree: BatchedMerkleTree, root: Commitment) -> Self { + fn plain(#[allow(unused_mut)] mut tree: BatchedMerkleTree, root: Commitment) -> Self { + let leaves_dropped = Self::retire_leaves(&mut tree); Self { tree: Arc::new(tree), root, precomputed_tree: None, precomputed_root: None, num_precomputed_cols: 0, + leaves_dropped, + } + } + + /// Free the tree's leaf half in streaming mode, returning the `leaves_len` + /// the opening path needs to rebuild a path without them. + /// + /// Halves a committed tree's footprint: every inner node is kept, so the + /// only node an opening has to regenerate is the leaf-level sibling, and the + /// paths stay byte-identical. Inert under `cuda`, where openings can come + /// off the device instead of the host tree. + #[allow(unused_variables)] + fn retire_leaves(tree: &mut BatchedMerkleTree) -> Option { + #[cfg(feature = "cuda")] + { + None + } + #[cfg(not(feature = "cuda"))] + { + if !streaming_retire_lde() { + return None; + } + let leaves_len = tree.nodes().len().div_ceil(2); + tree.drop_leaves(leaves_len).then_some(leaves_len) } } @@ -148,12 +177,16 @@ where precomputed_root: Commitment, num_precomputed_cols: usize, ) -> Self { + #[allow(unused_mut)] + let mut tree = tree; + let leaves_dropped = Self::retire_leaves(&mut tree); Self { tree: Arc::new(tree), root, precomputed_tree: Some(precomputed_tree), precomputed_root: Some(precomputed_root), num_precomputed_cols, + leaves_dropped, } } @@ -165,6 +198,7 @@ where precomputed_tree: self.precomputed_tree.as_ref().map(Arc::clone), precomputed_root: self.precomputed_root, num_precomputed_cols: self.num_precomputed_cols, + leaves_dropped: self.leaves_dropped, } } @@ -618,6 +652,40 @@ fn host_cores() -> usize { /// pool. Worst case against the best measured `k`: `num_airs` +1.6 % (inside /// noise), the old `cores*2/3` +13.0 %. Bounding concurrency is memory /// admission's job (`VramGate`), not this count's. +/// Source of truth for a table whose *trace* has been retired. +/// +/// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and +/// rebuilds it from the still-resident trace. This goes one rung further down +/// the same ladder: drop the trace too, and rebuild it from the compact routed +/// op lists it was built from. The prover asks for a trace at the two points it +/// needs one — the Round 1 main commit, and the table's fused chain — and drops +/// it again after each. +/// +/// `build_main` MUST be deterministic: the trace rebuilt for the fused chain has +/// to be byte-identical to the one Round 1 committed, or the root will not match +/// what the verifier recomputes. +/// +/// Port of Approach 1 step C.2b (PR #647, commit a7eabd3c). +pub trait TraceProvider: Sync +where + Field: IsSubFieldOf + IsField, + FieldExtension: IsField, +{ + /// Whether table `idx` is retired (built on demand) rather than resident. + fn is_retired(&self, idx: usize) -> bool; + + /// Row count of table `idx`'s main trace. Cheap: the pre-pass sizes the LDE + /// domain with it, without materializing the trace. + fn num_rows(&self, idx: usize) -> usize; + + /// Main-column count of table `idx`. Cheap, like `num_rows`: the memory + /// estimates need the table's width before anything is materialized. + fn num_main_columns(&self, idx: usize) -> usize; + + /// Build the main-only trace (no auxiliary columns) for retired table `idx`. + fn build_main(&self, idx: usize) -> TraceTable; +} + /// Retire each table's main LDE right after the Round 1 commit and rebuild it /// on demand inside the table's fused chain, instead of holding all N of them /// across the Round 1 barrier. @@ -2781,25 +2849,67 @@ pub trait IsStarkProver< tree: &BatchedMerkleTree, challenge: usize, gather: G, + leaves_dropped: Option, ) -> PolynomialOpenings where C: IsField, - FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send + math::traits::ByteConversion, G: Fn(usize) -> Vec>, { let domain_size = domain.lde_roots_of_unity_coset.len() as u64; + let proof = match leaves_dropped { + None => tree + .get_proof_by_pos(challenge) + .expect("FRI query index in bounds"), + // Leaf-dropped tree: every node of the path is retained except the + // leaf-level sibling, which is rehashed here from the same rows, in + // the same order and byte layout, that the commit hashed. + Some(leaves_len) => { + let sibling = BatchedMerkleTree::::sibling_leaf_position(challenge); + let leaf = Self::hash_row_pair_leaf(&gather, sibling, domain_size); + tree.get_proof_by_pos_with_leaf_sibling(challenge, leaves_len, leaf) + .expect("FRI query index in bounds") + } + }; // Rows `2·challenge` and `2·challenge+1` are committed together as the // single leaf at position `challenge`; one Merkle path authenticates both // the queried row and its symmetric counterpart. PolynomialOpenings { - proof: tree - .get_proof_by_pos(challenge) - .expect("FRI query index in bounds"), + proof, evaluations: gather(reverse_index(challenge * 2, domain_size)), evaluations_sym: gather(reverse_index(challenge * 2 + 1, domain_size)), } } + /// Rehash one Merkle leaf from the LDE rows behind it. + /// + /// Must mirror `commit_rows_bit_reversed_subset`'s `hash_leaf` exactly: the + /// `ROWS_PER_LEAF` bit-reversed rows concatenated, each element big-endian, + /// over the same column range — which `gather` already carries, since it is + /// the same closure the openings are read with. + fn hash_row_pair_leaf(gather: &G, leaf_idx: usize, num_rows: u64) -> Commitment + where + C: IsField, + FieldElement: AsBytes + Sync + Send + math::traits::ByteConversion, + G: Fn(usize) -> Vec>, + { + use math::traits::ByteConversion; + const ROWS_PER_LEAF: usize = crate::commitment::ROWS_PER_LEAF; + + let byte_len = as ByteConversion>::BYTE_LEN; + let mut buf = Vec::new(); + for k in 0..ROWS_PER_LEAF { + let row = gather(reverse_index(ROWS_PER_LEAF * leaf_idx + k, num_rows)); + let start = buf.len(); + buf.resize(start + row.len() * byte_len, 0u8); + for (i, elem) in row.iter().enumerate() { + let at = start + i * byte_len; + elem.write_bytes_be(&mut buf[at..at + byte_len]); + } + } + BatchedMerkleTreeBackend::::hash_bytes(&buf) + } + /// Like [`Self::open_polys_with`], but uses a Merkle proof already gathered /// from the resident device tree (see [`crate::gpu_lde::gather_proofs_dev`]) /// instead of walking a host tree. Row-pair leaf: one proof at position @@ -2915,10 +3025,11 @@ pub trait IsStarkProver< col_range: std::ops::Range, what: &str, gather: G, + leaves_dropped: Option, ) -> PolynomialOpenings where C: IsField, - FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send + math::traits::ByteConversion, G: Fn(usize) -> Vec>, { let Some(proofs) = dev_proofs else { @@ -2935,7 +3046,7 @@ pub trait IsStarkProver< !tree.is_root_only(), "R4 {what} opening fell back to a root-only host tree (nodes device-resident)" ); - return Self::open_polys_with(domain, tree, challenge, gather); + return Self::open_polys_with(domain, tree, challenge, gather, leaves_dropped); }; let proof = proofs[qi].clone(); let Some(dev_vals) = dev_values else { @@ -3168,12 +3279,17 @@ pub trait IsStarkProver< |row| { lde_trace.gather_main_row_range(row, num_precomputed_cols, total_cols) }, + main_commit.leaves_dropped, ) } #[cfg(not(feature = "cuda"))] - Self::open_polys_with(domain, &main_commit.tree, *index, |row| { - lde_trace.gather_main_row_range(row, num_precomputed_cols, total_cols) - }) + Self::open_polys_with( + domain, + &main_commit.tree, + *index, + |row| lde_trace.gather_main_row_range(row, num_precomputed_cols, total_cols), + main_commit.leaves_dropped, + ) } else { #[cfg(feature = "cuda")] { @@ -3189,13 +3305,18 @@ pub trait IsStarkProver< 0..total_cols, "main", |row| lde_trace.gather_main_row(row), + main_commit.leaves_dropped, ) } #[cfg(not(feature = "cuda"))] { - Self::open_polys_with(domain, &main_commit.tree, *index, |row| { - lde_trace.gather_main_row(row) - }) + Self::open_polys_with( + domain, + &main_commit.tree, + *index, + |row| lde_trace.gather_main_row(row), + main_commit.leaves_dropped, + ) } }; @@ -3248,16 +3369,24 @@ pub trait IsStarkProver< "R4 precomputed opening fell back to the host gather, \ but it is device-only (empty)" ); - Self::open_polys_with(domain, tree, *index, |row| { - lde_trace.gather_main_row_range(row, 0, num_precomputed_cols) - }) + Self::open_polys_with( + domain, + tree, + *index, + |row| lde_trace.gather_main_row_range(row, 0, num_precomputed_cols), + None, + ) } } } #[cfg(not(feature = "cuda"))] - Self::open_polys_with(domain, tree, *index, |row| { - lde_trace.gather_main_row_range(row, 0, num_precomputed_cols) - }) + Self::open_polys_with( + domain, + tree, + *index, + |row| lde_trace.gather_main_row_range(row, 0, num_precomputed_cols), + None, + ) }); let composition_openings = { @@ -3344,13 +3473,18 @@ pub trait IsStarkProver< 0..lde_trace.num_aux_cols(), "aux", |row| lde_trace.gather_aux_row(row), + aux.leaves_dropped, ) } #[cfg(not(feature = "cuda"))] { - Self::open_polys_with(domain, &aux.tree, *index, |row| { - lde_trace.gather_aux_row(row) - }) + Self::open_polys_with( + domain, + &aux.tree, + *index, + |row| lde_trace.gather_aux_row(row), + aux.leaves_dropped, + ) } }); @@ -3386,9 +3520,36 @@ pub trait IsStarkProver< /// /// The transcript must be safely initialized before passing it to this method. fn multi_prove( + air_trace_pairs: Vec>, + transcript: &mut (impl IsStarkTranscript + Clone + Send), + #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + ) -> Result, ProvingError> + where + FieldElement: AsBytes, + FieldElement: AsBytes, + PI: Send + Sync + Clone, + Field: Copy + 'static, + FieldExtension: Copy + 'static, + ::BaseType: SpillSafe, + ::BaseType: SpillSafe, + { + Self::multi_prove_with_provider( + air_trace_pairs, + transcript, + #[cfg(feature = "disk-spill")] + storage_mode, + None, + ) + } + + /// `multi_prove`, with the traces of some tables retired: `provider` rebuilds + /// them on demand. Passing `None` is byte-for-byte the resident path. + #[allow(clippy::too_many_arguments)] + fn multi_prove_with_provider( #[allow(unused_mut)] mut air_trace_pairs: Vec>, transcript: &mut (impl IsStarkTranscript + Clone + Send), #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + provider: Option<&(dyn TraceProvider + '_)>, ) -> Result, ProvingError> where FieldElement: AsBytes, @@ -3425,8 +3586,13 @@ pub trait IsStarkProver< let mut domains = Vec::with_capacity(num_airs); let mut twiddle_caches: Vec>> = Vec::with_capacity(num_airs); - for (air, trace, _pub_inputs) in &*air_trace_pairs { - let (domain, twiddles) = domain_and_twiddles(*air, trace.num_rows()); + for (idx, (air, trace, _pub_inputs)) in air_trace_pairs.iter().enumerate() { + // A retired table has no trace yet; its provider knows the shape. + let num_rows = match provider { + Some(p) if p.is_retired(idx) => p.num_rows(idx), + _ => trace.num_rows(), + }; + let (domain, twiddles) = domain_and_twiddles(*air, num_rows); domains.push(domain); twiddle_caches.push(twiddles); } @@ -3462,7 +3628,11 @@ pub trait IsStarkProver< .enumerate() .map(|(idx, (_, trace, _))| { let lde_size = domains[idx].interpolation_domain_size * domains[idx].blowup_factor; - estimate_table_vram_bytes(trace.num_main_columns, 0, lde_size) + let main_cols = match provider { + Some(p) if p.is_retired(idx) => p.num_main_columns(idx), + _ => trace.num_main_columns, + }; + estimate_table_vram_bytes(main_cols, 0, lde_size) }) .collect(); @@ -3522,10 +3692,22 @@ pub trait IsStarkProver< &vram_gate, k, |idx| { - let (air, trace, _) = &air_trace_pairs[idx]; + let (air, resident_trace, _) = &air_trace_pairs[idx]; let domain = &domains[idx]; let twiddles = &twiddle_caches[idx]; + // Retired: build the trace just to commit it, and let it die at + // the end of this closure. The table's fused chain builds its own + // copy later — `build_main` is deterministic, so both agree. + let rebuilt; + let trace: &TraceTable = match provider { + Some(p) if p.is_retired(idx) => { + rebuilt = p.build_main(idx); + &rebuilt + } + _ => resident_trace, + }; + let precomputed = air .is_preprocessed() .then(|| (air.precomputed_commitment(), air.num_precomputed_columns())); @@ -3536,7 +3718,7 @@ pub trait IsStarkProver< let device_only = Self::device_only_for(*air, domain); Self::commit_main_trace( - *trace, + trace, domain, twiddles, precomputed, @@ -3715,6 +3897,16 @@ pub trait IsStarkProver< let domain = &domains[idx]; let twiddles = &twiddle_caches[idx]; + // Retired: rebuild into the cell, not into a local. The aux columns + // are written into this trace below and the LDE rebuild in + // `rounds_stage` reads it, so it has to outlive this stage; that + // stage drops it again once the table's proof is done. + if let Some(p) = provider + && p.is_retired(idx) + { + **trace = p.build_main(idx); + } + #[cfg(feature = "instruments")] let __sp = crate::instruments::span("r1_aux_build_table"); let bus_public_inputs = if air.has_aux_trace() { @@ -4015,9 +4207,9 @@ pub trait IsStarkProver< commitment: Round1Commitments, lde: Lde| -> Result, ProvingError> { - let pair = pair_cells[idx].lock().unwrap(); - let (air, trace, pub_inputs) = &*pair; - let _ = trace; // used by instruments + let mut pair = pair_cells[idx].lock().unwrap(); + let (air, trace, pub_inputs) = &mut *pair; + let _ = &trace; // used by instruments, and dropped below when retired let domain = &domains[idx]; #[cfg(feature = "instruments")] @@ -4049,7 +4241,7 @@ pub trait IsStarkProver< let proof = Self::prove_rounds_2_to_4( *air, - *pub_inputs, + pub_inputs, &mut round_1_result, &mut *tguard, domain, @@ -4066,6 +4258,14 @@ pub trait IsStarkProver< sub_ops, )); } + // This table is proved: nothing reads its trace again, so a retired + // one goes back to being just its op lists. + if let Some(p) = provider + && p.is_retired(idx) + { + **trace = TraceTable::from_columns_main(Vec::new(), air.step_size()); + } + Ok(proof) }; diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 77394367e..d1705e012 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -19,6 +19,7 @@ mod debug_report; #[cfg(feature = "instruments")] pub mod instruments; mod paged_mem; +pub(crate) mod streaming; pub use stark::profile_markers; pub mod recursion; mod statement; @@ -1164,14 +1165,32 @@ pub fn prove_with_options_and_inputs( ); } - let mut traces = Traces::from_elf_and_logs( - &program, - &result.logs, - max_rows, - private_inputs, - #[cfg(feature = "disk-spill")] - storage_mode, - )?; + // Retiring the LDE also retires the traces: the same flag, one rung further + // down the same ladder. The chunked tables come back as placeholders and the + // provider rebuilds each chunk at the two points the prover needs it. + let retire_traces = stark::prover::streaming_retire_lde(); + let (mut traces, streaming) = if retire_traces { + let (traces, routed) = Traces::from_elf_and_logs_streaming( + &program, + &result.logs, + max_rows, + private_inputs, + #[cfg(feature = "disk-spill")] + storage_mode, + )?; + let provider = streaming::StreamingProvider::new(routed, max_rows.clone(), &traces); + (traces, Some(provider)) + } else { + let traces = Traces::from_elf_and_logs( + &program, + &result.logs, + max_rows, + private_inputs, + #[cfg(feature = "disk-spill")] + storage_mode, + )?; + (traces, None) + }; debug_assert_eq!( traces.public_output_bytes, result.return_values.memory_values, "public output diverged between executor view and trace reconstruction" @@ -1237,11 +1256,14 @@ pub fn prove_with_options_and_inputs( // Phase 4: Prove (multi_prove) #[cfg(feature = "instruments")] let __sp = stark::instruments::span("proving"); - let proof = Prover::multi_prove( + let proof = Prover::multi_prove_with_provider( airs.air_trace_pairs(&mut traces), &mut transcript, #[cfg(feature = "disk-spill")] storage_mode, + streaming + .as_ref() + .map(|p| p as &dyn stark::prover::TraceProvider<_, _>), ) .map_err(|e| Error::Prover(format!("{e:?}")))?; #[cfg(feature = "instruments")] diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs new file mode 100644 index 000000000..544534c0c --- /dev/null +++ b/prover/src/streaming.rs @@ -0,0 +1,130 @@ +//! On-demand trace source for the streaming prover. +//! +//! Approach 1 step C.2b: the chunked tables are built as empty placeholders and +//! their rows live only as the routed op lists they came from. The prover asks +//! this provider for a trace at the two points it needs one — the Round 1 main +//! commit, and the table's fused chain — and drops it again after each. + +use std::collections::HashMap; +use std::sync::Mutex; + +use stark::prover::TraceProvider; +use stark::trace::TraceTable; + +use crate::tables::MaxRowsConfig; +use crate::tables::trace_builder::{RoutedOps, TableKind, Traces}; +use crate::tables::types::{GoldilocksExtension, GoldilocksField}; + +/// The groups of chunked tables, in the order `VmAirs::air_trace_pairs` emits +/// them. `None` marks a group that stays resident (PAGE), which still consumes +/// AIR indices and so must be walked over. +const GROUP_ORDER: [Option; 15] = [ + Some(TableKind::Cpu), + Some(TableKind::Lt), + Some(TableKind::Shift), + Some(TableKind::Memw), + Some(TableKind::MemwAligned), + Some(TableKind::Load), + Some(TableKind::Mul), + Some(TableKind::Dvrm), + Some(TableKind::Branch), + None, // PAGE — built from the ELF image, not from an op list + Some(TableKind::MemwRegister), + Some(TableKind::Eq), + Some(TableKind::Bytewise), + Some(TableKind::Store), + Some(TableKind::Cpu32), +]; + +/// Number of singleton tables emitted before the chunked groups: BITWISE, +/// DECODE, COMMIT, KECCAK, KECCAK_RND, KECCAK_RC, ECSM, ECDAS, HINT, REGISTER. +const NUM_FIXED_AIRS: usize = 10; + +pub(crate) struct StreamingProvider { + routed: RoutedOps, + max_rows: MaxRowsConfig, + /// AIR index -> the chunk that rebuilds it, or `None` when it is resident. + slots: Vec>, + /// Memoized `(rows, main_columns)` per retired AIR index. Only the shape is + /// cached — caching the trace would give back the memory this mode exists + /// to save. + shapes: Mutex>, +} + +impl StreamingProvider { + /// Walk the AIR order and record which index each retired chunk answers to. + pub(crate) fn new(routed: RoutedOps, max_rows: MaxRowsConfig, traces: &Traces) -> Self { + let group_lengths = [ + traces.cpus.len(), + traces.lts.len(), + traces.shifts.len(), + traces.memws.len(), + traces.memw_aligneds.len(), + traces.loads.len(), + traces.muls.len(), + traces.dvrms.len(), + traces.branches.len(), + traces.pages.len(), + traces.memw_registers.len(), + traces.eqs.len(), + traces.bytewises.len(), + traces.stores.len(), + traces.cpu32s.len(), + ]; + + // HALT sits between the fixed tables and the groups, and is only emitted + // for a final epoch — which is the only kind this path proves. + let mut slots = vec![None; NUM_FIXED_AIRS + 1]; + for (kind, len) in GROUP_ORDER.iter().zip(group_lengths.iter()) { + for chunk in 0..*len { + slots.push(kind.map(|k| (k, chunk))); + } + } + + Self { + routed, + max_rows, + slots, + shapes: Mutex::new(HashMap::new()), + } + } + + fn slot(&self, idx: usize) -> Option<(TableKind, usize)> { + self.slots.get(idx).copied().flatten() + } + + /// Rows and width of a retired chunk, without building it. + /// + /// `chunk_shape` derives both from the op counts and the table's constant + /// width, so the pre-pass and the memory estimates no longer pay a full + /// trace generation each just to learn a row count. Still memoized: for the + /// deduplicating tables it is a counting pass, not free. + fn shape(&self, idx: usize) -> (usize, usize) { + if let Some(hit) = self.shapes.lock().unwrap().get(&idx) { + return *hit; + } + let (kind, chunk) = self.slot(idx).expect("shape asked for a resident table"); + let shape = self.routed.chunk_shape(kind, chunk, &self.max_rows); + self.shapes.lock().unwrap().insert(idx, shape); + shape + } +} + +impl TraceProvider for StreamingProvider { + fn is_retired(&self, idx: usize) -> bool { + self.slot(idx).is_some() + } + + fn num_rows(&self, idx: usize) -> usize { + self.shape(idx).0 + } + + fn num_main_columns(&self, idx: usize) -> usize { + self.shape(idx).1 + } + + fn build_main(&self, idx: usize) -> TraceTable { + let (kind, chunk) = self.slot(idx).expect("build asked for a resident table"); + self.routed.build_chunk(kind, chunk, &self.max_rows) + } +} diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 579e6ee3e..e50dbb3fd 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2951,6 +2951,7 @@ pub(crate) enum TableKind { /// built traces: the ops of a table are far smaller than its trace, and the /// trace is a pure function of them — `build_table` is deterministic, which /// `trace_build_is_deterministic_across_builds` pins. +#[derive(Default)] pub(crate) struct RoutedOps { pub(crate) cpu_ops: Vec, pub(crate) memw_ops: Vec, @@ -2969,6 +2970,190 @@ pub(crate) struct RoutedOps { } impl RoutedOps { + /// How many chunks `build_table` would produce for `kind`. + /// + /// Mirrors `chunk_and_generate`: an empty op list still yields one (padded) + /// chunk, so the table exists in the proof with the shape the verifier + /// expects. + pub(crate) fn num_chunks(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { + let (len, limit) = self.shape_of(kind, max_rows); + if len == 0 { 1 } else { len.div_ceil(limit) } + } + + /// Op count and chunk limit for `kind`. + fn shape_of(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> (usize, usize) { + match kind { + TableKind::Cpu => (self.cpu_ops.len(), max_rows.cpu), + TableKind::Memw => (self.memw_ops.len(), max_rows.memw), + TableKind::MemwAligned => (self.memw_aligned_ops.len(), max_rows.memw_aligned), + TableKind::MemwRegister => (self.memw_register_rows.len(), max_rows.memw_register), + TableKind::Load => (self.load_ops.len(), max_rows.load), + TableKind::Lt => (self.lt_ops.len(), max_rows.lt), + TableKind::Shift => (self.shift_ops.len(), max_rows.shift), + TableKind::Mul => (self.mul_ops.len(), max_rows.mul), + TableKind::Dvrm => (self.dvrm_ops.len(), max_rows.dvrm), + TableKind::Branch => (self.branch_ops.len(), max_rows.branch), + TableKind::Eq => (self.eq_ops.len(), max_rows.eq), + TableKind::Bytewise => (self.bytewise_ops.len(), max_rows.bytewise), + TableKind::Store => (self.store_ops.len(), max_rows.store), + TableKind::Cpu32 => (self.cpu32_ops.len(), max_rows.cpu32), + } + } + + /// Rows and main columns of one chunk, without building it. + /// + /// Every generator pads to `count.next_power_of_two().max(4)`, where `count` + /// is the chunk's op count — or, for the six tables that deduplicate, the + /// number of DISTINCT ops in it. The width is a per-table constant. So the + /// shape needs a counting pass at worst, never a trace. + /// + /// `chunk_shape_matches_the_built_chunk` pins this against real builds for + /// every kind; it is what catches a generator that changes its padding. + pub(crate) fn chunk_shape( + &self, + kind: TableKind, + chunk: usize, + max_rows: &super::MaxRowsConfig, + ) -> (usize, usize) { + use std::collections::HashSet; + + macro_rules! slice_of { + ($ops:expr, $limit:expr) => {{ + let ops = $ops; + let slice: &[_] = if ops.is_empty() { + &[] + } else { + ops.chunks($limit).nth(chunk).unwrap_or(&[]) + }; + slice + }}; + } + // One row per op. + macro_rules! plain { + ($ops:expr, $limit:expr, $cols:expr) => { + (slice_of!($ops, $limit).len(), $cols) + }; + } + // One row per DISTINCT op. + macro_rules! dedup { + ($ops:expr, $limit:expr, $cols:expr) => { + ( + slice_of!($ops, $limit).iter().collect::>().len(), + $cols, + ) + }; + } + // Same, where the op list pairs each op with a flag the dedup folds in. + macro_rules! dedup_tagged { + ($ops:expr, $limit:expr, $cols:expr) => { + ( + slice_of!($ops, $limit) + .iter() + .map(|(op, _)| op) + .collect::>() + .len(), + $cols, + ) + }; + } + + let (count, cols) = match kind { + TableKind::Cpu => plain!(&self.cpu_ops, max_rows.cpu, cpu::cols::NUM_COLUMNS), + TableKind::Memw => plain!(&self.memw_ops, max_rows.memw, memw::cols::NUM_COLUMNS), + TableKind::MemwAligned => plain!( + &self.memw_aligned_ops, + max_rows.memw_aligned, + memw_aligned::cols::NUM_COLUMNS + ), + TableKind::MemwRegister => plain!( + &self.memw_register_rows, + max_rows.memw_register, + memw_register::cols::NUM_COLUMNS + ), + TableKind::Load => plain!(&self.load_ops, max_rows.load, load::cols::NUM_COLUMNS), + TableKind::Shift => plain!(&self.shift_ops, max_rows.shift, shift::cols::NUM_COLUMNS), + TableKind::Store => plain!(&self.store_ops, max_rows.store, store::cols::NUM_COLUMNS), + TableKind::Cpu32 => plain!(&self.cpu32_ops, max_rows.cpu32, cpu32::cols::NUM_COLUMNS), + TableKind::Lt => dedup!(&self.lt_ops, max_rows.lt, lt::cols::NUM_COLUMNS), + TableKind::Branch => { + dedup!(&self.branch_ops, max_rows.branch, branch::cols::NUM_COLUMNS) + } + TableKind::Eq => dedup!(&self.eq_ops, max_rows.eq, eq::cols::NUM_COLUMNS), + TableKind::Bytewise => dedup!( + &self.bytewise_ops, + max_rows.bytewise, + bytewise::cols::NUM_COLUMNS + ), + TableKind::Mul => dedup_tagged!(&self.mul_ops, max_rows.mul, mul::cols::NUM_COLUMNS), + TableKind::Dvrm => { + dedup_tagged!(&self.dvrm_ops, max_rows.dvrm, dvrm::cols::NUM_COLUMNS) + } + }; + (count.next_power_of_two().max(4), cols) + } + + /// Build exactly one chunk of one table. + /// + /// Byte-identical to `build_table(kind)[chunk]`: same op slice into the same + /// generator. That equality is the whole point — it is what lets the fused + /// chain rebuild a trace the Round 1 commit already hashed. + pub(crate) fn build_chunk( + &self, + kind: TableKind, + chunk: usize, + max_rows: &super::MaxRowsConfig, + ) -> TraceTable { + macro_rules! chunk_of { + ($ops:expr, $limit:expr, $f:path) => {{ + let ops = $ops; + let slice: &[_] = if ops.is_empty() { + &[] + } else { + ops.chunks($limit).nth(chunk).unwrap_or(&[]) + }; + $f(slice) + }}; + } + match kind { + TableKind::Cpu => chunk_of!(&self.cpu_ops, max_rows.cpu, cpu::generate_cpu_trace), + TableKind::Memw => chunk_of!(&self.memw_ops, max_rows.memw, memw::generate_memw_trace), + TableKind::MemwAligned => chunk_of!( + &self.memw_aligned_ops, + max_rows.memw_aligned, + memw_aligned::generate_memw_aligned_trace + ), + TableKind::MemwRegister => chunk_of!( + &self.memw_register_rows, + max_rows.memw_register, + memw_register::generate_memw_register_trace_from_rows + ), + TableKind::Load => chunk_of!(&self.load_ops, max_rows.load, load::generate_load_trace), + TableKind::Lt => chunk_of!(&self.lt_ops, max_rows.lt, lt::generate_lt_trace), + TableKind::Shift => { + chunk_of!(&self.shift_ops, max_rows.shift, shift::generate_shift_trace) + } + TableKind::Mul => chunk_of!(&self.mul_ops, max_rows.mul, mul::generate_mul_trace), + TableKind::Dvrm => chunk_of!(&self.dvrm_ops, max_rows.dvrm, dvrm::generate_dvrm_trace), + TableKind::Branch => chunk_of!( + &self.branch_ops, + max_rows.branch, + branch::generate_branch_trace + ), + TableKind::Eq => chunk_of!(&self.eq_ops, max_rows.eq, eq::generate_eq_trace), + TableKind::Bytewise => chunk_of!( + &self.bytewise_ops, + max_rows.bytewise, + bytewise::generate_bytewise_trace + ), + TableKind::Store => { + chunk_of!(&self.store_ops, max_rows.store, store::generate_store_trace) + } + TableKind::Cpu32 => { + chunk_of!(&self.cpu32_ops, max_rows.cpu32, cpu32::generate_cpu32_trace) + } + } + } + /// Build every chunk of one table. Byte-identical whenever it is called, /// which is what lets a retired trace be rebuilt against the root its first /// build committed. @@ -3248,7 +3433,10 @@ fn build_traces( private_input: &[u8], is_final: bool, l2g_memory_bookend: bool, -) -> Result { + // `true` builds the chunked tables as empty placeholders, leaving the + // returned `RoutedOps` as the only way to get their rows. + retire_chunked: bool, +) -> Result<(Traces, RoutedOps), Error> { let CollectedOps { cpu_ops, memw_ops, @@ -3491,6 +3679,15 @@ fn build_traces( macro_rules! gen_of { ($kind:ident) => { || { + if retire_chunked { + // Placeholders: the right number of chunks, none of the rows. + // `table_counts` (and so the AIR layout) only reads the chunk + // count, and the prover asks the provider for every shape it + // needs before a trace exists. + return Ok((0..routed.num_chunks(TableKind::$kind, max_rows)) + .map(|_| TraceTable::from_columns_main(Vec::new(), 1)) + .collect()); + } routed.build_table( TableKind::$kind, max_rows, @@ -3727,7 +3924,7 @@ fn build_traces( #[cfg(feature = "instruments")] drop(__sp); - Ok(Traces { + let traces = Traces { cpus, bitwise, lts, @@ -3758,7 +3955,9 @@ fn build_traces( bytewises, stores, cpu32s, - }) + }; + + Ok((traces, routed)) } /// Padded row count after chunking. @@ -4586,6 +4785,37 @@ impl Traces { ) } + /// `from_elf_and_logs`, retiring the chunked tables. + /// + /// The returned `Traces` carries an empty placeholder per chunk — the right + /// count, none of the rows — and the `RoutedOps` beside it is what rebuilds + /// any of them on demand. + pub(crate) fn from_elf_and_logs_streaming( + elf: &Elf, + logs: &[Log], + max_rows: &super::MaxRowsConfig, + private_input: &[u8], + #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + ) -> Result<(Self, RoutedOps), Error> { + let initial_image = build_initial_image(elf, private_input); + let register_init = register::register_init_from_entry_point(elf.entry_point); + let artifacts = DecodeArtifacts::from_elf(elf)?; + let collected = + Self::collect_epoch(&artifacts, &initial_image, ®ister_init, logs, true)?; + Self::build_from_collected_streaming( + &artifacts, + collected, + Some(&initial_image), + ®ister_init, + max_rows, + private_input, + true, + false, + #[cfg(feature = "disk-spill")] + storage_mode, + ) + } + /// Build traces for one execution epoch starting from an explicit /// initial-memory image (the epoch's starting memory) rather than the ELF /// image. `elf` is still used for the program code (DECODE) and entry point. @@ -4742,6 +4972,36 @@ impl Traces { /// producer collects the next epoch. `initial_image` is only used for PAGE /// tables and their bitwise lookups, both skipped in continuation mode /// (`l2g_memory_bookend`), where callers pass `None`. + #[allow(clippy::too_many_arguments)] + /// `build_from_collected`, retiring the chunked tables: they come back as + /// empty placeholders and the returned `RoutedOps` is what rebuilds them. + #[allow(clippy::too_many_arguments)] + pub(crate) fn build_from_collected_streaming( + artifacts: &DecodeArtifacts, + collected: CollectedEpoch, + initial_image: Option<&I>, + register_init: &[u32], + max_rows: &super::MaxRowsConfig, + private_input: &[u8], + is_final: bool, + l2g_memory_bookend: bool, + #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + ) -> Result<(Self, RoutedOps), Error> { + Self::build_from_collected_inner( + artifacts, + collected, + initial_image, + register_init, + max_rows, + private_input, + is_final, + l2g_memory_bookend, + #[cfg(feature = "disk-spill")] + storage_mode, + true, + ) + } + #[allow(clippy::too_many_arguments)] pub fn build_from_collected( artifacts: &DecodeArtifacts, @@ -4754,6 +5014,35 @@ impl Traces { l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, ) -> Result { + Self::build_from_collected_inner( + artifacts, + collected, + initial_image, + register_init, + max_rows, + private_input, + is_final, + l2g_memory_bookend, + #[cfg(feature = "disk-spill")] + storage_mode, + false, + ) + .map(|(traces, _routed)| traces) + } + + #[allow(clippy::too_many_arguments)] + fn build_from_collected_inner( + artifacts: &DecodeArtifacts, + collected: CollectedEpoch, + initial_image: Option<&I>, + register_init: &[u32], + max_rows: &super::MaxRowsConfig, + private_input: &[u8], + is_final: bool, + l2g_memory_bookend: bool, + #[cfg(feature = "disk-spill")] storage_mode: StorageMode, + retire_chunked: bool, + ) -> Result<(Self, RoutedOps), Error> { // Phase 0 (cached): the pristine DECODE trace is cloned so // `build_traces` can fill this epoch's multiplicities. #[cfg(feature = "instruments")] @@ -4780,6 +5069,7 @@ impl Traces { private_input, is_final, l2g_memory_bookend, + retire_chunked, ); #[cfg(feature = "instruments")] drop(__sp); @@ -4854,6 +5144,8 @@ impl Traces { &[], true, false, + false, ) + .map(|(traces, _routed)| traces) } } diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 4c57b4bea..b220a89f7 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1207,3 +1207,115 @@ fn trace_build_is_deterministic_across_builds() { eq_chunks(&a.shifts, &b.shifts, "SHIFT"); eq_chunks(&a.loads, &b.loads, "LOAD"); } + +/// `build_chunk(kind, i)` must equal `build_table(kind)[i]`, byte for byte. +/// +/// This is the equality the streaming prover rests on: Round 1 commits the +/// table built one way, and the fused chain rebuilds the chunk it needs the +/// other way. If they ever diverge, the rebuilt trace hashes to a root the +/// verifier will not accept. +#[test] +fn build_chunk_matches_the_full_table_build() { + use crate::tables::trace_builder::{RoutedOps, TableKind}; + + // More ops than the chunk limit below, so several chunks exist and the + // last one is short. + let lt_ops: Vec<_> = (0..10u64) + .map(|i| crate::tables::lt::LtOperation::new(i, i * 7 + 1, false)) + .collect(); + let routed = RoutedOps { + lt_ops, + ..Default::default() + }; + + let max_rows = crate::tables::MaxRowsConfig { + lt: 4, + ..Default::default() + }; + + let whole = routed + .build_table( + TableKind::Lt, + &max_rows, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("full build"); + assert_eq!( + whole.len(), + routed.num_chunks(TableKind::Lt, &max_rows), + "num_chunks disagrees with what build_table produced" + ); + + for (i, expected) in whole.iter().enumerate() { + let one = routed.build_chunk(TableKind::Lt, i, &max_rows); + let (a, _) = expected.main_data_row_major(); + let (b, _) = one.main_data_row_major(); + assert_eq!( + a.iter().map(|fe| *fe.value()).collect::>(), + b.iter().map(|fe| *fe.value()).collect::>(), + "chunk {i}: on-demand build differs from the full build" + ); + } +} + +/// `chunk_shape` must agree with the chunk it declines to build, for every kind. +/// +/// It reads the shape off op counts and a constant width instead of generating +/// a trace, which is only valid while every generator pads to +/// `count.next_power_of_two().max(4)`. This is the test that fails if one of +/// them ever stops. +#[test] +fn chunk_shape_matches_the_built_chunk() { + use crate::tables::trace_builder::{RoutedOps, TableKind}; + + // Ops with deliberate repeats, so the deduplicating kinds and the plain ones + // disagree on count and the distinction is actually exercised. + // 8 ops, 3 distinct: the deduplicating rule pads to 4 rows where counting + // them raw would pad to 8. Without that gap the test would pass even if + // `chunk_shape` ignored deduplication entirely. + let lt_ops: Vec<_> = (0..8u64) + .map(|i| crate::tables::lt::LtOperation::new(i % 3, i % 3 + 1, false)) + .collect(); + let routed = RoutedOps { + lt_ops, + ..Default::default() + }; + + let max_rows = crate::tables::MaxRowsConfig { + lt: 16, + ..Default::default() + }; + + assert_eq!( + routed.chunk_shape(TableKind::Lt, 0, &max_rows).0, + 4, + "the LT fixture must exercise deduplication (8 ops, 3 distinct)" + ); + + for kind in [ + TableKind::Cpu, + TableKind::Memw, + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Load, + TableKind::Lt, + TableKind::Shift, + TableKind::Mul, + TableKind::Dvrm, + TableKind::Branch, + TableKind::Eq, + TableKind::Bytewise, + TableKind::Store, + TableKind::Cpu32, + ] { + for chunk in 0..routed.num_chunks(kind, &max_rows) { + let built = routed.build_chunk(kind, chunk, &max_rows); + assert_eq!( + routed.chunk_shape(kind, chunk, &max_rows), + (built.num_rows(), built.num_main_columns), + "{kind:?} chunk {chunk}: declared shape differs from the built one" + ); + } + } +} From 2ce57c4bd059e87be774585efdc76f2a297639d4 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 18:22:18 -0300 Subject: [PATCH 06/63] Count what the memory modes actually cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retire modes were described by design, not measured: "one extra expansion per table", "two trace builds per retired chunk". Both happened to be true, and neither was checkable — which is how a third trace build per chunk sat in the shape lookup until it was reasoned about rather than read off a counter. Add three counters under the instruments feature — main LDE expansions, retired trace builds, and shapes answered without building one — and a RESIDENCY block in the prover report. They print on the resident path too, where the expansions are the per-table floor and the retired counters are zero; that zero is itself the thing worth seeing. On fib_iterative_1M: resident is 28 expansions / 0 / 0, retired is 56 / 32 / 16. The expansions double exactly, as one barrier predicts; the 32 builds over 16 retired chunks are the two the design claims, where deriving the shape instead of building it took them from three. --- crypto/stark/src/instruments.rs | 40 +++++++++++++++++++++++++++++++++ crypto/stark/src/prover.rs | 4 ++++ prover/src/instruments.rs | 13 +++++++++++ prover/src/streaming.rs | 4 ++++ 4 files changed, 61 insertions(+) diff --git a/crypto/stark/src/instruments.rs b/crypto/stark/src/instruments.rs index 0f68059f4..e756dd16f 100644 --- a/crypto/stark/src/instruments.rs +++ b/crypto/stark/src/instruments.rs @@ -290,6 +290,43 @@ pub struct MultiProveTiming { pub heap_snapshots: Vec, } +/// Residency accounting for the retire-LDE / retire-traces modes. +/// +/// The honest budget, not an estimate: how many times a main LDE was actually +/// materialized, how many retired-chunk traces were actually built, and how +/// many shapes were answered without building one. A mode that trades time for +/// memory has to be able to say what the trade cost, or the next change to it +/// is guesswork. +static MAIN_LDE_EXPANSIONS: AtomicU64 = AtomicU64::new(0); +static RETIRED_TRACE_BUILDS: AtomicU64 = AtomicU64::new(0); +static RETIRED_SHAPE_QUERIES: AtomicU64 = AtomicU64::new(0); + +/// A main LDE was materialized from a trace — the Round 1 commit, or a rebuild +/// after it was retired. One per table is the floor; anything above it is what +/// the barriers cost. +pub fn count_main_lde_expansion() { + MAIN_LDE_EXPANSIONS.fetch_add(1, Ordering::Relaxed); +} + +/// A retired chunk's trace was built from its routed ops. +pub fn count_retired_trace_build() { + RETIRED_TRACE_BUILDS.fetch_add(1, Ordering::Relaxed); +} + +/// A retired chunk's shape was derived from op counts, with no trace built. +pub fn count_retired_shape_query() { + RETIRED_SHAPE_QUERIES.fetch_add(1, Ordering::Relaxed); +} + +/// `(main LDE expansions, retired trace builds, shapes answered build-free)`. +pub fn residency_counts() -> (u64, u64, u64) { + ( + MAIN_LDE_EXPANSIONS.load(Ordering::Relaxed), + RETIRED_TRACE_BUILDS.load(Ordering::Relaxed), + RETIRED_SHAPE_QUERIES.load(Ordering::Relaxed), + ) +} + /// Round 1 sub-timings: atomics so parallel rayon workers can accumulate safely. static R1_MAIN_LDE_US: AtomicU64 = AtomicU64::new(0); static R1_MAIN_MERKLE_US: AtomicU64 = AtomicU64::new(0); @@ -365,6 +402,9 @@ pub fn take_r1_sub() -> Round1SubOps { /// In practice this is safe because store/take pairs always execute within the /// same rayon task closure. pub fn reset_all() { + MAIN_LDE_EXPANSIONS.store(0, Ordering::Relaxed); + RETIRED_TRACE_BUILDS.store(0, Ordering::Relaxed); + RETIRED_SHAPE_QUERIES.store(0, Ordering::Relaxed); R1_MAIN_LDE_US.store(0, Ordering::Relaxed); R1_MAIN_MERKLE_US.store(0, Ordering::Relaxed); R1_AUX_LDE_US.store(0, Ordering::Relaxed); diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 2c0123a93..1642b4a81 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1432,6 +1432,8 @@ pub trait IsStarkProver< &twiddles.two_half_fwd, ) .expect("row-major coset LDE expansion"); + #[cfg(feature = "instruments")] + crate::instruments::count_main_lde_expansion(); #[cfg(feature = "instruments")] let main_lde_dur = t_sub.elapsed(); @@ -1565,6 +1567,8 @@ pub trait IsStarkProver< &twiddles.two_half_fwd, ) .expect("row-major coset LDE expansion"); + #[cfg(feature = "instruments")] + crate::instruments::count_main_lde_expansion(); (main_data, total_cols) } diff --git a/prover/src/instruments.rs b/prover/src/instruments.rs index f15a8a824..7f338bcb7 100644 --- a/prover/src/instruments.rs +++ b/prover/src/instruments.rs @@ -279,4 +279,17 @@ pub fn print_report( eprintln!(" {}", "─".repeat(56)); eprintln!(); } + + // What the memory modes actually cost, counted rather than assumed. Printed + // always: on the resident path the expansions are the per-table floor and + // the retired counters are zero, which is itself the thing to check. + let (expansions, trace_builds, shape_queries) = stark::instruments::residency_counts(); + eprintln!("=== RESIDENCY ==="); + eprintln!(" {:<36} {:>8}", "Main LDE expansions", expansions); + eprintln!(" {:<36} {:>8}", "Retired trace builds", trace_builds); + eprintln!( + " {:<36} {:>8}", + "Shapes answered without building", shape_queries + ); + eprintln!(); } diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index 544534c0c..9d2ce0967 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -105,6 +105,8 @@ impl StreamingProvider { } let (kind, chunk) = self.slot(idx).expect("shape asked for a resident table"); let shape = self.routed.chunk_shape(kind, chunk, &self.max_rows); + #[cfg(feature = "instruments")] + stark::instruments::count_retired_shape_query(); self.shapes.lock().unwrap().insert(idx, shape); shape } @@ -125,6 +127,8 @@ impl TraceProvider for StreamingProvider { fn build_main(&self, idx: usize) -> TraceTable { let (kind, chunk) = self.slot(idx).expect("build asked for a resident table"); + #[cfg(feature = "instruments")] + stark::instruments::count_retired_trace_build(); self.routed.build_chunk(kind, chunk, &self.max_rows) } } From 07453693524b03371a77f2b3b7217c1660f66427 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 19:07:39 -0300 Subject: [PATCH 07/63] Let the executor resume from a saved VM state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1 rests on being able to drop what the prover built and get exactly it back. Whatever is regenerated — a trace, a Merkle leaf, an op list — is a function of the execution, so regeneration ultimately means re-execution, and re-executing from cycle zero every time is not a mechanism, it is a threat. `Executor::snapshot` captures the mutable state (memory, registers, pc) and `from_snapshot` rebuilds an executor sitting exactly there, so the cost of regeneration is bounded by the distance to the previous checkpoint. The instruction cache is rebuilt from the ELF rather than carried; replay is deterministic because the private inputs are already in memory before the first cycle, so nothing nondeterministic enters after the snapshot. `snapshot_resume_produces_identical_logs` builds a program by hand — 100_005 ADDI then a JALR to 0 — runs it straight, then runs one chunk, snapshots, and resumes from the snapshot: the concatenated logs must equal the straight run's. The instruction count is over 100_000 on purpose so the cut lands mid-execution rather than where the chunking makes it easy. Advancing the restored pc by one instruction makes it fail. Nothing in the prover calls this yet: the phases that would (a LogUp pass and an opening pass over an execution proved under a single challenge) are not built. This is the capability they need, with its property pinned. Port of Approach 1 step B from the closed PR #647, which built it and left it unused. --- executor/src/tests/checkpoint_tests.rs | 71 ++++++++++++++++++++++++++ executor/src/tests/mod.rs | 1 + executor/src/vm/execution.rs | 45 ++++++++++++++++ executor/src/vm/logs.rs | 2 +- 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 executor/src/tests/checkpoint_tests.rs diff --git a/executor/src/tests/checkpoint_tests.rs b/executor/src/tests/checkpoint_tests.rs new file mode 100644 index 000000000..e0a6f519e --- /dev/null +++ b/executor/src/tests/checkpoint_tests.rs @@ -0,0 +1,71 @@ +//! Executor checkpoints: snapshot the VM mid-execution, rebuild an `Executor` +//! from it, and resume — the concatenated logs must equal a straight run's. +//! +//! That equality is the property everything built on re-execution rests on: a +//! prover may drop what it produced only if it can get exactly that back. +//! +//! The program is built by hand rather than loaded from an ELF fixture, so the +//! test is hermetic: 100_005 `ADDI x5, x5, 1` followed by `JALR x0, 0(x0)`, +//! which jumps to address 0 and halts. No syscalls, and the instruction count +//! is over 100_000 on purpose — the snapshot then lands mid-execution, across a +//! `resume()` chunk boundary, instead of at a point the chunking makes easy. + +use crate::elf::{Elf, Segment}; +use crate::vm::execution::Executor; + +const ADDI_X5_X5_1: u32 = 0x0012_8293; // addi x5, x5, 1 +const JALR_X0_0_X0: u32 = 0x0000_0067; // jalr x0, 0(x0) -> pc = 0 -> halt +const N_ADDI: usize = 100_005; +const BASE: u64 = 0x1000; + +fn long_program() -> Elf { + let mut values = vec![ADDI_X5_X5_1; N_ADDI]; + values.push(JALR_X0_0_X0); + Elf { + entry_point: BASE, + data: vec![Segment { + base_addr: BASE, + values, + is_executable: true, + }], + } +} + +#[test] +fn snapshot_resume_produces_identical_logs() { + let elf = long_program(); + + let full = Executor::new(&elf, vec![]).unwrap().run().unwrap().logs; + assert_eq!(full.len(), N_ADDI + 1, "every instruction should log once"); + assert!( + full.len() > 100_000, + "must span more than one resume() chunk" + ); + + // One chunk, then snapshot: the cut lands mid-execution. + let mut exec = Executor::new(&elf, vec![]).unwrap(); + let mut logs = Vec::new(); + { + let chunk0 = exec.resume().unwrap().expect("at least one chunk"); + logs.extend_from_slice(chunk0); + } + assert!( + logs.len() < full.len(), + "the snapshot must be taken before the program ends (got {} of {})", + logs.len(), + full.len() + ); + + let snapshot = exec.snapshot(); + let mut resumed = Executor::from_snapshot(&elf, snapshot).expect("recreate from snapshot"); + while let Some(chunk) = resumed.resume().unwrap() { + logs.extend_from_slice(chunk); + } + + assert_eq!( + logs.len(), + full.len(), + "log count differs after snapshot + resume" + ); + assert_eq!(logs, full, "resumed logs must equal the straight run's"); +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 244447b22..3dbb161cf 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,3 +1,4 @@ +pub mod checkpoint_tests; pub mod ecsm_tests; pub mod flamegraph_tests; pub mod hint_tests; diff --git a/executor/src/vm/execution.rs b/executor/src/vm/execution.rs index dc0660178..27a6c9788 100644 --- a/executor/src/vm/execution.rs +++ b/executor/src/vm/execution.rs @@ -41,6 +41,24 @@ pub struct EpochExecution { pub end_memory: Memory, } +/// The mutable VM state at a cycle boundary: enough, with the program ELF, to +/// recreate an [`Executor`] that resumes byte-identically. +/// +/// The instruction cache is rebuilt from the ELF rather than stored. Replay is +/// deterministic because every nondeterministic input (the private inputs) is +/// already loaded into `memory` before the first cycle, so a resumed run +/// produces the same logs the original would have. +/// +/// This is what lets a prover drop what it built and get it back: re-execution +/// from a checkpoint is bounded by the distance to the next boundary instead of +/// restarting at cycle zero. +#[derive(Clone)] +pub struct VmSnapshot { + memory: Memory, + registers: Registers, + pc: u64, +} + /// Executor state for chunked execution pub struct Executor { memory: Memory, @@ -66,6 +84,33 @@ impl Executor { }) } + /// Capture the VM state as a [`VmSnapshot`]. Cheap except for the memory + /// clone, which copies the touched-cell map. + pub fn snapshot(&self) -> VmSnapshot { + VmSnapshot { + memory: self.memory.clone(), + registers: self.registers.clone(), + pc: self.pc, + } + } + + /// Recreate an `Executor` sitting exactly where `snapshot` was taken. + /// + /// `program` must be the ELF the snapshot was taken under: it rebuilds the + /// instruction cache. The program image is NOT reloaded — the snapshot's + /// memory already carries it, along with everything execution has written + /// since. + pub fn from_snapshot(program: &Elf, snapshot: VmSnapshot) -> Result { + let instructions = InstructionCache::new(&program.data)?; + Ok(Self { + memory: snapshot.memory, + registers: snapshot.registers, + pc: snapshot.pc, + instructions, + logs: Vec::with_capacity(CHUNK_SIZE), + }) + } + /// Resume execution and return next logs. Returns None when program is finished. pub fn resume(&mut self) -> Result, ExecutorError> { self.resume_with_limit(CHUNK_SIZE) diff --git a/executor/src/vm/logs.rs b/executor/src/vm/logs.rs index de6b73d0b..a5aa426b0 100644 --- a/executor/src/vm/logs.rs +++ b/executor/src/vm/logs.rs @@ -11,7 +11,7 @@ /// - `src1_val` = syscall number (from x17): 64=Commit, 93=Halt, etc. /// - `src2_val` = buf_addr (x11) for Commit, 0 otherwise /// - `dst_val` = count (x12) for Commit, 0 otherwise -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Log { /// PC before instruction execution (use this to look up the instruction) pub current_pc: u64, From b0066bc5fa3b19051ba00993d66862a0a0a5b75d Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 19:09:58 -0300 Subject: [PATCH 08/63] Take HALT's presence from the AIR set The streaming provider maps an AIR index to the chunk that rebuilds it by walking the same order `VmAirs::air_trace_pairs` emits, and HALT sits between the fixed tables and the chunked groups. But `air_trace_pairs` only pushes it when `include_halt` holds, and the provider assumed it always did. That is true on the path wired today, which proves a single final epoch, and false for the intermediate epochs of a continuation. The failure it would cause is the bad kind: every slot shifts by one and each table is handed its neighbour's trace, which is not a crash but a wrong commitment. Take `include_halt` as an argument, from the caller's `VmAirs`, and pass the `true` this path genuinely has rather than leaving the assumption implicit for whoever wires the continuation path next. --- prover/src/lib.rs | 5 ++++- prover/src/streaming.rs | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index d1705e012..2c08de49f 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1178,7 +1178,10 @@ pub fn prove_with_options_and_inputs( #[cfg(feature = "disk-spill")] storage_mode, )?; - let provider = streaming::StreamingProvider::new(routed, max_rows.clone(), &traces); + // This path always proves a single, final epoch, so HALT is present — + // passed explicitly rather than assumed, because the slot map is only + // correct if it agrees with `VmAirs::air_trace_pairs`. + let provider = streaming::StreamingProvider::new(routed, max_rows.clone(), &traces, true); (traces, Some(provider)) } else { let traces = Traces::from_elf_and_logs( diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index 9d2ce0967..985751f64 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -53,7 +53,17 @@ pub(crate) struct StreamingProvider { impl StreamingProvider { /// Walk the AIR order and record which index each retired chunk answers to. - pub(crate) fn new(routed: RoutedOps, max_rows: MaxRowsConfig, traces: &Traces) -> Self { + /// + /// `include_halt` is not a detail: HALT sits between the fixed tables and + /// the chunked groups and is emitted only for a final epoch, so getting it + /// wrong shifts every slot by one and hands each table the trace of its + /// neighbour. It is taken from the caller's `VmAirs` rather than assumed. + pub(crate) fn new( + routed: RoutedOps, + max_rows: MaxRowsConfig, + traces: &Traces, + include_halt: bool, + ) -> Self { let group_lengths = [ traces.cpus.len(), traces.lts.len(), @@ -72,9 +82,7 @@ impl StreamingProvider { traces.cpu32s.len(), ]; - // HALT sits between the fixed tables and the groups, and is only emitted - // for a final epoch — which is the only kind this path proves. - let mut slots = vec![None; NUM_FIXED_AIRS + 1]; + let mut slots = vec![None; NUM_FIXED_AIRS + usize::from(include_halt)]; for (kind, len) in GROUP_ORDER.iter().zip(group_lengths.iter()) { for chunk in 0..*len { slots.push(kind.map(|k| (k, chunk))); From 3db72b702f1f0723e160bb344a9ecdbbddfff9ea Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 21:43:16 -0300 Subject: [PATCH 09/63] Walk the execution instead of being handed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prover took every log of the run at once and built from the slice. Approach 1's Commit phase cannot start that way: it advances through the execution and retires what it has finished with, so it has to consume the run rather than receive it. `collect_epoch_streaming` drives the executor and folds one chunk of logs at a time into the same op lists, carrying `MemoryState` and `RegisterState` across chunks. It comes out identical because phases 1-3 are segment-local once that state is carried: the LT ops a memory access implies are built from the timestamps the access already holds, not from an ordering over the whole run. Identical except for one thing, which the test caught: a cycle's timestamp is `index * 4 + 4` over the slice it was collected in, so collecting in pieces restarted time at every chunk. `collect_cpu_ops` now takes the index of its first log within the execution, and `collect_streaming_matches_collect_epoch` compares the built traces of both paths table by table. Under the retire flag the prove path drops the run's logs before building, keeping only the public output it still needs to check, and the collector re-executes. Measured, that trade is currently a loss: peak heap 23773 -> 24779 MB at 16M cycles and 45026 -> 47005 MB at 32M, +4% on both, with +2.4% prove time. The logs were never the peak — they were already freed before the phase that peaks — and the executor now lives through the collection holding its memory image, which is the larger object. Kept anyway because it is the shape the Commit phase needs: a prover that advances through an execution can retire what it has finished with, and one handed a finished log cannot. If that phase does not land, this should go back. Also snapshots the heap once the fused chain is done, which is what showed that the ~33 GB between the commits and the reported peak at 32M cycles is transient: 11.2 GB is still held there against a 47.2 GB peak. Read with the fact that TABLE_PARALLELISM=1 barely moves that peak, it says the number is the allocator's high-water mark rather than simultaneous residency. The peak-heap line now also says when the mark was set. `stats.allocated` is live bytes, so the number was always real residency — what it could not say is where it happened, and a peak inside one table's work and a peak spread across the run call for opposite fixes. On the real ethrex block the mark lands at 26% of the run in both the continuation and the monolithic paths, which places it inside one table's work rather than in anything accumulating toward the end. --- bin/cli/src/main.rs | 56 +++++++++--- crypto/stark/src/prover.rs | 7 ++ prover/src/lib.rs | 13 ++- prover/src/tables/trace_builder.rs | 117 ++++++++++++++++++++++-- prover/src/tests/trace_builder_tests.rs | 78 ++++++++++++++++ 5 files changed, 249 insertions(+), 22 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index a04e920db..61fc4f410 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -37,6 +37,14 @@ fn read_aligned_file(path: &Path) -> std::io::Result> /// Polls jemalloc `stats.allocated` every 10ms from a background thread, /// tracking the high-water mark. Near-zero overhead because jemalloc uses /// thread-local caches — `epoch::advance()` just merges cached counters. +/// +/// `stats.allocated` is live bytes, not resident pages, so the mark is real +/// simultaneous residency rather than an allocator watermark that freed memory +/// keeps propping up. What it does not say on its own is *when* the mark was +/// set, which is what makes a peak actionable — a peak inside one table's work +/// and a peak spread across every table call for opposite fixes. The tracker +/// therefore also records how far into the run the mark was set, to be read +/// against the phase timeline. #[cfg(feature = "jemalloc-stats")] mod heap_tracker { use std::sync::Arc; @@ -49,6 +57,8 @@ mod heap_tracker { pub struct HeapTracker { stop: Arc, peak: Arc, + /// Milliseconds from `start()` to the sample that set `peak`. + peak_at_ms: Arc, handle: Option>, } @@ -56,35 +66,47 @@ mod heap_tracker { pub fn start() -> Self { let stop = Arc::new(AtomicBool::new(false)); let peak = Arc::new(AtomicUsize::new(0)); + let peak_at_ms = Arc::new(AtomicUsize::new(0)); let stop_clone = stop.clone(); let peak_clone = peak.clone(); + let peak_at_clone = peak_at_ms.clone(); + let started = std::time::Instant::now(); let handle = thread::spawn(move || { - while !stop_clone.load(Ordering::Relaxed) { - // Refresh jemalloc's cached stats + // Records the elapsed time of the sample that raised the mark, + // so a peak can be placed against the phase timeline instead of + // being a number with no location. + let mut sample = |peak: &AtomicUsize, at: &AtomicUsize| { epoch::advance().ok(); - if let Ok(allocated) = stats::allocated::read() { - peak_clone.fetch_max(allocated, Ordering::Relaxed); + if let Ok(allocated) = stats::allocated::read() + && allocated > peak.fetch_max(allocated, Ordering::Relaxed) + { + at.store(started.elapsed().as_millis() as usize, Ordering::Relaxed); } + }; + while !stop_clone.load(Ordering::Relaxed) { + sample(&peak_clone, &peak_at_clone); thread::sleep(Duration::from_millis(10)); } // One final sample after stop signal - epoch::advance().ok(); - if let Ok(allocated) = stats::allocated::read() { - peak_clone.fetch_max(allocated, Ordering::Relaxed); - } + sample(&peak_clone, &peak_at_clone); }); Self { stop, peak, + peak_at_ms, handle: Some(handle), } } - pub fn stop(mut self) -> usize { + /// `(peak bytes, milliseconds into the run when it was set)`. + pub fn stop(mut self) -> (usize, usize) { self.shutdown(); - self.peak.load(Ordering::Relaxed) + ( + self.peak.load(Ordering::Relaxed), + self.peak_at_ms.load(Ordering::Relaxed), + ) } fn shutdown(&mut self) { @@ -669,7 +691,12 @@ fn cmd_prove( #[cfg(feature = "jemalloc-stats")] { let peak_bytes = tracker.stop(); - println!("Peak heap: {} MB", peak_bytes / (1024 * 1024)); + let (peak_bytes, peak_at_ms) = peak_bytes; + println!( + "Peak heap: {} MB (at {:.1}s)", + peak_bytes / (1024 * 1024), + peak_at_ms as f64 / 1000.0 + ); } ExitCode::SUCCESS } @@ -843,7 +870,12 @@ fn cmd_prove_continuation( #[cfg(feature = "jemalloc-stats")] { let peak_bytes = tracker.stop(); - println!("Peak heap: {} MB", peak_bytes / (1024 * 1024)); + let (peak_bytes, peak_at_ms) = peak_bytes; + println!( + "Peak heap: {} MB (at {:.1}s)", + peak_bytes / (1024 * 1024), + peak_at_ms as f64 / 1000.0 + ); } ExitCode::SUCCESS } diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 1642b4a81..7730fa733 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -4334,6 +4334,13 @@ pub trait IsStarkProver< for result in table_results { proofs.push(result.expect("run_admitted fills every slot")?); } + // Every table is proved and its transients are gone, so whatever is + // still held here is retained, not in flight. Read against the peak, + // this says how much of the peak a residency mode could ever reach. + #[cfg(feature = "instruments")] + if let Some(s) = crate::instruments::snap("After rounds 2-4") { + heap_snaps.push(s); + } #[cfg(feature = "instruments")] drop(__sp); #[cfg(feature = "instruments")] diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 2c08de49f..9c77e09af 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -1169,10 +1169,17 @@ pub fn prove_with_options_and_inputs( // down the same ladder. The chunked tables come back as placeholders and the // provider rebuilds each chunk at the two points the prover needs it. let retire_traces = stark::prover::streaming_retire_lde(); + // The public output is all this path still needs from the run above; keeping + // it lets the logs go before the build, which is the phase that peaks. + let executor_output = result.return_values.memory_values.clone(); let (mut traces, streaming) = if retire_traces { + // The collector walks the execution itself, one chunk of logs at a time, + // so this run's logs are dead weight from here on. Freeing them costs a + // second execution and is the shape the Commit phase needs: a prover + // that walks an execution instead of being handed it whole. + drop(result); let (traces, routed) = Traces::from_elf_and_logs_streaming( &program, - &result.logs, max_rows, private_inputs, #[cfg(feature = "disk-spill")] @@ -1192,13 +1199,13 @@ pub fn prove_with_options_and_inputs( #[cfg(feature = "disk-spill")] storage_mode, )?; + drop(result); (traces, None) }; debug_assert_eq!( - traces.public_output_bytes, result.return_values.memory_values, + traces.public_output_bytes, executor_output, "public output diverged between executor view and trace reconstruction" ); - drop(result); #[cfg(feature = "instruments")] drop(__sp); diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index e50dbb3fd..edac31e9f 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -336,6 +336,11 @@ fn pack_register_value(value: u64) -> [u32; 8] { fn collect_cpu_ops( logs: &[Log], instructions: &U64HashMap, + // Index of `logs[0]` within the whole execution. Zero when the caller holds + // every log; the running count when it is walking the execution in pieces, + // since the timestamp comes from the cycle's position and restarting it per + // piece would silently rewind time. + first_cycle: usize, ) -> Result, Error> { let mut cpu_ops = Vec::with_capacity(logs.len()); @@ -345,7 +350,7 @@ fn collect_cpu_ops( // Exactly 4 so that inline PC's prev_ts = timestamp - 3 = 1 on the first row, // matching the REGISTER table's initial PC token at timestamp 1 (per spec/memory.typ). for (i, log) in logs.iter().enumerate() { - let timestamp = (i as u64) * 4 + 4; + let timestamp = ((first_cycle + i) as u64) * 4 + 4; let instruction = instructions .get(&log.current_pc) .copied() @@ -409,6 +414,15 @@ struct MemwBuckets { } impl MemwBuckets { + /// Append another segment's buckets. Order is preserved, so collecting an + /// execution in pieces and appending them yields exactly what collecting it + /// whole would have. + fn append(&mut self, mut other: Self) { + self.register_rows.append(&mut other.register_rows); + self.aligned.append(&mut other.aligned); + self.general.append(&mut other.general); + } + fn with_register_capacity(n: usize) -> Self { Self { register_rows: Vec::with_capacity(n), @@ -2161,7 +2175,7 @@ pub(crate) fn epoch_touched_cells( ) -> Result, Error> { let instructions = decode::instructions_from_elf(elf) .map_err(|e| Error::Execution(format!("Failed to parse instructions: {e}")))?; - let cpu_ops = collect_cpu_ops(logs, &instructions)?; + let cpu_ops = collect_cpu_ops(logs, &instructions, 0)?; let mut memory_state = MemoryState::from_image(initial_image); let mut register_state = RegisterState::from_init(register_init); @@ -4792,7 +4806,6 @@ impl Traces { /// any of them on demand. pub(crate) fn from_elf_and_logs_streaming( elf: &Elf, - logs: &[Log], max_rows: &super::MaxRowsConfig, private_input: &[u8], #[cfg(feature = "disk-spill")] storage_mode: StorageMode, @@ -4800,8 +4813,15 @@ impl Traces { let initial_image = build_initial_image(elf, private_input); let register_init = register::register_init_from_entry_point(elf.entry_point); let artifacts = DecodeArtifacts::from_elf(elf)?; - let collected = - Self::collect_epoch(&artifacts, &initial_image, ®ister_init, logs, true)?; + // Drives its own executor: no caller holds the logs for it. + let collected = Self::collect_epoch_streaming( + &artifacts, + elf, + private_input.to_vec(), + &initial_image, + ®ister_init, + true, + )?; Self::build_from_collected_streaming( &artifacts, collected, @@ -4895,6 +4915,89 @@ impl Traces { /// order (the image advances between epochs); the table generation that /// consumes the result ([`Self::build_from_collected`]) is epoch-local and /// can run on another thread. + /// `collect_epoch`, driving the executor itself and consuming its logs one + /// chunk at a time instead of taking them all at once. + /// + /// Approach 1's Commit phase walks the execution and retires what it has + /// finished with; it cannot start by materializing every log. Phases 1-3 are + /// segment-local given the carried state — `MemoryState` and `RegisterState` + /// thread through, and the LT ops a MEMW access implies come from the + /// timestamps that access already carries, not from a global ordering — so + /// the same ops come out in the same order, and only one chunk of logs is + /// ever resident. + /// + /// `collect_streaming_matches_collect_epoch` pins that equality. + pub(crate) fn collect_epoch_streaming( + artifacts: &DecodeArtifacts, + elf: &Elf, + private_input: Vec, + initial_image: &I, + register_init: &[u32], + is_final: bool, + ) -> Result { + let mut executor = executor::vm::execution::Executor::new(elf, private_input) + .map_err(|e| Error::Prover(format!("executor: {e}")))?; + + let mut memory_state = MemoryState::from_image(initial_image); + let mut register_state = RegisterState::from_init(register_init); + + let mut cpu_ops: Vec = Vec::new(); + let mut memw = MemwBuckets::with_register_capacity(0); + let (mut load_ops, mut lt_ops, mut shift_ops, mut bitwise_ops) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut commit_ops, mut keccak_ops, mut cpu32_ops) = (Vec::new(), Vec::new(), Vec::new()); + let (mut ecsm_ops, mut ecdas_ops, mut hint_ops) = (Vec::new(), Vec::new(), Vec::new()); + + let mut cycles_so_far = 0usize; + while let Some(chunk) = executor + .resume() + .map_err(|e| Error::Prover(format!("executor: {e}")))? + { + if !is_final && chunk.iter().any(|log| log.next_pc == 0) { + return Err(Error::HaltInNonFinalEpoch); + } + let chunk_cpu = collect_cpu_ops(chunk, &artifacts.instructions, cycles_so_far)?; + let (m, ld, lt, sh, bw, cm, kc, c32, ec, ed, hn) = + collect_ops_from_cpu(&chunk_cpu, &mut memory_state, &mut register_state); + memw.append(m); + load_ops.extend(ld); + lt_ops.extend(lt); + shift_ops.extend(sh); + bitwise_ops.extend(bw); + commit_ops.extend(cm); + keccak_ops.extend(kc); + cpu32_ops.extend(c32); + ecsm_ops.extend(ec); + ecdas_ops.extend(ed); + hint_ops.extend(hn); + cycles_so_far += chunk_cpu.len(); + cpu_ops.extend(chunk_cpu); + } + + let ops = collect_all_ops( + cpu_ops, + memw, + load_ops, + lt_ops, + shift_ops, + bitwise_ops, + commit_ops, + keccak_ops, + cpu32_ops, + ecsm_ops, + ecdas_ops, + hint_ops, + &mut register_state, + is_final, + ); + + Ok(CollectedEpoch { + ops, + memory_state, + register_state, + }) + } + pub fn collect_epoch( artifacts: &DecodeArtifacts, initial_image: &I, @@ -4913,7 +5016,7 @@ impl Traces { // Phase 1: Logs → CPU operations #[cfg(feature = "instruments")] let __sp = stark::instruments::span("p1_cpu_ops"); - let cpu_ops = collect_cpu_ops(logs, &artifacts.instructions)?; + let cpu_ops = collect_cpu_ops(logs, &artifacts.instructions, 0)?; #[cfg(feature = "instruments")] drop(__sp); @@ -5088,7 +5191,7 @@ impl Traces { max_rows: &super::MaxRowsConfig, ) -> Result { // Phase 1: Logs → CPU operations - let cpu_ops = collect_cpu_ops(logs, &instructions)?; + let cpu_ops = collect_cpu_ops(logs, &instructions, 0)?; // Phase 2: Collect + route all ops let mut memory_state = MemoryState::new(); diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index b220a89f7..c2a7f0b08 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1319,3 +1319,81 @@ fn chunk_shape_matches_the_built_chunk() { } } } + +/// Collecting an execution chunk by chunk must produce exactly what collecting +/// it all at once produces. +/// +/// This is what lets the prover walk an execution instead of starting from a +/// materialized log of the whole thing. It holds because phases 1-3 are +/// segment-local once `MemoryState` and `RegisterState` are carried: the LT ops +/// a memory access implies come from the timestamps that access already +/// carries, not from an ordering over the whole run. +/// +/// Compared through the built traces rather than the op lists, since that is +/// what the commitment is taken over. +#[test] +fn collect_streaming_matches_collect_epoch() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + let max_rows = crate::tables::MaxRowsConfig::default(); + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let at_once = + T::collect_epoch(&artifacts, &image, ®ister_init, &logs, true).expect("collect at once"); + let streamed = + T::collect_epoch_streaming(&artifacts, &elf, vec![], &image, ®ister_init, true) + .expect("collect streaming"); + + let build = |collected| { + T::build_from_collected( + &artifacts, + collected, + Some(&image), + ®ister_init, + &max_rows, + &[], + true, + false, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("build") + }; + let a = build(at_once); + let b = build(streamed); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; + let same = |x: &[_], y: &[_], name: &str| { + assert_eq!(x.len(), y.len(), "{name}: chunk count differs"); + for (i, (p, q)) in x.iter().zip(y.iter()).enumerate() { + assert_eq!(flat(p), flat(q), "{name} chunk {i} differs"); + } + }; + same(&a.cpus, &b.cpus, "CPU"); + same(&a.memws, &b.memws, "MEMW"); + same(&a.lts, &b.lts, "LT"); + same(&a.loads, &b.loads, "LOAD"); + same(&a.shifts, &b.shifts, "SHIFT"); + same(&a.branches, &b.branches, "BRANCH"); + same(&a.memw_registers, &b.memw_registers, "MEMW_R"); + assert_eq!(flat(&a.bitwise), flat(&b.bitwise), "BITWISE differs"); + assert_eq!(flat(&a.register), flat(&b.register), "REGISTER differs"); +} From b9476c9ebb0c50710a162fc7ab364428542c0b96 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 21:49:53 -0300 Subject: [PATCH 10/63] Close and commit a table while the run continues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1's Commit phase turns on one question: can a table be closed and committed before the tables after it exist? Everything the phase claims — retiring as you go, memory that does not grow with the run — rests on the answer being yes, and on the commitment being the one the ordinary prover would have produced. `walk_and_emit_chunks` walks the execution and hands out each chunked table's chunk the moment it fills, dropping its ops right after, so its buffers never hold more than one chunk per table. `commit_table_root` commits a single table through the same row-major coset LDE and row-pair leaf layout Round 1 uses. Two tests carry the claim: the emitted chunks are byte-identical to the all-at-once build, and a chunk committed mid-walk carries the same root the resident chunk does. The tail is deliberately not emitted, and finding out why is the design result here. End-of-run finalization still appends to these op lists — the terminating ECALL's register writes land in MEMW — so a partial chunk is not final until the execution is over. A first version emitted tails too and the test caught it on MEMW_A. That is the spec's own split: full tables are committed and retired during the walk, and "at the end of the execution, the remaining tables are padded and committed". Only the tables whose ops leave `collect_ops_from_cpu` final are closed early. LT and MUL are not among them, since DVRM later appends range checks to one and a product to the other, and neither are the tables derived from the CPU ops afterwards. Those need their per-op derivations extracted before they can be closed mid-walk, which is the next step rather than a guess to make here. --- crypto/stark/src/prover.rs | 39 ++++ prover/src/tables/trace_builder.rs | 164 ++++++++++++++++- prover/src/tests/trace_builder_tests.rs | 229 ++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 1 deletion(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 7730fa733..e8f762ec4 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1651,6 +1651,45 @@ pub trait IsStarkProver< )) } + /// Commit one table and return its main-trace Merkle root. + /// + /// Approach 1's Commit phase closes a table mid-execution and commits it + /// right there, before the tables after it exist. This is that step alone: + /// the same row-major coset LDE and the same row-pair leaf layout Round 1 + /// uses, so a table committed during the walk carries the root Round 1 + /// would have given it. + /// + /// Only the root comes back. The tree is what the openings later read, and + /// a caller that just has to put a commitment in the transcript should not + /// pay to hold it. + fn commit_table_root( + air: &dyn AIR, + trace: &TraceTable, + ) -> Option + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let lde_size = domain.interpolation_domain_size * domain.blowup_factor; + let (data, cols) = trace.main_data_row_major(); + if cols == 0 || data.is_empty() { + return None; + } + let mut lde: Vec> = Vec::with_capacity(lde_size * cols); + lde.extend_from_slice(data); + Polynomial::>::coset_lde_full_expand_row_major::( + &mut lde, + cols, + domain.blowup_factor, + &twiddles.coset_weights, + &twiddles.two_half_inv, + &twiddles.two_half_fwd, + ) + .ok()?; + Self::commit_rows_bit_reversed::(&lde, cols).map(|(_, root)| root) + } + /// Reconstruct Round1 for every table, print the bus balance report, and /// validate each trace. Called once after every table's aux commit, which /// under `debug-checks` means between the fused chain's two admitted diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index edac31e9f..9aeb46b86 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2941,7 +2941,7 @@ struct CollectedOps { /// and PAGE are deliberately absent: they are not driven by one op list and the /// streaming prover keeps them resident. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum TableKind { +pub enum TableKind { Cpu, Memw, MemwAligned, @@ -2983,7 +2983,90 @@ pub(crate) struct RoutedOps { pub(crate) cpu32_ops: Vec, } +/// The tables this walk can close mid-execution: their ops come straight out of +/// `collect_ops_from_cpu` and nothing appends to them afterwards. +pub const CHUNKED_KINDS: [TableKind; 7] = [ + TableKind::Cpu, + TableKind::Memw, + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Load, + TableKind::Shift, + TableKind::Cpu32, +]; + +/// Chunk limit for one kind. +pub fn max_rows_for(kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { + match kind { + TableKind::Cpu => max_rows.cpu, + TableKind::Memw => max_rows.memw, + TableKind::MemwAligned => max_rows.memw_aligned, + TableKind::MemwRegister => max_rows.memw_register, + TableKind::Load => max_rows.load, + TableKind::Shift => max_rows.shift, + TableKind::Mul => max_rows.mul, + TableKind::Dvrm => max_rows.dvrm, + TableKind::Branch => max_rows.branch, + TableKind::Lt => max_rows.lt, + TableKind::Eq => max_rows.eq, + TableKind::Bytewise => max_rows.bytewise, + TableKind::Store => max_rows.store, + TableKind::Cpu32 => max_rows.cpu32, + } +} + impl RoutedOps { + /// Ops buffered for `kind` and not yet emitted. + fn buffered(&self, kind: TableKind) -> usize { + match kind { + TableKind::Cpu => self.cpu_ops.len(), + TableKind::Memw => self.memw_ops.len(), + TableKind::MemwAligned => self.memw_aligned_ops.len(), + TableKind::MemwRegister => self.memw_register_rows.len(), + TableKind::Load => self.load_ops.len(), + TableKind::Shift => self.shift_ops.len(), + TableKind::Cpu32 => self.cpu32_ops.len(), + _ => 0, + } + } + + /// Build a trace from the first `n` buffered ops of `kind` and drop them. + /// + /// Draining is the point: this is what keeps the walk's buffers from + /// growing with the run. + fn take_front( + &mut self, + kind: TableKind, + n: usize, + max_rows: &super::MaxRowsConfig, + ) -> TraceTable { + macro_rules! drain { + ($ops:expr, $f:path) => {{ + let front: Vec<_> = $ops.drain(..n).collect(); + $f(&front) + }}; + } + let _ = max_rows; + match kind { + TableKind::Cpu => drain!(self.cpu_ops, cpu::generate_cpu_trace), + TableKind::Memw => drain!(self.memw_ops, memw::generate_memw_trace), + TableKind::MemwAligned => { + drain!( + self.memw_aligned_ops, + memw_aligned::generate_memw_aligned_trace + ) + } + TableKind::MemwRegister => drain!( + self.memw_register_rows, + memw_register::generate_memw_register_trace_from_rows + ), + TableKind::Load => drain!(self.load_ops, load::generate_load_trace), + TableKind::Shift => drain!(self.shift_ops, shift::generate_shift_trace), + TableKind::Cpu32 => drain!(self.cpu32_ops, cpu32::generate_cpu32_trace), + other => unreachable!("{other:?} is not closed mid-walk"), + } + } + /// How many chunks `build_table` would produce for `kind`. /// /// Mirrors `chunk_and_generate`: an empty op list still yields one (padded) @@ -4915,6 +4998,85 @@ impl Traces { /// order (the image advances between epochs); the table generation that /// consumes the result ([`Self::build_from_collected`]) is epoch-local and /// can run on another thread. + /// Walk the execution and hand each chunked table's chunk to `on_chunk` as + /// soon as it is full, dropping its ops right after. + /// + /// This is Approach 1's Commit phase seen from the producer side: the spec + /// has the prover commit tables "once the memory pressure becomes too + /// large" and drop them, which it can only do if the tables arrive while + /// the execution is still being walked. Buffers here never exceed one + /// chunk per table, so what the walk holds does not grow with the run. + /// + /// The chunks come out exactly as `ops.chunks(max_rows)` would cut them and + /// each is built by the same generator, so a consumer sees byte-identical + /// traces to the all-at-once path — `commit_walk_emits_the_same_chunks` + /// pins that. + /// + /// Only the tables whose ops leave `collect_ops_from_cpu` final are emitted. + /// LT and MUL are not among them — later derivations append to both (DVRM + /// contributes range checks to LT and a product to MUL) — and neither are + /// the tables `collect_all_ops` derives from the CPU ops, nor the + /// accumulators (BITWISE), nor PAGE/DECODE/REGISTER, which are only final + /// once the run is over. Those are the spec's "remaining tables are padded + /// and committed" at the end; extracting the per-op derivations so they can + /// be emitted mid-walk too is the next step, not a guess to make here. + pub fn walk_and_emit_chunks( + artifacts: &DecodeArtifacts, + elf: &Elf, + private_input: Vec, + initial_image: &impl ImageSource, + register_init: &[u32], + max_rows: &super::MaxRowsConfig, + mut on_chunk: impl FnMut(TableKind, usize, TraceTable), + ) -> Result<(), Error> { + let mut executor = executor::vm::execution::Executor::new(elf, private_input) + .map_err(|e| Error::Prover(format!("executor: {e}")))?; + let mut memory_state = MemoryState::from_image(initial_image); + let mut register_state = RegisterState::from_init(register_init); + + let mut buf = RoutedOps::default(); + let mut emitted = [0usize; CHUNKED_KINDS.len()]; + let _ = &emitted; + let mut cycles_so_far = 0usize; + + while let Some(logs) = executor + .resume() + .map_err(|e| Error::Prover(format!("executor: {e}")))? + { + let cpu = collect_cpu_ops(logs, &artifacts.instructions, cycles_so_far)?; + cycles_so_far += cpu.len(); + let (memw, ld, lt, sh, _bw, _cm, _kc, c32, _ec, _ed, _hn) = + collect_ops_from_cpu(&cpu, &mut memory_state, &mut register_state); + buf.cpu_ops.extend(cpu); + buf.memw_register_rows.extend(memw.register_rows); + buf.memw_aligned_ops.extend(memw.aligned); + buf.memw_ops.extend(memw.general); + buf.load_ops.extend(ld); + buf.lt_ops.extend(lt); + buf.shift_ops.extend(sh); + buf.cpu32_ops.extend(c32); + + // Emit every chunk that is now full, and only those: a partial chunk + // may still grow, so it waits for the end. + for (slot, kind) in CHUNKED_KINDS.iter().enumerate() { + while buf.buffered(*kind) >= max_rows_for(*kind, max_rows) { + let limit = max_rows_for(*kind, max_rows); + let table = buf.take_front(*kind, limit, max_rows); + on_chunk(*kind, emitted[slot], table); + emitted[slot] += 1; + } + } + } + + // The tail is deliberately NOT emitted. End-of-run finalization still + // appends to these op lists — the terminating ECALL's register writes + // land in MEMW — so a partial chunk is not final until the execution + // is over. That is the spec's own split: full tables are committed and + // retired during the walk, and "at the end of the execution, the + // remaining tables are padded and committed". + Ok(()) + } + /// `collect_epoch`, driving the executor itself and consuming its logs one /// chunk at a time instead of taking them all at once. /// diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index c2a7f0b08..d3eede445 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1397,3 +1397,232 @@ fn collect_streaming_matches_collect_epoch() { assert_eq!(flat(&a.bitwise), flat(&b.bitwise), "BITWISE differs"); assert_eq!(flat(&a.register), flat(&b.register), "REGISTER differs"); } + +/// The Commit-phase walk must hand out exactly the chunks the all-at-once build +/// produces, in the same order. +/// +/// It is the same equality `build_chunk` rests on, one level up: a table closed +/// mid-execution and a table built from the finished op list have to be the +/// same table, or committing early means committing something else. +#[test] +fn commit_walk_emits_the_same_chunks() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{ + DecodeArtifacts, TableKind, Traces as T, build_initial_image, + }; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + // Small enough that the walk closes several chunks before the run ends, + // which is the case that matters — a single tail chunk would prove nothing. + let max_rows = crate::tables::MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 15, + load: 1 << 15, + shift: 1 << 15, + ..Default::default() + }; + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let collected = + T::collect_epoch(&artifacts, &image, ®ister_init, &logs, true).expect("collect"); + let expected = T::build_from_collected( + &artifacts, + collected, + Some(&image), + ®ister_init, + &max_rows, + &[], + true, + false, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("build"); + + let mut seen: Vec<(TableKind, usize, Vec)> = Vec::new(); + T::walk_and_emit_chunks( + &artifacts, + &elf, + vec![], + &image, + ®ister_init, + &max_rows, + |kind, chunk, table| { + let (data, _) = table.main_data_row_major(); + seen.push((kind, chunk, data.iter().map(|fe| *fe.value()).collect())); + }, + ) + .expect("walk"); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; + let check = |kind: TableKind, + want: &[stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >]| { + let got: Vec<_> = seen.iter().filter(|(k, _, _)| *k == kind).collect(); + // The walk emits only the chunks that filled up during the run; the + // partial tail waits for the end-of-run finalization, which still + // appends to these lists. So the emitted chunks are a prefix. + assert_eq!( + got.len(), + want.len().saturating_sub(1), + "{kind:?}: the walk should emit every chunk but the tail" + ); + for (i, (_, chunk, data)) in got.iter().enumerate() { + assert_eq!(*chunk, i, "{kind:?}: chunks arrived out of order"); + assert_eq!(*data, flat(&want[i]), "{kind:?} chunk {i} differs"); + } + }; + assert!( + expected.cpus.len() > 1, + "the fixture must close at least one chunk mid-walk" + ); + check(TableKind::Cpu, &expected.cpus); + check(TableKind::Memw, &expected.memws); + check(TableKind::MemwAligned, &expected.memw_aligneds); + check(TableKind::MemwRegister, &expected.memw_registers); + check(TableKind::Load, &expected.loads); + check(TableKind::Shift, &expected.shifts); + check(TableKind::Cpu32, &expected.cpu32s); +} + +/// A chunk committed during the walk must carry the root the normal prover +/// gives that same chunk. +/// +/// This is what makes Approach 1's Commit phase legitimate rather than merely +/// convenient: the phase closes a table before the tables after it exist and +/// puts its commitment in the transcript then and there. If that commitment +/// differed from the one the all-at-once prover would produce, everything +/// downstream — challenges, openings, the verifier — would be reading a +/// different table. +#[test] +fn chunks_committed_during_the_walk_carry_the_normal_roots() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{ + DecodeArtifacts, TableKind, Traces as T, build_initial_image, + }; + use executor::elf::Elf; + use stark::prover::IsStarkProver; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + let max_rows = crate::tables::MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 15, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let traces = T::from_elf_and_logs( + &elf, + &executor::vm::execution::Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + let counts = traces.table_counts(); + let airs = crate::VmAirs::new( + &elf, + &proof_options, + false, + &traces.page_configs, + &counts, + None, + true, + None, + None, + None, + ); + + // The AIR each emitted chunk belongs to, by kind and position. + let air_for = |kind: TableKind, chunk: usize| match kind { + TableKind::Cpu => airs.cpus.get(chunk).map(|a| a.as_ref()), + TableKind::Memw => airs.memws.get(chunk).map(|a| a.as_ref()), + TableKind::MemwAligned => airs.memw_aligneds.get(chunk).map(|a| a.as_ref()), + TableKind::MemwRegister => airs.memw_registers.get(chunk).map(|a| a.as_ref()), + TableKind::Load => airs.loads.get(chunk).map(|a| a.as_ref()), + TableKind::Shift => airs.shifts.get(chunk).map(|a| a.as_ref()), + TableKind::Cpu32 => airs.cpu32s.get(chunk).map(|a| a.as_ref()), + _ => None, + }; + + type P = stark::prover::Prover< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + (), + >; + let commit_root = |air: &dyn stark::traits::AIR< + Field = crate::tables::types::GoldilocksField, + FieldExtension = crate::tables::types::GoldilocksExtension, + PublicInputs = (), + >, + t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| {

>::commit_table_root(air, t) }; + + let mut checked = 0usize; + T::walk_and_emit_chunks( + &artifacts, + &elf, + vec![], + &image, + ®ister_init, + &max_rows, + |kind, chunk, table| { + let Some(air) = air_for(kind, chunk) else { + return; + }; + let walked = commit_root(air, &table).expect("the walk's chunk commits"); + let resident = match kind { + TableKind::Cpu => &traces.cpus[chunk], + TableKind::Memw => &traces.memws[chunk], + TableKind::MemwAligned => &traces.memw_aligneds[chunk], + TableKind::MemwRegister => &traces.memw_registers[chunk], + TableKind::Load => &traces.loads[chunk], + TableKind::Shift => &traces.shifts[chunk], + TableKind::Cpu32 => &traces.cpu32s[chunk], + _ => unreachable!(), + }; + let expected = commit_root(air, resident).expect("the resident chunk commits"); + assert_eq!( + walked, expected, + "{kind:?} chunk {chunk}: committed during the walk under a different root" + ); + checked += 1; + }, + ) + .expect("walk"); + + assert!( + checked > 0, + "the fixture must close at least one chunk mid-walk" + ); +} From 47e133821728bba191faeb39e227b4880c8affda Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 21:58:21 -0300 Subject: [PATCH 11/63] Share one derivation for the CPU-driven tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Commit phase could close seven of the chunked tables mid-run; the others were derived inside `collect_all_ops`, where the walk could not reach them without a second copy of each filter+map. Two copies of a derivation drift, and a table closed mid-walk has to be the table the finished run would produce. `derive_from_cpu` now produces BRANCH, EQ, BYTEWISE and STORE, and both the all-at-once path and the walk call it. That takes the phase from seven tables to eleven of the fourteen. DVRM, MUL and LT stay out, and the reason is ordering rather than effort. Each takes ops from more than one source — CPU32 appends to DVRM and MUL, DVRM appends to MUL and LT — and the finished run concatenates each source whole, while a walk would interleave them per segment. The op sets would match and the chunk boundaries would not, so the chunks would differ even though every table is internally sorted. Closing those early needs the concatenation order settled first, which is a change to what the prover commits, not a refactor. The tests now cover all eleven, and assert that the fixture actually splits at least three of them: a table with a single chunk compares an empty prefix and proves nothing about itself. --- prover/src/tables/trace_builder.rs | 146 ++++++++++++++++-------- prover/src/tests/trace_builder_tests.rs | 46 +++++++- 2 files changed, 141 insertions(+), 51 deletions(-) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 9aeb46b86..dc4796fd5 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2983,9 +2983,81 @@ pub(crate) struct RoutedOps { pub(crate) cpu32_ops: Vec, } +/// The tables that are a pure per-op function of the CPU ops, with no later +/// source appending to them. +/// +/// Extracted so the all-at-once path and the Commit-phase walk derive them with +/// the same code: a table closed mid-walk has to be the table the finished run +/// would have produced, and two copies of a filter+map drift. +/// +/// DVRM, MUL and LT are deliberately not here. Each takes ops from more than +/// one source — CPU32 appends to DVRM and MUL, DVRM appends to MUL and LT — and +/// the finished run concatenates those sources whole, so deriving them per +/// segment would interleave them differently and cut the chunks elsewhere. +struct DerivedFromCpu { + branch_ops: Vec, + eq_ops: Vec, + bytewise_ops: Vec, + store_ops: Vec, +} + +fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { + // BRANCH: CPU ops where branch_cond = true. + let branch_ops: Vec = cpu_ops + .iter() + .filter(|op| op.branch_cond) + .map(|op| { + BranchOperation::new( + op.decode.pc, + op.decode.imm, // offset as full 64-bit DWordWL (already sign-extended) + op.rv1, // register value must match the CPU's BRANCH bus signature + op.decode.fields.jalr(), + ) + }) + .collect(); + // EQ: BEQ/BNE (invert = alu_flags bit 6). + let eq_ops: Vec = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_eq()) + .map(|op| eq::EqOperation::new(op.rv1, op.arg2, op.decode.fields.alu_signed2_or_invert())) + .collect(); + // BYTEWISE: AND/OR/XOR (op = alu_op). + let bytewise_ops: Vec = cpu_ops + .iter() + .filter(|op| { + let f = &op.decode.fields; + !f.word_instr && (f.is_and() || f.is_or() || f.is_xor()) + }) + .map(|op| bytewise::BytewiseOperation::new(op.rv1, op.arg2, op.decode.fields.alu_op())) + .collect(); + // STORE: receives MEMORY(memory_op=1) from the CPU and sends the MEMW write + // at timestamp+1 (mirrors `collect_store_op_from_cpu`, which records the MEMW + // table row). The MEMORY bus and the STORE chip's MEMW write share the base + // timestamp (spec store.toml uses one `timestamp` for both). + let store_ops: Vec = cpu_ops + .iter() + .filter(|op| op.decode.fields.is_store()) + .map(|op| { + store::StoreOperation::new( + op.res, + op.timestamp, + op.rv2, + op.decode.fields.mem_bytes() as u8, + ) + }) + .collect(); + + DerivedFromCpu { + branch_ops, + eq_ops, + bytewise_ops, + store_ops, + } +} + /// The tables this walk can close mid-execution: their ops come straight out of /// `collect_ops_from_cpu` and nothing appends to them afterwards. -pub const CHUNKED_KINDS: [TableKind; 7] = [ +pub const CHUNKED_KINDS: [TableKind; 11] = [ TableKind::Cpu, TableKind::Memw, TableKind::MemwAligned, @@ -2993,6 +3065,10 @@ pub const CHUNKED_KINDS: [TableKind; 7] = [ TableKind::Load, TableKind::Shift, TableKind::Cpu32, + TableKind::Branch, + TableKind::Eq, + TableKind::Bytewise, + TableKind::Store, ]; /// Chunk limit for one kind. @@ -3026,6 +3102,10 @@ impl RoutedOps { TableKind::Load => self.load_ops.len(), TableKind::Shift => self.shift_ops.len(), TableKind::Cpu32 => self.cpu32_ops.len(), + TableKind::Branch => self.branch_ops.len(), + TableKind::Eq => self.eq_ops.len(), + TableKind::Bytewise => self.bytewise_ops.len(), + TableKind::Store => self.store_ops.len(), _ => 0, } } @@ -3063,6 +3143,10 @@ impl RoutedOps { TableKind::Load => drain!(self.load_ops, load::generate_load_trace), TableKind::Shift => drain!(self.shift_ops, shift::generate_shift_trace), TableKind::Cpu32 => drain!(self.cpu32_ops, cpu32::generate_cpu32_trace), + TableKind::Branch => drain!(self.branch_ops, branch::generate_branch_trace), + TableKind::Eq => drain!(self.eq_ops, eq::generate_eq_trace), + TableKind::Bytewise => drain!(self.bytewise_ops, bytewise::generate_bytewise_trace), + TableKind::Store => drain!(self.store_ops, store::generate_store_trace), other => unreachable!("{other:?} is not closed mid-walk"), } } @@ -3388,19 +3472,12 @@ fn collect_all_ops( general: memw_ops, } = memw; - // Collect BRANCH operations from CPU ops where branch_cond = true - let branch_ops: Vec = cpu_ops - .iter() - .filter(|op| op.branch_cond) - .map(|op| { - BranchOperation::new( - op.decode.pc, - op.decode.imm, // offset as full 64-bit DWordWL (already sign-extended) - op.rv1, // register value must match the CPU's BRANCH bus signature - op.decode.fields.jalr(), - ) - }) - .collect(); + let DerivedFromCpu { + branch_ops, + eq_ops, + bytewise_ops, + store_ops, + } = derive_from_cpu(&cpu_ops); // Collect MUL operations from non-word MUL instructions. lhs_signed = `signed` // (alu_flags bit 5); rhs_signed = `signed2` (bit 6); wants_hi = `muldiv` (bit 7). @@ -3429,39 +3506,6 @@ fn collect_all_ops( }) .collect(); - // Collect the ALU/MEMORY chip ops (non-word rows). - // EQ: BEQ/BNE (invert = alu_flags bit 6). BYTEWISE: AND/OR/XOR (op = alu_op). - let eq_ops: Vec = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_eq()) - .map(|op| eq::EqOperation::new(op.rv1, op.arg2, op.decode.fields.alu_signed2_or_invert())) - .collect(); - let bytewise_ops: Vec = cpu_ops - .iter() - .filter(|op| { - let f = &op.decode.fields; - !f.word_instr && (f.is_and() || f.is_or() || f.is_xor()) - }) - .map(|op| bytewise::BytewiseOperation::new(op.rv1, op.arg2, op.decode.fields.alu_op())) - .collect(); - // STORE: receives MEMORY(memory_op=1) from the CPU and sends the MEMW write - // at timestamp+1 (mirrors `collect_store_op_from_cpu`, which records the MEMW - // table row). - let store_ops: Vec = cpu_ops - .iter() - .filter(|op| op.decode.fields.is_store()) - .map(|op| { - // The MEMORY bus and the STORE chip's MEMW write share the base - // timestamp (spec store.toml uses one `timestamp` for both). - store::StoreOperation::new( - op.res, - op.timestamp, - op.rv2, - op.decode.fields.mem_bytes() as u8, - ) - }) - .collect(); - // CPU32 (word `*W`) dispatch: each CPU32 row that uses the full ALU sends to // the SHIFT/MUL/DVRM chips (ADDW/SUBW are the CPU32 ADD/SUB fast-path). These // word DVRM ops are added before the DVRM→LT/MUL loops so they get their own @@ -5047,6 +5091,14 @@ impl Traces { cycles_so_far += cpu.len(); let (memw, ld, lt, sh, _bw, _cm, _kc, c32, _ec, _ed, _hn) = collect_ops_from_cpu(&cpu, &mut memory_state, &mut register_state); + // Derived from THIS segment's ops, before they are moved into the + // buffer — the buffer is drained as chunks close, so it is not the + // segment. + let derived = derive_from_cpu(&cpu); + buf.branch_ops.extend(derived.branch_ops); + buf.eq_ops.extend(derived.eq_ops); + buf.bytewise_ops.extend(derived.bytewise_ops); + buf.store_ops.extend(derived.store_ops); buf.cpu_ops.extend(cpu); buf.memw_register_rows.extend(memw.register_rows); buf.memw_aligned_ops.extend(memw.aligned); diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index d3eede445..745b8021e 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1420,11 +1420,18 @@ fn commit_walk_emits_the_same_chunks() { let register_init = register_init_from_entry_point(elf.entry_point); // Small enough that the walk closes several chunks before the run ends, // which is the case that matters — a single tail chunk would prove nothing. + // Small enough that every table under test closes chunks mid-walk. Without + // that, a table with a single chunk would compare an empty prefix and the + // check would pass while proving nothing about it. let max_rows = crate::tables::MaxRowsConfig { cpu: 1 << 15, - memw: 1 << 15, - load: 1 << 15, + memw: 1 << 10, + load: 1 << 10, shift: 1 << 15, + branch: 1 << 12, + eq: 1 << 12, + bytewise: 1 << 12, + store: 1 << 12, ..Default::default() }; @@ -1490,10 +1497,29 @@ fn commit_walk_emits_the_same_chunks() { assert_eq!(*data, flat(&want[i]), "{kind:?} chunk {i} differs"); } }; + // Only the tables this fixture actually splits are worth asserting on: a + // table with one chunk compares an empty prefix and proves nothing. + let chunked: Vec<&str> = [ + ("CPU", expected.cpus.len()), + ("MEMW", expected.memws.len()), + ("MEMW_A", expected.memw_aligneds.len()), + ("MEMW_R", expected.memw_registers.len()), + ("LOAD", expected.loads.len()), + ("SHIFT", expected.shifts.len()), + ("BRANCH", expected.branches.len()), + ("EQ", expected.eqs.len()), + ("BYTEWISE", expected.bytewises.len()), + ("STORE", expected.stores.len()), + ] + .into_iter() + .filter(|(_, n)| *n > 1) + .map(|(name, _)| name) + .collect(); assert!( - expected.cpus.len() > 1, - "the fixture must close at least one chunk mid-walk" + chunked.len() >= 3, + "the fixture must split at least three tables mid-walk, split: {chunked:?}" ); + check(TableKind::Cpu, &expected.cpus); check(TableKind::Memw, &expected.memws); check(TableKind::MemwAligned, &expected.memw_aligneds); @@ -1501,6 +1527,10 @@ fn commit_walk_emits_the_same_chunks() { check(TableKind::Load, &expected.loads); check(TableKind::Shift, &expected.shifts); check(TableKind::Cpu32, &expected.cpu32s); + check(TableKind::Branch, &expected.branches); + check(TableKind::Eq, &expected.eqs); + check(TableKind::Bytewise, &expected.bytewises); + check(TableKind::Store, &expected.stores); } /// A chunk committed during the walk must carry the root the normal prover @@ -1570,6 +1600,10 @@ fn chunks_committed_during_the_walk_carry_the_normal_roots() { TableKind::Load => airs.loads.get(chunk).map(|a| a.as_ref()), TableKind::Shift => airs.shifts.get(chunk).map(|a| a.as_ref()), TableKind::Cpu32 => airs.cpu32s.get(chunk).map(|a| a.as_ref()), + TableKind::Branch => airs.branches.get(chunk).map(|a| a.as_ref()), + TableKind::Eq => airs.eqs.get(chunk).map(|a| a.as_ref()), + TableKind::Bytewise => airs.bytewises.get(chunk).map(|a| a.as_ref()), + TableKind::Store => airs.stores.get(chunk).map(|a| a.as_ref()), _ => None, }; @@ -1609,6 +1643,10 @@ fn chunks_committed_during_the_walk_carry_the_normal_roots() { TableKind::Load => &traces.loads[chunk], TableKind::Shift => &traces.shifts[chunk], TableKind::Cpu32 => &traces.cpu32s[chunk], + TableKind::Branch => &traces.branches[chunk], + TableKind::Eq => &traces.eqs[chunk], + TableKind::Bytewise => &traces.bytewises[chunk], + TableKind::Store => &traces.stores[chunk], _ => unreachable!(), }; let expected = commit_root(air, resident).expect("the resident chunk commits"); From 0fef09503600543ebfdd6f14cc0eee08004aea44 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:04:45 -0300 Subject: [PATCH 12/63] Stop closing SHIFT early: CPU32 still feeds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHIFT was closed mid-walk on the strength of its ops leaving `collect_ops_from_cpu` final. They do not stay final: `cpu32_chip_op` appends a SHIFT op for every word instruction, after the segment that produced it has gone. On a program with `*W` ops the walk would have cut SHIFT's chunks somewhere the finished run does not, and committed tables the prover never builds. The existing test did not catch it because a fibonacci fixture has no word instructions, so the append never happened. Adding a fixture that does is not enough either — catching it by data needs word instructions AND enough SHIFT ops to split a chunk, and a fixture that quietly stops meeting that stops testing it. So the hazard is stated instead: `CPU32_APPENDS_TO` lists the tables `cpu32_chip_op` feeds, sits next to it, and `cpu32_appends_are_excluded_from_early_closing` asserts none of them is closed early. A future edit that feeds another table has one list to update and a test that fails if it does not. The walk covers ten tables rather than eleven. A second case runs it over a word-instruction program, which is worth having even though the invariant is what actually holds the line. --- prover/src/tables/trace_builder.rs | 19 ++++-- prover/src/tests/trace_builder_tests.rs | 81 +++++++++++++++++-------- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index dc4796fd5..837f28f10 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1251,6 +1251,14 @@ fn collect_cpu32_bitwise(c: &cpu32::Cpu32Operation) -> Vec { /// The ALU-chip op a word ALU instruction dispatches (SHIFT/MUL/DVRM). ADDW/SUBW /// are the CPU32 ADD/SUB fast-path (no external chip), returning `None`. +/// The tables `cpu32_chip_op` appends to. +/// +/// Kept beside it because it is load-bearing elsewhere: a table listed here is +/// NOT final when a segment ends, so the Commit-phase walk must not close it +/// early — `cpu32_appends_are_excluded_from_early_closing` enforces that. If +/// this function starts feeding another table, add it here. +pub const CPU32_APPENDS_TO: [TableKind; 3] = [TableKind::Shift, TableKind::Mul, TableKind::Dvrm]; + #[allow(clippy::type_complexity)] fn cpu32_chip_op( c: &cpu32::Cpu32Operation, @@ -3057,13 +3065,12 @@ fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { /// The tables this walk can close mid-execution: their ops come straight out of /// `collect_ops_from_cpu` and nothing appends to them afterwards. -pub const CHUNKED_KINDS: [TableKind; 11] = [ +pub const CHUNKED_KINDS: [TableKind; 10] = [ TableKind::Cpu, TableKind::Memw, TableKind::MemwAligned, TableKind::MemwRegister, TableKind::Load, - TableKind::Shift, TableKind::Cpu32, TableKind::Branch, TableKind::Eq, @@ -3100,7 +3107,6 @@ impl RoutedOps { TableKind::MemwAligned => self.memw_aligned_ops.len(), TableKind::MemwRegister => self.memw_register_rows.len(), TableKind::Load => self.load_ops.len(), - TableKind::Shift => self.shift_ops.len(), TableKind::Cpu32 => self.cpu32_ops.len(), TableKind::Branch => self.branch_ops.len(), TableKind::Eq => self.eq_ops.len(), @@ -3141,7 +3147,6 @@ impl RoutedOps { memw_register::generate_memw_register_trace_from_rows ), TableKind::Load => drain!(self.load_ops, load::generate_load_trace), - TableKind::Shift => drain!(self.shift_ops, shift::generate_shift_trace), TableKind::Cpu32 => drain!(self.cpu32_ops, cpu32::generate_cpu32_trace), TableKind::Branch => drain!(self.branch_ops, branch::generate_branch_trace), TableKind::Eq => drain!(self.eq_ops, eq::generate_eq_trace), @@ -5056,7 +5061,11 @@ impl Traces { /// traces to the all-at-once path — `commit_walk_emits_the_same_chunks` /// pins that. /// - /// Only the tables whose ops leave `collect_ops_from_cpu` final are emitted. + /// Only the tables whose ops are final when the segment ends are emitted. + /// SHIFT is not one of them despite coming out of `collect_ops_from_cpu`: + /// `cpu32_chip_op` appends to it for every word instruction, so a program + /// with `*W` ops would have its SHIFT chunks cut elsewhere than the + /// finished run cuts them. /// LT and MUL are not among them — later derivations append to both (DVRM /// contributes range checks to LT and a product to MUL) — and neither are /// the tables `collect_all_ops` derives from the CPU ops, nor the diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 745b8021e..f20babb48 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1404,8 +1404,7 @@ fn collect_streaming_matches_collect_epoch() { /// It is the same equality `build_chunk` rests on, one level up: a table closed /// mid-execution and a table built from the finished op list have to be the /// same table, or committing early means committing something else. -#[test] -fn commit_walk_emits_the_same_chunks() { +fn assert_walk_matches(fixture: &str, max_rows: crate::tables::MaxRowsConfig, min_split: usize) { use crate::tables::register::register_init_from_entry_point; use crate::tables::trace_builder::{ DecodeArtifacts, TableKind, Traces as T, build_initial_image, @@ -1413,28 +1412,13 @@ fn commit_walk_emits_the_same_chunks() { use executor::elf::Elf; use executor::vm::execution::Executor; - let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf_bytes = crate::test_utils::asm_elf_bytes(fixture); let elf = Elf::load(&elf_bytes).expect("ELF load"); let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); let image = build_initial_image(&elf, &[]); let register_init = register_init_from_entry_point(elf.entry_point); // Small enough that the walk closes several chunks before the run ends, // which is the case that matters — a single tail chunk would prove nothing. - // Small enough that every table under test closes chunks mid-walk. Without - // that, a table with a single chunk would compare an empty prefix and the - // check would pass while proving nothing about it. - let max_rows = crate::tables::MaxRowsConfig { - cpu: 1 << 15, - memw: 1 << 10, - load: 1 << 10, - shift: 1 << 15, - branch: 1 << 12, - eq: 1 << 12, - bytewise: 1 << 12, - store: 1 << 12, - ..Default::default() - }; - let logs = Executor::new(&elf, vec![]) .expect("executor") .run() @@ -1505,7 +1489,6 @@ fn commit_walk_emits_the_same_chunks() { ("MEMW_A", expected.memw_aligneds.len()), ("MEMW_R", expected.memw_registers.len()), ("LOAD", expected.loads.len()), - ("SHIFT", expected.shifts.len()), ("BRANCH", expected.branches.len()), ("EQ", expected.eqs.len()), ("BYTEWISE", expected.bytewises.len()), @@ -1516,8 +1499,8 @@ fn commit_walk_emits_the_same_chunks() { .map(|(name, _)| name) .collect(); assert!( - chunked.len() >= 3, - "the fixture must split at least three tables mid-walk, split: {chunked:?}" + chunked.len() >= min_split, + "{fixture} must split at least {min_split} tables mid-walk, split: {chunked:?}" ); check(TableKind::Cpu, &expected.cpus); @@ -1525,7 +1508,6 @@ fn commit_walk_emits_the_same_chunks() { check(TableKind::MemwAligned, &expected.memw_aligneds); check(TableKind::MemwRegister, &expected.memw_registers); check(TableKind::Load, &expected.loads); - check(TableKind::Shift, &expected.shifts); check(TableKind::Cpu32, &expected.cpu32s); check(TableKind::Branch, &expected.branches); check(TableKind::Eq, &expected.eqs); @@ -1533,6 +1515,36 @@ fn commit_walk_emits_the_same_chunks() { check(TableKind::Store, &expected.stores); } +#[test] +fn commit_walk_emits_the_same_chunks() { + // Limits small enough that several tables close chunks mid-walk. + assert_walk_matches( + "fib_iterative_160k", + crate::tables::MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + eq: 1 << 12, + bytewise: 1 << 12, + store: 1 << 12, + ..Default::default() + }, + 3, + ); +} + +/// The same, on a program that uses the word instructions. +/// +/// `cpu32_chip_op` appends to SHIFT, MUL and DVRM for every `*W` op, so those +/// tables are not final when a segment ends. A fibonacci fixture has no word +/// instructions and would let a table that is closed too early pass unnoticed; +/// this one would not. +#[test] +fn commit_walk_emits_the_same_chunks_with_word_instructions() { + assert_walk_matches("basic_arith_32", crate::tables::MaxRowsConfig::small(), 1); +} + /// A chunk committed during the walk must carry the root the normal prover /// gives that same chunk. /// @@ -1598,7 +1610,6 @@ fn chunks_committed_during_the_walk_carry_the_normal_roots() { TableKind::MemwAligned => airs.memw_aligneds.get(chunk).map(|a| a.as_ref()), TableKind::MemwRegister => airs.memw_registers.get(chunk).map(|a| a.as_ref()), TableKind::Load => airs.loads.get(chunk).map(|a| a.as_ref()), - TableKind::Shift => airs.shifts.get(chunk).map(|a| a.as_ref()), TableKind::Cpu32 => airs.cpu32s.get(chunk).map(|a| a.as_ref()), TableKind::Branch => airs.branches.get(chunk).map(|a| a.as_ref()), TableKind::Eq => airs.eqs.get(chunk).map(|a| a.as_ref()), @@ -1641,7 +1652,6 @@ fn chunks_committed_during_the_walk_carry_the_normal_roots() { TableKind::MemwAligned => &traces.memw_aligneds[chunk], TableKind::MemwRegister => &traces.memw_registers[chunk], TableKind::Load => &traces.loads[chunk], - TableKind::Shift => &traces.shifts[chunk], TableKind::Cpu32 => &traces.cpu32s[chunk], TableKind::Branch => &traces.branches[chunk], TableKind::Eq => &traces.eqs[chunk], @@ -1664,3 +1674,26 @@ fn chunks_committed_during_the_walk_carry_the_normal_roots() { "the fixture must close at least one chunk mid-walk" ); } + +/// No table CPU32 feeds may be closed early by the Commit-phase walk. +/// +/// `cpu32_chip_op` appends to SHIFT, MUL and DVRM once per word instruction, so +/// those are not final when a segment ends — closing one early would cut its +/// chunks somewhere the finished run does not. +/// +/// Stated as an invariant rather than left to a fixture: catching it by data +/// needs a program with word instructions AND enough of the affected ops to +/// split a chunk, and a fixture that stops meeting that quietly stops testing +/// it. SHIFT was in fact closed early until this was noticed. +#[test] +fn cpu32_appends_are_excluded_from_early_closing() { + use crate::tables::trace_builder::{CHUNKED_KINDS, CPU32_APPENDS_TO}; + + for kind in CPU32_APPENDS_TO { + assert!( + !CHUNKED_KINDS.contains(&kind), + "{kind:?} takes ops from cpu32_chip_op after a segment ends, so the walk \ + must not close it early" + ); + } +} From 237b6885dbc80505c91b41242fdab4ad37756e8a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:10:15 -0300 Subject: [PATCH 13/63] Derive MUL and DVRM in the shared derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were still derived inside `collect_all_ops`, so the Commit-phase walk could not reach them without a second copy of each filter+map — the duplication the shared derivation exists to prevent. They move to `derive_from_cpu`, which now produces the six tables that are a pure per-op function of the CPU ops. This does not make MUL or DVRM closable mid-walk: CPU32 and DVRM both append to them after a segment ends, which is a separate obstacle from where the derivation lives. It makes the walk able to produce them at all, which is what a pass that no longer keeps the CPU ops to the end will need. --- prover/src/tables/trace_builder.rs | 59 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 837f28f10..fa8ea4fee 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -3007,6 +3007,8 @@ struct DerivedFromCpu { eq_ops: Vec, bytewise_ops: Vec, store_ops: Vec, + mul_ops: Vec<(MulOperation, bool)>, + dvrm_ops: Vec<(DvrmOperation, bool)>, } fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { @@ -3055,11 +3057,39 @@ fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { }) .collect(); + // MUL: non-word MUL instructions. lhs_signed = `signed` (alu_flags bit 5); + // rhs_signed = `signed2` (bit 6); wants_hi = `muldiv` (bit 7). + let mul_ops: Vec<(MulOperation, bool)> = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_mul()) + .map(|op| { + let f = op.decode.fields; + ( + MulOperation::new(op.rv1, f.alu_signed(), op.arg2, f.alu_signed2_or_invert()), + f.alu_muldiv(), + ) + }) + .collect(); + // DVRM: non-word DIV/REM instructions. + let dvrm_ops: Vec<(DvrmOperation, bool)> = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_divrem()) + .map(|op| { + let f = op.decode.fields; + ( + DvrmOperation::new(op.rv1, op.arg2, f.alu_signed()), + f.alu_muldiv(), + ) + }) + .collect(); + DerivedFromCpu { branch_ops, eq_ops, bytewise_ops, store_ops, + mul_ops, + dvrm_ops, } } @@ -3482,35 +3512,10 @@ fn collect_all_ops( eq_ops, bytewise_ops, store_ops, + mut mul_ops, + mut dvrm_ops, } = derive_from_cpu(&cpu_ops); - // Collect MUL operations from non-word MUL instructions. lhs_signed = `signed` - // (alu_flags bit 5); rhs_signed = `signed2` (bit 6); wants_hi = `muldiv` (bit 7). - let mut mul_ops: Vec<(MulOperation, bool)> = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_mul()) - .map(|op| { - let f = op.decode.fields; - ( - MulOperation::new(op.rv1, f.alu_signed(), op.arg2, f.alu_signed2_or_invert()), - f.alu_muldiv(), - ) - }) - .collect(); - - // Collect DVRM operations from non-word DIV/REM instructions. - let mut dvrm_ops: Vec<(DvrmOperation, bool)> = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_divrem()) - .map(|op| { - let f = op.decode.fields; - ( - DvrmOperation::new(op.rv1, op.arg2, f.alu_signed()), - f.alu_muldiv(), - ) - }) - .collect(); - // CPU32 (word `*W`) dispatch: each CPU32 row that uses the full ALU sends to // the SHIFT/MUL/DVRM chips (ADDW/SUBW are the CPU32 ADD/SUB fast-path). These // word DVRM ops are added before the DVRM→LT/MUL loops so they get their own From 8d8cc5bfb41e01ee6d482da860430250c9925c36 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:21:01 -0300 Subject: [PATCH 14/63] Keep one intermediate for the collected ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Commit-phase walk buffered a `RoutedOps` while the finished run produced a `CollectedOps`, and the two were the same thing minus six fields: the walk's type had no accumulators, because the walk did not need them yet. It will — what it cannot close has to reach the caller so the run can be finished — and carrying two types that drift apart in six fields is how a table ends up built from the wrong list. `RoutedOps` goes; `CollectedOps` is the one intermediate, and the chunk machinery (`build_chunk`, `chunk_shape`, `num_chunks`, `build_table`) moves onto it. The value the trace build hands the provider leaves the accumulators empty, since rebuilding a chunked table never reads them, and says so where it is built. No behaviour change: same chunks, same traces, same proofs. --- prover/src/streaming.rs | 6 +- prover/src/tables/trace_builder.rs | 665 ++++++++++++------------ prover/src/tests/trace_builder_tests.rs | 8 +- 3 files changed, 334 insertions(+), 345 deletions(-) diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index 985751f64..09cbe6e38 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -12,7 +12,7 @@ use stark::prover::TraceProvider; use stark::trace::TraceTable; use crate::tables::MaxRowsConfig; -use crate::tables::trace_builder::{RoutedOps, TableKind, Traces}; +use crate::tables::trace_builder::{CollectedOps, TableKind, Traces}; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; /// The groups of chunked tables, in the order `VmAirs::air_trace_pairs` emits @@ -41,7 +41,7 @@ const GROUP_ORDER: [Option; 15] = [ const NUM_FIXED_AIRS: usize = 10; pub(crate) struct StreamingProvider { - routed: RoutedOps, + routed: CollectedOps, max_rows: MaxRowsConfig, /// AIR index -> the chunk that rebuilds it, or `None` when it is resident. slots: Vec>, @@ -59,7 +59,7 @@ impl StreamingProvider { /// wrong shifts every slot by one and hands each table the trace of its /// neighbour. It is taken from the caller's `VmAirs` rather than assumed. pub(crate) fn new( - routed: RoutedOps, + routed: CollectedOps, max_rows: MaxRowsConfig, traces: &Traces, include_halt: bool, diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index fa8ea4fee..a96437979 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -81,7 +81,7 @@ type MemoryCell = (u8, u64); type RegisterCell = (u64, u64); /// Memory state tracker for generating MEMW/LOAD traces. -struct MemoryState { +pub(crate) struct MemoryState { /// Per byte-address `(value, timestamp)`, as a dense per-page store. This is /// the hot structure — `read_byte`/`write_byte` hit it on every memory access /// during the replay, and it's rebuilt each epoch — so a per-page array (small @@ -154,7 +154,7 @@ impl MemoryState { } /// Register state tracker for generating MEMW register traces. -struct RegisterState { +pub(crate) struct RegisterState { /// Register file: (value, last_write_timestamp) regs: [RegisterCell; 32], /// Synthetic x254 commit index register: (value, last_write_timestamp) @@ -2810,6 +2810,12 @@ impl CollectedEpoch { /// `build_traces` later stores in `Traces::touched_memory_cells` (both are /// [`touched_cells_from_memory_state`] over the same immutable /// `memory_state`), available before any table is built. + /// Ops collected for `kind`. Mirrors [`CollectedOps::buffered`] so a walk's + /// output and a finished run's can be compared on the same footing. + pub fn op_count(&self, kind: TableKind) -> usize { + self.ops.buffered(kind) + } + pub fn touched_memory_cells(&self) -> local_to_global::EpochTouches { touched_cells_from_memory_state(&self.memory_state) } @@ -2914,334 +2920,33 @@ pub struct Traces { /// Intermediate state from Phase 2: all ops collected from CPU, ready for /// Phases 3-5 (LT extension, bitwise, trace generation). -struct CollectedOps { - cpu_ops: Vec, - memw_ops: Vec, - memw_aligned_ops: Vec, - /// Direct-fill MEMW_R rows (register fast path). - memw_register_rows: Vec, - load_ops: Vec, - lt_ops: Vec, - shift_ops: Vec, - bitwise_ops: Vec, - branch_ops: Vec, - mul_ops: Vec<(MulOperation, bool)>, - dvrm_ops: Vec<(DvrmOperation, bool)>, - commit_ops: Vec, - keccak_ops: Vec, - // Auxiliary ALU / memory / CPU32 dispatch chips (driven by the CPU ALU/MEMORY dispatch). - eq_ops: Vec, - bytewise_ops: Vec, - store_ops: Vec, - cpu32_ops: Vec, - // EC scalar-multiplication accelerator chips. - ecsm_ops: Vec, - ecdas_ops: Vec, - // Non-constraining hint ecall. - hint_ops: Vec, -} - -/// One log-derived, chunked table — the ones whose trace is a function of a -/// single routed op list, so it can be rebuilt on demand long after the routing -/// that produced it. -/// -/// The preprocessed tables (BITWISE, DECODE, REGISTER, HALT, COMMIT, KECCAK*) -/// and PAGE are deliberately absent: they are not driven by one op list and the -/// streaming prover keeps them resident. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum TableKind { - Cpu, - Memw, - MemwAligned, - MemwRegister, - Load, - Lt, - Shift, - Mul, - Dvrm, - Branch, - Eq, - Bytewise, - Store, - Cpu32, -} - -/// The op lists after routing (phases 3-4), kept so any one table can be built -/// from them on demand. -/// -/// This is the compact intermediate the streaming prover holds instead of the -/// built traces: the ops of a table are far smaller than its trace, and the -/// trace is a pure function of them — `build_table` is deterministic, which -/// `trace_build_is_deterministic_across_builds` pins. -#[derive(Default)] -pub(crate) struct RoutedOps { - pub(crate) cpu_ops: Vec, - pub(crate) memw_ops: Vec, - pub(crate) memw_aligned_ops: Vec, - pub(crate) memw_register_rows: Vec, - pub(crate) load_ops: Vec, - pub(crate) lt_ops: Vec, - pub(crate) shift_ops: Vec, - pub(crate) branch_ops: Vec, - pub(crate) mul_ops: Vec<(MulOperation, bool)>, - pub(crate) dvrm_ops: Vec<(DvrmOperation, bool)>, - pub(crate) eq_ops: Vec, - pub(crate) bytewise_ops: Vec, - pub(crate) store_ops: Vec, - pub(crate) cpu32_ops: Vec, -} - -/// The tables that are a pure per-op function of the CPU ops, with no later -/// source appending to them. -/// -/// Extracted so the all-at-once path and the Commit-phase walk derive them with -/// the same code: a table closed mid-walk has to be the table the finished run -/// would have produced, and two copies of a filter+map drift. -/// -/// DVRM, MUL and LT are deliberately not here. Each takes ops from more than -/// one source — CPU32 appends to DVRM and MUL, DVRM appends to MUL and LT — and -/// the finished run concatenates those sources whole, so deriving them per -/// segment would interleave them differently and cut the chunks elsewhere. -struct DerivedFromCpu { - branch_ops: Vec, - eq_ops: Vec, - bytewise_ops: Vec, - store_ops: Vec, - mul_ops: Vec<(MulOperation, bool)>, - dvrm_ops: Vec<(DvrmOperation, bool)>, -} - -fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { - // BRANCH: CPU ops where branch_cond = true. - let branch_ops: Vec = cpu_ops - .iter() - .filter(|op| op.branch_cond) - .map(|op| { - BranchOperation::new( - op.decode.pc, - op.decode.imm, // offset as full 64-bit DWordWL (already sign-extended) - op.rv1, // register value must match the CPU's BRANCH bus signature - op.decode.fields.jalr(), - ) - }) - .collect(); - // EQ: BEQ/BNE (invert = alu_flags bit 6). - let eq_ops: Vec = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_eq()) - .map(|op| eq::EqOperation::new(op.rv1, op.arg2, op.decode.fields.alu_signed2_or_invert())) - .collect(); - // BYTEWISE: AND/OR/XOR (op = alu_op). - let bytewise_ops: Vec = cpu_ops - .iter() - .filter(|op| { - let f = &op.decode.fields; - !f.word_instr && (f.is_and() || f.is_or() || f.is_xor()) - }) - .map(|op| bytewise::BytewiseOperation::new(op.rv1, op.arg2, op.decode.fields.alu_op())) - .collect(); - // STORE: receives MEMORY(memory_op=1) from the CPU and sends the MEMW write - // at timestamp+1 (mirrors `collect_store_op_from_cpu`, which records the MEMW - // table row). The MEMORY bus and the STORE chip's MEMW write share the base - // timestamp (spec store.toml uses one `timestamp` for both). - let store_ops: Vec = cpu_ops - .iter() - .filter(|op| op.decode.fields.is_store()) - .map(|op| { - store::StoreOperation::new( - op.res, - op.timestamp, - op.rv2, - op.decode.fields.mem_bytes() as u8, - ) - }) - .collect(); - - // MUL: non-word MUL instructions. lhs_signed = `signed` (alu_flags bit 5); - // rhs_signed = `signed2` (bit 6); wants_hi = `muldiv` (bit 7). - let mul_ops: Vec<(MulOperation, bool)> = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_mul()) - .map(|op| { - let f = op.decode.fields; - ( - MulOperation::new(op.rv1, f.alu_signed(), op.arg2, f.alu_signed2_or_invert()), - f.alu_muldiv(), - ) - }) - .collect(); - // DVRM: non-word DIV/REM instructions. - let dvrm_ops: Vec<(DvrmOperation, bool)> = cpu_ops - .iter() - .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_divrem()) - .map(|op| { - let f = op.decode.fields; - ( - DvrmOperation::new(op.rv1, op.arg2, f.alu_signed()), - f.alu_muldiv(), - ) - }) - .collect(); - - DerivedFromCpu { - branch_ops, - eq_ops, - bytewise_ops, - store_ops, - mul_ops, - dvrm_ops, - } -} - -/// The tables this walk can close mid-execution: their ops come straight out of -/// `collect_ops_from_cpu` and nothing appends to them afterwards. -pub const CHUNKED_KINDS: [TableKind; 10] = [ - TableKind::Cpu, - TableKind::Memw, - TableKind::MemwAligned, - TableKind::MemwRegister, - TableKind::Load, - TableKind::Cpu32, - TableKind::Branch, - TableKind::Eq, - TableKind::Bytewise, - TableKind::Store, -]; - -/// Chunk limit for one kind. -pub fn max_rows_for(kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { - match kind { - TableKind::Cpu => max_rows.cpu, - TableKind::Memw => max_rows.memw, - TableKind::MemwAligned => max_rows.memw_aligned, - TableKind::MemwRegister => max_rows.memw_register, - TableKind::Load => max_rows.load, - TableKind::Shift => max_rows.shift, - TableKind::Mul => max_rows.mul, - TableKind::Dvrm => max_rows.dvrm, - TableKind::Branch => max_rows.branch, - TableKind::Lt => max_rows.lt, - TableKind::Eq => max_rows.eq, - TableKind::Bytewise => max_rows.bytewise, - TableKind::Store => max_rows.store, - TableKind::Cpu32 => max_rows.cpu32, - } -} - -impl RoutedOps { - /// Ops buffered for `kind` and not yet emitted. - fn buffered(&self, kind: TableKind) -> usize { - match kind { - TableKind::Cpu => self.cpu_ops.len(), - TableKind::Memw => self.memw_ops.len(), - TableKind::MemwAligned => self.memw_aligned_ops.len(), - TableKind::MemwRegister => self.memw_register_rows.len(), - TableKind::Load => self.load_ops.len(), - TableKind::Cpu32 => self.cpu32_ops.len(), - TableKind::Branch => self.branch_ops.len(), - TableKind::Eq => self.eq_ops.len(), - TableKind::Bytewise => self.bytewise_ops.len(), - TableKind::Store => self.store_ops.len(), - _ => 0, - } - } - - /// Build a trace from the first `n` buffered ops of `kind` and drop them. +impl CollectedOps { + /// Rows and main columns of one chunk, without building it. /// - /// Draining is the point: this is what keeps the walk's buffers from - /// growing with the run. - fn take_front( - &mut self, + /// Every generator pads to `count.next_power_of_two().max(4)`, where `count` + /// is the chunk's op count — or, for the six tables that deduplicate, the + /// number of DISTINCT ops in it. The width is a per-table constant. So the + /// shape needs a counting pass at worst, never a trace. + /// + /// `chunk_shape_matches_the_built_chunk` pins this against real builds for + /// every kind; it is what catches a generator that changes its padding. + pub(crate) fn chunk_shape( + &self, kind: TableKind, - n: usize, + chunk: usize, max_rows: &super::MaxRowsConfig, - ) -> TraceTable { - macro_rules! drain { - ($ops:expr, $f:path) => {{ - let front: Vec<_> = $ops.drain(..n).collect(); - $f(&front) - }}; - } - let _ = max_rows; - match kind { - TableKind::Cpu => drain!(self.cpu_ops, cpu::generate_cpu_trace), - TableKind::Memw => drain!(self.memw_ops, memw::generate_memw_trace), - TableKind::MemwAligned => { - drain!( - self.memw_aligned_ops, - memw_aligned::generate_memw_aligned_trace - ) - } - TableKind::MemwRegister => drain!( - self.memw_register_rows, - memw_register::generate_memw_register_trace_from_rows - ), - TableKind::Load => drain!(self.load_ops, load::generate_load_trace), - TableKind::Cpu32 => drain!(self.cpu32_ops, cpu32::generate_cpu32_trace), - TableKind::Branch => drain!(self.branch_ops, branch::generate_branch_trace), - TableKind::Eq => drain!(self.eq_ops, eq::generate_eq_trace), - TableKind::Bytewise => drain!(self.bytewise_ops, bytewise::generate_bytewise_trace), - TableKind::Store => drain!(self.store_ops, store::generate_store_trace), - other => unreachable!("{other:?} is not closed mid-walk"), - } - } - - /// How many chunks `build_table` would produce for `kind`. - /// - /// Mirrors `chunk_and_generate`: an empty op list still yields one (padded) - /// chunk, so the table exists in the proof with the shape the verifier - /// expects. - pub(crate) fn num_chunks(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { - let (len, limit) = self.shape_of(kind, max_rows); - if len == 0 { 1 } else { len.div_ceil(limit) } - } - - /// Op count and chunk limit for `kind`. - fn shape_of(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> (usize, usize) { - match kind { - TableKind::Cpu => (self.cpu_ops.len(), max_rows.cpu), - TableKind::Memw => (self.memw_ops.len(), max_rows.memw), - TableKind::MemwAligned => (self.memw_aligned_ops.len(), max_rows.memw_aligned), - TableKind::MemwRegister => (self.memw_register_rows.len(), max_rows.memw_register), - TableKind::Load => (self.load_ops.len(), max_rows.load), - TableKind::Lt => (self.lt_ops.len(), max_rows.lt), - TableKind::Shift => (self.shift_ops.len(), max_rows.shift), - TableKind::Mul => (self.mul_ops.len(), max_rows.mul), - TableKind::Dvrm => (self.dvrm_ops.len(), max_rows.dvrm), - TableKind::Branch => (self.branch_ops.len(), max_rows.branch), - TableKind::Eq => (self.eq_ops.len(), max_rows.eq), - TableKind::Bytewise => (self.bytewise_ops.len(), max_rows.bytewise), - TableKind::Store => (self.store_ops.len(), max_rows.store), - TableKind::Cpu32 => (self.cpu32_ops.len(), max_rows.cpu32), - } - } - - /// Rows and main columns of one chunk, without building it. - /// - /// Every generator pads to `count.next_power_of_two().max(4)`, where `count` - /// is the chunk's op count — or, for the six tables that deduplicate, the - /// number of DISTINCT ops in it. The width is a per-table constant. So the - /// shape needs a counting pass at worst, never a trace. - /// - /// `chunk_shape_matches_the_built_chunk` pins this against real builds for - /// every kind; it is what catches a generator that changes its padding. - pub(crate) fn chunk_shape( - &self, - kind: TableKind, - chunk: usize, - max_rows: &super::MaxRowsConfig, - ) -> (usize, usize) { - use std::collections::HashSet; - - macro_rules! slice_of { - ($ops:expr, $limit:expr) => {{ - let ops = $ops; - let slice: &[_] = if ops.is_empty() { - &[] - } else { - ops.chunks($limit).nth(chunk).unwrap_or(&[]) - }; - slice + ) -> (usize, usize) { + use std::collections::HashSet; + + macro_rules! slice_of { + ($ops:expr, $limit:expr) => {{ + let ops = $ops; + let slice: &[_] = if ops.is_empty() { + &[] + } else { + ops.chunks($limit).nth(chunk).unwrap_or(&[]) + }; + slice }}; } // One row per op. @@ -3429,6 +3134,286 @@ impl RoutedOps { } } } + + /// Build a trace from the first `n` buffered ops of `kind` and drop them. + /// + /// Draining is the point: this is what keeps the walk's buffers from + /// growing with the run. + fn take_front( + &mut self, + kind: TableKind, + n: usize, + max_rows: &super::MaxRowsConfig, + ) -> TraceTable { + macro_rules! drain { + ($ops:expr, $f:path) => {{ + let front: Vec<_> = $ops.drain(..n).collect(); + $f(&front) + }}; + } + let _ = max_rows; + match kind { + TableKind::Cpu => drain!(self.cpu_ops, cpu::generate_cpu_trace), + TableKind::Memw => drain!(self.memw_ops, memw::generate_memw_trace), + TableKind::MemwAligned => { + drain!( + self.memw_aligned_ops, + memw_aligned::generate_memw_aligned_trace + ) + } + TableKind::MemwRegister => drain!( + self.memw_register_rows, + memw_register::generate_memw_register_trace_from_rows + ), + TableKind::Load => drain!(self.load_ops, load::generate_load_trace), + TableKind::Cpu32 => drain!(self.cpu32_ops, cpu32::generate_cpu32_trace), + TableKind::Branch => drain!(self.branch_ops, branch::generate_branch_trace), + TableKind::Eq => drain!(self.eq_ops, eq::generate_eq_trace), + TableKind::Bytewise => drain!(self.bytewise_ops, bytewise::generate_bytewise_trace), + TableKind::Store => drain!(self.store_ops, store::generate_store_trace), + other => unreachable!("{other:?} is not closed mid-walk"), + } + } + + /// How many chunks `build_table` would produce for `kind`. + /// + /// Mirrors `chunk_and_generate`: an empty op list still yields one (padded) + /// chunk, so the table exists in the proof with the shape the verifier + /// expects. + pub(crate) fn num_chunks(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { + let (len, limit) = self.shape_of(kind, max_rows); + if len == 0 { 1 } else { len.div_ceil(limit) } + } + + /// Op count and chunk limit for `kind`. + fn shape_of(&self, kind: TableKind, max_rows: &super::MaxRowsConfig) -> (usize, usize) { + match kind { + TableKind::Cpu => (self.cpu_ops.len(), max_rows.cpu), + TableKind::Memw => (self.memw_ops.len(), max_rows.memw), + TableKind::MemwAligned => (self.memw_aligned_ops.len(), max_rows.memw_aligned), + TableKind::MemwRegister => (self.memw_register_rows.len(), max_rows.memw_register), + TableKind::Load => (self.load_ops.len(), max_rows.load), + TableKind::Lt => (self.lt_ops.len(), max_rows.lt), + TableKind::Shift => (self.shift_ops.len(), max_rows.shift), + TableKind::Mul => (self.mul_ops.len(), max_rows.mul), + TableKind::Dvrm => (self.dvrm_ops.len(), max_rows.dvrm), + TableKind::Branch => (self.branch_ops.len(), max_rows.branch), + TableKind::Eq => (self.eq_ops.len(), max_rows.eq), + TableKind::Bytewise => (self.bytewise_ops.len(), max_rows.bytewise), + TableKind::Store => (self.store_ops.len(), max_rows.store), + TableKind::Cpu32 => (self.cpu32_ops.len(), max_rows.cpu32), + } + } + + /// Ops collected for `kind`, for the kinds a Commit-phase walk can close. + pub(crate) fn buffered(&self, kind: TableKind) -> usize { + match kind { + TableKind::Cpu => self.cpu_ops.len(), + TableKind::Memw => self.memw_ops.len(), + TableKind::MemwAligned => self.memw_aligned_ops.len(), + TableKind::MemwRegister => self.memw_register_rows.len(), + TableKind::Load => self.load_ops.len(), + TableKind::Cpu32 => self.cpu32_ops.len(), + TableKind::Branch => self.branch_ops.len(), + TableKind::Eq => self.eq_ops.len(), + TableKind::Bytewise => self.bytewise_ops.len(), + TableKind::Store => self.store_ops.len(), + TableKind::Shift => self.shift_ops.len(), + TableKind::Lt => self.lt_ops.len(), + TableKind::Mul => self.mul_ops.len(), + TableKind::Dvrm => self.dvrm_ops.len(), + } + } +} + +#[derive(Default)] +pub(crate) struct CollectedOps { + pub(crate) cpu_ops: Vec, + pub(crate) memw_ops: Vec, + pub(crate) memw_aligned_ops: Vec, + /// Direct-fill MEMW_R rows (register fast path). + pub(crate) memw_register_rows: Vec, + pub(crate) load_ops: Vec, + pub(crate) lt_ops: Vec, + pub(crate) shift_ops: Vec, + pub(crate) bitwise_ops: Vec, + pub(crate) branch_ops: Vec, + pub(crate) mul_ops: Vec<(MulOperation, bool)>, + pub(crate) dvrm_ops: Vec<(DvrmOperation, bool)>, + pub(crate) commit_ops: Vec, + pub(crate) keccak_ops: Vec, + // Auxiliary ALU / memory / CPU32 dispatch chips (driven by the CPU ALU/MEMORY dispatch). + pub(crate) eq_ops: Vec, + pub(crate) bytewise_ops: Vec, + pub(crate) store_ops: Vec, + pub(crate) cpu32_ops: Vec, + // EC scalar-multiplication accelerator chips. + pub(crate) ecsm_ops: Vec, + pub(crate) ecdas_ops: Vec, + // Non-constraining hint ecall. + pub(crate) hint_ops: Vec, +} + +/// One log-derived, chunked table — the ones whose trace is a function of a +/// single routed op list, so it can be rebuilt on demand long after the routing +/// that produced it. +/// +/// The preprocessed tables (BITWISE, DECODE, REGISTER, HALT, COMMIT, KECCAK*) +/// and PAGE are deliberately absent: they are not driven by one op list and the +/// streaming prover keeps them resident. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TableKind { + Cpu, + Memw, + MemwAligned, + MemwRegister, + Load, + Lt, + Shift, + Mul, + Dvrm, + Branch, + Eq, + Bytewise, + Store, + Cpu32, +} + +/// The tables that are a pure per-op function of the CPU ops, with no later +/// source appending to them. +/// +/// Extracted so the all-at-once path and the Commit-phase walk derive them with +/// the same code: a table closed mid-walk has to be the table the finished run +/// would have produced, and two copies of a filter+map drift. +/// +/// DVRM, MUL and LT are deliberately not here. Each takes ops from more than +/// one source — CPU32 appends to DVRM and MUL, DVRM appends to MUL and LT — and +/// the finished run concatenates those sources whole, so deriving them per +/// segment would interleave them differently and cut the chunks elsewhere. +struct DerivedFromCpu { + branch_ops: Vec, + eq_ops: Vec, + bytewise_ops: Vec, + store_ops: Vec, + mul_ops: Vec<(MulOperation, bool)>, + dvrm_ops: Vec<(DvrmOperation, bool)>, +} + +fn derive_from_cpu(cpu_ops: &[CpuOperation]) -> DerivedFromCpu { + // BRANCH: CPU ops where branch_cond = true. + let branch_ops: Vec = cpu_ops + .iter() + .filter(|op| op.branch_cond) + .map(|op| { + BranchOperation::new( + op.decode.pc, + op.decode.imm, // offset as full 64-bit DWordWL (already sign-extended) + op.rv1, // register value must match the CPU's BRANCH bus signature + op.decode.fields.jalr(), + ) + }) + .collect(); + // EQ: BEQ/BNE (invert = alu_flags bit 6). + let eq_ops: Vec = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_eq()) + .map(|op| eq::EqOperation::new(op.rv1, op.arg2, op.decode.fields.alu_signed2_or_invert())) + .collect(); + // BYTEWISE: AND/OR/XOR (op = alu_op). + let bytewise_ops: Vec = cpu_ops + .iter() + .filter(|op| { + let f = &op.decode.fields; + !f.word_instr && (f.is_and() || f.is_or() || f.is_xor()) + }) + .map(|op| bytewise::BytewiseOperation::new(op.rv1, op.arg2, op.decode.fields.alu_op())) + .collect(); + // STORE: receives MEMORY(memory_op=1) from the CPU and sends the MEMW write + // at timestamp+1 (mirrors `collect_store_op_from_cpu`, which records the MEMW + // table row). The MEMORY bus and the STORE chip's MEMW write share the base + // timestamp (spec store.toml uses one `timestamp` for both). + let store_ops: Vec = cpu_ops + .iter() + .filter(|op| op.decode.fields.is_store()) + .map(|op| { + store::StoreOperation::new( + op.res, + op.timestamp, + op.rv2, + op.decode.fields.mem_bytes() as u8, + ) + }) + .collect(); + + // MUL: non-word MUL instructions. lhs_signed = `signed` (alu_flags bit 5); + // rhs_signed = `signed2` (bit 6); wants_hi = `muldiv` (bit 7). + let mul_ops: Vec<(MulOperation, bool)> = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_mul()) + .map(|op| { + let f = op.decode.fields; + ( + MulOperation::new(op.rv1, f.alu_signed(), op.arg2, f.alu_signed2_or_invert()), + f.alu_muldiv(), + ) + }) + .collect(); + // DVRM: non-word DIV/REM instructions. + let dvrm_ops: Vec<(DvrmOperation, bool)> = cpu_ops + .iter() + .filter(|op| !op.decode.fields.word_instr && op.decode.fields.is_divrem()) + .map(|op| { + let f = op.decode.fields; + ( + DvrmOperation::new(op.rv1, op.arg2, f.alu_signed()), + f.alu_muldiv(), + ) + }) + .collect(); + + DerivedFromCpu { + branch_ops, + eq_ops, + bytewise_ops, + store_ops, + mul_ops, + dvrm_ops, + } +} + +/// The tables this walk can close mid-execution: their ops come straight out of +/// `collect_ops_from_cpu` and nothing appends to them afterwards. +pub const CHUNKED_KINDS: [TableKind; 10] = [ + TableKind::Cpu, + TableKind::Memw, + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Load, + TableKind::Cpu32, + TableKind::Branch, + TableKind::Eq, + TableKind::Bytewise, + TableKind::Store, +]; + +/// Chunk limit for one kind. +pub fn max_rows_for(kind: TableKind, max_rows: &super::MaxRowsConfig) -> usize { + match kind { + TableKind::Cpu => max_rows.cpu, + TableKind::Memw => max_rows.memw, + TableKind::MemwAligned => max_rows.memw_aligned, + TableKind::MemwRegister => max_rows.memw_register, + TableKind::Load => max_rows.load, + TableKind::Shift => max_rows.shift, + TableKind::Mul => max_rows.mul, + TableKind::Dvrm => max_rows.dvrm, + TableKind::Branch => max_rows.branch, + TableKind::Lt => max_rows.lt, + TableKind::Eq => max_rows.eq, + TableKind::Bytewise => max_rows.bytewise, + TableKind::Store => max_rows.store, + TableKind::Cpu32 => max_rows.cpu32, + } } /// Chunk raw ops and generate one trace table per chunk. When `storage_mode` @@ -3585,9 +3570,9 @@ fn build_traces( is_final: bool, l2g_memory_bookend: bool, // `true` builds the chunked tables as empty placeholders, leaving the - // returned `RoutedOps` as the only way to get their rows. + // returned `CollectedOps` as the only way to get their rows. retire_chunked: bool, -) -> Result<(Traces, RoutedOps), Error> { +) -> Result<(Traces, CollectedOps), Error> { let CollectedOps { cpu_ops, memw_ops, @@ -3804,9 +3789,9 @@ fn build_traces( // generate→spill order keeps trace memory bounded. // Phases 3-4 are settled, so every cross-table coupling is already folded in // and each of these tables is now a pure function of one routed op list. - // Pack them into `RoutedOps`: the same intermediate builds them here and can + // Pack them into `CollectedOps`: the same intermediate builds them here and can // rebuild any one of them later, which is what a retired trace needs. - let routed = RoutedOps { + let routed = CollectedOps { cpu_ops, memw_ops, memw_aligned_ops, @@ -3821,6 +3806,9 @@ fn build_traces( bytewise_ops, store_ops, cpu32_ops, + // The accumulators stay with the phase-5 closures below: this value + // exists to rebuild the chunked tables, which do not read them. + ..Default::default() }; let cpu_ops_ref = &routed.cpu_ops; @@ -4939,14 +4927,14 @@ impl Traces { /// `from_elf_and_logs`, retiring the chunked tables. /// /// The returned `Traces` carries an empty placeholder per chunk — the right - /// count, none of the rows — and the `RoutedOps` beside it is what rebuilds + /// count, none of the rows — and the `CollectedOps` beside it is what rebuilds /// any of them on demand. pub(crate) fn from_elf_and_logs_streaming( elf: &Elf, max_rows: &super::MaxRowsConfig, private_input: &[u8], #[cfg(feature = "disk-spill")] storage_mode: StorageMode, - ) -> Result<(Self, RoutedOps), Error> { + ) -> Result<(Self, CollectedOps), Error> { let initial_image = build_initial_image(elf, private_input); let register_init = register::register_init_from_entry_point(elf.entry_point); let artifacts = DecodeArtifacts::from_elf(elf)?; @@ -5092,7 +5080,7 @@ impl Traces { let mut memory_state = MemoryState::from_image(initial_image); let mut register_state = RegisterState::from_init(register_init); - let mut buf = RoutedOps::default(); + let mut buf = CollectedOps::default(); let mut emitted = [0usize; CHUNKED_KINDS.len()]; let _ = &emitted; let mut cycles_so_far = 0usize; @@ -5139,7 +5127,8 @@ impl Traces { // land in MEMW — so a partial chunk is not final until the execution // is over. That is the spec's own split: full tables are committed and // retired during the walk, and "at the end of the execution, the - // remaining tables are padded and committed". + // remaining tables are padded and committed". What is left goes back to + // the caller so it can do exactly that. Ok(()) } @@ -5305,7 +5294,7 @@ impl Traces { /// (`l2g_memory_bookend`), where callers pass `None`. #[allow(clippy::too_many_arguments)] /// `build_from_collected`, retiring the chunked tables: they come back as - /// empty placeholders and the returned `RoutedOps` is what rebuilds them. + /// empty placeholders and the returned `CollectedOps` is what rebuilds them. #[allow(clippy::too_many_arguments)] pub(crate) fn build_from_collected_streaming( artifacts: &DecodeArtifacts, @@ -5317,7 +5306,7 @@ impl Traces { is_final: bool, l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, - ) -> Result<(Self, RoutedOps), Error> { + ) -> Result<(Self, CollectedOps), Error> { Self::build_from_collected_inner( artifacts, collected, @@ -5373,7 +5362,7 @@ impl Traces { l2g_memory_bookend: bool, #[cfg(feature = "disk-spill")] storage_mode: StorageMode, retire_chunked: bool, - ) -> Result<(Self, RoutedOps), Error> { + ) -> Result<(Self, CollectedOps), Error> { // Phase 0 (cached): the pristine DECODE trace is cloned so // `build_traces` can fill this epoch's multiplicities. #[cfg(feature = "instruments")] diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index f20babb48..090f513a9 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1216,14 +1216,14 @@ fn trace_build_is_deterministic_across_builds() { /// verifier will not accept. #[test] fn build_chunk_matches_the_full_table_build() { - use crate::tables::trace_builder::{RoutedOps, TableKind}; + use crate::tables::trace_builder::{CollectedOps, TableKind}; // More ops than the chunk limit below, so several chunks exist and the // last one is short. let lt_ops: Vec<_> = (0..10u64) .map(|i| crate::tables::lt::LtOperation::new(i, i * 7 + 1, false)) .collect(); - let routed = RoutedOps { + let routed = CollectedOps { lt_ops, ..Default::default() }; @@ -1267,7 +1267,7 @@ fn build_chunk_matches_the_full_table_build() { /// them ever stops. #[test] fn chunk_shape_matches_the_built_chunk() { - use crate::tables::trace_builder::{RoutedOps, TableKind}; + use crate::tables::trace_builder::{CollectedOps, TableKind}; // Ops with deliberate repeats, so the deduplicating kinds and the plain ones // disagree on count and the distinction is actually exercised. @@ -1277,7 +1277,7 @@ fn chunk_shape_matches_the_built_chunk() { let lt_ops: Vec<_> = (0..8u64) .map(|i| crate::tables::lt::LtOperation::new(i % 3, i % 3 + 1, false)) .collect(); - let routed = RoutedOps { + let routed = CollectedOps { lt_ops, ..Default::default() }; From 8482352c42d532f9b6d68fb3b0d766a07b3ebecb Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:33:46 -0300 Subject: [PATCH 15/63] Run the Commit phase: commit and drop as you walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1's first pass, end to end. `commit_phase::run` walks the execution and commits each chunked table the moment it fills, dropping the trace right after, then hands back what it could not close — the partial tails and the tables that are still being fed. That leftover is what the spec's next step pads and commits. The obstacle worth naming is how a table gets committed before the number of chunks is known. Per-chunk AIRs differ only by the name used in reports; the commitment follows from the trace and the domain. So one AIR per kind serves every chunk of that kind, and the pass does not need the table counts it could not have yet. The test is the pass as a whole: every commitment it produced equals the one the ordinary prover gives that chunk, the cycles it walked match the straight run, and the chunks it closed plus the tail it kept are the chunks the resident build produced. That last part is exact for CPU — one op per executed cycle, nothing from finalization — and a bound elsewhere, since finalization appends after the last cycle and can spill a tail into another chunk. A kind listed in `CHUNKED_KINDS` without an AIR here fails the run rather than committing under the wrong one. The walk's end memory and register state is not carried yet. The Challenge phase that would use it does not exist, and a field nobody reads is a field that quietly goes wrong. --- prover/src/commit_phase.rs | 104 ++++++++++++++++++ prover/src/lib.rs | 1 + prover/src/tables/trace_builder.rs | 52 ++++++++- prover/src/tests/trace_builder_tests.rs | 135 ++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 prover/src/commit_phase.rs diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs new file mode 100644 index 000000000..38d843dae --- /dev/null +++ b/prover/src/commit_phase.rs @@ -0,0 +1,104 @@ +//! Approach 1's Commit phase: walk the execution, committing and retiring each +//! table as it fills. +//! +//! The spec has the prover go through execution "and once the memory pressure +//! becomes too large, batch commit to all full tables in memory; then these +//! tables are dropped". This is that pass. What it produces is a commitment per +//! closed chunk and, at the end, whatever the walk could not close — which the +//! Challenge phase pads and commits, as the spec's next step. + +use stark::config::Commitment; +use stark::proof::options::ProofOptions; +use stark::prover::IsStarkProver; + +use crate::Error; +use crate::tables::MaxRowsConfig; +use crate::tables::trace_builder::{TableKind, Traces, WalkLeftover, build_initial_image}; +use crate::tables::{register, types::*}; +use executor::elf::Elf; + +/// What the Commit phase produced. +pub struct CommitPhase { + /// One entry per chunk closed during the walk, in the order they closed. + pub closed: Vec<(TableKind, usize, Commitment)>, + /// Everything the walk still held when the execution ended. + pub leftover: WalkLeftover, +} + +/// Run the Commit phase over `elf`. +/// +/// Each chunk is committed the moment it fills and its trace is dropped, so +/// nothing that has been committed is still resident. The per-chunk AIRs differ +/// only by the name used in reports — the commitment depends on the trace and +/// the domain — so one AIR per kind serves every chunk of that kind, which is +/// what lets a table be committed before the number of chunks is known. +pub fn run( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, +) -> Result { + let image = build_initial_image(elf, private_input); + let register_init = register::register_init_from_entry_point(elf.entry_point); + let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(elf)?; + + let cpu = crate::test_utils::create_cpu_air(proof_options); + let memw = crate::test_utils::create_memw_air(proof_options); + let memw_aligned = crate::test_utils::create_memw_aligned_air(proof_options); + let memw_register = crate::test_utils::create_memw_register_air(proof_options); + let load = crate::test_utils::create_load_air(proof_options); + let cpu32 = crate::test_utils::create_cpu32_air(proof_options); + let branch = crate::test_utils::create_branch_air(proof_options); + let eq = crate::test_utils::create_eq_air(proof_options); + let bytewise = crate::test_utils::create_bytewise_air(proof_options); + let store = crate::test_utils::create_store_air(proof_options); + + let mut closed = Vec::new(); + let mut failed: Option = None; + let leftover = Traces::walk_and_emit_chunks( + &artifacts, + elf, + private_input.to_vec(), + &image, + ®ister_init, + max_rows, + |kind, chunk, table| { + let air: &dyn stark::traits::AIR< + Field = GoldilocksField, + FieldExtension = GoldilocksExtension, + PublicInputs = (), + > = match kind { + TableKind::Cpu => &cpu, + TableKind::Memw => &memw, + TableKind::MemwAligned => &memw_aligned, + TableKind::MemwRegister => &memw_register, + TableKind::Load => &load, + TableKind::Cpu32 => &cpu32, + TableKind::Branch => &branch, + TableKind::Eq => &eq, + TableKind::Bytewise => &bytewise, + TableKind::Store => &store, + other => { + // Unreachable through `CHUNKED_KINDS`; recorded rather than + // panicked so a kind added there without an AIR here fails + // the run instead of committing under the wrong one. + failed = Some(other); + return; + } + }; + type P = stark::prover::Prover; + match

>::commit_table_root(air, &table) { + Some(root) => closed.push((kind, chunk, root)), + None => failed = Some(kind), + } + }, + )?; + + if let Some(kind) = failed { + return Err(Error::Prover(format!( + "commit phase: no commitment for a {kind:?} chunk" + ))); + } + + Ok(CommitPhase { closed, leftover }) +} diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 9c77e09af..d809e1717 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -12,6 +12,7 @@ #[cfg(feature = "disk-spill")] pub mod auto_storage; +pub mod commit_phase; pub mod constraints; pub mod continuation; #[cfg(feature = "debug-checks")] diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index a96437979..865c54383 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1251,6 +1251,45 @@ fn collect_cpu32_bitwise(c: &cpu32::Cpu32Operation) -> Vec { /// The ALU-chip op a word ALU instruction dispatches (SHIFT/MUL/DVRM). ADDW/SUBW /// are the CPU32 ADD/SUB fast-path (no external chip), returning `None`. +/// What a Commit-phase walk still holds when the execution ends. +/// +/// The chunks it closed are gone — committed and dropped as they filled. This +/// is the rest: every table's partial tail, the tables the walk cannot close, +/// and the end state the finalization needs. The spec's "remaining tables are +/// padded and committed" operates on exactly this. +pub struct WalkLeftover { + /// Ops not yet turned into a committed chunk, including the lists the walk + /// never closes: the accumulators, and the tables CPU32 and DVRM still feed. + pub(crate) tail: CollectedOps, + /// Chunks already emitted per kind, indexed like [`CHUNKED_KINDS`], so the + /// tail's chunk numbering continues where the walk stopped. + pub(crate) emitted: [usize; CHUNKED_KINDS.len()], + /// Cycles executed. + pub(crate) cycles: usize, +} + +impl WalkLeftover { + /// Cycles the walk executed. + pub fn cycles(&self) -> usize { + self.cycles + } + + /// Ops still held for `kind` — the tail that the end-of-run phase pads and + /// commits. + pub fn buffered(&self, kind: TableKind) -> usize { + self.tail.buffered(kind) + } + + /// Chunks the walk closed for `kind`, so the tail can be numbered after + /// them. + pub fn emitted(&self, kind: TableKind) -> usize { + CHUNKED_KINDS + .iter() + .position(|k| *k == kind) + .map_or(0, |slot| self.emitted[slot]) + } +} + /// The tables `cpu32_chip_op` appends to. /// /// Kept beside it because it is load-bearing elsewhere: a table listed here is @@ -5074,7 +5113,7 @@ impl Traces { register_init: &[u32], max_rows: &super::MaxRowsConfig, mut on_chunk: impl FnMut(TableKind, usize, TraceTable), - ) -> Result<(), Error> { + ) -> Result { let mut executor = executor::vm::execution::Executor::new(elf, private_input) .map_err(|e| Error::Prover(format!("executor: {e}")))?; let mut memory_state = MemoryState::from_image(initial_image); @@ -5082,7 +5121,6 @@ impl Traces { let mut buf = CollectedOps::default(); let mut emitted = [0usize; CHUNKED_KINDS.len()]; - let _ = &emitted; let mut cycles_so_far = 0usize; while let Some(logs) = executor @@ -5129,7 +5167,15 @@ impl Traces { // retired during the walk, and "at the end of the execution, the // remaining tables are padded and committed". What is left goes back to // the caller so it can do exactly that. - Ok(()) + // The end state the finalization needs is not carried yet: the Challenge + // phase that pads and commits these tails does not exist, and a field + // nobody reads is a field that quietly goes wrong. + let _ = (&memory_state, ®ister_state); + Ok(WalkLeftover { + tail: buf, + emitted, + cycles: cycles_so_far, + }) } /// `collect_epoch`, driving the executor itself and consuming its logs one diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 090f513a9..10246c50f 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1697,3 +1697,138 @@ fn cpu32_appends_are_excluded_from_early_closing() { ); } } + +/// The Commit phase must commit every chunk it closes under the root the +/// ordinary prover gives that chunk, and hand back the rest of the run. +/// +/// This is the pass end to end: it walks the execution, commits and drops each +/// table as it fills, and returns what it could not close. Two things have to +/// hold for that to be a prover and not just a producer — the commitments have +/// to be the right ones, and nothing may fall between the chunks it closed and +/// the tail it kept. +#[test] +fn the_commit_phase_commits_what_it_closes_and_keeps_the_rest() { + use crate::tables::trace_builder::{TableKind, Traces as T}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = crate::tables::MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let phase = + crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit phase"); + assert!( + !phase.closed.is_empty(), + "the fixture must close at least one chunk mid-walk" + ); + + // Every commitment must be the one the resident chunk carries. + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + let counts = resident.table_counts(); + let airs = crate::VmAirs::new( + &elf, + &proof_options, + false, + &resident.page_configs, + &counts, + None, + true, + None, + None, + None, + ); + type P = stark::prover::Prover< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + (), + >; + use stark::prover::IsStarkProver; + for (kind, chunk, root) in &phase.closed { + let (air, table) = match kind { + TableKind::Cpu => (airs.cpus[*chunk].as_ref(), &resident.cpus[*chunk]), + TableKind::Memw => (airs.memws[*chunk].as_ref(), &resident.memws[*chunk]), + TableKind::MemwAligned => ( + airs.memw_aligneds[*chunk].as_ref(), + &resident.memw_aligneds[*chunk], + ), + TableKind::MemwRegister => ( + airs.memw_registers[*chunk].as_ref(), + &resident.memw_registers[*chunk], + ), + TableKind::Load => (airs.loads[*chunk].as_ref(), &resident.loads[*chunk]), + TableKind::Cpu32 => (airs.cpu32s[*chunk].as_ref(), &resident.cpu32s[*chunk]), + TableKind::Branch => (airs.branches[*chunk].as_ref(), &resident.branches[*chunk]), + TableKind::Eq => (airs.eqs[*chunk].as_ref(), &resident.eqs[*chunk]), + TableKind::Bytewise => (airs.bytewises[*chunk].as_ref(), &resident.bytewises[*chunk]), + TableKind::Store => (airs.stores[*chunk].as_ref(), &resident.stores[*chunk]), + other => unreachable!("{other:?} is not closed mid-walk"), + }; + let expected = +

>::commit_table_root(air, table).expect("resident commits"); + assert_eq!( + *root, expected, + "{kind:?} chunk {chunk}: the Commit phase used a different root" + ); + } + + // And nothing falls between what it closed and what it kept: the chunks it + // closed plus the tail it kept are the chunks the resident build produced. + // Exact for CPU, which is one op per executed cycle and takes nothing from + // the end-of-run finalization; a bound elsewhere, since that finalization + // appends after the last cycle and can spill the tail into another chunk. + assert_eq!( + phase.leftover.cycles(), + logs.len(), + "the walk executed a different number of cycles than the straight run" + ); + let closed_of = |kind: TableKind| phase.closed.iter().filter(|(k, _, _)| *k == kind).count(); + assert_eq!( + closed_of(TableKind::Cpu) + 1, + resident.cpus.len(), + "CPU: the closed chunks plus the tail are not the run's chunks" + ); + for (kind, produced) in [ + (TableKind::Memw, resident.memws.len()), + (TableKind::MemwAligned, resident.memw_aligneds.len()), + (TableKind::MemwRegister, resident.memw_registers.len()), + (TableKind::Load, resident.loads.len()), + (TableKind::Cpu32, resident.cpu32s.len()), + (TableKind::Branch, resident.branches.len()), + (TableKind::Eq, resident.eqs.len()), + (TableKind::Bytewise, resident.bytewises.len()), + (TableKind::Store, resident.stores.len()), + ] { + assert_eq!( + phase.leftover.emitted(kind), + closed_of(kind), + "{kind:?}: the leftover disagrees with what was committed" + ); + assert!( + closed_of(kind) < produced, + "{kind:?}: closed {} of the run's {produced} chunks, leaving no tail", + closed_of(kind) + ); + } +} From b5419eae5e17f4966625c8a07bb00fb869da2588 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:40:16 -0300 Subject: [PATCH 16/63] Pad and commit what the walk could not close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's step after Commit is that "at the end of the execution, the remaining tables are padded and commited to". `commit_remaining` is that: the partial tail of every table the walk closed, and every chunk of the four it could not close at all, because CPU32 and DVRM keep feeding them after a segment ends. Finalization runs first, as it does in the ordinary build — HALT appends 33 register MEMW ops at `u64::MAX`, and the MEMW-derived LT ops are collected after them so those accesses get their timestamp checks. That is also what makes the phase possible without the CPU ops the walk already dropped: finalization is driven from the register state alone. Together the two phases now account for the whole chunked side of the proof. The test checks that against a real proof's AIRs, per chunk and in position, over CPU, BRANCH, MEMW and LT — LT among them on purpose, since it is one of the tables the walk never closes, so the tail path is what produces it. Nothing may be committed twice, and a chunk nobody committed fails the test. Still outside: the preprocessed and accumulator tables, built from the ELF and from counts gathered across the whole run rather than from an op list. The walk's end memory state, which the PAGE build needs, is not carried for the same reason the register state is — it is only worth holding once something reads it. --- prover/src/commit_phase.rs | 100 +++++++++++++++++++++++ prover/src/tables/trace_builder.rs | 67 +++++++++++++-- prover/src/tests/trace_builder_tests.rs | 103 ++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 6 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 38d843dae..e21eb4ad8 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -25,6 +25,9 @@ pub struct CommitPhase { pub leftover: WalkLeftover, } +/// One chunked table's commitment, by kind and position. +pub type ChunkCommitment = (TableKind, usize, Commitment); + /// Run the Commit phase over `elf`. /// /// Each chunk is committed the moment it fills and its trace is dropped, so @@ -102,3 +105,100 @@ pub fn run( Ok(CommitPhase { closed, leftover }) } + +/// The Challenge phase's first half: pad and commit what the walk could not +/// close. +/// +/// The spec's step after Commit is "at the end of the execution, the remaining +/// tables are padded and commited to". That is this: the partial tail of every +/// table the walk closed, plus every chunk of the tables it could not close +/// because CPU32 and DVRM keep feeding them. +/// +/// Finalization comes first, as it does in the ordinary build: HALT appends 33 +/// register MEMW ops, and the MEMW-derived LT ops are collected after them so +/// those accesses get their timestamp checks. +/// +/// The preprocessed and accumulator tables — BITWISE, DECODE, REGISTER, PAGE and +/// the rest — are not here yet. They are built from the ELF and from counts +/// accumulated across the whole run rather than from an op list, so they need +/// their own step. +pub fn commit_remaining( + mut leftover: WalkLeftover, + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, +) -> Result, Error> { + leftover.finalize(); + + let cpu = crate::test_utils::create_cpu_air(proof_options); + let memw = crate::test_utils::create_memw_air(proof_options); + let memw_aligned = crate::test_utils::create_memw_aligned_air(proof_options); + let memw_register = crate::test_utils::create_memw_register_air(proof_options); + let load = crate::test_utils::create_load_air(proof_options); + let cpu32 = crate::test_utils::create_cpu32_air(proof_options); + let branch = crate::test_utils::create_branch_air(proof_options); + let eq = crate::test_utils::create_eq_air(proof_options); + let bytewise = crate::test_utils::create_bytewise_air(proof_options); + let store = crate::test_utils::create_store_air(proof_options); + let lt = crate::test_utils::create_lt_air(proof_options); + let mul = crate::test_utils::create_mul_air(proof_options); + let dvrm = crate::test_utils::create_dvrm_air(proof_options); + let shift = crate::test_utils::create_shift_air(proof_options); + + type P = stark::prover::Prover; + let mut out = Vec::new(); + for kind in ALL_CHUNKED { + let air: &dyn stark::traits::AIR< + Field = GoldilocksField, + FieldExtension = GoldilocksExtension, + PublicInputs = (), + > = match kind { + TableKind::Cpu => &cpu, + TableKind::Memw => &memw, + TableKind::MemwAligned => &memw_aligned, + TableKind::MemwRegister => &memw_register, + TableKind::Load => &load, + TableKind::Cpu32 => &cpu32, + TableKind::Branch => &branch, + TableKind::Eq => &eq, + TableKind::Bytewise => &bytewise, + TableKind::Store => &store, + TableKind::Lt => <, + TableKind::Mul => &mul, + TableKind::Dvrm => &dvrm, + TableKind::Shift => &shift, + }; + // Chunks the walk already closed keep their numbering; what is left + // continues from there. + let first = leftover.emitted(kind); + for (offset, table) in leftover + .take_remaining(kind, max_rows) + .into_iter() + .enumerate() + { + let root = +

>::commit_table_root(air, &table).ok_or_else(|| { + Error::Prover(format!("commit phase: no commitment for a {kind:?} tail")) + })?; + out.push((kind, first + offset, root)); + } + } + Ok(out) +} + +/// Every chunked table, closable mid-walk or not. +const ALL_CHUNKED: [TableKind; 14] = [ + TableKind::Cpu, + TableKind::Memw, + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Load, + TableKind::Cpu32, + TableKind::Branch, + TableKind::Eq, + TableKind::Bytewise, + TableKind::Store, + TableKind::Lt, + TableKind::Mul, + TableKind::Dvrm, + TableKind::Shift, +]; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 865c54383..4f3e2d74a 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1264,11 +1264,63 @@ pub struct WalkLeftover { /// Chunks already emitted per kind, indexed like [`CHUNKED_KINDS`], so the /// tail's chunk numbering continues where the walk stopped. pub(crate) emitted: [usize; CHUNKED_KINDS.len()], + /// Register state at the last cycle. The end-of-run finalization is driven + /// from it — HALT appends 33 register MEMW ops at `u64::MAX` — so the phase + /// that pads and commits the tails needs nothing else from the run. + /// + /// The memory state is not carried: the only thing that reads it is the + /// PAGE build, which is part of the preprocessed step that does not exist + /// yet, and a field nobody reads is a field that quietly goes wrong. + pub(crate) register_state: RegisterState, /// Cycles executed. pub(crate) cycles: usize, } impl WalkLeftover { + /// Apply the end-of-run finalization and the routing that depends on it. + /// + /// HALT appends 33 register MEMW ops at `u64::MAX`, and those need their + /// timestamp checks like any other access, so the MEMW-derived LT ops are + /// collected after them — the same order `build_traces` uses, where + /// finalization runs before phase 3. + pub(crate) fn finalize(&mut self) { + let halt = collect_halt_ops(&mut self.register_state); + let mut buckets = MemwBuckets::with_register_capacity(halt.len()); + buckets.extend_ops(halt); + self.tail.memw_register_rows.extend(buckets.register_rows); + self.tail.memw_aligned_ops.extend(buckets.aligned); + self.tail.memw_ops.extend(buckets.general); + + self.tail + .lt_ops + .extend(collect_lt_from_memw(&self.tail.memw_ops)); + self.tail + .lt_ops + .extend(collect_lt_from_memw_aligned(&self.tail.memw_aligned_ops)); + } + + /// Build every chunk still held for `kind`, draining it. + /// + /// An empty list still yields one padded chunk when the walk never closed + /// any, matching `chunk_and_generate`: the table exists in the proof with + /// the shape the verifier expects. + pub(crate) fn take_remaining( + &mut self, + kind: TableKind, + max_rows: &super::MaxRowsConfig, + ) -> Vec> { + let limit = max_rows_for(kind, max_rows); + let mut out = Vec::new(); + while self.tail.buffered(kind) > limit { + out.push(self.tail.take_front(kind, limit, max_rows)); + } + let left = self.tail.buffered(kind); + if left > 0 || (out.is_empty() && self.emitted(kind) == 0) { + out.push(self.tail.take_front(kind, left, max_rows)); + } + out + } + /// Cycles the walk executed. pub fn cycles(&self) -> usize { self.cycles @@ -3210,7 +3262,12 @@ impl CollectedOps { TableKind::Eq => drain!(self.eq_ops, eq::generate_eq_trace), TableKind::Bytewise => drain!(self.bytewise_ops, bytewise::generate_bytewise_trace), TableKind::Store => drain!(self.store_ops, store::generate_store_trace), - other => unreachable!("{other:?} is not closed mid-walk"), + // Not closable mid-walk, but the end-of-run phase builds them the + // same way once nothing can append to them any more. + TableKind::Lt => drain!(self.lt_ops, lt::generate_lt_trace), + TableKind::Mul => drain!(self.mul_ops, mul::generate_mul_trace), + TableKind::Dvrm => drain!(self.dvrm_ops, dvrm::generate_dvrm_trace), + TableKind::Shift => drain!(self.shift_ops, shift::generate_shift_trace), } } @@ -3300,7 +3357,7 @@ pub(crate) struct CollectedOps { /// The preprocessed tables (BITWISE, DECODE, REGISTER, HALT, COMMIT, KECCAK*) /// and PAGE are deliberately absent: they are not driven by one op list and the /// streaming prover keeps them resident. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum TableKind { Cpu, Memw, @@ -5167,13 +5224,11 @@ impl Traces { // retired during the walk, and "at the end of the execution, the // remaining tables are padded and committed". What is left goes back to // the caller so it can do exactly that. - // The end state the finalization needs is not carried yet: the Challenge - // phase that pads and commits these tails does not exist, and a field - // nobody reads is a field that quietly goes wrong. - let _ = (&memory_state, ®ister_state); + let _ = memory_state; Ok(WalkLeftover { tail: buf, emitted, + register_state, cycles: cycles_so_far, }) } diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 10246c50f..746af08e0 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1832,3 +1832,106 @@ fn the_commit_phase_commits_what_it_closes_and_keeps_the_rest() { ); } } + +/// Commit plus Challenge must cover every chunked table, each under the root +/// the ordinary prover gives it. +/// +/// Together the two passes are supposed to account for the chunked side of the +/// proof with nothing missing and nothing committed twice: the chunks closed +/// mid-walk, the tails padded at the end, and the tables the walk could not +/// close at all. Checked against a real proof's roots, in position, so a table +/// committed under the wrong root or in the wrong slot fails here. +#[test] +fn the_two_phases_cover_every_chunked_table() { + use crate::tables::trace_builder::{TableKind, Traces as T}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + use std::collections::HashMap; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = crate::tables::MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); + let closed = phase.closed.clone(); + let rest = crate::commit_phase::commit_remaining(phase.leftover, &max_rows, &proof_options) + .expect("challenge"); + + let mut got: HashMap<(TableKind, usize), _> = HashMap::new(); + for (kind, chunk, root) in closed.into_iter().chain(rest) { + assert!( + got.insert((kind, chunk), root).is_none(), + "{kind:?} chunk {chunk} was committed twice" + ); + } + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + let counts = resident.table_counts(); + let airs = crate::VmAirs::new( + &elf, + &proof_options, + false, + &resident.page_configs, + &counts, + None, + true, + None, + None, + None, + ); + type P = stark::prover::Prover< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + (), + >; + use stark::prover::IsStarkProver; + + let groups: [(TableKind, &Vec<_>, &Vec<_>); 4] = [ + (TableKind::Cpu, &airs.cpus, &resident.cpus), + (TableKind::Branch, &airs.branches, &resident.branches), + (TableKind::Lt, &airs.lts, &resident.lts), + (TableKind::Memw, &airs.memws, &resident.memws), + ]; + let mut checked = 0usize; + for (kind, kind_airs, kind_traces) in groups { + assert_eq!( + kind_airs.len(), + kind_traces.len(), + "{kind:?}: AIR and trace counts disagree" + ); + for (chunk, (air, table)) in kind_airs.iter().zip(kind_traces.iter()).enumerate() { + let expected =

>::commit_table_root(air.as_ref(), table) + .expect("resident commits"); + let actual = got + .get(&(kind, chunk)) + .unwrap_or_else(|| panic!("{kind:?} chunk {chunk} was never committed")); + assert_eq!( + *actual, expected, + "{kind:?} chunk {chunk}: committed under a different root" + ); + checked += 1; + } + } + assert!(checked > 4, "the fixture must cover several chunks"); +} From 56b0b9dc5fb819449074df4c405e0b9cffca2ab1 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:50:17 -0300 Subject: [PATCH 17/63] Keep a retired chunk's BITWISE lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BITWISE counts lookups from tables the Commit phase closes and drops. Its multiplicities are accumulated across the whole run, so a chunk's contribution has to be taken while the chunk still exists — and the phase was dropping chunks without taking it. The table would have come out short and the bus would have stopped balancing at verification, a long way from the chunk that caused it. The walk now folds a chunk's lookups into a running histogram just before draining its ops, and carries the result. `build_bitwise` adds what the tables still held contribute and fills the multiplicity columns. Only three of the ten kinds the walk closes feed BITWISE — MEMW_A, MEMW_R and BRANCH — so that is what is folded; the others have no collector and owe nothing. The sources that are not retired (LT, MUL, DVRM, SHIFT, the accelerators, PAGE) still need adding. BITWISE comes back as a table rather than a commitment: it is preprocessed, so its commitment splits into two trees, and that path is its own step. The test compares the table the phase builds against the same three sources over a finished run, and fails if the fold before draining is removed. --- prover/src/commit_phase.rs | 21 +++++- prover/src/tables/bitwise.rs | 1 + prover/src/tables/trace_builder.rs | 85 +++++++++++++++++++++++++ prover/src/tests/trace_builder_tests.rs | 71 ++++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index e21eb4ad8..0b0e2b1a9 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -16,6 +16,7 @@ use crate::tables::MaxRowsConfig; use crate::tables::trace_builder::{TableKind, Traces, WalkLeftover, build_initial_image}; use crate::tables::{register, types::*}; use executor::elf::Elf; +use stark::trace::TraceTable; /// What the Commit phase produced. pub struct CommitPhase { @@ -126,7 +127,7 @@ pub fn commit_remaining( mut leftover: WalkLeftover, max_rows: &MaxRowsConfig, proof_options: &ProofOptions, -) -> Result, Error> { +) -> Result { leftover.finalize(); let cpu = crate::test_utils::create_cpu_air(proof_options); @@ -182,7 +183,23 @@ pub fn commit_remaining( out.push((kind, first + offset, root)); } } - Ok(out) + // BITWISE comes back as a table rather than a commitment: it is + // preprocessed, so its commitment splits into two trees, and that path does + // not exist here yet. The lookups are what this phase is responsible for + // having kept — the chunks that owed them are long gone. + let bitwise = leftover.build_bitwise(); + Ok(Remaining { + chunks: out, + bitwise, + }) +} + +/// What the end-of-run phase produced. +pub struct Remaining { + /// The tails, and every chunk of the tables the walk could not close. + pub chunks: Vec, + /// The BITWISE table, carrying the lookups of every retired chunk. + pub bitwise: TraceTable, } /// Every chunked table, closable mid-walk or not. diff --git a/prover/src/tables/bitwise.rs b/prover/src/tables/bitwise.rs index c73e1e341..6e921c016 100644 --- a/prover/src/tables/bitwise.rs +++ b/prover/src/tables/bitwise.rs @@ -523,6 +523,7 @@ const _: () = { /// [`update_multiplicities`] produces (both just sum the same lookups per cell). /// /// Memory: `NUM_ROWS * NUM_LOOKUP_TYPES * 8` bytes = 2^20 * 10 * 8 = 80 MiB. +#[derive(PartialEq, Eq)] pub(crate) struct BitwiseHistogram { counters: Box<[u64]>, } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 4f3e2d74a..f1d96b263 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1264,6 +1264,9 @@ pub struct WalkLeftover { /// Chunks already emitted per kind, indexed like [`CHUNKED_KINDS`], so the /// tail's chunk numbering continues where the walk stopped. pub(crate) emitted: [usize; CHUNKED_KINDS.len()], + /// BITWISE lookups owed by the chunks the walk closed and dropped. The + /// end-of-run phase folds the rest in on top of this. + pub(crate) retired_bitwise: bitwise::BitwiseHistogram, /// Register state at the last cycle. The end-of-run finalization is driven /// from it — HALT appends 33 register MEMW ops at `u64::MAX` — so the phase /// that pads and commits the tails needs nothing else from the run. @@ -1321,6 +1324,37 @@ impl WalkLeftover { out } + /// Build the BITWISE table from what the run owes it. + /// + /// The lookups of the chunks the walk retired were folded in as they were + /// dropped; the tables still held contribute here. BITWISE is a fixed table + /// whose rows are the lookup space — only its multiplicity columns depend + /// on the run — so it is built once, at the end, and never chunked. + /// + /// Only the three sources a walk can retire are folded so far. The rest — + /// LT, MUL, DVRM, SHIFT, the accelerators, PAGE — feed BITWISE too and are + /// not here yet. + pub(crate) fn build_bitwise(&self) -> TraceTable { + let mut hist = bitwise::BitwiseHistogram::new(); + hist.merge(&self.retired_bitwise); + self.tail.fold_bitwise_from_front( + TableKind::MemwAligned, + self.tail.memw_aligned_ops.len(), + &mut hist, + ); + self.tail.fold_bitwise_from_front( + TableKind::MemwRegister, + self.tail.memw_register_rows.len(), + &mut hist, + ); + self.tail + .fold_bitwise_from_front(TableKind::Branch, self.tail.branch_ops.len(), &mut hist); + + let mut table = bitwise::generate_bitwise_trace(); + hist.fill_multiplicities(&mut table); + table + } + /// Cycles the walk executed. pub fn cycles(&self) -> usize { self.cycles @@ -2901,6 +2935,24 @@ impl CollectedEpoch { /// `build_traces` later stores in `Traces::touched_memory_cells` (both are /// [`touched_cells_from_memory_state`] over the same immutable /// `memory_state`), available before any table is built. + /// The same three BITWISE sources over a finished run, for comparison with + /// what a walk retired plus what it kept. + #[cfg(test)] + pub(crate) fn fold_bitwise_for_test(&self, hist: &mut bitwise::BitwiseHistogram) { + self.ops.fold_bitwise_from_front( + TableKind::MemwAligned, + self.ops.memw_aligned_ops.len(), + hist, + ); + self.ops.fold_bitwise_from_front( + TableKind::MemwRegister, + self.ops.memw_register_rows.len(), + hist, + ); + self.ops + .fold_bitwise_from_front(TableKind::Branch, self.ops.branch_ops.len(), hist); + } + /// Ops collected for `kind`. Mirrors [`CollectedOps::buffered`] so a walk's /// output and a finished run's can be compared on the same footing. pub fn op_count(&self, kind: TableKind) -> usize { @@ -3226,6 +3278,34 @@ impl CollectedOps { } } + /// Fold the BITWISE lookups the first `n` ops of `kind` imply into `hist`. + /// + /// Must run before those ops are drained. BITWISE accumulates across the + /// whole run from tables the Commit phase retires, so a chunk's + /// contribution has to be taken while the chunk still exists — otherwise + /// the table it lands in comes out short and the bus does not balance. + /// + /// Only three of the kinds the walk closes feed BITWISE; the others have no + /// collector and contribute nothing. + pub(crate) fn fold_bitwise_from_front( + &self, + kind: TableKind, + n: usize, + hist: &mut bitwise::BitwiseHistogram, + ) { + match kind { + TableKind::MemwAligned => hist.add_ops(&collect_bitwise_from_memw_aligned( + &self.memw_aligned_ops[..n], + )), + TableKind::MemwRegister => memw_register::collect_bitwise_from_memw_register( + &self.memw_register_rows[..n], + hist, + ), + TableKind::Branch => hist.add_ops(&collect_bitwise_from_branch(&self.branch_ops[..n])), + _ => {} + } + } + /// Build a trace from the first `n` buffered ops of `kind` and drop them. /// /// Draining is the point: this is what keeps the walk's buffers from @@ -5177,6 +5257,7 @@ impl Traces { let mut register_state = RegisterState::from_init(register_init); let mut buf = CollectedOps::default(); + let mut bitwise_hist = bitwise::BitwiseHistogram::new(); let mut emitted = [0usize; CHUNKED_KINDS.len()]; let mut cycles_so_far = 0usize; @@ -5210,6 +5291,9 @@ impl Traces { for (slot, kind) in CHUNKED_KINDS.iter().enumerate() { while buf.buffered(*kind) >= max_rows_for(*kind, max_rows) { let limit = max_rows_for(*kind, max_rows); + // Before the ops go: BITWISE counts them across the whole + // run, and this chunk is about to stop existing. + buf.fold_bitwise_from_front(*kind, limit, &mut bitwise_hist); let table = buf.take_front(*kind, limit, max_rows); on_chunk(*kind, emitted[slot], table); emitted[slot] += 1; @@ -5227,6 +5311,7 @@ impl Traces { let _ = memory_state; Ok(WalkLeftover { tail: buf, + retired_bitwise: bitwise_hist, emitted, register_state, cycles: cycles_so_far, diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 746af08e0..432a3136b 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1863,7 +1863,8 @@ fn the_two_phases_cover_every_chunked_table() { let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); let closed = phase.closed.clone(); let rest = crate::commit_phase::commit_remaining(phase.leftover, &max_rows, &proof_options) - .expect("challenge"); + .expect("challenge") + .chunks; let mut got: HashMap<(TableKind, usize), _> = HashMap::new(); for (kind, chunk, root) in closed.into_iter().chain(rest) { @@ -1935,3 +1936,71 @@ fn the_two_phases_cover_every_chunked_table() { } assert!(checked > 4, "the fixture must cover several chunks"); } + +/// A retired chunk must leave its BITWISE lookups behind. +/// +/// BITWISE counts lookups from tables the Commit phase closes and drops, so the +/// contribution has to be taken while the chunk still exists. If it is not, the +/// BITWISE table comes out short and the bus stops balancing — a failure that +/// surfaces at verification, far from the chunk that caused it. +#[test] +fn retiring_a_chunk_keeps_its_bitwise_lookups() { + use crate::tables::bitwise::BitwiseHistogram; + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + // Small limits for the three kinds that feed BITWISE, so chunks of them + // actually close mid-walk and their lookups have to be folded in early. + let max_rows = crate::tables::MaxRowsConfig { + memw_aligned: 1 << 10, + memw_register: 1 << 10, + branch: 1 << 10, + ..Default::default() + }; + + let mut leftover = T::walk_and_emit_chunks( + &artifacts, + &elf, + vec![], + &image, + ®ister_init, + &max_rows, + |_, _, _| {}, + ) + .expect("walk"); + + // The same three sources over the finished run, whole. + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let whole = T::collect_epoch(&artifacts, &image, ®ister_init, &logs, true).expect("collect"); + let mut expected = BitwiseHistogram::new(); + whole.fold_bitwise_for_test(&mut expected); + + // Finalization first, as the Challenge phase does it: HALT's register + // writes are part of the run and land in these same tables. + leftover.finalize(); + + // What the walk retired plus what it still holds, read off the table the + // phase actually builds. + let mut reference = crate::tables::bitwise::generate_bitwise_trace(); + expected.fill_multiplicities(&mut reference); + let built = leftover.build_bitwise(); + + let (a, _) = reference.main_data_row_major(); + let (b, _) = built.main_data_row_major(); + assert_eq!( + a.iter().map(|fe| *fe.value()).collect::>(), + b.iter().map(|fe| *fe.value()).collect::>(), + "the lookups of the retired chunks plus the tail do not add up to the run's" + ); +} From 567eeae17241183d96fd062bf34f002dea30c3de Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 22:55:41 -0300 Subject: [PATCH 18/63] Build the tables written once from the whole run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COMMIT, KECCAK with its two round tables, and the three accelerator tables are functions of an op list accumulated across the entire execution. Nothing closes them mid-walk, so the Commit phase's job for them is the opposite of retiring: keep feeding them while it drops everything else. It was discarding those lists instead. The walk accumulates them now and `build_accumulated` writes the seven tables at the end, where KECCAK_RC's multiplicities come from the keccak op count and KECCAK_RND's rows are derived from the same ops. The risk here runs the other way from the chunked tables: a list quietly not accumulated does not fail loudly, it produces an empty table that still looks well-formed. So the test compares all seven against the ordinary build on a fixture that actually uses keccak, and asserts that fixture leaves KECCAK non-empty — otherwise it would pass on a program that exercises none of them. Dropping the keccak accumulation makes it fail. --- prover/src/commit_phase.rs | 4 ++ prover/src/tables/trace_builder.rs | 52 ++++++++++++++++- prover/src/tests/trace_builder_tests.rs | 74 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 0b0e2b1a9..ace31fe69 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -188,9 +188,11 @@ pub fn commit_remaining( // not exist here yet. The lookups are what this phase is responsible for // having kept — the chunks that owed them are long gone. let bitwise = leftover.build_bitwise(); + let accumulated = leftover.build_accumulated(); Ok(Remaining { chunks: out, bitwise, + accumulated, }) } @@ -200,6 +202,8 @@ pub struct Remaining { pub chunks: Vec, /// The BITWISE table, carrying the lookups of every retired chunk. pub bitwise: TraceTable, + /// The tables written once from an accumulated op list. + pub accumulated: crate::tables::trace_builder::AccumulatedTables, } /// Every chunked table, closable mid-walk or not. diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index f1d96b263..181e98adf 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1355,6 +1355,37 @@ impl WalkLeftover { table } + /// Build the tables that are a function of one accumulated op list. + /// + /// COMMIT, KECCAK and its two round tables, and the three accelerator + /// tables. None of them is ever closed mid-walk — they are written once, at + /// the end, from everything the run produced — so this is where they + /// belong rather than in the chunk machinery. + pub(crate) fn build_accumulated(&self) -> AccumulatedTables { + let keccak_rnd_ops: Vec = self + .tail + .keccak_ops + .iter() + .map(|op| KeccakRoundOperation { + timestamp: op.timestamp, + input: op.input, + output: op.output, + }) + .collect(); + let mut keccak_rc = keccak_rc::generate_keccak_rc_trace(); + keccak_rc::update_multiplicities(&mut keccak_rc, self.tail.keccak_ops.len()); + + AccumulatedTables { + commit: commit::generate_commit_trace(&self.tail.commit_ops), + keccak: keccak::generate_keccak_trace(&self.tail.keccak_ops), + keccak_rnd: keccak_rnd::generate_keccak_rnd_trace(&keccak_rnd_ops), + keccak_rc, + ecsm: ecsm::generate_ecsm_trace(&self.tail.ecsm_ops), + ecdas: ecdas::generate_ecdas_trace(&self.tail.ecdas_ops), + hint: hint::generate_hint_trace(&self.tail.hint_ops), + } + } + /// Cycles the walk executed. pub fn cycles(&self) -> usize { self.cycles @@ -1376,6 +1407,17 @@ impl WalkLeftover { } } +/// The tables built once, at the end, from an accumulated op list. +pub struct AccumulatedTables { + pub commit: TraceTable, + pub keccak: TraceTable, + pub keccak_rnd: TraceTable, + pub keccak_rc: TraceTable, + pub ecsm: TraceTable, + pub ecdas: TraceTable, + pub hint: TraceTable, +} + /// The tables `cpu32_chip_op` appends to. /// /// Kept beside it because it is load-bearing elsewhere: a table listed here is @@ -5267,7 +5309,7 @@ impl Traces { { let cpu = collect_cpu_ops(logs, &artifacts.instructions, cycles_so_far)?; cycles_so_far += cpu.len(); - let (memw, ld, lt, sh, _bw, _cm, _kc, c32, _ec, _ed, _hn) = + let (memw, ld, lt, sh, bw, cm, kc, c32, ec, ed, hn) = collect_ops_from_cpu(&cpu, &mut memory_state, &mut register_state); // Derived from THIS segment's ops, before they are moved into the // buffer — the buffer is drained as chunks close, so it is not the @@ -5285,6 +5327,14 @@ impl Traces { buf.lt_ops.extend(lt); buf.shift_ops.extend(sh); buf.cpu32_ops.extend(c32); + // Never closed mid-walk: these are accumulators or accelerator + // tables built once, at the end, from the whole run. + buf.bitwise_ops.extend(bw); + buf.commit_ops.extend(cm); + buf.keccak_ops.extend(kc); + buf.ecsm_ops.extend(ec); + buf.ecdas_ops.extend(ed); + buf.hint_ops.extend(hn); // Emit every chunk that is now full, and only those: a partial chunk // may still grow, so it waits for the end. diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 432a3136b..5e960a624 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -2004,3 +2004,77 @@ fn retiring_a_chunk_keeps_its_bitwise_lookups() { "the lookups of the retired chunks plus the tail do not add up to the run's" ); } + +/// The tables built from accumulated op lists must match the ordinary build. +/// +/// COMMIT, KECCAK and its round tables, and the accelerator tables are written +/// once at the end from everything the run produced. The Commit phase drops the +/// chunked tables as it goes but has to keep feeding these, so the risk is the +/// opposite one: an op list quietly not accumulated comes out as an empty table +/// that still looks well-formed. +#[test] +fn the_accumulated_tables_match_the_ordinary_build() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + // Uses keccak and the commit ecall, so the tables under test are not empty. + let elf_bytes = crate::test_utils::asm_elf_bytes("test_keccak"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + let max_rows = crate::tables::MaxRowsConfig::default(); + + let mut leftover = T::walk_and_emit_chunks( + &artifacts, + &elf, + vec![], + &image, + ®ister_init, + &max_rows, + |_, _, _| {}, + ) + .expect("walk"); + leftover.finalize(); + let built = leftover.build_accumulated(); + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; + for (name, a, b) in [ + ("COMMIT", &built.commit, &resident.commit), + ("KECCAK", &built.keccak, &resident.keccak), + ("KECCAK_RND", &built.keccak_rnd, &resident.keccak_rnd), + ("KECCAK_RC", &built.keccak_rc, &resident.keccak_rc), + ("ECSM", &built.ecsm, &resident.ecsm), + ("ECDAS", &built.ecdas, &resident.ecdas), + ("HINT", &built.hint, &resident.hint), + ] { + assert_eq!(flat(a), flat(b), "{name} differs from the ordinary build"); + } + assert!( + flat(&built.keccak).iter().any(|v| *v != 0), + "the fixture must exercise KECCAK, or this proves nothing" + ); +} From 4ce864d320f7af43cbddaf2b42919829570ad398 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Tue, 15 Sep 2026 23:03:08 -0300 Subject: [PATCH 19/63] Count DECODE lookups by pc, not per cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DECODE takes one lookup per executed cycle at that cycle's pc, and the ordinary build gets them by listing every CPU op's pc. The Commit phase drops those ops as it goes, so it has to count the lookups while they still exist — and listing them would cost one entry per cycle, which is the thing the phase exists to avoid. The walk counts by pc instead: one entry per distinct program counter, bounded by the program rather than by how long it runs. `add_multiplicities` applies a count in one step where `update_multiplicities` applies a list. Padding rows look DECODE up too, at the padding pc, and each CPU chunk's share is known when the chunk closes, so the walk adds it there and `build_decode` only has to account for the tail's own. The test runs with a CPU limit that is deliberately not a power of two. With a power-of-two limit a full chunk pads by zero, the padding term is always correct by accident, and zeroing it leaves the test passing — which is exactly what happened before the limit was changed. --- prover/src/commit_phase.rs | 10 +++ prover/src/tables/decode.rs | 21 ++++++ prover/src/tables/trace_builder.rs | 53 +++++++++++++- prover/src/tests/trace_builder_tests.rs | 94 ++++++++++++++++++++++++- 4 files changed, 172 insertions(+), 6 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index ace31fe69..759492a94 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -125,6 +125,7 @@ pub fn run( /// their own step. pub fn commit_remaining( mut leftover: WalkLeftover, + artifacts: &crate::tables::trace_builder::DecodeArtifacts, max_rows: &MaxRowsConfig, proof_options: &ProofOptions, ) -> Result { @@ -189,9 +190,15 @@ pub fn commit_remaining( // having kept — the chunks that owed them are long gone. let bitwise = leftover.build_bitwise(); let accumulated = leftover.build_accumulated(); + let decode = leftover.build_decode( + artifacts.decode_trace.clone(), + &artifacts.decode_pc_to_row, + max_rows, + ); Ok(Remaining { chunks: out, bitwise, + decode, accumulated, }) } @@ -202,6 +209,9 @@ pub struct Remaining { pub chunks: Vec, /// The BITWISE table, carrying the lookups of every retired chunk. pub bitwise: TraceTable, + /// The DECODE table, with one lookup counted per executed cycle and per + /// padding row. + pub decode: TraceTable, /// The tables written once from an accumulated op list. pub accumulated: crate::tables::trace_builder::AccumulatedTables, } diff --git a/prover/src/tables/decode.rs b/prover/src/tables/decode.rs index bfd1ddb90..c92537f07 100644 --- a/prover/src/tables/decode.rs +++ b/prover/src/tables/decode.rs @@ -175,6 +175,27 @@ pub fn generate_decode_trace( /// Updates multiplicities in the DECODE trace table. /// /// For each PC in `lookups`, increments the MU column in the corresponding row. +/// Add `count` lookups of `pc` at once. +/// +/// The per-lookup form needs one entry per executed cycle, which a prover that +/// walks the execution and drops what it has proved cannot keep. Counting by pc +/// costs one entry per distinct program counter instead — bounded by the +/// program, not by how long it runs. +pub fn add_multiplicities( + trace: &mut TraceTable, + pc_to_row: &PcToRow, + counts: &std::collections::HashMap, +) { + for (pc, count) in counts { + if let Some(&row_idx) = pc_to_row.get(pc) { + let current = trace.main_table.get(row_idx, cols::MU); + trace + .main_table + .set_fe(row_idx, cols::MU, current + FE::from(*count)); + } + } +} + pub fn update_multiplicities( trace: &mut TraceTable, pc_to_row: &PcToRow, diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 181e98adf..e0447c3c3 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1267,6 +1267,11 @@ pub struct WalkLeftover { /// BITWISE lookups owed by the chunks the walk closed and dropped. The /// end-of-run phase folds the rest in on top of this. pub(crate) retired_bitwise: bitwise::BitwiseHistogram, + /// DECODE lookups per program counter, counted rather than listed. + pub(crate) decode_counts: HashMap, + /// Padding rows the CPU chunks closed so far added, each of which looks + /// DECODE up at the padding pc. + pub(crate) padding_rows: usize, /// Register state at the last cycle. The end-of-run finalization is driven /// from it — HALT appends 33 register MEMW ops at `u64::MAX` — so the phase /// that pads and commits the tails needs nothing else from the run. @@ -1386,6 +1391,33 @@ impl WalkLeftover { } } + /// Build the DECODE table for the run. + /// + /// One lookup per executed cycle at that cycle's pc, plus one per padding + /// row at the padding pc. The walk counted both as it went — the cycles + /// because their CPU ops are long gone, the padding because each chunk's + /// share is known when the chunk closes — so this only has to add the tail's + /// own cycles and its padding. + /// + /// `decode_trace` is the pristine table from the ELF; the multiplicities are + /// the only part that depends on the run. + pub(crate) fn build_decode( + &self, + decode_trace: TraceTable, + pc_to_row: &decode::PcToRow, + max_rows: &super::MaxRowsConfig, + ) -> TraceTable { + let mut counts = self.decode_counts.clone(); + let tail = self.tail.cpu_ops.len(); + let padding = self.padding_rows + tail.next_power_of_two().max(4) - tail; + let _ = max_rows; + *counts.entry(cpu::CPU_PADDING_PC).or_insert(0) += padding as u64; + + let mut decode = decode_trace; + decode::add_multiplicities(&mut decode, pc_to_row, &counts); + decode + } + /// Cycles the walk executed. pub fn cycles(&self) -> usize { self.cycles @@ -2936,9 +2968,9 @@ fn generate_page_tables( /// build ([`Traces::from_image_and_logs_with_decode`]) instead of re-parsing /// the ELF and regenerating the trace per epoch. pub struct DecodeArtifacts { - instructions: U64HashMap, - decode_trace: TraceTable, - decode_pc_to_row: decode::PcToRow, + pub(crate) instructions: U64HashMap, + pub(crate) decode_trace: TraceTable, + pub(crate) decode_pc_to_row: decode::PcToRow, } impl DecodeArtifacts { @@ -5300,6 +5332,8 @@ impl Traces { let mut buf = CollectedOps::default(); let mut bitwise_hist = bitwise::BitwiseHistogram::new(); + let mut decode_counts: HashMap = HashMap::new(); + let mut padding_rows = 0usize; let mut emitted = [0usize; CHUNKED_KINDS.len()]; let mut cycles_so_far = 0usize; @@ -5314,6 +5348,12 @@ impl Traces { // Derived from THIS segment's ops, before they are moved into the // buffer — the buffer is drained as chunks close, so it is not the // segment. + // DECODE counts one lookup per executed cycle. Counting by pc keeps + // that bounded by the program instead of by the run, which is what + // lets the CPU ops be dropped at all. + for op in &cpu { + *decode_counts.entry(op.decode.pc).or_insert(0) += 1; + } let derived = derive_from_cpu(&cpu); buf.branch_ops.extend(derived.branch_ops); buf.eq_ops.extend(derived.eq_ops); @@ -5344,6 +5384,11 @@ impl Traces { // Before the ops go: BITWISE counts them across the whole // run, and this chunk is about to stop existing. buf.fold_bitwise_from_front(*kind, limit, &mut bitwise_hist); + if *kind == TableKind::Cpu { + // Each CPU chunk pads to a power of two, and every + // padding row looks DECODE up at the padding pc. + padding_rows += limit.next_power_of_two().max(4) - limit; + } let table = buf.take_front(*kind, limit, max_rows); on_chunk(*kind, emitted[slot], table); emitted[slot] += 1; @@ -5362,6 +5407,8 @@ impl Traces { Ok(WalkLeftover { tail: buf, retired_bitwise: bitwise_hist, + decode_counts, + padding_rows, emitted, register_state, cycles: cycles_so_far, diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 5e960a624..6ba5d65b3 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1862,9 +1862,16 @@ fn the_two_phases_cover_every_chunked_table() { let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); let closed = phase.closed.clone(); - let rest = crate::commit_phase::commit_remaining(phase.leftover, &max_rows, &proof_options) - .expect("challenge") - .chunks; + let artifacts = + crate::tables::trace_builder::DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let rest = crate::commit_phase::commit_remaining( + phase.leftover, + &artifacts, + &max_rows, + &proof_options, + ) + .expect("challenge") + .chunks; let mut got: HashMap<(TableKind, usize), _> = HashMap::new(); for (kind, chunk, root) in closed.into_iter().chain(rest) { @@ -2078,3 +2085,84 @@ fn the_accumulated_tables_match_the_ordinary_build() { "the fixture must exercise KECCAK, or this proves nothing" ); } + +/// DECODE's multiplicities must survive the chunks being dropped. +/// +/// Every executed cycle looks DECODE up at its pc, and every padding row looks +/// it up at the padding pc. The Commit phase drops the CPU ops that carry those +/// pcs, so the lookups have to be counted while they still exist — by pc, since +/// listing them costs one entry per cycle, which is the thing being avoided. +#[test] +fn decode_multiplicities_survive_retiring_the_cpu_chunks() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + // Several CPU chunks, so most of the lookups belong to chunks that were + // closed and dropped rather than to the tail. Deliberately NOT a power of + // two: a full chunk then pads, and the padding lookups are a term that a + // power-of-two limit would leave at zero and therefore untested. + let max_rows = crate::tables::MaxRowsConfig { + cpu: 10_000, + ..Default::default() + }; + + let mut closed_cpu = 0usize; + let leftover = T::walk_and_emit_chunks( + &artifacts, + &elf, + vec![], + &image, + ®ister_init, + &max_rows, + |kind, _, _| { + if kind == crate::tables::trace_builder::TableKind::Cpu { + closed_cpu += 1; + } + }, + ) + .expect("walk"); + assert!( + closed_cpu > 1, + "the fixture must drop several CPU chunks, or the counting is untested" + ); + let built = leftover.build_decode( + artifacts.decode_trace.clone(), + &artifacts.decode_pc_to_row, + &max_rows, + ); + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; + assert_eq!( + flat(&built), + flat(&resident.decode), + "DECODE's multiplicities differ from the ordinary build" + ); +} From f53347e24b97998c4a48a02af883e4dff31b287f Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 10:00:45 -0300 Subject: [PATCH 20/63] Finish the end-of-run tables: HALT, REGISTER, PAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three that depend on state rather than on an op list. HALT comes from the run's terminating ECALL, which the walk notes in passing since the CPU ops that carry it are dropped. REGISTER then finalizes the PC: the padding rows chain inline-PC tokens at a +4 cadence from HALT's emit, so the final token has to match the last padding write or the memory argument does not balance. PAGE reads the memory image at the last cycle, and owes BITWISE lookups of its own, so BITWISE is written only once those are in. Two ordering hazards came out of building it, and both are now structural rather than remembered. The CPU padding count is frozen by `finalize` while the tail is still whole. HALT's register token and DECODE's padding lookups are both derived from it, and both are built after the tails have been drained into chunks — reading it then counts a tail that no longer exists. It was a silent four-timestamp error in REGISTER before the freeze; now building either table before finalization is a panic with a message rather than a wrong number. An empty tail is not a chunk. `ops.chunks(n)` over a length that divides evenly yields no trailing empty one, so counting its padding invents four rows the run never had. BITWISE also gained the sources it was missing. BYTEWISE, EQ and STORE are retired by the walk, so their lookups are folded as their chunks close, like MEMW_A, MEMW_R and BRANCH; everything never retired — LT, MUL, DVRM, SHIFT, the accelerators, the padding byte checks — is folded at finalization. The test now compares the whole table against the ordinary build, and fails if the fold before draining is removed. --- prover/src/commit_phase.rs | 31 +++- prover/src/tables/trace_builder.rs | 188 ++++++++++++++++++----- prover/src/tests/trace_builder_tests.rs | 196 ++++++++++++++++++++---- 3 files changed, 348 insertions(+), 67 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 759492a94..79a212340 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -123,13 +123,17 @@ pub fn run( /// the rest — are not here yet. They are built from the ELF and from counts /// accumulated across the whole run rather than from an op list, so they need /// their own step. -pub fn commit_remaining( +#[allow(clippy::too_many_arguments)] +pub fn commit_remaining( mut leftover: WalkLeftover, artifacts: &crate::tables::trace_builder::DecodeArtifacts, + initial_image: &I, + register_init: &[u32], + private_input: &[u8], max_rows: &MaxRowsConfig, proof_options: &ProofOptions, ) -> Result { - leftover.finalize(); + leftover.finalize(max_rows); let cpu = crate::test_utils::create_cpu_air(proof_options); let memw = crate::test_utils::create_memw_air(proof_options); @@ -146,6 +150,11 @@ pub fn commit_remaining( let dvrm = crate::test_utils::create_dvrm_air(proof_options); let shift = crate::test_utils::create_shift_air(proof_options); + // HALT and REGISTER first: REGISTER's final PC token is derived from the CPU + // padding, and the padding of the tail cannot be counted once the tail has + // been drained into a chunk below. + let (halt, register) = leftover.build_halt_and_register(register_init)?; + type P = stark::prover::Prover; let mut out = Vec::new(); for kind in ALL_CHUNKED { @@ -188,17 +197,26 @@ pub fn commit_remaining( // preprocessed, so its commitment splits into two trees, and that path does // not exist here yet. The lookups are what this phase is responsible for // having kept — the chunks that owed them are long gone. - let bitwise = leftover.build_bitwise(); let accumulated = leftover.build_accumulated(); let decode = leftover.build_decode( artifacts.decode_trace.clone(), &artifacts.decode_pc_to_row, max_rows, ); + // PAGE last: it owes BITWISE lookups of its own, so BITWISE is written only + // once those are in. + let mut hist = leftover.bitwise_histogram(); + let (pages, page_configs) = leftover.build_pages(initial_image, private_input, &mut hist); + let bitwise = WalkLeftover::build_bitwise_from(&hist); + Ok(Remaining { chunks: out, bitwise, decode, + halt, + register, + pages, + page_configs, accumulated, }) } @@ -212,6 +230,13 @@ pub struct Remaining { /// The DECODE table, with one lookup counted per executed cycle and per /// padding row. pub decode: TraceTable, + /// HALT, from the run's terminating ECALL. + pub halt: TraceTable, + /// REGISTER, whose final PC token has to match the last padding write. + pub register: TraceTable, + /// The PAGE tables and their configs. + pub pages: Vec>, + pub page_configs: Vec, /// The tables written once from an accumulated op list. pub accumulated: crate::tables::trace_builder::AccumulatedTables, } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index e0447c3c3..b19fa0fc0 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1272,6 +1272,13 @@ pub struct WalkLeftover { /// Padding rows the CPU chunks closed so far added, each of which looks /// DECODE up at the padding pc. pub(crate) padding_rows: usize, + /// CPU padding rows over the whole run, frozen by `finalize` while the tail + /// is still intact. + pub(crate) total_cpu_padding: Option, + /// Timestamp and next pc of the run's last ECALL, which HALT is built from. + pub(crate) last_ecall: Option<(u64, u64)>, + /// Memory at the last cycle, which the PAGE build reads. + pub(crate) memory_state: MemoryState, /// Register state at the last cycle. The end-of-run finalization is driven /// from it — HALT appends 33 register MEMW ops at `u64::MAX` — so the phase /// that pads and commits the tails needs nothing else from the run. @@ -1291,7 +1298,20 @@ impl WalkLeftover { /// timestamp checks like any other access, so the MEMW-derived LT ops are /// collected after them — the same order `build_traces` uses, where /// finalization runs before phase 3. - pub(crate) fn finalize(&mut self) { + pub(crate) fn finalize(&mut self, max_rows: &super::MaxRowsConfig) { + // Freeze the CPU padding here, while the tail is still whole. Both HALT's + // register token and DECODE's padding lookups are derived from it, and + // both are built after the tails have been drained into chunks — reading + // it then would count a tail that no longer exists. + let tail = self.tail.cpu_ops.len(); + self.total_cpu_padding = Some(if tail == 0 && self.emitted(TableKind::Cpu) > 0 { + // An empty tail is not a chunk: `ops.chunks(n)` over a length that + // divides evenly yields no trailing empty one. + self.padding_rows + } else { + self.padding_rows + tail.next_power_of_two().max(4) - tail + }); + let halt = collect_halt_ops(&mut self.register_state); let mut buckets = MemwBuckets::with_register_capacity(halt.len()); buckets.extend_ops(halt); @@ -1305,6 +1325,41 @@ impl WalkLeftover { self.tail .lt_ops .extend(collect_lt_from_memw_aligned(&self.tail.memw_aligned_ops)); + + // Fold the tail's own BITWISE lookups in now, while the tail is whole. + // The retired chunks contributed theirs as they closed; from here the + // histogram is complete and draining the tail cannot change it. + let mut hist = + std::mem::replace(&mut self.retired_bitwise, bitwise::BitwiseHistogram::new()); + for kind in [ + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Branch, + TableKind::Bytewise, + TableKind::Eq, + TableKind::Store, + ] { + let n = self.tail.buffered(kind); + self.tail.fold_bitwise_from_front(kind, n, &mut hist); + } + // The sources nothing ever retires, so their whole list is here. + hist.add_ops(&collect_bitwise_from_lt(&self.tail.lt_ops)); + hist.add_ops(&collect_bitwise_from_mul(&self.tail.mul_ops, max_rows.mul)); + hist.add_ops(&collect_bitwise_from_dvrm( + &self.tail.dvrm_ops, + max_rows.dvrm, + )); + hist.add_ops(&shift::collect_bitwise_from_shift(&self.tail.shift_ops)); + hist.add_ops(&collect_bitwise_from_commit(&self.tail.commit_ops)); + hist.add_ops(&collect_bitwise_from_keccak(&self.tail.keccak_ops)); + hist.add_ops(&collect_bitwise_from_ecsm(&self.tail.ecsm_ops)); + hist.add_ops(&collect_bitwise_from_ecdas(&self.tail.ecdas_ops)); + hist.add_ops(&collect_bitwise_from_hint(&self.tail.hint_ops)); + // CPU padding rows send ARE_BYTES with all-zero values. + add_padding_byte_checks(&mut hist, self.cpu_padding_rows()); + // The lookups the walk itself collected while routing. + hist.add_ops(&self.tail.bitwise_ops); + self.retired_bitwise = hist; } /// Build every chunk still held for `kind`, draining it. @@ -1339,22 +1394,23 @@ impl WalkLeftover { /// Only the three sources a walk can retire are folded so far. The rest — /// LT, MUL, DVRM, SHIFT, the accelerators, PAGE — feed BITWISE too and are /// not here yet. - pub(crate) fn build_bitwise(&self) -> TraceTable { + /// Every BITWISE lookup the run owes, from the retired chunks and from the + /// tail — `finalize` folded both in, so this is complete whatever has been + /// drained since. PAGE's own lookups are added by `build_pages`. + pub(crate) fn bitwise_histogram(&self) -> bitwise::BitwiseHistogram { let mut hist = bitwise::BitwiseHistogram::new(); hist.merge(&self.retired_bitwise); - self.tail.fold_bitwise_from_front( - TableKind::MemwAligned, - self.tail.memw_aligned_ops.len(), - &mut hist, - ); - self.tail.fold_bitwise_from_front( - TableKind::MemwRegister, - self.tail.memw_register_rows.len(), - &mut hist, - ); - self.tail - .fold_bitwise_from_front(TableKind::Branch, self.tail.branch_ops.len(), &mut hist); + hist + } + /// Fill BITWISE's multiplicity columns from a histogram. + /// + /// Taken separately from [`bitwise_histogram`](Self::bitwise_histogram) so + /// PAGE — which owes BITWISE its own lookups and is built later, from the + /// memory image — can fold them in before the table is written. + pub(crate) fn build_bitwise_from( + hist: &bitwise::BitwiseHistogram, + ) -> TraceTable { let mut table = bitwise::generate_bitwise_trace(); hist.fill_multiplicities(&mut table); table @@ -1408,8 +1464,7 @@ impl WalkLeftover { max_rows: &super::MaxRowsConfig, ) -> TraceTable { let mut counts = self.decode_counts.clone(); - let tail = self.tail.cpu_ops.len(); - let padding = self.padding_rows + tail.next_power_of_two().max(4) - tail; + let padding = self.cpu_padding_rows(); let _ = max_rows; *counts.entry(cpu::CPU_PADDING_PC).or_insert(0) += padding as u64; @@ -1418,6 +1473,57 @@ impl WalkLeftover { decode } + /// Total padding rows the CPU table adds, over the closed chunks and the + /// tail. + fn cpu_padding_rows(&self) -> usize { + self.total_cpu_padding + .expect("finalize must run before the end-of-run tables are built") + } + + /// Build HALT and REGISTER, in that order because the second depends on the + /// first. + /// + /// HALT comes from the run's terminating ECALL. REGISTER then has to finalize + /// the PC: the CPU padding rows chain inline-PC tokens at a +4 cadence from + /// the HALT chip's emit at `halt_timestamp + 1`, so the last write lands at + /// `halt_timestamp + 4 * padding + 1` and REGISTER's final token must match + /// it or the memory argument does not balance. Both numbers were counted + /// during the walk, since the ops that carry them are long dropped. + pub(crate) fn build_halt_and_register( + &mut self, + register_init: &[u32], + ) -> Result { + let (halt_timestamp, halt_next_pc) = self.last_ecall.ok_or(Error::MissingHaltEcall)?; + let padding = self.cpu_padding_rows(); + self.register_state + .write_pc(1, halt_timestamp + 4 * padding as u64 + 1); + let register_final_state = self.register_state.to_final_state_map(); + + Ok(( + halt::generate_halt_trace(halt_timestamp, halt_next_pc), + register::generate_register_trace(®ister_final_state, register_init), + )) + } + + /// Build the PAGE tables from the run's end memory. + /// + /// PAGE also owes BITWISE its lookups, so they are folded into `hist` here + /// rather than left for a caller to remember. + pub(crate) fn build_pages( + &self, + initial_image: &I, + private_input: &[u8], + hist: &mut bitwise::BitwiseHistogram, + ) -> ( + Vec>, + Vec, + ) { + let (tables, configs) = + generate_page_tables(initial_image, &self.memory_state, private_input, false); + collect_bitwise_from_page(initial_image, &self.memory_state, false, hist); + (tables, configs) + } + /// Cycles the walk executed. pub fn cycles(&self) -> usize { self.cycles @@ -1439,6 +1545,13 @@ impl WalkLeftover { } } +/// HALT and REGISTER, which are built together because the second depends on +/// the first. +pub type HaltAndRegister = ( + TraceTable, + TraceTable, +); + /// The tables built once, at the end, from an accumulated op list. pub struct AccumulatedTables { pub commit: TraceTable, @@ -3009,24 +3122,6 @@ impl CollectedEpoch { /// `build_traces` later stores in `Traces::touched_memory_cells` (both are /// [`touched_cells_from_memory_state`] over the same immutable /// `memory_state`), available before any table is built. - /// The same three BITWISE sources over a finished run, for comparison with - /// what a walk retired plus what it kept. - #[cfg(test)] - pub(crate) fn fold_bitwise_for_test(&self, hist: &mut bitwise::BitwiseHistogram) { - self.ops.fold_bitwise_from_front( - TableKind::MemwAligned, - self.ops.memw_aligned_ops.len(), - hist, - ); - self.ops.fold_bitwise_from_front( - TableKind::MemwRegister, - self.ops.memw_register_rows.len(), - hist, - ); - self.ops - .fold_bitwise_from_front(TableKind::Branch, self.ops.branch_ops.len(), hist); - } - /// Ops collected for `kind`. Mirrors [`CollectedOps::buffered`] so a walk's /// output and a finished run's can be compared on the same footing. pub fn op_count(&self, kind: TableKind) -> usize { @@ -3376,6 +3471,21 @@ impl CollectedOps { hist, ), TableKind::Branch => hist.add_ops(&collect_bitwise_from_branch(&self.branch_ops[..n])), + TableKind::Bytewise => { + for op in &self.bytewise_ops[..n] { + hist.add_ops(&op.collect_bitwise_ops()); + } + } + TableKind::Eq => { + for op in &self.eq_ops[..n] { + hist.add_ops(&op.collect_bitwise_ops()); + } + } + TableKind::Store => { + for op in &self.store_ops[..n] { + hist.add_ops(&op.collect_bitwise_ops()); + } + } _ => {} } } @@ -5334,6 +5444,9 @@ impl Traces { let mut bitwise_hist = bitwise::BitwiseHistogram::new(); let mut decode_counts: HashMap = HashMap::new(); let mut padding_rows = 0usize; + // HALT is built from the run's terminating ECALL, and the CPU ops that + // carry it are dropped as their chunk closes, so it is noted in passing. + let mut last_ecall: Option<(u64, u64)> = None; let mut emitted = [0usize; CHUNKED_KINDS.len()]; let mut cycles_so_far = 0usize; @@ -5353,6 +5466,9 @@ impl Traces { // lets the CPU ops be dropped at all. for op in &cpu { *decode_counts.entry(op.decode.pc).or_insert(0) += 1; + if op.decode.fields.ecall { + last_ecall = Some((op.timestamp, op.next_pc)); + } } let derived = derive_from_cpu(&cpu); buf.branch_ops.extend(derived.branch_ops); @@ -5403,12 +5519,14 @@ impl Traces { // retired during the walk, and "at the end of the execution, the // remaining tables are padded and committed". What is left goes back to // the caller so it can do exactly that. - let _ = memory_state; Ok(WalkLeftover { tail: buf, retired_bitwise: bitwise_hist, decode_counts, padding_rows, + total_cpu_padding: None, + last_ecall, + memory_state, emitted, register_state, cycles: cycles_so_far, diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 6ba5d65b3..43d7fb984 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1864,9 +1864,14 @@ fn the_two_phases_cover_every_chunked_table() { let closed = phase.closed.clone(); let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = crate::tables::trace_builder::build_initial_image(&elf, &[]); + let register_init = crate::tables::register::register_init_from_entry_point(elf.entry_point); let rest = crate::commit_phase::commit_remaining( phase.leftover, &artifacts, + &image, + ®ister_init, + &[], &max_rows, &proof_options, ) @@ -1948,13 +1953,18 @@ fn the_two_phases_cover_every_chunked_table() { /// /// BITWISE counts lookups from tables the Commit phase closes and drops, so the /// contribution has to be taken while the chunk still exists. If it is not, the -/// BITWISE table comes out short and the bus stops balancing — a failure that -/// surfaces at verification, far from the chunk that caused it. +/// table comes out short and the bus stops balancing — a failure that surfaces +/// at verification, far from the chunk that caused it. +/// +/// The limits here are small for the kinds that owe BITWISE, so most of their +/// lookups belong to chunks that were closed and dropped rather than to the +/// tail. Removing the fold that runs before a chunk is drained fails this. #[test] fn retiring_a_chunk_keeps_its_bitwise_lookups() { - use crate::tables::bitwise::BitwiseHistogram; use crate::tables::register::register_init_from_entry_point; - use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use crate::tables::trace_builder::{ + DecodeArtifacts, TableKind, Traces as T, WalkLeftover, build_initial_image, + }; use executor::elf::Elf; use executor::vm::execution::Executor; @@ -1963,15 +1973,17 @@ fn retiring_a_chunk_keeps_its_bitwise_lookups() { let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); let image = build_initial_image(&elf, &[]); let register_init = register_init_from_entry_point(elf.entry_point); - // Small limits for the three kinds that feed BITWISE, so chunks of them - // actually close mid-walk and their lookups have to be folded in early. let max_rows = crate::tables::MaxRowsConfig { memw_aligned: 1 << 10, memw_register: 1 << 10, branch: 1 << 10, + eq: 1 << 10, + bytewise: 1 << 10, + store: 1 << 10, ..Default::default() }; + let mut retired = 0usize; let mut leftover = T::walk_and_emit_chunks( &artifacts, &elf, @@ -1979,36 +1991,57 @@ fn retiring_a_chunk_keeps_its_bitwise_lookups() { &image, ®ister_init, &max_rows, - |_, _, _| {}, + |kind, _, _| { + if matches!( + kind, + TableKind::MemwAligned + | TableKind::MemwRegister + | TableKind::Branch + | TableKind::Eq + | TableKind::Bytewise + | TableKind::Store + ) { + retired += 1; + } + }, ) .expect("walk"); + assert!( + retired > 1, + "the fixture must retire chunks that owe BITWISE, or this proves nothing" + ); + leftover.finalize(&max_rows); + + let mut hist = leftover.bitwise_histogram(); + leftover.build_pages(&image, &[], &mut hist); + let built = WalkLeftover::build_bitwise_from(&hist); - // The same three sources over the finished run, whole. let logs = Executor::new(&elf, vec![]) .expect("executor") .run() .expect("run") .logs; - let whole = T::collect_epoch(&artifacts, &image, ®ister_init, &logs, true).expect("collect"); - let mut expected = BitwiseHistogram::new(); - whole.fold_bitwise_for_test(&mut expected); - - // Finalization first, as the Challenge phase does it: HALT's register - // writes are part of the run and land in these same tables. - leftover.finalize(); - - // What the walk retired plus what it still holds, read off the table the - // phase actually builds. - let mut reference = crate::tables::bitwise::generate_bitwise_trace(); - expected.fill_multiplicities(&mut reference); - let built = leftover.build_bitwise(); - - let (a, _) = reference.main_data_row_major(); - let (b, _) = built.main_data_row_major(); + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; assert_eq!( - a.iter().map(|fe| *fe.value()).collect::>(), - b.iter().map(|fe| *fe.value()).collect::>(), - "the lookups of the retired chunks plus the tail do not add up to the run's" + flat(&built), + flat(&resident.bitwise), + "the lookups of the retired chunks are missing from BITWISE" ); } @@ -2044,7 +2077,7 @@ fn the_accumulated_tables_match_the_ordinary_build() { |_, _, _| {}, ) .expect("walk"); - leftover.finalize(); + leftover.finalize(&max_rows); let built = leftover.build_accumulated(); let logs = Executor::new(&elf, vec![]) @@ -2114,7 +2147,7 @@ fn decode_multiplicities_survive_retiring_the_cpu_chunks() { }; let mut closed_cpu = 0usize; - let leftover = T::walk_and_emit_chunks( + let mut leftover = T::walk_and_emit_chunks( &artifacts, &elf, vec![], @@ -2128,6 +2161,9 @@ fn decode_multiplicities_survive_retiring_the_cpu_chunks() { }, ) .expect("walk"); + // DECODE is built in the end-of-run phase, after finalization freezes the + // padding count; building it before would read a tail that is still growing. + leftover.finalize(&max_rows); assert!( closed_cpu > 1, "the fixture must drop several CPU chunks, or the counting is untested" @@ -2166,3 +2202,105 @@ fn decode_multiplicities_survive_retiring_the_cpu_chunks() { "DECODE's multiplicities differ from the ordinary build" ); } + +/// The end-of-run phase must produce every non-chunked table the ordinary build +/// produces, identically. +/// +/// These are the tables that cannot be closed while the run continues: HALT +/// comes from the terminating ECALL, REGISTER's final PC token has to match the +/// last padding write, PAGE reads the memory image at the last cycle, and +/// BITWISE owes lookups that include PAGE's. Each depends on state the walk had +/// to carry rather than on an op list it could keep. +#[test] +fn the_end_of_run_tables_match_the_ordinary_build() { + use crate::tables::register::register_init_from_entry_point; + use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use executor::elf::Elf; + use executor::vm::execution::Executor; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); + let image = build_initial_image(&elf, &[]); + let register_init = register_init_from_entry_point(elf.entry_point); + // Not a power of two, so the CPU chunks pad and REGISTER's final PC token + // depends on a padding count the walk had to accumulate. + let max_rows = crate::tables::MaxRowsConfig { + cpu: 10_000, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); + let rest = crate::commit_phase::commit_remaining( + phase.leftover, + &artifacts, + &image, + ®ister_init, + &[], + &max_rows, + &proof_options, + ) + .expect("challenge"); + + let logs = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("run") + .logs; + let resident = T::from_elf_and_logs( + &elf, + &logs, + &max_rows, + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("traces"); + + let flat = |t: &stark::trace::TraceTable< + crate::tables::types::GoldilocksField, + crate::tables::types::GoldilocksExtension, + >| { + let (data, _) = t.main_data_row_major(); + data.iter().map(|fe| *fe.value()).collect::>() + }; + assert_eq!(flat(&rest.halt), flat(&resident.halt), "HALT differs"); + { + let a = flat(&rest.register); + let b = flat(&resident.register); + let first = a.iter().zip(b.iter()).position(|(x, y)| x != y); + eprintln!( + "REGISTER: len {} vs {}, first diff at {:?} -> {:?} vs {:?}", + a.len(), + b.len(), + first, + first.map(|i| a[i]), + first.map(|i| b[i]) + ); + } + assert_eq!( + flat(&rest.register), + flat(&resident.register), + "REGISTER differs" + ); + assert_eq!(flat(&rest.decode), flat(&resident.decode), "DECODE differs"); + assert_eq!( + flat(&rest.bitwise), + flat(&resident.bitwise), + "BITWISE differs" + ); + assert_eq!( + rest.pages.len(), + resident.pages.len(), + "a different number of PAGE tables" + ); + assert!( + !rest.pages.is_empty(), + "the fixture must produce PAGE tables" + ); + for (i, (a, b)) in rest.pages.iter().zip(resident.pages.iter()).enumerate() { + assert_eq!(flat(a), flat(b), "PAGE {i} differs"); + } +} From dcdc2e31100bc79a8ebcdb600b491c35b4429f08 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 11:34:05 -0300 Subject: [PATCH 21/63] Split a preprocessed table's Round 1 roots A preprocessed table commits as two trees: its precomputed columns, whose root is a constant of the AIR, and the multiplicities, which depend on the execution. The transcript absorbs both, precomputed first. commit_table_root committed the whole width as one tree, so for BITWISE, DECODE, KECCAK_RC, REGISTER and every PAGE it returned a root the proof never carries. Mirror commit_main_trace's branch instead, and return the pair as MainRoots rather than a bare Commitment so the absorption order travels with the value. The precomputed half is re-derived only to be checked against the AIR's constant, which is the same check the production path makes and the one that catches a table whose precomputed columns were built wrong. --- crypto/stark/src/prover.rs | 44 ++++++++++++++++++++++++++++++++++++-- prover/src/commit_phase.rs | 7 +++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index e8f762ec4..1ba7118c0 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -652,6 +652,22 @@ fn host_cores() -> usize { /// pool. Worst case against the best measured `k`: `num_airs` +1.6 % (inside /// noise), the old `cores*2/3` +13.0 %. Bounding concurrency is memory /// admission's job (`VramGate`), not this count's. +/// A table's Round 1 roots, in the order Fiat-Shamir absorbs them. +/// +/// A plain table contributes one root. A preprocessed one contributes two: its +/// precomputed columns commit separately from the multiplicities, and the +/// transcript takes the precomputed root first. Getting that order or that +/// count wrong yields different challenges from the same execution, which is +/// why the pair travels together instead of as a bare `Commitment`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MainRoots { + /// The precomputed columns' root — `Some` iff the AIR is preprocessed. + pub precomputed: Option, + /// The root of the columns that depend on the execution: the whole trace + /// for a plain AIR, the multiplicities for a preprocessed one. + pub main: Commitment, +} + /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1665,7 +1681,7 @@ pub trait IsStarkProver< fn commit_table_root( air: &dyn AIR, trace: &TraceTable, - ) -> Option + ) -> Option where FieldElement: AsBytes + math::traits::ByteConversion, FieldElement: AsBytes + math::traits::ByteConversion, @@ -1687,7 +1703,31 @@ pub trait IsStarkProver< &twiddles.two_half_fwd, ) .ok()?; - Self::commit_rows_bit_reversed::(&lde, cols).map(|(_, root)| root) + if !air.is_preprocessed() { + return Self::commit_rows_bit_reversed::(&lde, cols).map(|(_, root)| { + MainRoots { + precomputed: None, + main: root, + } + }); + } + // A preprocessed table commits as two trees, and the transcript absorbs + // both. The precomputed half is a constant of the AIR, so it is derived + // here only to be checked against that constant — the same check + // `commit_main_trace` makes, and the one that catches a table whose + // precomputed columns were built wrong. + let num_precomputed = air.num_precomputed_columns(); + let (_, precomputed_root) = + Self::commit_rows_bit_reversed_subset::(&lde, cols, 0, num_precomputed)?; + if precomputed_root != air.precomputed_commitment() { + return None; + } + let (_, main) = + Self::commit_rows_bit_reversed_subset::(&lde, cols, num_precomputed, cols)?; + Some(MainRoots { + precomputed: Some(precomputed_root), + main, + }) } /// Reconstruct Round1 for every table, print the bus balance report, and diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 79a212340..fc91ce975 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -7,9 +7,8 @@ //! closed chunk and, at the end, whatever the walk could not close — which the //! Challenge phase pads and commits, as the spec's next step. -use stark::config::Commitment; use stark::proof::options::ProofOptions; -use stark::prover::IsStarkProver; +use stark::prover::{IsStarkProver, MainRoots}; use crate::Error; use crate::tables::MaxRowsConfig; @@ -21,13 +20,13 @@ use stark::trace::TraceTable; /// What the Commit phase produced. pub struct CommitPhase { /// One entry per chunk closed during the walk, in the order they closed. - pub closed: Vec<(TableKind, usize, Commitment)>, + pub closed: Vec, /// Everything the walk still held when the execution ended. pub leftover: WalkLeftover, } /// One chunked table's commitment, by kind and position. -pub type ChunkCommitment = (TableKind, usize, Commitment); +pub type ChunkCommitment = (TableKind, usize, MainRoots); /// Run the Commit phase over `elf`. /// From ead5b0d3144964824fe1c6ac4b296ddd1235652c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 11:35:01 -0300 Subject: [PATCH 22/63] Carry what the statement needs out of the walk The transcript binds the statement before any root is absorbed, so the Challenge phase cannot sample without the run's public output and its runtime page ranges. Neither was reachable from the walk: the output bytes were folded inline in the resident build, and the page ranges were a method on Traces, which is the one thing the Commit phase does not produce. Read the output off the COMMIT ops, which are an accumulator the walk never closes and so are all still there at the end, and make the page-range encoding a free function over the configs. Traces keeps its method, now delegating. --- prover/src/commit_phase.rs | 5 ++ prover/src/tables/trace_builder.rs | 87 +++++++++++++++++++----------- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index fc91ce975..26232174b 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -196,6 +196,7 @@ pub fn commit_remaining( // preprocessed, so its commitment splits into two trees, and that path does // not exist here yet. The lookups are what this phase is responsible for // having kept — the chunks that owed them are long gone. + let public_output = leftover.public_output_bytes(); let accumulated = leftover.build_accumulated(); let decode = leftover.build_decode( artifacts.decode_trace.clone(), @@ -210,6 +211,7 @@ pub fn commit_remaining( Ok(Remaining { chunks: out, + public_output, bitwise, decode, halt, @@ -224,6 +226,9 @@ pub fn commit_remaining( pub struct Remaining { /// The tails, and every chunk of the tables the walk could not close. pub chunks: Vec, + /// The bytes the run committed, which the statement binds into the + /// transcript before any root is absorbed. + pub public_output: Vec, /// The BITWISE table, carrying the lookups of every retired chunk. pub bitwise: TraceTable, /// The DECODE table, with one lookup counted per executed cycle and per diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index b19fa0fc0..8bcc3032a 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1422,6 +1422,22 @@ impl WalkLeftover { /// tables. None of them is ever closed mid-walk — they are written once, at /// the end, from everything the run produced — so this is where they /// belong rather than in the chunk machinery. + /// The committed public output, in the order the run wrote it. + /// + /// COMMIT is an accumulator — the walk never closes it — so every op is + /// still here at the end of the run, and this is the same fold over the + /// same list that the ordinary build does. The statement absorbed into the + /// transcript carries these bytes, so the Challenge phase cannot sample + /// without them. + pub(crate) fn public_output_bytes(&self) -> Vec { + self.tail + .commit_ops + .iter() + .filter(|op| !op.end) + .map(|op| op.value) + .collect() + } + pub(crate) fn build_accumulated(&self) -> AccumulatedTables { let keccak_rnd_ops: Vec = self .tail @@ -2438,6 +2454,45 @@ fn private_input_bytes(private_input: &[u8]) -> Vec { /// Build the initial-memory image (byte address -> value) from the ELF segments /// and the private-input region. Single source of "what memory starts as", read /// by both `MemoryState` seeding and PAGE/bitwise init. +/// Run-length encode the runtime (non-ELF) page bases into `(base, count)`. +/// +/// Zero-init pages are the runtime ones, so `init_values == None` identifies +/// them without rescanning the ELF segments. The result goes into the statement +/// the transcript absorbs, which is why it takes the configs rather than a +/// built `Traces`: the Commit phase has the configs and no `Traces`. +pub(crate) fn runtime_page_ranges( + page_configs: &[page::PageConfig], +) -> Vec { + let page_size = page::DEFAULT_PAGE_SIZE as u64; + + let runtime_bases: Vec = page_configs + .iter() + .filter(|config| config.init_values.is_none()) + .map(|config| config.page_base) + .collect(); + + let mut ranges = Vec::new(); + if runtime_bases.is_empty() { + return ranges; + } + + let mut start = runtime_bases[0]; + let mut count = 1u64; + + for &base in &runtime_bases[1..] { + if base == start + count * page_size { + count += 1; + } else { + ranges.push(crate::RuntimePageRange { base: start, count }); + start = base; + count = 1; + } + } + ranges.push(crate::RuntimePageRange { base: start, count }); + + ranges +} + pub(crate) fn build_initial_image(elf: &Elf, private_input: &[u8]) -> HashMap { let mut image: HashMap = HashMap::new(); for segment in &elf.data { @@ -5219,37 +5274,7 @@ impl Traces { /// Runtime (non-ELF) pages are identified by `init_values == None` /// (zero-init), avoiding a redundant ELF segment scan. pub fn runtime_page_ranges(&self) -> Vec { - let page_size = page::DEFAULT_PAGE_SIZE as u64; - - // Collect sorted non-ELF page bases (zero-init pages are runtime pages) - let runtime_bases: Vec = self - .page_configs - .iter() - .filter(|config| config.init_values.is_none()) - .map(|config| config.page_base) - .collect(); - - // Run-length encode contiguous pages into (base, count) ranges - let mut ranges = Vec::new(); - if runtime_bases.is_empty() { - return ranges; - } - - let mut start = runtime_bases[0]; - let mut count = 1u64; - - for &base in &runtime_bases[1..] { - if base == start + count * page_size { - count += 1; - } else { - ranges.push(crate::RuntimePageRange { base: start, count }); - start = base; - count = 1; - } - } - ranges.push(crate::RuntimePageRange { base: start, count }); - - ranges + runtime_page_ranges(&self.page_configs) } /// Generates all traces from ELF and execution logs using phased collection. From d1e025d8d238107100ddc163685c4093dd3867c3 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 11:35:12 -0300 Subject: [PATCH 23/63] Run the Commit phase to the end in one call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committing the chunks and padding what is left were two calls, and the second needed the image, the decode artifacts and the register init that the first had built and dropped. Every caller had to rebuild all three to get from one to the other. Split the walk out of run so both entry points share it, and add run_to_end, which does the walk and the padding over one image and hands back a root per chunk of every chunked table. What it leaves resident is exactly the tables that are not built from an op list — the preprocessed ones and the accumulators — which is the state the Challenge phase starts from. --- prover/src/commit_phase.rs | 77 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 26232174b..8a65dad3b 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -44,7 +44,28 @@ pub fn run( let image = build_initial_image(elf, private_input); let register_init = register::register_init_from_entry_point(elf.entry_point); let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(elf)?; + walk_and_commit( + &artifacts, + elf, + private_input, + &image, + ®ister_init, + max_rows, + proof_options, + ) +} +/// The walk itself, over an already-built image and decode table. +#[allow(clippy::too_many_arguments)] +fn walk_and_commit( + artifacts: &crate::tables::trace_builder::DecodeArtifacts, + elf: &Elf, + private_input: &[u8], + image: &I, + register_init: &[u32], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, +) -> Result { let cpu = crate::test_utils::create_cpu_air(proof_options); let memw = crate::test_utils::create_memw_air(proof_options); let memw_aligned = crate::test_utils::create_memw_aligned_air(proof_options); @@ -59,11 +80,11 @@ pub fn run( let mut closed = Vec::new(); let mut failed: Option = None; let leftover = Traces::walk_and_emit_chunks( - &artifacts, + artifacts, elf, private_input.to_vec(), - &image, - ®ister_init, + image, + register_init, max_rows, |kind, chunk, table| { let air: &dyn stark::traits::AIR< @@ -106,6 +127,54 @@ pub fn run( Ok(CommitPhase { closed, leftover }) } +/// The Commit phase end to end. +/// +/// The walk, then the padding of everything it could not close. What comes back +/// is a root per chunk of every chunked table and, still resident, only the +/// tables that are not built from an op list: the preprocessed ones and the +/// accumulators. That is the state the Challenge phase starts from. +pub fn run_to_end( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, +) -> Result { + let image = build_initial_image(elf, private_input); + let register_init = register::register_init_from_entry_point(elf.entry_point); + let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(elf)?; + + let phase = walk_and_commit( + &artifacts, + elf, + private_input, + &image, + ®ister_init, + max_rows, + proof_options, + )?; + let mut chunks = phase.closed; + let mut remaining = commit_remaining( + phase.leftover, + &artifacts, + &image, + ®ister_init, + private_input, + max_rows, + proof_options, + )?; + chunks.append(&mut remaining.chunks); + + Ok(Committed { chunks, remaining }) +} + +/// Every chunk committed, and what the Commit phase leaves resident. +pub struct Committed { + /// A root per chunk of every chunked table, walk-closed and tail alike. + pub chunks: Vec, + /// The tables the walk could not commit, still as traces. + pub remaining: Remaining, +} + /// The Challenge phase's first half: pad and commit what the walk could not /// close. /// @@ -225,6 +294,8 @@ pub fn commit_remaining( /// What the end-of-run phase produced. pub struct Remaining { /// The tails, and every chunk of the tables the walk could not close. + /// + /// [`run_to_end`] drains this into [`Committed::chunks`]; read it there. pub chunks: Vec, /// The bytes the run committed, which the statement binds into the /// transcript before any root is absorbed. From 352dd0a507e7e60eee3546ac21c869e3b4815692 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 11:35:19 -0300 Subject: [PATCH 24/63] Sample one challenge for the whole execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is where Approach 1 parts from the continuations in main. There each epoch samples its own challenges, so tables from different epochs sit on different buses and need the local-to-global apparatus to be tied back together. Here every chunk of the run is absorbed into one transcript and answers to one (z, alpha), so there is nothing to tie. The phase commits the tables the walk could not — the preprocessed ones, the accumulators and PAGE — then assembles every root in air_trace_pairs order and absorbs it on top of the bound statement, a preprocessed table's precomputed root ahead of its own. The order is the protocol, so it is pinned against a real proof rather than against this module's idea of it: the test proves the same program the ordinary way and checks every root in position, then compares the challenge with the verifier's own replay, rebuilt from the proof's statement fields. Dropping a precomputed root or swapping two AIRs both fail it. --- prover/src/challenge_phase.rs | 247 ++++++++++++++++++++++ prover/src/lib.rs | 1 + prover/src/streaming.rs | 4 +- prover/src/tests/challenge_phase_tests.rs | 117 ++++++++++ prover/src/tests/mod.rs | 1 + 5 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 prover/src/challenge_phase.rs create mode 100644 prover/src/tests/challenge_phase_tests.rs diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs new file mode 100644 index 000000000..b410b32e6 --- /dev/null +++ b/prover/src/challenge_phase.rs @@ -0,0 +1,247 @@ +//! Approach 1's Challenge phase: absorb every root and sample the one challenge +//! the whole execution shares. +//! +//! The spec's step after Commit is to pad and commit the remaining tables and +//! then sample the LogUp challenges. [`crate::commit_phase::run_to_end`] does +//! the padding; this does the sampling, and it is where Approach 1 differs from +//! the continuations in `main`. There, each epoch samples its own challenges, so +//! the tables of different epochs live on different buses and need the +//! local-to-global apparatus to be tied back together. Here every chunk of the +//! run is absorbed into one transcript and answers to one `(z, alpha)`, so there +//! is nothing to tie. +//! +//! The order the roots are absorbed in *is* the protocol: it has to be the AIR +//! order the ordinary prover uses, with a preprocessed table's precomputed root +//! ahead of its own. `challenge_matches_the_ordinary_prover` pins that against a +//! real proof rather than against this file's idea of the order. + +use std::collections::HashMap; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use crypto::fiat_shamir::is_transcript::IsTranscript; +use stark::proof::options::ProofOptions; +use stark::prover::MainRoots; + +use crate::Error; +use crate::commit_phase::Committed; +use crate::statement::{StatementKind, absorb_statement}; +use crate::streaming::{GROUP_ORDER, NUM_FIXED_AIRS}; +use crate::tables::trace_builder::{TableKind, runtime_page_ranges}; +use crate::tables::types::{GoldilocksExtension, GoldilocksField}; +use crate::{TableCounts, VmAirs}; +use executor::elf::Elf; +use math::field::element::FieldElement; +use stark::trace::TraceTable; + +/// The one challenge the whole execution shares, and the roots it was drawn +/// from. +pub struct Challenge { + /// `z` and `alpha`, in sampling order. + pub challenges: Vec>, + /// Every root absorbed, in AIR order. + pub roots: Vec, +} + +/// Sample the shared LogUp challenges from a finished Commit phase. +/// +/// `elf_bytes` is the raw program: the statement binds its digest, so the +/// challenge depends on the program proved and not only on the tables it +/// produced. +pub fn run( + committed: &Committed, + elf: &Elf, + elf_bytes: &[u8], + proof_options: &ProofOptions, +) -> Result { + let remaining = &committed.remaining; + let table_counts = count_chunks(&committed.chunks); + let airs = VmAirs::new( + elf, + proof_options, + false, + &remaining.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + + let roots = assemble_roots(committed, &airs, &table_counts)?; + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + elf_bytes, + &remaining.public_output, + &table_counts, + remaining + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(), + &runtime_page_ranges(&remaining.page_configs), + proof_options.fri_final_poly_log_degree, + ); + for root in &roots { + if let Some(ref precomputed) = root.precomputed { + transcript.append_bytes(precomputed); + } + transcript.append_bytes(&root.main); + } + + let challenges = (0..stark::lookup::LOGUP_NUM_CHALLENGES) + .map(|_| transcript.sample_field_element()) + .collect(); + + Ok(Challenge { challenges, roots }) +} + +/// Every root the transcript absorbs, in `VmAirs::air_trace_pairs` order. +/// +/// The tables the walk committed come back keyed by `(kind, chunk)` in the order +/// they closed, which is not the AIR order; the ones it could not commit are +/// still traces and are committed here. +fn assemble_roots( + committed: &Committed, + airs: &VmAirs, + table_counts: &TableCounts, +) -> Result, Error> { + let remaining = &committed.remaining; + let accumulated = &remaining.accumulated; + + let mut roots = Vec::new(); + let fixed: [( + &crate::VmAir, + &TraceTable, + &str, + ); NUM_FIXED_AIRS] = [ + (&airs.bitwise, &remaining.bitwise, "BITWISE"), + (&airs.decode, &remaining.decode, "DECODE"), + (&airs.commit, &accumulated.commit, "COMMIT"), + (&airs.keccak, &accumulated.keccak, "KECCAK"), + (&airs.keccak_rnd, &accumulated.keccak_rnd, "KECCAK_RND"), + (&airs.keccak_rc, &accumulated.keccak_rc, "KECCAK_RC"), + (&airs.ecsm, &accumulated.ecsm, "ECSM"), + (&airs.ecdas, &accumulated.ecdas, "ECDAS"), + (&airs.hint, &accumulated.hint, "HINT"), + (&airs.register, &remaining.register, "REGISTER"), + ]; + for (air, trace, name) in fixed { + roots.push(commit_resident(air, trace, name)?); + } + if airs.include_halt { + roots.push(commit_resident(&airs.halt, &remaining.halt, "HALT")?); + } + + let mut by_slot: HashMap<(TableKind, usize), &MainRoots> = HashMap::new(); + for (kind, chunk, root) in &committed.chunks { + if by_slot.insert((*kind, *chunk), root).is_some() { + return Err(Error::Prover(format!( + "challenge phase: {kind:?} chunk {chunk} committed twice" + ))); + } + } + + let mut page_airs = airs.pages.iter().zip(remaining.pages.iter()); + for group in GROUP_ORDER { + let Some(kind) = group else { + // PAGE is built from the ELF image rather than from an op list, so + // it is never retired and is committed here with the rest. + for (air, trace) in page_airs.by_ref() { + roots.push(commit_resident(air, trace, "PAGE")?); + } + continue; + }; + for chunk in 0..count_for(table_counts, kind) { + let root = by_slot.remove(&(kind, chunk)).ok_or_else(|| { + Error::Prover(format!( + "challenge phase: no root for {kind:?} chunk {chunk}" + )) + })?; + roots.push(root.clone()); + } + } + if let Some(((kind, chunk), _)) = by_slot.into_iter().next() { + return Err(Error::Prover(format!( + "challenge phase: {kind:?} chunk {chunk} has a root but no AIR" + ))); + } + + Ok(roots) +} + +fn commit_resident( + air: &crate::VmAir, + trace: &TraceTable, + name: &str, +) -> Result { + type P = stark::prover::Prover; +

>::commit_table_root(air.as_ref(), trace) + .ok_or_else(|| Error::Prover(format!("challenge phase: no commitment for {name}"))) +} + +/// How many chunks the Commit phase produced per kind. +fn count_chunks(chunks: &[crate::commit_phase::ChunkCommitment]) -> TableCounts { + let mut counts = TableCounts { + cpu: 0, + lt: 0, + memw: 0, + memw_aligned: 0, + load: 0, + mul: 0, + dvrm: 0, + shift: 0, + branch: 0, + memw_register: 0, + eq: 0, + bytewise: 0, + store: 0, + cpu32: 0, + }; + for (kind, chunk, _) in chunks { + let slot = slot_for(&mut counts, *kind); + *slot = (*slot).max(chunk + 1); + } + counts +} + +fn count_for(counts: &TableCounts, kind: TableKind) -> usize { + match kind { + TableKind::Cpu => counts.cpu, + TableKind::Lt => counts.lt, + TableKind::Memw => counts.memw, + TableKind::MemwAligned => counts.memw_aligned, + TableKind::Load => counts.load, + TableKind::Mul => counts.mul, + TableKind::Dvrm => counts.dvrm, + TableKind::Shift => counts.shift, + TableKind::Branch => counts.branch, + TableKind::MemwRegister => counts.memw_register, + TableKind::Eq => counts.eq, + TableKind::Bytewise => counts.bytewise, + TableKind::Store => counts.store, + TableKind::Cpu32 => counts.cpu32, + } +} + +fn slot_for(counts: &mut TableCounts, kind: TableKind) -> &mut usize { + match kind { + TableKind::Cpu => &mut counts.cpu, + TableKind::Lt => &mut counts.lt, + TableKind::Memw => &mut counts.memw, + TableKind::MemwAligned => &mut counts.memw_aligned, + TableKind::Load => &mut counts.load, + TableKind::Mul => &mut counts.mul, + TableKind::Dvrm => &mut counts.dvrm, + TableKind::Shift => &mut counts.shift, + TableKind::Branch => &mut counts.branch, + TableKind::MemwRegister => &mut counts.memw_register, + TableKind::Eq => &mut counts.eq, + TableKind::Bytewise => &mut counts.bytewise, + TableKind::Store => &mut counts.store, + TableKind::Cpu32 => &mut counts.cpu32, + } +} diff --git a/prover/src/lib.rs b/prover/src/lib.rs index d809e1717..13f3a35b6 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -12,6 +12,7 @@ #[cfg(feature = "disk-spill")] pub mod auto_storage; +pub mod challenge_phase; pub mod commit_phase; pub mod constraints; pub mod continuation; diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index 09cbe6e38..98ceb79d9 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -18,7 +18,7 @@ use crate::tables::types::{GoldilocksExtension, GoldilocksField}; /// The groups of chunked tables, in the order `VmAirs::air_trace_pairs` emits /// them. `None` marks a group that stays resident (PAGE), which still consumes /// AIR indices and so must be walked over. -const GROUP_ORDER: [Option; 15] = [ +pub(crate) const GROUP_ORDER: [Option; 15] = [ Some(TableKind::Cpu), Some(TableKind::Lt), Some(TableKind::Shift), @@ -38,7 +38,7 @@ const GROUP_ORDER: [Option; 15] = [ /// Number of singleton tables emitted before the chunked groups: BITWISE, /// DECODE, COMMIT, KECCAK, KECCAK_RND, KECCAK_RC, ECSM, ECDAS, HINT, REGISTER. -const NUM_FIXED_AIRS: usize = 10; +pub(crate) const NUM_FIXED_AIRS: usize = 10; pub(crate) struct StreamingProvider { routed: CollectedOps, diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs new file mode 100644 index 000000000..0836cec6c --- /dev/null +++ b/prover/src/tests/challenge_phase_tests.rs @@ -0,0 +1,117 @@ +//! The Challenge phase must reconstruct the ordinary prover's transcript. + +use crate::tables::MaxRowsConfig; +use crate::tables::trace_builder::Traces; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use executor::elf::Elf; +use stark::proof::view::MultiProofView; + +/// Approach 1's second pass must draw the very challenge the tables were built +/// against. +/// +/// This is the whole claim of the single-challenge design: the roots the Commit +/// phase produced, absorbed in AIR order on top of the bound statement, +/// reproduce the transcript the production prover builds. Anything that shifts +/// the order, omits a preprocessed table's precomputed root, or commits a table +/// differently moves `(z, alpha)`, and a challenge that differs from the +/// prover's is a proof that does not verify. +/// +/// The roots are checked in position first, so a mismatch names the table +/// rather than surfacing only as a different field element at the end. The +/// challenge itself is then compared against the *verifier's* replay, rebuilt +/// from the proof's own statement fields, so the comparison does not run +/// through `challenge_phase` twice. +#[test] +fn challenge_matches_the_ordinary_prover() { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + // Small enough that the walk closes several chunks and leaves tails: the + // order only gets exercised when a group has more than one member. + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + + assert_eq!( + challenge.roots.len(), + vm_proof.proof.proofs.len(), + "the Commit phase accounted for a different number of tables than the proof has" + ); + let mut preprocessed = 0usize; + for (idx, (got, want)) in challenge + .roots + .iter() + .zip(vm_proof.proof.proofs.iter()) + .enumerate() + { + assert_eq!( + got.main, want.lde_trace_main_merkle_root, + "table {idx}: committed under a different root than the proof carries" + ); + if got.precomputed.is_some() { + preprocessed += 1; + } + } + assert!( + preprocessed >= 4, + "the fixture must cover the preprocessed tables (BITWISE, DECODE, KECCAK_RC, \ + REGISTER and the pages); saw {preprocessed}" + ); + + // The verifier's path: statement from the proof, AIRs reconstructed the way + // verification reconstructs them. + let page_configs = Traces::page_configs_from_elf_and_runtime( + &elf, + &vm_proof.runtime_page_ranges, + vm_proof.num_private_input_pages, + vm_proof.proof.proofs.len(), + ) + .expect("page configs"); + let verifier_airs = crate::VmAirs::new( + &elf, + &proof_options, + false, + &page_configs, + &vm_proof.table_counts, + None, + true, + None, + None, + None, + ); + let mut transcript = DefaultTranscript::new(&[]); + crate::statement::absorb_statement( + &mut transcript, + crate::statement::StatementKind::Monolithic, + &elf_bytes, + &vm_proof.public_output, + &vm_proof.table_counts, + vm_proof.num_private_input_pages, + &vm_proof.runtime_page_ranges, + proof_options.fri_final_poly_log_degree, + ); + let (z, alpha) = crate::replay_transcript_phase_a_view( + &verifier_airs.air_refs(), + MultiProofView::Owned(&vm_proof.proof), + &mut transcript, + ); + + assert_eq!( + challenge.challenges, + vec![z, alpha], + "the Challenge phase sampled a different challenge from the same execution" + ); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 9288cf2ac..ece45b5b8 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -10,6 +10,7 @@ pub mod branch_bus_tests; pub mod branch_constraints_tests; #[cfg(test)] pub mod bytewise_tests; +mod challenge_phase_tests; #[cfg(test)] pub mod commit_tests; #[cfg(test)] From 3efd3318247cf18abaacd450a0215d3d1cee1902 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 11:35:56 -0300 Subject: [PATCH 25/63] Measure each trace-build path on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peak heap is per process, so the two production paths cannot be compared inside one run. Add a trace-build subcommand that runs either the ordinary build or the Commit phase and reports chunks, time and peak, and give the ordinary build an entry point beside the Commit phase so both arms are driven identically — and so the storage-mode argument stays on the prover's side of the feature gate, which the CLI's does not track. On the ethrex mainnet block, 96 cores: resident 41077 MB in 8.95s against 7629 MB in 37.99s for the Commit phase, which also computes the LDE and the Merkle tree of all 173 chunks that the resident arm never touches. main's complete proof of the same block peaks at 110261 MB. --- bin/cli/src/main.rs | 101 ++++++++++++++++++++++++++++++++++++- prover/src/commit_phase.rs | 26 ++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 61fc4f410..70d905464 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -76,7 +76,7 @@ mod heap_tracker { // Records the elapsed time of the sample that raised the mark, // so a peak can be placed against the phase timeline instead of // being a number with no location. - let mut sample = |peak: &AtomicUsize, at: &AtomicUsize| { + let sample = |peak: &AtomicUsize, at: &AtomicUsize| { epoch::advance().ok(); if let Ok(allocated) = stats::allocated::read() && allocated > peak.fetch_max(allocated, Ordering::Relaxed) @@ -250,6 +250,23 @@ enum Commands { #[arg(long, value_hint = ValueHint::FilePath)] private_input: Option, }, + + /// Build the traces without proving, to compare what each production path + /// holds. Peak heap is per process, so each path is measured on its own run. + TraceBuild { + /// Path to the ELF file + #[arg(value_parser, value_hint = ValueHint::FilePath)] + elf: PathBuf, + + /// Path to the private input file + #[arg(long, value_hint = ValueHint::FilePath)] + private_input: Option, + + /// Walk the execution, committing and retiring each table as it fills + /// (Approach 1's Commit phase), instead of building every trace first. + #[arg(long)] + streaming: bool, + }, } fn main() -> ExitCode { @@ -315,6 +332,11 @@ fn main() -> ExitCode { } } Commands::CountElements { elf, private_input } => cmd_count_elements(elf, private_input), + Commands::TraceBuild { + elf, + private_input, + streaming, + } => cmd_trace_build(elf, private_input, streaming), } } @@ -997,6 +1019,83 @@ fn parse_epoch_size_log2(value: &str) -> Result { Ok(epoch_size_log2) } +/// Build the traces one way or the other, so the two production paths can be +/// compared on what they hold. Nothing is proved: this measures the side of the +/// prover that Approach 1's Commit phase replaces. +fn cmd_trace_build( + elf_path: PathBuf, + private_input_path: Option, + streaming: bool, +) -> ExitCode { + let elf_data = match std::fs::read(&elf_path) { + Ok(data) => data, + Err(e) => { + eprintln!("Failed to read ELF file: {e}"); + return ExitCode::FAILURE; + } + }; + let private_inputs = match read_private_input(private_input_path.as_ref()) { + Ok(inputs) => inputs, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let elf = match executor::elf::Elf::load(&elf_data) { + Ok(elf) => elf, + Err(e) => { + eprintln!("Failed to load ELF: {e}"); + return ExitCode::FAILURE; + } + }; + + #[cfg(feature = "jemalloc-stats")] + let tracker = heap_tracker::HeapTracker::start(); + let started = std::time::Instant::now(); + + let max_rows = prover::tables::MaxRowsConfig::default(); + let outcome = if streaming { + let options = match stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) { + Ok(o) => o, + Err(e) => { + eprintln!("bad proof options: {e:?}"); + return ExitCode::FAILURE; + } + }; + prover::commit_phase::run_to_end(&elf, &private_inputs, &max_rows, &options) + .map(|committed| committed.chunks.len()) + .map_err(|e| format!("{e:?}")) + } else { + prover::commit_phase::build_resident(&elf, &private_inputs, &max_rows) + .map(|t| t.cpus.len()) + .map_err(|e| format!("{e:?}")) + }; + + let elapsed = started.elapsed(); + match outcome { + Ok(n) => println!( + "Trace build ({}): {n} chunks, {:.3}s", + if streaming { "streaming" } else { "resident" }, + elapsed.as_secs_f64() + ), + Err(e) => { + eprintln!("trace build failed: {e}"); + return ExitCode::FAILURE; + } + } + + #[cfg(feature = "jemalloc-stats")] + { + let (peak_bytes, peak_at_ms) = tracker.stop(); + println!( + "Peak heap: {} MB (at {:.1}s)", + peak_bytes / (1024 * 1024), + peak_at_ms as f64 / 1000.0 + ); + } + ExitCode::SUCCESS +} + #[cfg(test)] mod tests { use super::*; diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 8a65dad3b..5edc1098f 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -127,6 +127,32 @@ fn walk_and_commit( Ok(CommitPhase { closed, leftover }) } +/// The ordinary build, for comparison against [`run_to_end`]. +/// +/// The same execution and the same tables, built the way `prove` builds them: +/// every trace resident at once and nothing committed. It lives beside the +/// Commit phase so the two arms of the comparison are driven identically, and +/// so the storage-mode argument stays on this side of the feature gate — the +/// lint enables `lambda-vm-prover/disk-spill` without enabling the CLI's, and a +/// caller there would not agree with this signature. +pub fn build_resident( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, +) -> Result { + let executed = executor::vm::execution::Executor::new(elf, private_input.to_vec()) + .and_then(|e| e.run()) + .map_err(|e| Error::Prover(format!("execution failed: {e}")))?; + Traces::from_elf_and_logs( + elf, + &executed.logs, + max_rows, + private_input, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) +} + /// The Commit phase end to end. /// /// The walk, then the padding of everything it could not close. What comes back From a6ab17763baf9ee3a1fa64618dc39aed7c441bcc Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 12:41:13 -0300 Subject: [PATCH 26/63] Walk the execution once per pass, not per phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1 goes through the execution more than once: to commit the main traces, to build the auxiliary columns against the challenge that produced, and to open the Merkle trees. The three differ only in what they do with a finished table — the walk, the end-of-run finalization and the tables that cannot be retired are the same every time. Pull that out as a Visitor and give the Commit phase's own machinery back to it, so there is one walk rather than a copy per phase. The existing tests are what guards the move: they drove the Commit phase through this code and still do, including the one that checks the walk closes chunks mid-run instead of deferring them to the end. Also collects the AIRs a pass dispatches to. One AIR per kind serves every chunk of that kind — what a table commits to depends on its trace and its domain, not on the name in a report — which is what lets a chunk be dealt with before the number of chunks is known. --- prover/src/commit_phase.rs | 389 ++++++------------------ prover/src/lib.rs | 1 + prover/src/pass.rs | 274 +++++++++++++++++ prover/src/tests/trace_builder_tests.rs | 41 +-- 4 files changed, 375 insertions(+), 330 deletions(-) create mode 100644 prover/src/pass.rs diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 5edc1098f..60f5449f2 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -3,128 +3,128 @@ //! //! The spec has the prover go through execution "and once the memory pressure //! becomes too large, batch commit to all full tables in memory; then these -//! tables are dropped". This is that pass. What it produces is a commitment per -//! closed chunk and, at the end, whatever the walk could not close — which the -//! Challenge phase pads and commits, as the spec's next step. +//! tables are dropped". This is that pass, expressed as a [`pass::Visitor`]: +//! what it does with a finished table is commit its main trace and let the +//! table die. What it produces is a root per chunk and, at the end, the tables +//! that cannot be retired — which the Challenge phase commits and samples from. use stark::proof::options::ProofOptions; use stark::prover::{IsStarkProver, MainRoots}; use crate::Error; +use crate::pass::{self, ChunkAirs, Resident, Visitor}; use crate::tables::MaxRowsConfig; -use crate::tables::trace_builder::{TableKind, Traces, WalkLeftover, build_initial_image}; -use crate::tables::{register, types::*}; +use crate::tables::trace_builder::{TableKind, Traces}; +use crate::tables::types::*; use executor::elf::Elf; use stark::trace::TraceTable; -/// What the Commit phase produced. +/// One chunked table's commitment, by kind and position. +pub type ChunkCommitment = (TableKind, usize, MainRoots); + +/// Every chunk committed, and what the Commit phase leaves resident. +pub struct Committed { + /// A root per chunk of every chunked table, walk-closed and tail alike. + pub chunks: Vec, + /// The tables the walk could not commit, still as traces. + pub remaining: Resident, +} + +/// What the walk alone produced. pub struct CommitPhase { /// One entry per chunk closed during the walk, in the order they closed. pub closed: Vec, /// Everything the walk still held when the execution ended. - pub leftover: WalkLeftover, + pub walked: pass::Walked, } -/// One chunked table's commitment, by kind and position. -pub type ChunkCommitment = (TableKind, usize, MainRoots); +/// Commits each table's main trace and drops it. +struct CommitMain<'a> { + airs: &'a ChunkAirs, + roots: Vec, +} + +impl Visitor for CommitMain<'_> { + fn table( + &mut self, + kind: TableKind, + chunk: usize, + trace: &mut TraceTable, + ) -> Result<(), Error> { + type P = stark::prover::Prover; + let root = +

>::commit_table_root(self.airs.get(kind).as_ref(), trace) + .ok_or_else(|| { + Error::Prover(format!("commit phase: no commitment for a {kind:?} chunk")) + })?; + self.roots.push((kind, chunk, root)); + Ok(()) + } +} -/// Run the Commit phase over `elf`. +/// The Commit phase's walk, stopping before the end-of-run tables. /// /// Each chunk is committed the moment it fills and its trace is dropped, so -/// nothing that has been committed is still resident. The per-chunk AIRs differ -/// only by the name used in reports — the commitment depends on the trace and -/// the domain — so one AIR per kind serves every chunk of that kind, which is -/// what lets a table be committed before the number of chunks is known. +/// nothing that has been committed is still resident. pub fn run( elf: &Elf, private_input: &[u8], max_rows: &MaxRowsConfig, proof_options: &ProofOptions, ) -> Result { - let image = build_initial_image(elf, private_input); - let register_init = register::register_init_from_entry_point(elf.entry_point); - let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(elf)?; - walk_and_commit( - &artifacts, - elf, - private_input, - &image, - ®ister_init, - max_rows, - proof_options, - ) + let airs = ChunkAirs::new(proof_options); + let mut visitor = CommitMain { + airs: &airs, + roots: Vec::new(), + }; + let walked = pass::walk(elf, private_input, max_rows, &mut visitor)?; + Ok(CommitPhase { + closed: visitor.roots, + walked, + }) } -/// The walk itself, over an already-built image and decode table. -#[allow(clippy::too_many_arguments)] -fn walk_and_commit( - artifacts: &crate::tables::trace_builder::DecodeArtifacts, +/// The Commit phase end to end. +/// +/// The walk, then the padding of everything it could not close. What comes back +/// is a root per chunk of every chunked table and, still resident, only the +/// tables that are not built from an op list: the preprocessed ones and the +/// accumulators. That is the state the Challenge phase starts from. +pub fn run_to_end( elf: &Elf, private_input: &[u8], - image: &I, - register_init: &[u32], max_rows: &MaxRowsConfig, proof_options: &ProofOptions, -) -> Result { - let cpu = crate::test_utils::create_cpu_air(proof_options); - let memw = crate::test_utils::create_memw_air(proof_options); - let memw_aligned = crate::test_utils::create_memw_aligned_air(proof_options); - let memw_register = crate::test_utils::create_memw_register_air(proof_options); - let load = crate::test_utils::create_load_air(proof_options); - let cpu32 = crate::test_utils::create_cpu32_air(proof_options); - let branch = crate::test_utils::create_branch_air(proof_options); - let eq = crate::test_utils::create_eq_air(proof_options); - let bytewise = crate::test_utils::create_bytewise_air(proof_options); - let store = crate::test_utils::create_store_air(proof_options); - - let mut closed = Vec::new(); - let mut failed: Option = None; - let leftover = Traces::walk_and_emit_chunks( - artifacts, - elf, - private_input.to_vec(), - image, - register_init, - max_rows, - |kind, chunk, table| { - let air: &dyn stark::traits::AIR< - Field = GoldilocksField, - FieldExtension = GoldilocksExtension, - PublicInputs = (), - > = match kind { - TableKind::Cpu => &cpu, - TableKind::Memw => &memw, - TableKind::MemwAligned => &memw_aligned, - TableKind::MemwRegister => &memw_register, - TableKind::Load => &load, - TableKind::Cpu32 => &cpu32, - TableKind::Branch => &branch, - TableKind::Eq => &eq, - TableKind::Bytewise => &bytewise, - TableKind::Store => &store, - other => { - // Unreachable through `CHUNKED_KINDS`; recorded rather than - // panicked so a kind added there without an AIR here fails - // the run instead of committing under the wrong one. - failed = Some(other); - return; - } - }; - type P = stark::prover::Prover; - match

>::commit_table_root(air, &table) { - Some(root) => closed.push((kind, chunk, root)), - None => failed = Some(kind), - } - }, - )?; - - if let Some(kind) = failed { - return Err(Error::Prover(format!( - "commit phase: no commitment for a {kind:?} chunk" - ))); - } +) -> Result { + let airs = ChunkAirs::new(proof_options); + let mut visitor = CommitMain { + airs: &airs, + roots: Vec::new(), + }; + let remaining = pass::run(elf, private_input, max_rows, &mut visitor)?; + Ok(Committed { + chunks: visitor.roots, + remaining, + }) +} - Ok(CommitPhase { closed, leftover }) +/// Pad and commit what a walk could not close. +/// +/// The Commit phase's half of the end-of-run step, kept as its own entry point +/// for the caller that ran [`run`] and wants the tails separately. +pub fn commit_remaining( + walked: pass::Walked, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, +) -> Result<(Vec, Resident), Error> { + let airs = ChunkAirs::new(proof_options); + let mut visitor = CommitMain { + airs: &airs, + roots: Vec::new(), + }; + let resident = pass::finish(walked, private_input, max_rows, &mut visitor)?; + Ok((visitor.roots, resident)) } /// The ordinary build, for comparison against [`run_to_end`]. @@ -152,210 +152,3 @@ pub fn build_resident( stark::storage_mode::StorageMode::Ram, ) } - -/// The Commit phase end to end. -/// -/// The walk, then the padding of everything it could not close. What comes back -/// is a root per chunk of every chunked table and, still resident, only the -/// tables that are not built from an op list: the preprocessed ones and the -/// accumulators. That is the state the Challenge phase starts from. -pub fn run_to_end( - elf: &Elf, - private_input: &[u8], - max_rows: &MaxRowsConfig, - proof_options: &ProofOptions, -) -> Result { - let image = build_initial_image(elf, private_input); - let register_init = register::register_init_from_entry_point(elf.entry_point); - let artifacts = crate::tables::trace_builder::DecodeArtifacts::from_elf(elf)?; - - let phase = walk_and_commit( - &artifacts, - elf, - private_input, - &image, - ®ister_init, - max_rows, - proof_options, - )?; - let mut chunks = phase.closed; - let mut remaining = commit_remaining( - phase.leftover, - &artifacts, - &image, - ®ister_init, - private_input, - max_rows, - proof_options, - )?; - chunks.append(&mut remaining.chunks); - - Ok(Committed { chunks, remaining }) -} - -/// Every chunk committed, and what the Commit phase leaves resident. -pub struct Committed { - /// A root per chunk of every chunked table, walk-closed and tail alike. - pub chunks: Vec, - /// The tables the walk could not commit, still as traces. - pub remaining: Remaining, -} - -/// The Challenge phase's first half: pad and commit what the walk could not -/// close. -/// -/// The spec's step after Commit is "at the end of the execution, the remaining -/// tables are padded and commited to". That is this: the partial tail of every -/// table the walk closed, plus every chunk of the tables it could not close -/// because CPU32 and DVRM keep feeding them. -/// -/// Finalization comes first, as it does in the ordinary build: HALT appends 33 -/// register MEMW ops, and the MEMW-derived LT ops are collected after them so -/// those accesses get their timestamp checks. -/// -/// The preprocessed and accumulator tables — BITWISE, DECODE, REGISTER, PAGE and -/// the rest — are not here yet. They are built from the ELF and from counts -/// accumulated across the whole run rather than from an op list, so they need -/// their own step. -#[allow(clippy::too_many_arguments)] -pub fn commit_remaining( - mut leftover: WalkLeftover, - artifacts: &crate::tables::trace_builder::DecodeArtifacts, - initial_image: &I, - register_init: &[u32], - private_input: &[u8], - max_rows: &MaxRowsConfig, - proof_options: &ProofOptions, -) -> Result { - leftover.finalize(max_rows); - - let cpu = crate::test_utils::create_cpu_air(proof_options); - let memw = crate::test_utils::create_memw_air(proof_options); - let memw_aligned = crate::test_utils::create_memw_aligned_air(proof_options); - let memw_register = crate::test_utils::create_memw_register_air(proof_options); - let load = crate::test_utils::create_load_air(proof_options); - let cpu32 = crate::test_utils::create_cpu32_air(proof_options); - let branch = crate::test_utils::create_branch_air(proof_options); - let eq = crate::test_utils::create_eq_air(proof_options); - let bytewise = crate::test_utils::create_bytewise_air(proof_options); - let store = crate::test_utils::create_store_air(proof_options); - let lt = crate::test_utils::create_lt_air(proof_options); - let mul = crate::test_utils::create_mul_air(proof_options); - let dvrm = crate::test_utils::create_dvrm_air(proof_options); - let shift = crate::test_utils::create_shift_air(proof_options); - - // HALT and REGISTER first: REGISTER's final PC token is derived from the CPU - // padding, and the padding of the tail cannot be counted once the tail has - // been drained into a chunk below. - let (halt, register) = leftover.build_halt_and_register(register_init)?; - - type P = stark::prover::Prover; - let mut out = Vec::new(); - for kind in ALL_CHUNKED { - let air: &dyn stark::traits::AIR< - Field = GoldilocksField, - FieldExtension = GoldilocksExtension, - PublicInputs = (), - > = match kind { - TableKind::Cpu => &cpu, - TableKind::Memw => &memw, - TableKind::MemwAligned => &memw_aligned, - TableKind::MemwRegister => &memw_register, - TableKind::Load => &load, - TableKind::Cpu32 => &cpu32, - TableKind::Branch => &branch, - TableKind::Eq => &eq, - TableKind::Bytewise => &bytewise, - TableKind::Store => &store, - TableKind::Lt => <, - TableKind::Mul => &mul, - TableKind::Dvrm => &dvrm, - TableKind::Shift => &shift, - }; - // Chunks the walk already closed keep their numbering; what is left - // continues from there. - let first = leftover.emitted(kind); - for (offset, table) in leftover - .take_remaining(kind, max_rows) - .into_iter() - .enumerate() - { - let root = -

>::commit_table_root(air, &table).ok_or_else(|| { - Error::Prover(format!("commit phase: no commitment for a {kind:?} tail")) - })?; - out.push((kind, first + offset, root)); - } - } - // BITWISE comes back as a table rather than a commitment: it is - // preprocessed, so its commitment splits into two trees, and that path does - // not exist here yet. The lookups are what this phase is responsible for - // having kept — the chunks that owed them are long gone. - let public_output = leftover.public_output_bytes(); - let accumulated = leftover.build_accumulated(); - let decode = leftover.build_decode( - artifacts.decode_trace.clone(), - &artifacts.decode_pc_to_row, - max_rows, - ); - // PAGE last: it owes BITWISE lookups of its own, so BITWISE is written only - // once those are in. - let mut hist = leftover.bitwise_histogram(); - let (pages, page_configs) = leftover.build_pages(initial_image, private_input, &mut hist); - let bitwise = WalkLeftover::build_bitwise_from(&hist); - - Ok(Remaining { - chunks: out, - public_output, - bitwise, - decode, - halt, - register, - pages, - page_configs, - accumulated, - }) -} - -/// What the end-of-run phase produced. -pub struct Remaining { - /// The tails, and every chunk of the tables the walk could not close. - /// - /// [`run_to_end`] drains this into [`Committed::chunks`]; read it there. - pub chunks: Vec, - /// The bytes the run committed, which the statement binds into the - /// transcript before any root is absorbed. - pub public_output: Vec, - /// The BITWISE table, carrying the lookups of every retired chunk. - pub bitwise: TraceTable, - /// The DECODE table, with one lookup counted per executed cycle and per - /// padding row. - pub decode: TraceTable, - /// HALT, from the run's terminating ECALL. - pub halt: TraceTable, - /// REGISTER, whose final PC token has to match the last padding write. - pub register: TraceTable, - /// The PAGE tables and their configs. - pub pages: Vec>, - pub page_configs: Vec, - /// The tables written once from an accumulated op list. - pub accumulated: crate::tables::trace_builder::AccumulatedTables, -} - -/// Every chunked table, closable mid-walk or not. -const ALL_CHUNKED: [TableKind; 14] = [ - TableKind::Cpu, - TableKind::Memw, - TableKind::MemwAligned, - TableKind::MemwRegister, - TableKind::Load, - TableKind::Cpu32, - TableKind::Branch, - TableKind::Eq, - TableKind::Bytewise, - TableKind::Store, - TableKind::Lt, - TableKind::Mul, - TableKind::Dvrm, - TableKind::Shift, -]; diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 13f3a35b6..6913c4ee3 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -21,6 +21,7 @@ mod debug_report; #[cfg(feature = "instruments")] pub mod instruments; mod paged_mem; +pub mod pass; pub(crate) mod streaming; pub use stark::profile_markers; pub mod recursion; diff --git a/prover/src/pass.rs b/prover/src/pass.rs new file mode 100644 index 000000000..beaba449e --- /dev/null +++ b/prover/src/pass.rs @@ -0,0 +1,274 @@ +//! The walk Approach 1 makes once per pass. +//! +//! The approach goes through the execution more than once: to commit the main +//! traces, to build the auxiliary columns against the challenge that commit +//! produced, and to open the Merkle trees. The three differ only in what they +//! do with a finished table — the walk itself, the end-of-run finalization and +//! the tables that are not built from an op list are the same every time, and +//! live here once. +//! +//! Trading re-execution for memory is the whole bargain: a pass never keeps a +//! chunk it has dealt with, so what it holds is one table plus the tables that +//! cannot be retired, no matter how long the run is. + +use stark::proof::options::ProofOptions; + +use crate::Error; +use crate::tables::MaxRowsConfig; +use crate::tables::trace_builder::{ + DecodeArtifacts, TableKind, Traces, WalkLeftover, build_initial_image, +}; +use crate::tables::{register, types::*}; +use executor::elf::Elf; +use stark::trace::TraceTable; + +/// What a pass does with each table the walk produces. +/// +/// `chunk` numbers the table within its kind and is continuous across the +/// walk's chunks and the tail the end-of-run step pads, so a visitor can index +/// by `(kind, chunk)` without knowing which of the two produced it. +pub trait Visitor { + fn table( + &mut self, + kind: TableKind, + chunk: usize, + trace: &mut TraceTable, + ) -> Result<(), Error>; +} + +/// The tables a pass cannot retire, still as traces. +/// +/// They are the ones not built from an op list — the preprocessed tables, the +/// accumulators and PAGE — so there is no compact intermediate to rebuild them +/// from and nothing to be gained by dropping them. Every pass gets them back +/// and decides what to do with them. +pub struct Resident { + pub bitwise: TraceTable, + pub decode: TraceTable, + pub halt: TraceTable, + pub register: TraceTable, + pub pages: Vec>, + pub page_configs: Vec, + pub accumulated: crate::tables::trace_builder::AccumulatedTables, + /// The bytes the run committed, which the statement binds into the + /// transcript before any root is absorbed. + pub public_output: Vec, +} + +/// Walk the execution once, handing every chunked table to `visitor`. +/// +/// The walk closes a table the moment it fills; what it could not close — the +/// partial tail of each kind, and every chunk of the kinds CPU32 and DVRM keep +/// feeding — is padded by [`finish`] and handed over the same way, numbered +/// where the walk left off. +pub fn run( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + visitor: &mut V, +) -> Result { + let walked = walk(elf, private_input, max_rows, visitor)?; + finish(walked, private_input, max_rows, visitor) +} + +/// What the walk leaves behind, and what [`finish`] needs to close it out. +/// +/// The image and the decode artifacts are built once and kept because the +/// end-of-run step reads them: rebuilding them there would walk the ELF a +/// second time for no reason. +pub struct Walked { + /// Everything the walk still held when the execution ended. + pub leftover: WalkLeftover, + artifacts: DecodeArtifacts, + image: std::collections::HashMap, + register_init: Vec, +} + +/// The walk alone. +/// +/// Separate from [`finish`] for the caller that wants the leftover itself — +/// which is what shows the walk is retiring as it goes rather than deferring +/// every chunk to the end. +pub fn walk( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + visitor: &mut V, +) -> Result { + let image = build_initial_image(elf, private_input); + let register_init = register::register_init_from_entry_point(elf.entry_point); + let artifacts = DecodeArtifacts::from_elf(elf)?; + + let mut failed: Option = None; + let leftover = Traces::walk_and_emit_chunks( + &artifacts, + elf, + private_input.to_vec(), + &image, + ®ister_init, + max_rows, + |kind, chunk, mut table| { + if failed.is_none() + && let Err(e) = visitor.table(kind, chunk, &mut table) + { + failed = Some(e); + } + }, + )?; + match failed { + Some(e) => Err(e), + None => Ok(Walked { + leftover, + artifacts, + image, + register_init, + }), + } +} + +/// Pad and hand over what the walk could not close, then build the tables that +/// stay. +/// +/// The spec's step after the walk is "at the end of the execution, the +/// remaining tables are padded and commited to". Finalization comes first, as +/// it does in the ordinary build: HALT appends 33 register MEMW ops, and the +/// MEMW-derived LT ops are collected after them so those accesses get their +/// timestamp checks. +pub fn finish( + walked: Walked, + private_input: &[u8], + max_rows: &MaxRowsConfig, + visitor: &mut V, +) -> Result { + let Walked { + mut leftover, + artifacts, + image, + register_init, + } = walked; + leftover.finalize(max_rows); + + // HALT and REGISTER first: REGISTER's final PC token is derived from the CPU + // padding, and the padding of the tail cannot be counted once the tail has + // been drained into a chunk below. + let (halt, register) = leftover.build_halt_and_register(®ister_init)?; + + for kind in ALL_CHUNKED { + // Chunks the walk already closed keep their numbering; what is left + // continues from there. + let first = leftover.emitted(kind); + for (offset, mut table) in leftover + .take_remaining(kind, max_rows) + .into_iter() + .enumerate() + { + visitor.table(kind, first + offset, &mut table)?; + } + } + + let public_output = leftover.public_output_bytes(); + let accumulated = leftover.build_accumulated(); + let decode = leftover.build_decode( + artifacts.decode_trace.clone(), + &artifacts.decode_pc_to_row, + max_rows, + ); + // PAGE last: it owes BITWISE lookups of its own, so BITWISE is written only + // once those are in. + let mut hist = leftover.bitwise_histogram(); + let (pages, page_configs) = leftover.build_pages(&image, private_input, &mut hist); + let bitwise = WalkLeftover::build_bitwise_from(&hist); + + Ok(Resident { + bitwise, + decode, + halt, + register, + pages, + page_configs, + accumulated, + public_output, + }) +} + +/// Every chunked table, closable mid-walk or not. +pub const ALL_CHUNKED: [TableKind; 14] = [ + TableKind::Cpu, + TableKind::Memw, + TableKind::MemwAligned, + TableKind::MemwRegister, + TableKind::Load, + TableKind::Cpu32, + TableKind::Branch, + TableKind::Eq, + TableKind::Bytewise, + TableKind::Store, + TableKind::Lt, + TableKind::Mul, + TableKind::Dvrm, + TableKind::Shift, +]; + +/// The AIRs a pass dispatches a chunk to, one per kind. +/// +/// The per-chunk AIRs differ only by the name used in reports — what a table +/// commits to depends on the trace and the domain — so one AIR per kind serves +/// every chunk of that kind, which is what lets a table be dealt with before +/// the number of chunks is known. +pub struct ChunkAirs { + cpu: crate::VmAir, + memw: crate::VmAir, + memw_aligned: crate::VmAir, + memw_register: crate::VmAir, + load: crate::VmAir, + cpu32: crate::VmAir, + branch: crate::VmAir, + eq: crate::VmAir, + bytewise: crate::VmAir, + store: crate::VmAir, + lt: crate::VmAir, + mul: crate::VmAir, + dvrm: crate::VmAir, + shift: crate::VmAir, +} + +impl ChunkAirs { + pub fn new(proof_options: &ProofOptions) -> Self { + use crate::test_utils::*; + Self { + cpu: Box::new(create_cpu_air(proof_options)), + memw: Box::new(create_memw_air(proof_options)), + memw_aligned: Box::new(create_memw_aligned_air(proof_options)), + memw_register: Box::new(create_memw_register_air(proof_options)), + load: Box::new(create_load_air(proof_options)), + cpu32: Box::new(create_cpu32_air(proof_options)), + branch: Box::new(create_branch_air(proof_options)), + eq: Box::new(create_eq_air(proof_options)), + bytewise: Box::new(create_bytewise_air(proof_options)), + store: Box::new(create_store_air(proof_options)), + lt: Box::new(create_lt_air(proof_options)), + mul: Box::new(create_mul_air(proof_options)), + dvrm: Box::new(create_dvrm_air(proof_options)), + shift: Box::new(create_shift_air(proof_options)), + } + } + + pub fn get(&self, kind: TableKind) -> &crate::VmAir { + match kind { + TableKind::Cpu => &self.cpu, + TableKind::Memw => &self.memw, + TableKind::MemwAligned => &self.memw_aligned, + TableKind::MemwRegister => &self.memw_register, + TableKind::Load => &self.load, + TableKind::Cpu32 => &self.cpu32, + TableKind::Branch => &self.branch, + TableKind::Eq => &self.eq, + TableKind::Bytewise => &self.bytewise, + TableKind::Store => &self.store, + TableKind::Lt => &self.lt, + TableKind::Mul => &self.mul, + TableKind::Dvrm => &self.dvrm, + TableKind::Shift => &self.shift, + } + } +} diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 43d7fb984..c9742f203 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1799,7 +1799,7 @@ fn the_commit_phase_commits_what_it_closes_and_keeps_the_rest() { // the end-of-run finalization; a bound elsewhere, since that finalization // appends after the last cycle and can spill the tail into another chunk. assert_eq!( - phase.leftover.cycles(), + phase.walked.leftover.cycles(), logs.len(), "the walk executed a different number of cycles than the straight run" ); @@ -1821,7 +1821,7 @@ fn the_commit_phase_commits_what_it_closes_and_keeps_the_rest() { (TableKind::Store, resident.stores.len()), ] { assert_eq!( - phase.leftover.emitted(kind), + phase.walked.leftover.emitted(kind), closed_of(kind), "{kind:?}: the leftover disagrees with what was committed" ); @@ -1862,21 +1862,9 @@ fn the_two_phases_cover_every_chunked_table() { let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); let closed = phase.closed.clone(); - let artifacts = - crate::tables::trace_builder::DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); - let image = crate::tables::trace_builder::build_initial_image(&elf, &[]); - let register_init = crate::tables::register::register_init_from_entry_point(elf.entry_point); - let rest = crate::commit_phase::commit_remaining( - phase.leftover, - &artifacts, - &image, - ®ister_init, - &[], - &max_rows, - &proof_options, - ) - .expect("challenge") - .chunks; + let (rest, _) = + crate::commit_phase::commit_remaining(phase.walked, &[], &max_rows, &proof_options) + .expect("challenge"); let mut got: HashMap<(TableKind, usize), _> = HashMap::new(); for (kind, chunk, root) in closed.into_iter().chain(rest) { @@ -2213,16 +2201,12 @@ fn decode_multiplicities_survive_retiring_the_cpu_chunks() { /// to carry rather than on an op list it could keep. #[test] fn the_end_of_run_tables_match_the_ordinary_build() { - use crate::tables::register::register_init_from_entry_point; - use crate::tables::trace_builder::{DecodeArtifacts, Traces as T, build_initial_image}; + use crate::tables::trace_builder::Traces as T; use executor::elf::Elf; use executor::vm::execution::Executor; let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); let elf = Elf::load(&elf_bytes).expect("ELF load"); - let artifacts = DecodeArtifacts::from_elf(&elf).expect("decode artifacts"); - let image = build_initial_image(&elf, &[]); - let register_init = register_init_from_entry_point(elf.entry_point); // Not a power of two, so the CPU chunks pad and REGISTER's final PC token // depends on a padding count the walk had to accumulate. let max_rows = crate::tables::MaxRowsConfig { @@ -2233,16 +2217,9 @@ fn the_end_of_run_tables_match_the_ordinary_build() { .expect("blowup 2 is valid"); let phase = crate::commit_phase::run(&elf, &[], &max_rows, &proof_options).expect("commit"); - let rest = crate::commit_phase::commit_remaining( - phase.leftover, - &artifacts, - &image, - ®ister_init, - &[], - &max_rows, - &proof_options, - ) - .expect("challenge"); + let (_, rest) = + crate::commit_phase::commit_remaining(phase.walked, &[], &max_rows, &proof_options) + .expect("challenge"); let logs = Executor::new(&elf, vec![]) .expect("executor") From 26281d6861fe32848c3b8462b6a0e10eee1fa67a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 12:43:30 -0300 Subject: [PATCH 27/63] Build the LogUp columns against the one challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's third step is a re-execution, and it has to be: the LogUp columns are a function of the challenge, and the challenge is not known until every main root has been absorbed — by which time the tables that produced them are gone. The ordinary prover avoids the second walk by keeping every trace resident across the Round 1 barrier, which is exactly the residency this approach refuses to pay. So the tables are rebuilt. The build is deterministic, so the trace a chunk gets here is byte-identical to the one the Commit phase committed, and the auxiliary columns are the ones that root answers for. Pinned against a real proof the way the Challenge phase is: every auxiliary root compared in position with the one the ordinary prover committed. Three things have to hold at once for that to land — the rebuild is identical, the challenge is the prover's, and the table is in the slot the proof expects — so all three fail the same assertion. Doubling the challenge fails it at table 0. --- crypto/stark/src/prover.rs | 46 +++++ prover/src/challenge_phase.rs | 23 ++- prover/src/lib.rs | 1 + prover/src/logup_phase.rs | 196 ++++++++++++++++++++++ prover/src/tests/challenge_phase_tests.rs | 61 +++++++ 5 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 prover/src/logup_phase.rs diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 1ba7118c0..2b80ece20 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1730,6 +1730,52 @@ pub trait IsStarkProver< }) } + /// A table's auxiliary commitment, built against the shared challenges and + /// dropped with the call. + /// + /// [`Self::commit_table_root`]'s counterpart for the LogUp pass. The aux + /// columns are written into `trace`, expanded and committed, and everything + /// this allocated dies here — which is the point: a prover that holds one + /// table at a time cannot keep the aux LDE around for later rounds, so it + /// re-derives it when it needs it again. + /// + /// Returns the root together with the bus public inputs the aux build + /// produced, since the proof carries them. A table with no aux trace has + /// nothing to commit; callers ask `air.has_aux_trace()` rather than reading + /// that off a `None`, which also means a failed commit. + fn commit_aux_root( + air: &dyn AIR, + trace: &mut TraceTable, + challenges: &[FieldElement], + ) -> Option<(Commitment, Option>)> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let lde_size = domain.interpolation_domain_size * domain.blowup_factor; + let bus_public_inputs = air.build_auxiliary_trace(trace, challenges); + + let (trace_data, total_cols) = trace.aux_data_row_major(); + if total_cols == 0 || trace_data.is_empty() { + return None; + } + let mut aux_data: Vec> = + Vec::with_capacity(lde_size * total_cols); + aux_data.extend_from_slice(trace_data); + Polynomial::>::coset_lde_full_expand_row_major::( + &mut aux_data, + total_cols, + domain.blowup_factor, + &twiddles.coset_weights, + &twiddles.two_half_inv, + &twiddles.two_half_fwd, + ) + .ok()?; + let (_, root) = Self::commit_rows_bit_reversed(&aux_data, total_cols)?; + Some((root, bus_public_inputs)) + } + /// Reconstruct Round1 for every table, print the bus balance report, and /// validate each trace. Called once after every table's aux commit, which /// under `debug-checks` means between the fused chain's two admitted diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs index b410b32e6..7434ec559 100644 --- a/prover/src/challenge_phase.rs +++ b/prover/src/challenge_phase.rs @@ -54,7 +54,12 @@ pub fn run( proof_options: &ProofOptions, ) -> Result { let remaining = &committed.remaining; - let table_counts = count_chunks(&committed.chunks); + let table_counts = count_chunks_by_kind( + committed + .chunks + .iter() + .map(|(kind, chunk, _)| (*kind, *chunk)), + ); let airs = VmAirs::new( elf, proof_options, @@ -183,8 +188,14 @@ fn commit_resident( .ok_or_else(|| Error::Prover(format!("challenge phase: no commitment for {name}"))) } -/// How many chunks the Commit phase produced per kind. -fn count_chunks(chunks: &[crate::commit_phase::ChunkCommitment]) -> TableCounts { +/// How many chunks a pass produced per kind. +/// +/// Taken as `(kind, chunk)` pairs rather than as a phase's own output, because +/// every pass over the execution produces the same layout and each has its own +/// per-chunk payload. +pub(crate) fn count_chunks_by_kind( + chunks: impl Iterator, +) -> TableCounts { let mut counts = TableCounts { cpu: 0, lt: 0, @@ -201,14 +212,14 @@ fn count_chunks(chunks: &[crate::commit_phase::ChunkCommitment]) -> TableCounts store: 0, cpu32: 0, }; - for (kind, chunk, _) in chunks { - let slot = slot_for(&mut counts, *kind); + for (kind, chunk) in chunks { + let slot = slot_for(&mut counts, kind); *slot = (*slot).max(chunk + 1); } counts } -fn count_for(counts: &TableCounts, kind: TableKind) -> usize { +pub(crate) fn count_for(counts: &TableCounts, kind: TableKind) -> usize { match kind { TableKind::Cpu => counts.cpu, TableKind::Lt => counts.lt, diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 6913c4ee3..ab9ac4c5e 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -20,6 +20,7 @@ pub mod continuation; mod debug_report; #[cfg(feature = "instruments")] pub mod instruments; +pub mod logup_phase; mod paged_mem; pub mod pass; pub(crate) mod streaming; diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs new file mode 100644 index 000000000..8a8a7ef04 --- /dev/null +++ b/prover/src/logup_phase.rs @@ -0,0 +1,196 @@ +//! Approach 1's LogUp pass: walk the execution again and build each table's +//! auxiliary columns against the challenge the Commit phase produced. +//! +//! The spec's third step is a re-execution. It has to be: the LogUp columns are +//! a function of the challenge, and the challenge is not known until every main +//! root has been absorbed — by which time the tables that produced them are +//! gone. The ordinary prover avoids the second walk by keeping every trace +//! resident across the Round 1 barrier, which is exactly the residency this +//! approach refuses to pay. +//! +//! So the tables are rebuilt. `build_main` is deterministic, so the trace a +//! chunk gets here is byte-identical to the one the Commit phase committed, and +//! the aux columns are therefore the ones that root answers for. + +use stark::config::Commitment; +use stark::lookup::BusPublicInputs; +use stark::proof::options::ProofOptions; +use stark::prover::IsStarkProver; + +use crate::Error; +use crate::challenge_phase::Challenge; +use crate::pass::{self, ChunkAirs, Resident, Visitor}; +use crate::streaming::{GROUP_ORDER, NUM_FIXED_AIRS}; +use crate::tables::MaxRowsConfig; +use crate::tables::trace_builder::TableKind; +use crate::tables::types::*; +use executor::elf::Elf; +use math::field::element::FieldElement; +use stark::trace::TraceTable; + +/// A table's auxiliary commitment. +/// +/// `bus` is what the aux build reported for the LogUp bus; the proof carries it +/// per table, so it travels with the root rather than being recomputed. +pub struct AuxRoot { + pub root: Commitment, + pub bus: Option>, +} + +/// What the LogUp pass produced. +pub struct LogUp { + /// One entry per table, in `VmAirs::air_trace_pairs` order. `None` for a + /// table with no auxiliary trace. + pub aux: Vec>, + /// The tables the pass could not retire, rebuilt by this walk. + pub resident: Resident, +} + +struct CommitAux<'a> { + airs: &'a ChunkAirs, + challenges: &'a [FieldElement], + roots: Vec<(TableKind, usize, Option)>, +} + +impl Visitor for CommitAux<'_> { + fn table( + &mut self, + kind: TableKind, + chunk: usize, + trace: &mut TraceTable, + ) -> Result<(), Error> { + let aux = commit_aux(self.airs.get(kind).as_ref(), trace, self.challenges) + .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; + self.roots.push((kind, chunk, aux)); + Ok(()) + } +} + +fn commit_aux( + air: &dyn stark::traits::AIR< + Field = GoldilocksField, + FieldExtension = GoldilocksExtension, + PublicInputs = (), + >, + trace: &mut TraceTable, + challenges: &[FieldElement], +) -> Result, String> { + if !air.has_aux_trace() { + return Ok(None); + } + type P = stark::prover::Prover; + let (root, bus) =

>::commit_aux_root(air, trace, challenges) + .ok_or_else(|| "no auxiliary commitment".to_string())?; + Ok(Some(AuxRoot { root, bus })) +} + +/// Run the LogUp pass over `elf`, against the challenge `challenge` sampled. +/// +/// `challenge` has to come from the Commit phase's roots over this same +/// execution: an aux trace built against a different challenge commits to a bus +/// the main traces never balanced. +pub fn run( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, + challenge: &Challenge, +) -> Result { + let airs = ChunkAirs::new(proof_options); + let mut visitor = CommitAux { + airs: &airs, + challenges: &challenge.challenges, + roots: Vec::new(), + }; + let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; + + let aux = assemble(visitor.roots, &mut resident, elf, proof_options, challenge)?; + Ok(LogUp { aux, resident }) +} + +/// Every auxiliary root in `VmAirs::air_trace_pairs` order. +/// +/// The chunked tables come back in the order the walk produced them; the tables +/// that stay have their aux built here, as the Challenge phase built their +/// mains. +fn assemble( + chunks: Vec<(TableKind, usize, Option)>, + resident: &mut Resident, + elf: &Elf, + proof_options: &ProofOptions, + challenge: &Challenge, +) -> Result>, Error> { + use std::collections::HashMap; + + let counts = crate::challenge_phase::count_chunks_by_kind( + chunks.iter().map(|(kind, chunk, _)| (*kind, *chunk)), + ); + let airs = crate::VmAirs::new( + elf, + proof_options, + false, + &resident.page_configs, + &counts, + None, + true, + None, + None, + None, + ); + + let mut by_slot: HashMap<(TableKind, usize), Option> = HashMap::new(); + for (kind, chunk, aux) in chunks { + if by_slot.insert((kind, chunk), aux).is_some() { + return Err(Error::Prover(format!( + "logup phase: {kind:?} chunk {chunk} built twice" + ))); + } + } + + let ch = &challenge.challenges; + let mut out = Vec::new(); + let fixed: [( + &crate::VmAir, + &mut TraceTable, + ); 10] = [ + (&airs.bitwise, &mut resident.bitwise), + (&airs.decode, &mut resident.decode), + (&airs.commit, &mut resident.accumulated.commit), + (&airs.keccak, &mut resident.accumulated.keccak), + (&airs.keccak_rnd, &mut resident.accumulated.keccak_rnd), + (&airs.keccak_rc, &mut resident.accumulated.keccak_rc), + (&airs.ecsm, &mut resident.accumulated.ecsm), + (&airs.ecdas, &mut resident.accumulated.ecdas), + (&airs.hint, &mut resident.accumulated.hint), + (&airs.register, &mut resident.register), + ]; + debug_assert_eq!(fixed.len(), NUM_FIXED_AIRS); + for (air, trace) in fixed { + out.push(commit_aux(air.as_ref(), trace, ch).map_err(Error::Prover)?); + } + if airs.include_halt { + out.push(commit_aux(airs.halt.as_ref(), &mut resident.halt, ch).map_err(Error::Prover)?); + } + + let mut page_airs = airs.pages.iter().zip(resident.pages.iter_mut()); + for group in GROUP_ORDER { + let Some(kind) = group else { + for (air, trace) in page_airs.by_ref() { + out.push(commit_aux(air.as_ref(), trace, ch).map_err(Error::Prover)?); + } + continue; + }; + for chunk in 0..crate::challenge_phase::count_for(&counts, kind) { + out.push(by_slot.remove(&(kind, chunk)).ok_or_else(|| { + Error::Prover(format!("logup phase: no aux for {kind:?} chunk {chunk}")) + })?); + } + } + if let Some(((kind, chunk), _)) = by_slot.into_iter().next() { + return Err(Error::Prover(format!( + "logup phase: {kind:?} chunk {chunk} has an aux root but no AIR" + ))); + } + + Ok(out) +} diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs index 0836cec6c..d0ff92012 100644 --- a/prover/src/tests/challenge_phase_tests.rs +++ b/prover/src/tests/challenge_phase_tests.rs @@ -115,3 +115,64 @@ fn challenge_matches_the_ordinary_prover() { "the Challenge phase sampled a different challenge from the same execution" ); } + +/// The LogUp pass must commit the auxiliary columns the proof carries. +/// +/// The pass rebuilds every table from scratch — the ones the Commit phase +/// dropped no longer exist — and builds their auxiliary columns against the +/// challenge that phase produced. Three separate things have to hold for the +/// root to land: the rebuild is byte-identical to what was committed, the +/// challenge is the prover's, and the table is in the slot the proof expects. +/// Any one of them failing changes the bus the verifier checks, so all three +/// are pinned here at once against a real proof. +#[test] +fn logup_matches_the_ordinary_prover() { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let logup = crate::logup_phase::run(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("logup phase"); + + assert_eq!( + logup.aux.len(), + vm_proof.proof.proofs.len(), + "the LogUp pass accounted for a different number of tables than the proof has" + ); + let mut with_aux = 0usize; + for (idx, (got, want)) in logup + .aux + .iter() + .zip(vm_proof.proof.proofs.iter()) + .enumerate() + { + assert_eq!( + got.as_ref().map(|a| a.root), + want.lde_trace_aux_merkle_root, + "table {idx}: auxiliary trace committed under a different root than the proof carries" + ); + if got.is_some() { + with_aux += 1; + } + } + assert!( + with_aux > 20, + "the fixture must cover the tables that carry a bus; saw {with_aux}" + ); +} From e11422aa8b98357c990c8e035f739ffccf7d6e4b Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 12:43:54 -0300 Subject: [PATCH 28/63] Measure Approach 1 one pass at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peak heap is per process, so a pipeline that grows a pass at a time has to be runnable a pass at a time. --through picks how far down it to go. On the ethrex mainnet block, 96 cores: 7629 MB in 38.2s through Commit, 10534 MB in 45.6s through Challenge, 15323 MB in 142.1s through LogUp, against 110261 MB in 93.0s for main's complete proof. Seven times less memory for three of the five passes, at 1.53x the time. The peak has moved. Every stage peaks in its last seconds, and what runs there is the commit of the tables that cannot be retired — BITWISE at 2^20 rows, DECODE, REGISTER and the pages — whose auxiliary columns are extension-field and so three base elements wide. The chunks are no longer what costs; the resident set is. --- bin/cli/src/main.rs | 63 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 70d905464..49cd0257d 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -266,9 +266,25 @@ enum Commands { /// (Approach 1's Commit phase), instead of building every trace first. #[arg(long)] streaming: bool, + + /// How far down Approach 1's pipeline to run. Only meaningful with + /// --streaming; each stage includes the ones before it. + #[arg(long, value_enum, default_value = "logup", requires = "streaming")] + through: Stage, }, } +/// Approach 1's passes, in order. +#[derive(Copy, Clone, PartialEq, Eq, clap::ValueEnum)] +enum Stage { + /// Walk and commit every chunk's main trace. + Commit, + /// Also commit the tables that stay, and sample the shared challenge. + Challenge, + /// Also walk again to build and commit the auxiliary columns. + Logup, +} + fn main() -> ExitCode { env_logger::init(); let cli = Cli::parse(); @@ -336,7 +352,8 @@ fn main() -> ExitCode { elf, private_input, streaming, - } => cmd_trace_build(elf, private_input, streaming), + through, + } => cmd_trace_build(elf, private_input, streaming, through), } } @@ -1019,6 +1036,36 @@ fn parse_epoch_size_log2(value: &str) -> Result { Ok(epoch_size_log2) } +/// Approach 1's pipeline, as far as `through`. +/// +/// Each stage is measured in its own process because peak heap is per process, +/// and reported as the number of tables it accounted for — chunks for the +/// Commit phase, every table in AIR order once the later passes have run. +fn run_approach_1( + elf: &Elf, + elf_bytes: &[u8], + private_inputs: &[u8], + max_rows: &prover::tables::MaxRowsConfig, + options: &stark::proof::options::ProofOptions, + through: Stage, +) -> Result { + let committed = prover::commit_phase::run_to_end(elf, private_inputs, max_rows, options) + .map_err(|e| format!("{e:?}"))?; + if through == Stage::Commit { + return Ok(committed.chunks.len()); + } + let challenge = prover::challenge_phase::run(&committed, elf, elf_bytes, options) + .map_err(|e| format!("{e:?}"))?; + // The mains are committed; nothing downstream reads their traces again. + drop(committed); + if through == Stage::Challenge { + return Ok(challenge.roots.len()); + } + let logup = prover::logup_phase::run(elf, private_inputs, max_rows, options, &challenge) + .map_err(|e| format!("{e:?}"))?; + Ok(logup.aux.len()) +} + /// Build the traces one way or the other, so the two production paths can be /// compared on what they hold. Nothing is proved: this measures the side of the /// prover that Approach 1's Commit phase replaces. @@ -1026,6 +1073,7 @@ fn cmd_trace_build( elf_path: PathBuf, private_input_path: Option, streaming: bool, + through: Stage, ) -> ExitCode { let elf_data = match std::fs::read(&elf_path) { Ok(data) => data, @@ -1062,9 +1110,14 @@ fn cmd_trace_build( return ExitCode::FAILURE; } }; - prover::commit_phase::run_to_end(&elf, &private_inputs, &max_rows, &options) - .map(|committed| committed.chunks.len()) - .map_err(|e| format!("{e:?}")) + run_approach_1( + &elf, + &elf_data, + &private_inputs, + &max_rows, + &options, + through, + ) } else { prover::commit_phase::build_resident(&elf, &private_inputs, &max_rows) .map(|t| t.cpus.len()) @@ -1074,7 +1127,7 @@ fn cmd_trace_build( let elapsed = started.elapsed(); match outcome { Ok(n) => println!( - "Trace build ({}): {n} chunks, {:.3}s", + "Trace build ({}): {n} tables, {:.3}s", if streaming { "streaming" } else { "resident" }, elapsed.as_secs_f64() ), From 3b7a2c5964fb16962e91c6d1ccc2e629644aa812 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 14:37:39 -0300 Subject: [PATCH 29/63] Fold rounds 2 and 3 into the LogUp pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composition polynomial needs the main trace and the auxiliary one at the same time. The LogUp pass has both in hand at the moment it builds the auxiliary columns, so putting rounds 2 and 3 there saves a whole walk over the execution — and there is no cheaper place to put them, since any later pass would have to rebuild both traces to get back to this point. Each table runs against its own transcript fork: the shared state after the challenge, domain-separated by AIR index. The fork is reproducible without changing the protocol, so what comes out is what the ordinary prover produces, and can be pinned against it. Which is the point of doing this before the FRI changes shape: rounds 1-3 are now a checked foundation rather than something that has to be argued about afterwards. The fork is also left standing where round 4 would pick it up — the two out-of-domain blocks and then the composition parts, in the verifier's order — because batching the FRI means folding these states together. Knowing where a chunk sits in the AIR order is now a question a pass can ask, which it has to be: a walk produces chunks in the order they close, and the fork is separated by index. The first walk already counted the chunks, so the layout is known before the second one starts. The test grows to cover the composition root and both out-of-domain blocks for every table. It caught one thing worth recording: between the auxiliary root and round 2, the fork also takes bus_public_inputs.table_contribution. Leaving it out moves beta and everything below it, and the symptom — a composition root that differs while the main and auxiliary roots match — points nowhere near the transcript. Measured on the ethrex mainnet block: 21570 MB in 242.2s for rounds 1-3 of all 227 tables, against 110261 MB in 93.0s for main's complete proof. Rounds 2-3 cost 6.2 GB and 100s of that. --- bin/cli/src/main.rs | 2 +- crypto/stark/src/prover.rs | 229 ++++++++++++++++++++++ prover/src/challenge_phase.rs | 19 +- prover/src/logup_phase.rs | 168 +++++++++------- prover/src/streaming.rs | 75 +++++++ prover/src/tests/challenge_phase_tests.rs | 35 +++- 6 files changed, 446 insertions(+), 82 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 49cd0257d..83ad97fb9 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1063,7 +1063,7 @@ fn run_approach_1( } let logup = prover::logup_phase::run(elf, private_inputs, max_rows, options, &challenge) .map_err(|e| format!("{e:?}"))?; - Ok(logup.aux.len()) + Ok(logup.tables.len()) } /// Build the traces one way or the other, so the two production paths can be diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 2b80ece20..7d22a69b4 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -668,6 +668,27 @@ pub struct MainRoots { pub main: Commitment, } +/// One table's rounds 2 and 3, for a pass that rebuilds the table to get them. +/// +/// Carries the round 1 roots too: the same rebuild produced them, and a caller +/// that already has them from an earlier pass can check the two agree — which +/// is what says the rebuild was deterministic. +pub struct TableRounds23 { + pub main_roots: MainRoots, + pub aux_root: Option, + pub bus_public_inputs: Option>, + pub composition_poly_root: Commitment, + /// The current-row block: every trace column at `z`. + pub trace_ood_evaluations: Table, + /// The next-row block, pruned to the columns a transition constraint reads + /// at `g·z`. Split here rather than by the caller because the transcript + /// absorbs the two blocks in this shape. + pub trace_ood_next_evaluations: Table, + pub composition_poly_parts_ood_evaluation: Vec>, + /// The out-of-domain point rounds 4 and 5 open against. + pub z: FieldElement, +} + /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1776,6 +1797,214 @@ pub trait IsStarkProver< Some((root, bus_public_inputs)) } + /// Rounds 2 and 3 for one table, rebuilt from its trace and dropped with + /// the call. + /// + /// The composition polynomial needs both LDEs at once, so this is where a + /// pass that holds one table at a time pays its widest moment: main LDE, + /// auxiliary LDE and the composition parts, for one table. The ordinary + /// prover keeps all three for every table simultaneously. + /// + /// `transcript` must be the table's own fork — the shared state after the + /// LogUp challenges, domain-separated by AIR index — with nothing appended + /// yet. The auxiliary root goes in here, as it does in the fused path, so + /// the challenges of rounds 2 and 3 come out identical. + fn rounds_2_to_3_for_table( + air: &dyn AIR, + pub_inputs: &PI, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut (impl IsStarkTranscript + Clone), + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + PI: Send + Sync + Clone, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let lde_size = domain.interpolation_domain_size * domain.blowup_factor; + + let bus_public_inputs = if air.has_aux_trace() { + air.build_auxiliary_trace(trace, challenges) + } else { + None + }; + + let expand_main = |data: &[FieldElement], cols: usize| { + let mut out: Vec> = Vec::with_capacity(lde_size * cols); + out.extend_from_slice(data); + Polynomial::>::coset_lde_full_expand_row_major::( + &mut out, + cols, + domain.blowup_factor, + &twiddles.coset_weights, + &twiddles.two_half_inv, + &twiddles.two_half_fwd, + ) + .map(|_| out) + }; + + let (main_src, num_main_cols) = trace.main_data_row_major(); + let main_data = + expand_main(main_src, num_main_cols).map_err(|_| ProvingError::EmptyCommitment)?; + let main = Self::table_commit_for(air, &main_data, num_main_cols)?; + + let (aux_data, num_aux_cols, aux) = if air.has_aux_trace() { + let (aux_src, cols) = trace.aux_data_row_major(); + let mut out: Vec> = Vec::with_capacity(lde_size * cols); + out.extend_from_slice(aux_src); + Polynomial::>::coset_lde_full_expand_row_major::( + &mut out, + cols, + domain.blowup_factor, + &twiddles.coset_weights, + &twiddles.two_half_inv, + &twiddles.two_half_fwd, + ) + .map_err(|_| ProvingError::EmptyCommitment)?; + let (tree, root) = + Self::commit_rows_bit_reversed(&out, cols).ok_or(ProvingError::EmptyCommitment)?; + (out, cols, Some(TableCommit::plain(tree, root))) + } else { + (Vec::new(), 0, None) + }; + + // The fork takes the auxiliary root, then the table's bus contribution, + // before round 2 samples anything. Both, in that order — the + // contribution is what ties this table's share of the LogUp bus into + // its own challenges, and leaving it out moves every one of them. + if let Some(ref c) = aux { + transcript.append_bytes(&c.root); + } + if let Some(ref bpi) = bus_public_inputs { + transcript.append_field_element(&bpi.table_contribution); + } + + let mut round_1_result = Round1 { + lde_trace: LDETraceTable::from_row_major( + main_data, + num_main_cols, + aux_data, + num_aux_cols, + air.step_size(), + domain.blowup_factor, + ), + main, + aux, + rap_challenges: challenges.to_vec(), + bus_public_inputs, + }; + + let beta = transcript.sample_field_element(); + let num_boundary_constraints = air + .boundary_constraints( + pub_inputs, + &round_1_result.rap_challenges, + round_1_result.bus_public_inputs.as_ref(), + domain.interpolation_domain_size, + ) + .constraints + .len(); + let num_transition_constraints = air.context().num_transition_constraints; + let mut coefficients: Vec<_> = + core::iter::successors(Some(FieldElement::one()), |x| Some(x * &beta)) + .take(num_boundary_constraints + num_transition_constraints) + .collect(); + let transition_coefficients: Vec<_> = + coefficients.drain(..num_transition_constraints).collect(); + let boundary_coefficients = coefficients; + + let mut round_2_result = Self::round_2_compute_composition_polynomial( + air, + pub_inputs, + &domain, + &twiddles, + &mut round_1_result, + &transition_coefficients, + &boundary_coefficients, + )?; + transcript.append_bytes(&round_2_result.composition_poly_root); + + let z = transcript.sample_z_ood( + &domain.lde_roots_of_unity_coset, + &domain.trace_roots_of_unity, + ); + let round_3_result = Self::round_3_evaluate_polynomials_in_out_of_domain_element( + air, + &domain, + &mut round_1_result, + &mut round_2_result, + &z, + ); + + // The fork is left standing where round 4 would pick it up: the two + // out-of-domain blocks and then the composition parts, in the order the + // verifier absorbs them. A pass that batches the FRI has to fold these + // states together, so it needs them advanced this far. + let (ood_block0, ood_block1) = + Self::ood_layout(air).split_full(&round_3_result.trace_ood_evaluations); + for block in [&ood_block0, &ood_block1] { + for col in block.columns().iter() { + for elem in col.iter() { + transcript.append_field_element(elem); + } + } + } + for element in round_3_result.composition_poly_parts_ood_evaluation.iter() { + transcript.append_field_element(element); + } + + Ok(TableRounds23 { + main_roots: MainRoots { + precomputed: round_1_result.main.precomputed_root, + main: round_1_result.main.root, + }, + aux_root: round_1_result.aux.as_ref().map(|c| c.root), + bus_public_inputs: round_1_result.bus_public_inputs.clone(), + composition_poly_root: round_2_result.composition_poly_root, + trace_ood_evaluations: ood_block0, + trace_ood_next_evaluations: ood_block1, + composition_poly_parts_ood_evaluation: round_3_result + .composition_poly_parts_ood_evaluation, + z, + }) + } + + /// The main commitment of an already-expanded LDE, split when the AIR is + /// preprocessed. Shares [`Self::commit_table_root`]'s rule, but keeps the + /// trees, which rounds 2-4 need. + fn table_commit_for( + air: &dyn AIR, + lde: &[FieldElement], + cols: usize, + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + { + if !air.is_preprocessed() { + let (tree, root) = + Self::commit_rows_bit_reversed(lde, cols).ok_or(ProvingError::EmptyCommitment)?; + return Ok(TableCommit::plain(tree, root)); + } + let num_precomputed = air.num_precomputed_columns(); + let (precomputed_tree, precomputed_root) = + Self::commit_rows_bit_reversed_subset(lde, cols, 0, num_precomputed) + .ok_or(ProvingError::EmptyCommitment)?; + if precomputed_root != air.precomputed_commitment() { + return Err(ProvingError::PrecomputedCommitmentMismatch); + } + let (mult_tree, mult_root) = + Self::commit_rows_bit_reversed_subset(lde, cols, num_precomputed, cols) + .ok_or(ProvingError::EmptyCommitment)?; + Ok(TableCommit::preprocessed( + mult_tree, + mult_root, + std::sync::Arc::new(precomputed_tree), + precomputed_root, + num_precomputed, + )) + } + /// Reconstruct Round1 for every table, print the bus balance report, and /// validate each trace. Called once after every table's aux commit, which /// under `debug-checks` means between the fused chain's two admitted diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs index 7434ec559..03705d9ea 100644 --- a/prover/src/challenge_phase.rs +++ b/prover/src/challenge_phase.rs @@ -40,6 +40,14 @@ pub struct Challenge { pub challenges: Vec>, /// Every root absorbed, in AIR order. pub roots: Vec, + /// The transcript right after the sampling, which every later pass forks + /// per table. Kept rather than rebuilt: re-absorbing 227 roots to get back + /// to this state is both slower and a second place for the order to be + /// wrong. + pub transcript: DefaultTranscript, + /// The layout the roots were assembled in, so a later pass can ask where a + /// chunk sits without recounting. + pub(crate) order: crate::streaming::AirOrder, } /// Sample the shared LogUp challenges from a finished Commit phase. @@ -101,7 +109,16 @@ pub fn run( .map(|_| transcript.sample_field_element()) .collect(); - Ok(Challenge { challenges, roots }) + Ok(Challenge { + challenges, + roots, + transcript, + order: crate::streaming::AirOrder::new( + table_counts, + airs.include_halt, + remaining.page_configs.len(), + ), + }) } /// Every root the transcript absorbs, in `VmAirs::air_trace_pairs` order. diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 8a8a7ef04..9f3cf9497 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -12,15 +12,15 @@ //! chunk gets here is byte-identical to the one the Commit phase committed, and //! the aux columns are therefore the ones that root answers for. -use stark::config::Commitment; -use stark::lookup::BusPublicInputs; +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use crypto::fiat_shamir::is_transcript::IsTranscript; use stark::proof::options::ProofOptions; -use stark::prover::IsStarkProver; +use stark::prover::{IsStarkProver, TableRounds23}; use crate::Error; use crate::challenge_phase::Challenge; use crate::pass::{self, ChunkAirs, Resident, Visitor}; -use crate::streaming::{GROUP_ORDER, NUM_FIXED_AIRS}; +use crate::streaming::NUM_FIXED_AIRS; use crate::tables::MaxRowsConfig; use crate::tables::trace_builder::TableKind; use crate::tables::types::*; @@ -28,45 +28,63 @@ use executor::elf::Elf; use math::field::element::FieldElement; use stark::trace::TraceTable; -/// A table's auxiliary commitment. -/// -/// `bus` is what the aux build reported for the LogUp bus; the proof carries it -/// per table, so it travels with the root rather than being recomputed. -pub struct AuxRoot { - pub root: Commitment, - pub bus: Option>, -} - /// What the LogUp pass produced. pub struct LogUp { - /// One entry per table, in `VmAirs::air_trace_pairs` order. `None` for a - /// table with no auxiliary trace. - pub aux: Vec>, + /// One entry per table, in `VmAirs::air_trace_pairs` order. + pub tables: Vec>, /// The tables the pass could not retire, rebuilt by this walk. pub resident: Resident, } -struct CommitAux<'a> { +struct BuildAux<'a> { airs: &'a ChunkAirs, challenges: &'a [FieldElement], - roots: Vec<(TableKind, usize, Option)>, + shared: &'a DefaultTranscript, + order: &'a crate::streaming::AirOrder, + done: Vec<(usize, TableRounds23)>, } -impl Visitor for CommitAux<'_> { +impl Visitor for BuildAux<'_> { fn table( &mut self, kind: TableKind, chunk: usize, trace: &mut TraceTable, ) -> Result<(), Error> { - let aux = commit_aux(self.airs.get(kind).as_ref(), trace, self.challenges) - .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; - self.roots.push((kind, chunk, aux)); + let idx = self.order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "logup phase: {kind:?} chunk {chunk} is not in the layout the Commit phase produced" + )) + })?; + let mut transcript = fork(self.shared, idx, self.order.len()); + let rounds = rounds_2_to_3( + self.airs.get(kind).as_ref(), + trace, + self.challenges, + &mut transcript, + ) + .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; + self.done.push((idx, rounds)); Ok(()) } } -fn commit_aux( +/// A table's own transcript: the shared state after the challenge, separated by +/// AIR index. Reproduces the fused prover's forking exactly — a single-table +/// proof takes no index, and getting that wrong shifts every challenge. +fn fork( + shared: &DefaultTranscript, + idx: usize, + num_airs: usize, +) -> DefaultTranscript { + let mut t = shared.clone(); + if num_airs > 1 { + t.append_bytes(&(idx as u64).to_le_bytes()); + } + t +} + +fn rounds_2_to_3( air: &dyn stark::traits::AIR< Field = GoldilocksField, FieldExtension = GoldilocksExtension, @@ -74,14 +92,11 @@ fn commit_aux( >, trace: &mut TraceTable, challenges: &[FieldElement], -) -> Result, String> { - if !air.has_aux_trace() { - return Ok(None); - } + transcript: &mut DefaultTranscript, +) -> Result, String> { type P = stark::prover::Prover; - let (root, bus) =

>::commit_aux_root(air, trace, challenges) - .ok_or_else(|| "no auxiliary commitment".to_string())?; - Ok(Some(AuxRoot { root, bus })) +

>::rounds_2_to_3_for_table(air, &(), trace, challenges, transcript) + .map_err(|e| format!("{e:?}")) } /// Run the LogUp pass over `elf`, against the challenge `challenge` sampled. @@ -97,40 +112,38 @@ pub fn run( challenge: &Challenge, ) -> Result { let airs = ChunkAirs::new(proof_options); - let mut visitor = CommitAux { + let mut visitor = BuildAux { airs: &airs, challenges: &challenge.challenges, - roots: Vec::new(), + shared: &challenge.transcript, + order: &challenge.order, + done: Vec::new(), }; let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; - let aux = assemble(visitor.roots, &mut resident, elf, proof_options, challenge)?; - Ok(LogUp { aux, resident }) + let tables = assemble(visitor.done, &mut resident, elf, proof_options, challenge)?; + Ok(LogUp { tables, resident }) } -/// Every auxiliary root in `VmAirs::air_trace_pairs` order. +/// Every table's rounds 2-3 in `VmAirs::air_trace_pairs` order. /// -/// The chunked tables come back in the order the walk produced them; the tables -/// that stay have their aux built here, as the Challenge phase built their +/// The chunked tables come back keyed by the index the walk resolved; the +/// tables that stay have theirs built here, as the Challenge phase built their /// mains. fn assemble( - chunks: Vec<(TableKind, usize, Option)>, + chunks: Vec<(usize, TableRounds23)>, resident: &mut Resident, elf: &Elf, proof_options: &ProofOptions, challenge: &Challenge, -) -> Result>, Error> { - use std::collections::HashMap; - - let counts = crate::challenge_phase::count_chunks_by_kind( - chunks.iter().map(|(kind, chunk, _)| (*kind, *chunk)), - ); +) -> Result>, Error> { + let order = &challenge.order; let airs = crate::VmAirs::new( elf, proof_options, false, &resident.page_configs, - &counts, + order.counts(), None, true, None, @@ -138,21 +151,35 @@ fn assemble( None, ); - let mut by_slot: HashMap<(TableKind, usize), Option> = HashMap::new(); - for (kind, chunk, aux) in chunks { - if by_slot.insert((kind, chunk), aux).is_some() { + let mut slots: Vec>> = + (0..order.len()).map(|_| None).collect(); + for (idx, rounds) in chunks { + let slot = slots + .get_mut(idx) + .ok_or_else(|| Error::Prover(format!("logup phase: table {idx} is past the layout")))?; + if slot.is_some() { return Err(Error::Prover(format!( - "logup phase: {kind:?} chunk {chunk} built twice" + "logup phase: table {idx} was built twice" ))); } + *slot = Some(rounds); } let ch = &challenge.challenges; - let mut out = Vec::new(); + let n = order.len(); + let build = |idx: usize, + air: &crate::VmAir, + trace: &mut TraceTable| + -> Result, Error> { + let mut transcript = fork(&challenge.transcript, idx, n); + rounds_2_to_3(air.as_ref(), trace, ch, &mut transcript) + .map_err(|e| Error::Prover(format!("logup phase: table {idx}: {e}"))) + }; + let fixed: [( &crate::VmAir, &mut TraceTable, - ); 10] = [ + ); NUM_FIXED_AIRS] = [ (&airs.bitwise, &mut resident.bitwise), (&airs.decode, &mut resident.decode), (&airs.commit, &mut resident.accumulated.commit), @@ -164,33 +191,24 @@ fn assemble( (&airs.hint, &mut resident.accumulated.hint), (&airs.register, &mut resident.register), ]; - debug_assert_eq!(fixed.len(), NUM_FIXED_AIRS); - for (air, trace) in fixed { - out.push(commit_aux(air.as_ref(), trace, ch).map_err(Error::Prover)?); + for (idx, (air, trace)) in fixed.into_iter().enumerate() { + slots[idx] = Some(build(idx, air, trace)?); } if airs.include_halt { - out.push(commit_aux(airs.halt.as_ref(), &mut resident.halt, ch).map_err(Error::Prover)?); - } - - let mut page_airs = airs.pages.iter().zip(resident.pages.iter_mut()); - for group in GROUP_ORDER { - let Some(kind) = group else { - for (air, trace) in page_airs.by_ref() { - out.push(commit_aux(air.as_ref(), trace, ch).map_err(Error::Prover)?); - } - continue; - }; - for chunk in 0..crate::challenge_phase::count_for(&counts, kind) { - out.push(by_slot.remove(&(kind, chunk)).ok_or_else(|| { - Error::Prover(format!("logup phase: no aux for {kind:?} chunk {chunk}")) - })?); - } + slots[NUM_FIXED_AIRS] = Some(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); } - if let Some(((kind, chunk), _)) = by_slot.into_iter().next() { - return Err(Error::Prover(format!( - "logup phase: {kind:?} chunk {chunk} has an aux root but no AIR" - ))); + for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { + let idx = order + .page_index(i) + .ok_or_else(|| Error::Prover(format!("logup phase: page {i} is not in the layout")))?; + slots[idx] = Some(build(idx, air, trace)?); } - Ok(out) + slots + .into_iter() + .enumerate() + .map(|(idx, slot)| { + slot.ok_or_else(|| Error::Prover(format!("logup phase: table {idx} was never built"))) + }) + .collect() } diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index 98ceb79d9..e40c62c98 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -40,6 +40,81 @@ pub(crate) const GROUP_ORDER: [Option; 15] = [ /// DECODE, COMMIT, KECCAK, KECCAK_RND, KECCAK_RC, ECSM, ECDAS, HINT, REGISTER. pub(crate) const NUM_FIXED_AIRS: usize = 10; +/// Where each table sits in `VmAirs::air_trace_pairs`. +/// +/// The order is the protocol — the transcript absorbs roots in it, and each +/// table's own fork is domain-separated by its index — so every pass has to +/// agree on it. A pass that walks the execution produces chunks in the order +/// they close, which is not this order, so it needs to be able to ask. +/// +/// Knowable before the second walk because the first one already counted the +/// chunks. +pub(crate) struct AirOrder { + counts: crate::TableCounts, + include_halt: bool, + num_pages: usize, +} + +impl AirOrder { + pub(crate) fn new(counts: crate::TableCounts, include_halt: bool, num_pages: usize) -> Self { + Self { + counts, + include_halt, + num_pages, + } + } + + /// The index of the first chunked table, after the fixed ones and HALT. + fn first_chunked(&self) -> usize { + NUM_FIXED_AIRS + usize::from(self.include_halt) + } + + fn group_len(&self, group: Option) -> usize { + match group { + None => self.num_pages, + Some(kind) => crate::challenge_phase::count_for(&self.counts, kind), + } + } + + /// The AIR index of a chunk, or `None` when the layout has no such chunk. + pub(crate) fn index_of(&self, kind: TableKind, chunk: usize) -> Option { + let mut idx = self.first_chunked(); + for group in GROUP_ORDER { + let len = self.group_len(group); + if group == Some(kind) { + return (chunk < len).then_some(idx + chunk); + } + idx += len; + } + None + } + + /// The AIR index of the `i`th PAGE table. + pub(crate) fn page_index(&self, i: usize) -> Option { + let mut idx = self.first_chunked(); + for group in GROUP_ORDER { + if group.is_none() { + return (i < self.num_pages).then_some(idx + i); + } + idx += self.group_len(group); + } + None + } + + pub(crate) fn counts(&self) -> &crate::TableCounts { + &self.counts + } + + /// How many tables the layout has in total. + pub(crate) fn len(&self) -> usize { + self.first_chunked() + + GROUP_ORDER + .iter() + .map(|g| self.group_len(*g)) + .sum::() + } +} + pub(crate) struct StreamingProvider { routed: CollectedOps, max_rows: MaxRowsConfig, diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs index d0ff92012..f8645c3f6 100644 --- a/prover/src/tests/challenge_phase_tests.rs +++ b/prover/src/tests/challenge_phase_tests.rs @@ -151,23 +151,48 @@ fn logup_matches_the_ordinary_prover() { .expect("logup phase"); assert_eq!( - logup.aux.len(), + logup.tables.len(), vm_proof.proof.proofs.len(), "the LogUp pass accounted for a different number of tables than the proof has" ); let mut with_aux = 0usize; for (idx, (got, want)) in logup - .aux + .tables .iter() .zip(vm_proof.proof.proofs.iter()) .enumerate() { assert_eq!( - got.as_ref().map(|a| a.root), - want.lde_trace_aux_merkle_root, + got.aux_root, want.lde_trace_aux_merkle_root, "table {idx}: auxiliary trace committed under a different root than the proof carries" ); - if got.is_some() { + assert_eq!( + got.main_roots.main, want.lde_trace_main_merkle_root, + "table {idx}: the rebuild produced a different main trace than the Commit phase did" + ); + assert_eq!( + got.composition_poly_root, want.composition_poly_root, + "table {idx}: composition polynomial committed under a different root" + ); + assert_eq!( + got.composition_poly_parts_ood_evaluation, want.composition_poly_parts_ood_evaluation, + "table {idx}: composition parts evaluated at a different out-of-domain point" + ); + for (label, got, want) in [ + ("z", &got.trace_ood_evaluations, &want.trace_ood_evaluations), + ( + "g*z", + &got.trace_ood_next_evaluations, + &want.trace_ood_next_evaluations, + ), + ] { + assert_eq!( + (got.width, got.columns()), + (want.width, want.columns()), + "table {idx}: different out-of-domain trace evaluations at {label}" + ); + } + if got.aux_root.is_some() { with_aux += 1; } } From 80c436c5921349b14213c0b440f77c0c5e780c2a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 14:52:09 -0300 Subject: [PATCH 30/63] Stop building a Merkle tree the pass throws away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 2 and 3 read the trace LDE, never the commitment, and the LogUp pass opens nothing — so the main Merkle tree it built was constructed and dropped without ever being asked a question. Nothing is lost by not having it. The composition polynomial is computed from the very LDE that root would be taken over, so a rebuild that drifted moves the composition root instead; incrementing every element of the main LDE fails the test on exactly that assertion. Worth 8.5s of 242.2s on the ethrex mainnet block, and no memory: the tree was transient either way. I had predicted far more, reading main's "Main commit (Merkle) 104.99s" as wall time when it is summed over tables — a single table's Merkle build already uses every core, so a pass that does one table at a time gets it cheaply. --- crypto/stark/src/prover.rs | 18 +++++++++--------- prover/src/tests/challenge_phase_tests.rs | 4 ---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 7d22a69b4..0685949f9 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -670,11 +670,11 @@ pub struct MainRoots { /// One table's rounds 2 and 3, for a pass that rebuilds the table to get them. /// -/// Carries the round 1 roots too: the same rebuild produced them, and a caller -/// that already has them from an earlier pass can check the two agree — which -/// is what says the rebuild was deterministic. +/// No main root: rounds 2 and 3 never read that commitment, and the +/// composition root already pins the main trace — it is computed from the very +/// LDE the root would be taken over, so a rebuild that drifted shows up here +/// too, and more cheaply. pub struct TableRounds23 { - pub main_roots: MainRoots, pub aux_root: Option, pub bus_public_inputs: Option>, pub composition_poly_root: Commitment, @@ -1847,7 +1847,11 @@ pub trait IsStarkProver< let (main_src, num_main_cols) = trace.main_data_row_major(); let main_data = expand_main(main_src, num_main_cols).map_err(|_| ProvingError::EmptyCommitment)?; - let main = Self::table_commit_for(air, &main_data, num_main_cols)?; + // No main Merkle tree. Rounds 2 and 3 read the LDE, never the + // commitment, and this pass opens nothing — the tree would be built and + // thrown away. It is the most expensive thing a sequential pass can do + // for nothing, and the composition root already pins the same LDE. + let main = TableCommit::plain(BatchedMerkleTree::from_root([0u8; 32]), [0u8; 32]); let (aux_data, num_aux_cols, aux) = if air.has_aux_trace() { let (aux_src, cols) = trace.aux_data_row_major(); @@ -1955,10 +1959,6 @@ pub trait IsStarkProver< } Ok(TableRounds23 { - main_roots: MainRoots { - precomputed: round_1_result.main.precomputed_root, - main: round_1_result.main.root, - }, aux_root: round_1_result.aux.as_ref().map(|c| c.root), bus_public_inputs: round_1_result.bus_public_inputs.clone(), composition_poly_root: round_2_result.composition_poly_root, diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs index f8645c3f6..a1fba6548 100644 --- a/prover/src/tests/challenge_phase_tests.rs +++ b/prover/src/tests/challenge_phase_tests.rs @@ -166,10 +166,6 @@ fn logup_matches_the_ordinary_prover() { got.aux_root, want.lde_trace_aux_merkle_root, "table {idx}: auxiliary trace committed under a different root than the proof carries" ); - assert_eq!( - got.main_roots.main, want.lde_trace_main_merkle_root, - "table {idx}: the rebuild produced a different main trace than the Commit phase did" - ); assert_eq!( got.composition_poly_root, want.composition_poly_root, "table {idx}: composition polynomial committed under a different root" From 2f9f93a4052e076a77990339a44c682e751c4981 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 15:19:59 -0300 Subject: [PATCH 31/63] Work on several tables at a time in each pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk hands tables over one by one and the work was done right there, so a pass ran with one table's worth of parallelism on a machine with far more of it. The ordinary prover runs 32 tables at once; these passes ran one. Batching k of them costs k times one table's working set and no more, which is the bargain the approach is built on: bounded residency, not minimal. On the ethrex mainnet block, 96 cores, through the LogUp pass: k=1 233.4s 21550 MB k=4 143.5s 21554 MB k=8 129.9s 21550 MB k=16 123.5s 21550 MB k=32 122.3s 26640 MB 1.9x faster and the peak does not move at all up to 16, for the same reason the residue is where it is: the peak is set at the end of the run by the tables that cannot be retired, and a batch of chunks is small beside them. At 32 the batch becomes the peak — it moves to halfway through the run — and buys 1% of time for 5 GB. The bound is memory rather than cores, since a smaller run has a smaller resident set for a batch to hide behind, so the default is cores/6 capped at 16 with A1_TABLE_PARALLELISM as the knob. A pass's results now arrive unordered, which nothing relied on: both phases index by (kind, chunk) or by AIR index. --- prover/src/commit_phase.rs | 103 +++++++++++++++++++++++++++---------- prover/src/logup_phase.rs | 96 +++++++++++++++++++++++----------- prover/src/pass.rs | 93 +++++++++++++++++++++++++++++++-- 3 files changed, 230 insertions(+), 62 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 60f5449f2..f5faeb2a9 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -32,16 +32,68 @@ pub struct Committed { /// What the walk alone produced. pub struct CommitPhase { - /// One entry per chunk closed during the walk, in the order they closed. + /// One entry per chunk closed during the walk. Unordered: the batch runs + /// its tables in parallel, so what reads this indexes by `(kind, chunk)`. pub closed: Vec, /// Everything the walk still held when the execution ended. pub walked: pass::Walked, } -/// Commits each table's main trace and drops it. +/// Commits each table's main trace and drops it, `k` tables at a time. struct CommitMain<'a> { - airs: &'a ChunkAirs, - roots: Vec, + batch: pass::Batched< + ( + TableKind, + usize, + TraceTable, + ), + Box< + dyn FnMut( + Vec<( + TableKind, + usize, + TraceTable, + )>, + ) -> Result<(), Error> + + 'a, + >, + >, +} + +impl<'a> CommitMain<'a> { + fn new(airs: &'a ChunkAirs, roots: &'a std::sync::Mutex>) -> Self { + Self { + batch: pass::Batched::new(Box::new(move |items| commit_batch(airs, roots, items))), + } + } +} + +/// One batch, in parallel. The tables in a batch are independent — each commits +/// its own trace against its own AIR — so the only shared thing is where the +/// roots land. +fn commit_batch( + airs: &ChunkAirs, + roots: &std::sync::Mutex>, + items: Vec<( + TableKind, + usize, + TraceTable, + )>, +) -> Result<(), Error> { + use rayon::prelude::*; + type P = stark::prover::Prover; + let done: Result, Error> = items + .into_par_iter() + .map(|(kind, chunk, trace)| { +

>::commit_table_root(airs.get(kind).as_ref(), &trace) + .map(|root| (kind, chunk, root)) + .ok_or_else(|| { + Error::Prover(format!("commit phase: no commitment for a {kind:?} chunk")) + }) + }) + .collect(); + roots.lock().expect("roots").extend(done?); + Ok(()) } impl Visitor for CommitMain<'_> { @@ -49,16 +101,13 @@ impl Visitor for CommitMain<'_> { &mut self, kind: TableKind, chunk: usize, - trace: &mut TraceTable, + trace: TraceTable, ) -> Result<(), Error> { - type P = stark::prover::Prover; - let root = -

>::commit_table_root(self.airs.get(kind).as_ref(), trace) - .ok_or_else(|| { - Error::Prover(format!("commit phase: no commitment for a {kind:?} chunk")) - })?; - self.roots.push((kind, chunk, root)); - Ok(()) + self.batch.push((kind, chunk, trace)) + } + + fn flush(&mut self) -> Result<(), Error> { + self.batch.drain() } } @@ -73,13 +122,13 @@ pub fn run( proof_options: &ProofOptions, ) -> Result { let airs = ChunkAirs::new(proof_options); - let mut visitor = CommitMain { - airs: &airs, - roots: Vec::new(), - }; + let roots = std::sync::Mutex::new(Vec::new()); + let mut visitor = CommitMain::new(&airs, &roots); let walked = pass::walk(elf, private_input, max_rows, &mut visitor)?; + visitor.flush()?; + drop(visitor); Ok(CommitPhase { - closed: visitor.roots, + closed: roots.into_inner().expect("roots"), walked, }) } @@ -97,13 +146,12 @@ pub fn run_to_end( proof_options: &ProofOptions, ) -> Result { let airs = ChunkAirs::new(proof_options); - let mut visitor = CommitMain { - airs: &airs, - roots: Vec::new(), - }; + let roots = std::sync::Mutex::new(Vec::new()); + let mut visitor = CommitMain::new(&airs, &roots); let remaining = pass::run(elf, private_input, max_rows, &mut visitor)?; + drop(visitor); Ok(Committed { - chunks: visitor.roots, + chunks: roots.into_inner().expect("roots"), remaining, }) } @@ -119,12 +167,11 @@ pub fn commit_remaining( proof_options: &ProofOptions, ) -> Result<(Vec, Resident), Error> { let airs = ChunkAirs::new(proof_options); - let mut visitor = CommitMain { - airs: &airs, - roots: Vec::new(), - }; + let roots = std::sync::Mutex::new(Vec::new()); + let mut visitor = CommitMain::new(&airs, &roots); let resident = pass::finish(walked, private_input, max_rows, &mut visitor)?; - Ok((visitor.roots, resident)) + drop(visitor); + Ok((roots.into_inner().expect("roots"), resident)) } /// The ordinary build, for comparison against [`run_to_end`]. diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 9f3cf9497..90e2bf45e 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -36,12 +36,63 @@ pub struct LogUp { pub resident: Resident, } +type Item = ( + TableKind, + usize, + TraceTable, +); + struct BuildAux<'a> { - airs: &'a ChunkAirs, - challenges: &'a [FieldElement], - shared: &'a DefaultTranscript, - order: &'a crate::streaming::AirOrder, - done: Vec<(usize, TableRounds23)>, + #[allow(clippy::type_complexity)] + batch: pass::Batched) -> Result<(), Error> + 'a>>, +} + +impl<'a> BuildAux<'a> { + fn new( + airs: &'a ChunkAirs, + challenge: &'a Challenge, + done: &'a std::sync::Mutex)>>, + ) -> Self { + Self { + batch: pass::Batched::new(Box::new(move |items| { + rounds_batch(airs, challenge, done, items) + })), + } + } +} + +/// One batch, in parallel. Each table runs against its own transcript fork, so +/// nothing crosses between them — the shared state is only where results land. +fn rounds_batch( + airs: &ChunkAirs, + challenge: &Challenge, + done: &std::sync::Mutex)>>, + items: Vec, +) -> Result<(), Error> { + use rayon::prelude::*; + let order = &challenge.order; + let n = order.len(); + let built: Result, Error> = items + .into_par_iter() + .map(|(kind, chunk, mut trace)| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "logup phase: {kind:?} chunk {chunk} is not in the layout the Commit phase produced" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let rounds = rounds_2_to_3( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + ) + .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, rounds)) + }) + .collect(); + done.lock().expect("logup results").extend(built?); + Ok(()) } impl Visitor for BuildAux<'_> { @@ -49,23 +100,13 @@ impl Visitor for BuildAux<'_> { &mut self, kind: TableKind, chunk: usize, - trace: &mut TraceTable, + trace: TraceTable, ) -> Result<(), Error> { - let idx = self.order.index_of(kind, chunk).ok_or_else(|| { - Error::Prover(format!( - "logup phase: {kind:?} chunk {chunk} is not in the layout the Commit phase produced" - )) - })?; - let mut transcript = fork(self.shared, idx, self.order.len()); - let rounds = rounds_2_to_3( - self.airs.get(kind).as_ref(), - trace, - self.challenges, - &mut transcript, - ) - .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; - self.done.push((idx, rounds)); - Ok(()) + self.batch.push((kind, chunk, trace)) + } + + fn flush(&mut self) -> Result<(), Error> { + self.batch.drain() } } @@ -112,16 +153,13 @@ pub fn run( challenge: &Challenge, ) -> Result { let airs = ChunkAirs::new(proof_options); - let mut visitor = BuildAux { - airs: &airs, - challenges: &challenge.challenges, - shared: &challenge.transcript, - order: &challenge.order, - done: Vec::new(), - }; + let done = std::sync::Mutex::new(Vec::new()); + let mut visitor = BuildAux::new(&airs, challenge, &done); let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; + drop(visitor); - let tables = assemble(visitor.done, &mut resident, elf, proof_options, challenge)?; + let chunks = done.into_inner().expect("logup results"); + let tables = assemble(chunks, &mut resident, elf, proof_options, challenge)?; Ok(LogUp { tables, resident }) } diff --git a/prover/src/pass.rs b/prover/src/pass.rs index beaba449e..73a3d17c9 100644 --- a/prover/src/pass.rs +++ b/prover/src/pass.rs @@ -32,8 +32,90 @@ pub trait Visitor { &mut self, kind: TableKind, chunk: usize, - trace: &mut TraceTable, + trace: TraceTable, ) -> Result<(), Error>; + + /// Called once after the last table. A visitor that holds tables back to + /// work on several at a time deals with the remainder here. + fn flush(&mut self) -> Result<(), Error> { + Ok(()) + } +} + +/// How many tables a pass works on at a time. +/// +/// The walk hands tables over one by one, and doing the work right there means +/// one table's worth of parallelism on a machine with far more of it. Holding +/// `k` back costs `k` times one table's working set and no more, which is the +/// bargain the whole approach is built on — bounded residency, not minimal. +/// +/// Measured on the ethrex mainnet block, 96 cores, through the LogUp pass: +/// +/// | k | time | peak | +/// |---|---|---| +/// | 1 | 233.4s | 21550 MB | +/// | 4 | 143.5s | 21554 MB | +/// | 8 | 129.9s | 21550 MB | +/// | 16 | 123.5s | 21550 MB | +/// | 32 | 122.3s | 26640 MB | +/// +/// Up to 16 the peak does not move at all, because it is set at the end of the +/// run by the tables that cannot be retired — BITWISE, DECODE, the pages — and +/// a batch of chunks is small beside them. At 32 the batch itself becomes the +/// peak (it moves to halfway through the run) and buys 1% of time for 5 GB. +/// +/// So the knee is 16 on that machine, and the bound is memory rather than +/// cores: a smaller run has a smaller resident set for a batch to hide behind. +/// `A1_TABLE_PARALLELISM` overrides it. +pub fn table_parallelism() -> usize { + if let Some(k) = std::env::var("A1_TABLE_PARALLELISM") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|k| *k > 0) + { + return k; + } + std::thread::available_parallelism() + .map(|n| (n.get() / 6).clamp(1, 16)) + .unwrap_or(1) +} + +/// Collects tables until there are `k` of them, then hands the batch over. +/// +/// Sits between the walk and a pass so the pass only says what to do with one +/// table; the batching, and the bound on how many are alive, live here once. +pub struct Batched { + batch: Vec, + k: usize, + run: F, +} + +impl Batched +where + F: FnMut(Vec) -> Result<(), Error>, +{ + pub fn new(run: F) -> Self { + Self { + batch: Vec::new(), + k: table_parallelism(), + run, + } + } + + pub fn push(&mut self, item: T) -> Result<(), Error> { + self.batch.push(item); + if self.batch.len() >= self.k { + return self.drain(); + } + Ok(()) + } + + pub fn drain(&mut self) -> Result<(), Error> { + if self.batch.is_empty() { + return Ok(()); + } + (self.run)(std::mem::take(&mut self.batch)) + } } /// The tables a pass cannot retire, still as traces. @@ -107,9 +189,9 @@ pub fn walk( &image, ®ister_init, max_rows, - |kind, chunk, mut table| { + |kind, chunk, table| { if failed.is_none() - && let Err(e) = visitor.table(kind, chunk, &mut table) + && let Err(e) = visitor.table(kind, chunk, table) { failed = Some(e); } @@ -157,14 +239,15 @@ pub fn finish( // Chunks the walk already closed keep their numbering; what is left // continues from there. let first = leftover.emitted(kind); - for (offset, mut table) in leftover + for (offset, table) in leftover .take_remaining(kind, max_rows) .into_iter() .enumerate() { - visitor.table(kind, first + offset, &mut table)?; + visitor.table(kind, first + offset, table)?; } } + visitor.flush()?; let public_output = leftover.public_output_bytes(); let accumulated = leftover.build_accumulated(); From 7f7a5d018f833894d39fa75928c9d9b69c47b9fc Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 15:59:47 -0300 Subject: [PATCH 32/63] Prove each table through round 4, and verify it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass stopped at round 3, which left Approach 1 with commitments and no proof. Carrying each table through round 4 gives one, and it is the checkpoint worth having: the test no longer only compares against the ordinary prover piece by piece — auxiliary root, composition root, FRI layer roots, final polynomial — it assembles the MultiProof the pass produced and verifies it. So this is a complete prover that holds one batch of tables at a time, not a partial one. On the ethrex mainnet block, 96 cores: 21952 MB in 143.5s against 110261 MB in 92.7s for main. Five times less memory at 1.55x the time, proof verified. Round 4 costs 400 MB over the rounds 1-3 peak: a table's FRI layers and openings are small beside what is already held. The main Merkle tree comes back. Rounds 2-3 never read it, which is why the previous commit dropped it, but round 4's openings walk it. That measurement still holds for the spec's five-pass shape, where opening is its own pass; it does not hold here. What is left is the batched FRI — one FRI for the run instead of 227. It is no longer on the critical path to having a prover, since there is one and it verifies, so it becomes an optimization of proof size and verification time with this as its baseline. --- crypto/stark/src/prover.rs | 109 +++------------------- prover/src/commit_phase.rs | 33 ++----- prover/src/logup_phase.rs | 52 +++++------ prover/src/pass.rs | 18 ++-- prover/src/tests/challenge_phase_tests.rs | 56 ++++++----- 5 files changed, 88 insertions(+), 180 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 0685949f9..303f9cef6 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -668,27 +668,6 @@ pub struct MainRoots { pub main: Commitment, } -/// One table's rounds 2 and 3, for a pass that rebuilds the table to get them. -/// -/// No main root: rounds 2 and 3 never read that commitment, and the -/// composition root already pins the main trace — it is computed from the very -/// LDE the root would be taken over, so a rebuild that drifted shows up here -/// too, and more cheaply. -pub struct TableRounds23 { - pub aux_root: Option, - pub bus_public_inputs: Option>, - pub composition_poly_root: Commitment, - /// The current-row block: every trace column at `z`. - pub trace_ood_evaluations: Table, - /// The next-row block, pruned to the columns a transition constraint reads - /// at `g·z`. Split here rather than by the caller because the transcript - /// absorbs the two blocks in this shape. - pub trace_ood_next_evaluations: Table, - pub composition_poly_parts_ood_evaluation: Vec>, - /// The out-of-domain point rounds 4 and 5 open against. - pub z: FieldElement, -} - /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1797,8 +1776,8 @@ pub trait IsStarkProver< Some((root, bus_public_inputs)) } - /// Rounds 2 and 3 for one table, rebuilt from its trace and dropped with - /// the call. + /// One table's whole proof, rebuilt from its trace and dropped with the + /// call. /// /// The composition polynomial needs both LDEs at once, so this is where a /// pass that holds one table at a time pays its widest moment: main LDE, @@ -1807,15 +1786,16 @@ pub trait IsStarkProver< /// /// `transcript` must be the table's own fork — the shared state after the /// LogUp challenges, domain-separated by AIR index — with nothing appended - /// yet. The auxiliary root goes in here, as it does in the fused path, so - /// the challenges of rounds 2 and 3 come out identical. - fn rounds_2_to_3_for_table( + /// yet. The auxiliary root and the table's bus contribution go in here, as + /// they do in the fused path, so every challenge below comes out identical + /// and the proof is the one the ordinary prover would have written. + fn prove_table_from_trace( air: &dyn AIR, pub_inputs: &PI, trace: &mut TraceTable, challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), - ) -> Result, ProvingError> + ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, FieldElement: AsBytes + math::traits::ByteConversion, @@ -1847,11 +1827,7 @@ pub trait IsStarkProver< let (main_src, num_main_cols) = trace.main_data_row_major(); let main_data = expand_main(main_src, num_main_cols).map_err(|_| ProvingError::EmptyCommitment)?; - // No main Merkle tree. Rounds 2 and 3 read the LDE, never the - // commitment, and this pass opens nothing — the tree would be built and - // thrown away. It is the most expensive thing a sequential pass can do - // for nothing, and the composition root already pins the same LDE. - let main = TableCommit::plain(BatchedMerkleTree::from_root([0u8; 32]), [0u8; 32]); + let main = Self::table_commit_for(air, &main_data, num_main_cols)?; let (aux_data, num_aux_cols, aux) = if air.has_aux_trace() { let (aux_src, cols) = trace.aux_data_row_major(); @@ -1899,75 +1875,14 @@ pub trait IsStarkProver< bus_public_inputs, }; - let beta = transcript.sample_field_element(); - let num_boundary_constraints = air - .boundary_constraints( - pub_inputs, - &round_1_result.rap_challenges, - round_1_result.bus_public_inputs.as_ref(), - domain.interpolation_domain_size, - ) - .constraints - .len(); - let num_transition_constraints = air.context().num_transition_constraints; - let mut coefficients: Vec<_> = - core::iter::successors(Some(FieldElement::one()), |x| Some(x * &beta)) - .take(num_boundary_constraints + num_transition_constraints) - .collect(); - let transition_coefficients: Vec<_> = - coefficients.drain(..num_transition_constraints).collect(); - let boundary_coefficients = coefficients; - - let mut round_2_result = Self::round_2_compute_composition_polynomial( + Self::prove_rounds_2_to_4( air, pub_inputs, - &domain, - &twiddles, &mut round_1_result, - &transition_coefficients, - &boundary_coefficients, - )?; - transcript.append_bytes(&round_2_result.composition_poly_root); - - let z = transcript.sample_z_ood( - &domain.lde_roots_of_unity_coset, - &domain.trace_roots_of_unity, - ); - let round_3_result = Self::round_3_evaluate_polynomials_in_out_of_domain_element( - air, + transcript, &domain, - &mut round_1_result, - &mut round_2_result, - &z, - ); - - // The fork is left standing where round 4 would pick it up: the two - // out-of-domain blocks and then the composition parts, in the order the - // verifier absorbs them. A pass that batches the FRI has to fold these - // states together, so it needs them advanced this far. - let (ood_block0, ood_block1) = - Self::ood_layout(air).split_full(&round_3_result.trace_ood_evaluations); - for block in [&ood_block0, &ood_block1] { - for col in block.columns().iter() { - for elem in col.iter() { - transcript.append_field_element(elem); - } - } - } - for element in round_3_result.composition_poly_parts_ood_evaluation.iter() { - transcript.append_field_element(element); - } - - Ok(TableRounds23 { - aux_root: round_1_result.aux.as_ref().map(|c| c.root), - bus_public_inputs: round_1_result.bus_public_inputs.clone(), - composition_poly_root: round_2_result.composition_poly_root, - trace_ood_evaluations: ood_block0, - trace_ood_next_evaluations: ood_block1, - composition_poly_parts_ood_evaluation: round_3_result - .composition_poly_parts_ood_evaluation, - z, - }) + &twiddles, + ) } /// The main commitment of an already-expanded LDE, split when the AIR is diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index f5faeb2a9..c18accc0d 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -39,31 +39,22 @@ pub struct CommitPhase { pub walked: pass::Walked, } +/// One table on its way to a pass: what it is, where it sits, and its trace. +type Item = ( + TableKind, + usize, + TraceTable, +); + /// Commits each table's main trace and drops it, `k` tables at a time. struct CommitMain<'a> { - batch: pass::Batched< - ( - TableKind, - usize, - TraceTable, - ), - Box< - dyn FnMut( - Vec<( - TableKind, - usize, - TraceTable, - )>, - ) -> Result<(), Error> - + 'a, - >, - >, + batch: pass::Batched<'a, Item>, } impl<'a> CommitMain<'a> { fn new(airs: &'a ChunkAirs, roots: &'a std::sync::Mutex>) -> Self { Self { - batch: pass::Batched::new(Box::new(move |items| commit_batch(airs, roots, items))), + batch: pass::Batched::new(move |items| commit_batch(airs, roots, items)), } } } @@ -74,11 +65,7 @@ impl<'a> CommitMain<'a> { fn commit_batch( airs: &ChunkAirs, roots: &std::sync::Mutex>, - items: Vec<( - TableKind, - usize, - TraceTable, - )>, + items: Vec, ) -> Result<(), Error> { use rayon::prelude::*; type P = stark::prover::Prover; diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 90e2bf45e..e980fd101 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -1,5 +1,5 @@ -//! Approach 1's LogUp pass: walk the execution again and build each table's -//! auxiliary columns against the challenge the Commit phase produced. +//! Approach 1's proving pass: walk the execution again and prove each table +//! against the challenge the Commit phase produced. //! //! The spec's third step is a re-execution. It has to be: the LogUp columns are //! a function of the challenge, and the challenge is not known until every main @@ -15,7 +15,8 @@ use crypto::fiat_shamir::default_transcript::DefaultTranscript; use crypto::fiat_shamir::is_transcript::IsTranscript; use stark::proof::options::ProofOptions; -use stark::prover::{IsStarkProver, TableRounds23}; +use stark::proof::stark::StarkProof; +use stark::prover::IsStarkProver; use crate::Error; use crate::challenge_phase::Challenge; @@ -28,10 +29,10 @@ use executor::elf::Elf; use math::field::element::FieldElement; use stark::trace::TraceTable; -/// What the LogUp pass produced. +/// What the pass produced. pub struct LogUp { - /// One entry per table, in `VmAirs::air_trace_pairs` order. - pub tables: Vec>, + /// One proof per table, in `VmAirs::air_trace_pairs` order. + pub tables: Vec>, /// The tables the pass could not retire, rebuilt by this walk. pub resident: Resident, } @@ -42,21 +43,20 @@ type Item = ( TraceTable, ); +/// One table's finished proof, tagged with where it sits in the AIR order. +type Proved = (usize, StarkProof); + +/// Where a batch of proofs lands. Shared because the batch runs in parallel. +type Proofs = std::sync::Mutex>; + struct BuildAux<'a> { - #[allow(clippy::type_complexity)] - batch: pass::Batched) -> Result<(), Error> + 'a>>, + batch: pass::Batched<'a, Item>, } impl<'a> BuildAux<'a> { - fn new( - airs: &'a ChunkAirs, - challenge: &'a Challenge, - done: &'a std::sync::Mutex)>>, - ) -> Self { + fn new(airs: &'a ChunkAirs, challenge: &'a Challenge, done: &'a Proofs) -> Self { Self { - batch: pass::Batched::new(Box::new(move |items| { - rounds_batch(airs, challenge, done, items) - })), + batch: pass::Batched::new(move |items| rounds_batch(airs, challenge, done, items)), } } } @@ -66,7 +66,7 @@ impl<'a> BuildAux<'a> { fn rounds_batch( airs: &ChunkAirs, challenge: &Challenge, - done: &std::sync::Mutex)>>, + done: &Proofs, items: Vec, ) -> Result<(), Error> { use rayon::prelude::*; @@ -81,7 +81,7 @@ fn rounds_batch( )) })?; let mut transcript = fork(&challenge.transcript, idx, n); - let rounds = rounds_2_to_3( + let rounds = prove_table( airs.get(kind).as_ref(), &mut trace, &challenge.challenges, @@ -125,7 +125,7 @@ fn fork( t } -fn rounds_2_to_3( +fn prove_table( air: &dyn stark::traits::AIR< Field = GoldilocksField, FieldExtension = GoldilocksExtension, @@ -134,9 +134,9 @@ fn rounds_2_to_3( trace: &mut TraceTable, challenges: &[FieldElement], transcript: &mut DefaultTranscript, -) -> Result, String> { +) -> Result, String> { type P = stark::prover::Prover; -

>::rounds_2_to_3_for_table(air, &(), trace, challenges, transcript) +

>::prove_table_from_trace(air, &(), trace, challenges, transcript) .map_err(|e| format!("{e:?}")) } @@ -169,12 +169,12 @@ pub fn run( /// tables that stay have theirs built here, as the Challenge phase built their /// mains. fn assemble( - chunks: Vec<(usize, TableRounds23)>, + chunks: Vec, resident: &mut Resident, elf: &Elf, proof_options: &ProofOptions, challenge: &Challenge, -) -> Result>, Error> { +) -> Result>, Error> { let order = &challenge.order; let airs = crate::VmAirs::new( elf, @@ -189,7 +189,7 @@ fn assemble( None, ); - let mut slots: Vec>> = + let mut slots: Vec>> = (0..order.len()).map(|_| None).collect(); for (idx, rounds) in chunks { let slot = slots @@ -208,9 +208,9 @@ fn assemble( let build = |idx: usize, air: &crate::VmAir, trace: &mut TraceTable| - -> Result, Error> { + -> Result, Error> { let mut transcript = fork(&challenge.transcript, idx, n); - rounds_2_to_3(air.as_ref(), trace, ch, &mut transcript) + prove_table(air.as_ref(), trace, ch, &mut transcript) .map_err(|e| Error::Prover(format!("logup phase: table {idx}: {e}"))) }; diff --git a/prover/src/pass.rs b/prover/src/pass.rs index 73a3d17c9..11535f2c8 100644 --- a/prover/src/pass.rs +++ b/prover/src/pass.rs @@ -83,22 +83,22 @@ pub fn table_parallelism() -> usize { /// Collects tables until there are `k` of them, then hands the batch over. /// /// Sits between the walk and a pass so the pass only says what to do with one -/// table; the batching, and the bound on how many are alive, live here once. -pub struct Batched { +/// batch; the batching, and the bound on how many tables are alive, live here +/// once. The action is boxed so a pass names `Batched<'_, Item>` and not a +/// closure type. +pub struct Batched<'a, T> { batch: Vec, k: usize, - run: F, + #[allow(clippy::type_complexity)] + run: Box) -> Result<(), Error> + 'a>, } -impl Batched -where - F: FnMut(Vec) -> Result<(), Error>, -{ - pub fn new(run: F) -> Self { +impl<'a, T> Batched<'a, T> { + pub fn new(run: impl FnMut(Vec) -> Result<(), Error> + 'a) -> Self { Self { batch: Vec::new(), k: table_parallelism(), - run, + run: Box::new(run), } } diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs index a1fba6548..53b4b82fb 100644 --- a/prover/src/tests/challenge_phase_tests.rs +++ b/prover/src/tests/challenge_phase_tests.rs @@ -153,9 +153,8 @@ fn logup_matches_the_ordinary_prover() { assert_eq!( logup.tables.len(), vm_proof.proof.proofs.len(), - "the LogUp pass accounted for a different number of tables than the proof has" + "the pass accounted for a different number of tables than the proof has" ); - let mut with_aux = 0usize; for (idx, (got, want)) in logup .tables .iter() @@ -163,37 +162,44 @@ fn logup_matches_the_ordinary_prover() { .enumerate() { assert_eq!( - got.aux_root, want.lde_trace_aux_merkle_root, - "table {idx}: auxiliary trace committed under a different root than the proof carries" + got.lde_trace_aux_merkle_root, want.lde_trace_aux_merkle_root, + "table {idx}: auxiliary trace committed under a different root" ); assert_eq!( got.composition_poly_root, want.composition_poly_root, "table {idx}: composition polynomial committed under a different root" ); assert_eq!( - got.composition_poly_parts_ood_evaluation, want.composition_poly_parts_ood_evaluation, - "table {idx}: composition parts evaluated at a different out-of-domain point" + got.fri_layers_merkle_roots, want.fri_layers_merkle_roots, + "table {idx}: a different FRI commitment" + ); + assert_eq!( + got.fri_final_poly_coeffs, want.fri_final_poly_coeffs, + "table {idx}: a different FRI final polynomial" ); - for (label, got, want) in [ - ("z", &got.trace_ood_evaluations, &want.trace_ood_evaluations), - ( - "g*z", - &got.trace_ood_next_evaluations, - &want.trace_ood_next_evaluations, - ), - ] { - assert_eq!( - (got.width, got.columns()), - (want.width, want.columns()), - "table {idx}: different out-of-domain trace evaluations at {label}" - ); - } - if got.aux_root.is_some() { - with_aux += 1; - } } + + // The decisive one: the pass's own proof, verified. Everything above says + // it matches the ordinary prover piece by piece; this says the assembled + // whole is a proof. + let rebuilt = crate::VmProof { + proof: stark::proof::stark::MultiProof { + proofs: logup.tables, + }, + runtime_page_ranges: crate::tables::trace_builder::runtime_page_ranges( + &logup.resident.page_configs, + ), + table_counts: vm_proof.table_counts.clone(), + public_output: logup.resident.public_output.clone(), + num_private_input_pages: logup + .resident + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(), + }; assert!( - with_aux > 20, - "the fixture must cover the tables that carry a bus; saw {with_aux}" + crate::verify(&rebuilt, &elf_bytes).expect("verify"), + "the proof the pass assembled does not verify" ); } From 11607ce320e3f54940b64c7c6e805583f0d2b7d2 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 16:05:12 -0300 Subject: [PATCH 33/63] Report what one FRI per table costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0 of the batched-FRI analysis is to instrument the serialized size per component before deciding anything, so the estimate is confirmed or killed with data. This prints it: how many tables collapse into how many distinct heights, and what fraction of the proof the per-table FRI data is. Batching can only merge tables that share a domain exactly — the fold squares the coset offset each layer, so a short table over offset* never lines up with a tall fold over offset^2* — which makes the height histogram the whole question. On the ethrex mainnet block: 227 tables over 13 distinct heights, with 206 of them in just three, and the per-table FRI data is 448 MB of 775 MB (57.9%). Collapsing 227 FRI instances into 13 should leave about 26 MB of that, so roughly 54% less proof — inside the 40-60% the analysis estimated. --- Cargo.lock | 1 + bin/cli/Cargo.toml | 1 + bin/cli/src/main.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 93fd6b417..bcbda1c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,7 @@ dependencies = [ "executor", "lambda-vm-prover", "rkyv", + "serde_cbor", "stark", "tempfile", "tikv-jemalloc-ctl", diff --git a/bin/cli/Cargo.toml b/bin/cli/Cargo.toml index b9140e34c..776d8acd0 100644 --- a/bin/cli/Cargo.toml +++ b/bin/cli/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" license.workspace = true [dependencies] +serde_cbor = "0.11" executor = { path = "../../executor" } prover = { path = "../../prover", package = "lambda-vm-prover" } stark = { path = "../../crypto/stark" } diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 83ad97fb9..ca1b6eea2 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1063,9 +1063,55 @@ fn run_approach_1( } let logup = prover::logup_phase::run(elf, private_inputs, max_rows, options, &challenge) .map_err(|e| format!("{e:?}"))?; + report_fri_shape(&logup.tables); Ok(logup.tables.len()) } +/// What one FRI per table costs, and what batching by height would collapse. +/// +/// Step 0 of the batched-FRI analysis: the prize is the per-table FRI data, and +/// batching can only merge tables that share a domain exactly — Lambda's fold +/// squares the coset offset each layer, so a short table over `offset·` does +/// not line up with a tall fold over `offset²·`. So the number worth knowing +/// is how many tables collapse into how many distinct heights, against the +/// bytes that would be saved. +fn report_fri_shape( + proofs: &[stark::proof::stark::StarkProof< + prover::tables::types::GoldilocksField, + prover::tables::types::GoldilocksExtension, + (), + >], +) { + use std::collections::BTreeMap; + + let mut by_height: BTreeMap = BTreeMap::new(); + let (mut fri_bytes, mut total_bytes) = (0usize, 0usize); + for p in proofs { + *by_height.entry(p.trace_length).or_default() += 1; + fri_bytes += serde_cbor::to_vec(&p.fri_layers_merkle_roots) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&p.fri_final_poly_coeffs) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&p.query_list) + .map(|v| v.len()) + .unwrap_or(0); + total_bytes += serde_cbor::to_vec(p).map(|v| v.len()).unwrap_or(0); + } + println!( + "FRI: {} tables over {} distinct heights; per-table FRI data {} MB of {} MB ({:.1}%)", + proofs.len(), + by_height.len(), + fri_bytes / (1024 * 1024), + total_bytes / (1024 * 1024), + 100.0 * fri_bytes as f64 / total_bytes.max(1) as f64, + ); + for (rows, tables) in by_height.iter().rev() { + println!(" {rows:>9} rows x{tables}"); + } +} + /// Build the traces one way or the other, so the two production paths can be /// compared on what they hold. Nothing is proved: this measures the side of the /// prover that Approach 1's Commit phase replaces. From 05d1257926532e83c869d738dbe8cb9bb260370f Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 16:49:37 -0300 Subject: [PATCH 34/63] Fold a group of tables into one FRI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec wants one FRI for the execution where there are 227, one per table. Measured on the ethrex mainnet block, those 227 sit on 13 distinct heights with 206 of them in just three, and the per-table FRI data is 57.9% of the proof — so collapsing them is worth about half the proof size. Two pieces, both pinned against a real proof: deep_for_table takes a table through rounds 1-3 and computes its DEEP composition codeword, and stops there, because the next thing is a fold whose coefficient binds every table in the batch. It keeps the codeword rather than the LDEs it came from, which is what makes the split affordable: the trace and composition LDEs of all 227 tables are tens of gigabytes, their DEEP codewords together about 6.5 GB — one extension element per row instead of every column. That is a walk over the execution saved. batch_fri accumulates the fold in place. A member is added and dropped, so what is held is one codeword and not the group's worth of them, which is the spec's "accumulate FRI polys into one batch polynomial" taken literally. Grouping is by exact height and can only be: the fold squares the coset offset each layer, so a short codeword over offset* never lines up with a tall fold over offset^2*. The test is a batch of one, which must reproduce the proof's FRI exactly since folding a single member is the identity. Splitting round 3 from round 4 is where this can silently go wrong, and it did — the recovered rounds 2-3 already absorbed the out-of-domain blocks and the extraction absorbed them again, which moved gamma and with it the whole codeword. The symptom was FRI roots differing while the composition root, z and the out-of-domain values all matched; what found it was comparing the transcript state either side of the absorption. --- crypto/stark/src/prover.rs | 262 +++++++++++++++++++++++++- prover/src/logup_phase.rs | 31 +++ prover/src/tests/batched_fri_tests.rs | 89 +++++++++ prover/src/tests/mod.rs | 1 + 4 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 prover/src/tests/batched_fri_tests.rs diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 303f9cef6..6a21ebd85 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -668,6 +668,26 @@ pub struct MainRoots { pub main: Commitment, } +/// One table's contribution to a batched FRI. +pub struct TableDeep { + /// The domain the codeword lives on. Only tables that agree on this can be + /// folded together: the fold squares the coset offset each layer, so a + /// short codeword over `offset·` never lines up with a tall fold over + /// `offset²·`. + pub lde_size: usize, + /// Rows before the blowup. Carried rather than divided out of `lde_size`, + /// because the batch has to rebuild the very domain the codeword was + /// computed on and a wrong one gives wrong twiddles and a silently wrong + /// fold. + pub trace_rows: usize, + /// The DEEP composition codeword, `lde_size` long. + pub deep: Vec>, + /// The fork's state once this table is done with it. The batch's + /// coefficient is drawn from every one of these, in AIR order, which is + /// what binds the fold to all the data it folds. + pub fork_state: Vec, +} + /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1800,6 +1820,35 @@ pub trait IsStarkProver< FieldElement: AsBytes + math::traits::ByteConversion, FieldElement: AsBytes + math::traits::ByteConversion, PI: Send + Sync + Clone, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + Self::prove_rounds_2_to_4( + air, + pub_inputs, + &mut round_1_result, + transcript, + &domain, + &twiddles, + ) + } + + /// Round 1 for one table, rebuilt from its trace. + /// + /// Both LDEs and both commitments, and the two things the table's own fork + /// takes before round 2 samples anything: the auxiliary root, then the bus + /// contribution. Leaving the contribution out moves beta and everything + /// below it, while the main and auxiliary roots still match — a symptom + /// that points nowhere near the transcript. + fn round_1_from_trace( + air: &dyn AIR, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut (impl IsStarkTranscript + Clone), + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let lde_size = domain.interpolation_domain_size * domain.blowup_factor; @@ -1860,7 +1909,7 @@ pub trait IsStarkProver< transcript.append_field_element(&bpi.table_contribution); } - let mut round_1_result = Round1 { + let round_1_result = Round1 { lde_trace: LDETraceTable::from_row_major( main_data, num_main_cols, @@ -1875,14 +1924,221 @@ pub trait IsStarkProver< bus_public_inputs, }; - Self::prove_rounds_2_to_4( + Ok(round_1_result) + } + + /// Rounds 2 and 3 for one table, against its own fork. + /// + /// Returns the out-of-domain point with them: rounds 4 and 5 open against + /// it, and re-deriving it would mean re-running round 2 to get the + /// composition root the transcript needs first. + #[allow(clippy::type_complexity)] + fn rounds_2_and_3( + air: &dyn AIR, + pub_inputs: &PI, + round_1_result: &mut Round1, + transcript: &mut (impl IsStarkTranscript + Clone), + domain: &Domain, + twiddles: &LdeTwiddles, + ) -> Result< + ( + Round2, + Round3, + FieldElement, + ), + ProvingError, + > + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + PI: Send + Sync + Clone, + { + let beta = transcript.sample_field_element(); + let num_boundary_constraints = air + .boundary_constraints( + pub_inputs, + &round_1_result.rap_challenges, + round_1_result.bus_public_inputs.as_ref(), + domain.interpolation_domain_size, + ) + .constraints + .len(); + let num_transition_constraints = air.context().num_transition_constraints; + let mut coefficients: Vec<_> = + core::iter::successors(Some(FieldElement::one()), |x| Some(x * &beta)) + .take(num_boundary_constraints + num_transition_constraints) + .collect(); + let transition_coefficients: Vec<_> = + coefficients.drain(..num_transition_constraints).collect(); + let boundary_coefficients = coefficients; + + let mut round_2_result = Self::round_2_compute_composition_polynomial( + air, + pub_inputs, + domain, + twiddles, + round_1_result, + &transition_coefficients, + &boundary_coefficients, + )?; + transcript.append_bytes(&round_2_result.composition_poly_root); + + let z = transcript.sample_z_ood( + &domain.lde_roots_of_unity_coset, + &domain.trace_roots_of_unity, + ); + let round_3_result = Self::round_3_evaluate_polynomials_in_out_of_domain_element( + air, + domain, + round_1_result, + &mut round_2_result, + &z, + ); + + // The fork is left standing where round 4 would pick it up: the two + // out-of-domain blocks and then the composition parts, in the order the + // verifier absorbs them. A pass that batches the FRI has to fold these + // states together, so it needs them advanced this far. + let (ood_block0, ood_block1) = + Self::ood_layout(air).split_full(&round_3_result.trace_ood_evaluations); + for block in [&ood_block0, &ood_block1] { + for col in block.columns().iter() { + for elem in col.iter() { + transcript.append_field_element(elem); + } + } + } + for element in round_3_result.composition_poly_parts_ood_evaluation.iter() { + transcript.append_field_element(element); + } + + Ok((round_2_result, round_3_result, z)) + } + + /// One table taken as far as a batched FRI lets it go on its own. + /// + /// Rounds 1 to 3, then the DEEP composition codeword — and there it stops, + /// because the next thing is a fold whose coefficient binds every table in + /// the batch and so cannot be known yet. + /// + /// The codeword is kept rather than the LDEs it came from. That is the + /// whole reason this split is affordable: on the ethrex block the trace and + /// composition LDEs of all 227 tables are tens of gigabytes, while their + /// DEEP codewords together are about 6.5 GB — one extension element per row + /// instead of every column. Holding them is what saves walking the + /// execution again just to recompute them once the coefficient is known. + fn deep_for_table( + air: &dyn AIR, + pub_inputs: &PI, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut (impl IsStarkTranscript + Clone), + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + PI: Send + Sync + Clone, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + let (mut round_2_result, round_3_result, z) = Self::rounds_2_and_3( air, pub_inputs, &mut round_1_result, transcript, &domain, &twiddles, - ) + )?; + + // Round 4's opening move, up to the point where the batch takes over: + // gamma is this table's own, sampled from its own fork. + let gamma = transcript.sample_field_element(); + let n_terms_composition_poly = round_2_result.lde_composition_poly_evaluations.len(); + let layout = Self::ood_layout(air); + let num_terms_trace = layout.num_surviving(); + let mut coefficients: Vec<_> = + core::iter::successors(Some(FieldElement::one()), |x| Some(x * &gamma)) + .take(n_terms_composition_poly + num_terms_trace) + .collect(); + let trace_term_powers: Vec<_> = coefficients.drain(..num_terms_trace).collect(); + let trace_term_coeffs = layout.build_trace_term_coeffs(&trace_term_powers); + + let deep = Self::compute_deep_composition_poly_evaluations( + &mut round_1_result.lde_trace, + &mut round_2_result, + &round_3_result, + &z, + &domain, + &domain.trace_primitive_root, + &coefficients, + &trace_term_coeffs, + ); + + // Bit-reversed here rather than at fold time. FRI wants it that way, and + // the permutation depends only on the length — which every member of a + // group shares — so permuting each codeword before the fold gives the + // same result as permuting the sum, one pass earlier. + let mut deep = deep; + in_place_bit_reverse_permute(&mut deep); + + Ok(TableDeep { + lde_size: domain.interpolation_domain_size * domain.blowup_factor, + trace_rows: domain.interpolation_domain_size, + deep, + fork_state: transcript.state().to_vec(), + }) + } + + /// One FRI over a whole group of tables. + /// + /// The members share a domain, so their codewords add directly: the batch + /// is `Σ αᵏ·deepₖ` with `k` running in AIR order. Accumulating in place is + /// the point — a member is folded in and dropped, so what this holds is one + /// codeword, not the group's worth of them. + /// + /// `transcript` is the batch's, not any member's, and `alpha` must have + /// been drawn from it after every member's fork state went in. That is what + /// binds the fold to all the data it folds. + fn batch_fri( + air: &dyn AIR, + members: Vec>, + alpha: &FieldElement, + transcript: &mut (impl IsStarkTranscript + Clone), + ) -> Option> + where + FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send, + { + let lde_size = members.first()?.lde_size; + let trace_rows = members.first()?.trace_rows; + if members + .iter() + .any(|m| m.lde_size != lde_size || m.trace_rows != trace_rows) + { + return None; + } + let mut acc = vec![FieldElement::::zero(); lde_size]; + let mut power = FieldElement::::one(); + + for member in members { + for (dst, src) in acc.iter_mut().zip(member.deep.iter()) { + *dst = &*dst + &power * src; + } + power *= alpha; + } + + let (domain, _) = domain_and_twiddles(air, trace_rows); + let coset_offset = FieldElement::::from(air.context().proof_options.coset_offset); + let (_, layers) = fri::commit_phase_from_evaluations( + acc, + transcript, + &coset_offset, + domain.lde_roots_of_unity_coset.len(), + domain.blowup_factor.trailing_zeros(), + air.options().fri_final_poly_log_degree as u32, + domain.fri_inv_twiddles(), + ); + Some(layers.iter().map(|l| l.merkle_tree.root).collect()) } /// The main commitment of an already-expanded LDE, split when the AIR is diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index e980fd101..ba7ce4358 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -113,6 +113,37 @@ impl Visitor for BuildAux<'_> { /// A table's own transcript: the shared state after the challenge, separated by /// AIR index. Reproduces the fused prover's forking exactly — a single-table /// proof takes no index, and getting that wrong shifts every challenge. +#[cfg(test)] +pub(crate) fn fork_for( + challenge: &Challenge, + idx: usize, + num_airs: usize, +) -> DefaultTranscript { + fork(&challenge.transcript, idx, num_airs) +} + +/// Rebuild only the tables a pass cannot retire, for a caller that wants one of +/// them without proving the run. +#[cfg(test)] +pub(crate) fn resident_tables( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, +) -> Result { + struct Skip; + impl Visitor for Skip { + fn table( + &mut self, + _kind: TableKind, + _chunk: usize, + _trace: TraceTable, + ) -> Result<(), Error> { + Ok(()) + } + } + pass::run(elf, private_input, max_rows, &mut Skip) +} + fn fork( shared: &DefaultTranscript, idx: usize, diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs new file mode 100644 index 000000000..f9e914be2 --- /dev/null +++ b/prover/src/tests/batched_fri_tests.rs @@ -0,0 +1,89 @@ +//! The batched FRI, pinned at the one size where it has to agree with the +//! unbatched one. + +use crate::tables::MaxRowsConfig; +use crate::tables::types::{GoldilocksExtension, GoldilocksField}; +use executor::elf::Elf; +use stark::prover::IsStarkProver; + +/// A batch of one must reproduce the proof's FRI exactly. +/// +/// `Σ αᵏ·deepₖ` over a single member is `deepₖ`, so the batched path and the +/// per-table one fold the same codeword over the same domain. If their layer +/// roots differ, the difference is in the codeword or in the domain — which is +/// the whole substance of the batching, and worth catching before any group has +/// more than one member in it. +#[test] +fn a_batch_of_one_matches_the_unbatched_fri() { + type P = stark::prover::Prover; + + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + + let page_configs = crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime( + &elf, + &vm_proof.runtime_page_ranges, + vm_proof.num_private_input_pages, + vm_proof.proof.proofs.len(), + ) + .expect("page configs"); + let airs = crate::VmAirs::new( + &elf, + &proof_options, + false, + &page_configs, + &vm_proof.table_counts, + None, + true, + None, + None, + None, + ); + let mut resident = crate::logup_phase::resident_tables(&elf, &[], &max_rows).expect("resident"); + + // BITWISE is table 0 and the largest resident one, so it exercises a real + // domain rather than a one-row corner. + let idx = 0usize; + let n = challenge.roots.len(); + let mut fork = crate::logup_phase::fork_for(&challenge, idx, n); + let deep =

>::deep_for_table( + airs.bitwise.as_ref(), + &(), + &mut resident.bitwise, + &challenge.challenges, + &mut fork, + ) + .expect("deep"); + + let one = math::field::element::FieldElement::::one(); + let roots =

>::batch_fri( + airs.bitwise.as_ref(), + vec![deep], + &one, + &mut fork, + ) + .expect("batched fri"); + + assert_eq!( + roots, vm_proof.proof.proofs[idx].fri_layers_merkle_roots, + "a batch of one folded to a different FRI than the proof carries" + ); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index ece45b5b8..dcd180464 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -1,5 +1,6 @@ #[cfg(all(test, feature = "disk-spill"))] pub mod auto_storage_tests; +mod batched_fri_tests; #[cfg(test)] pub mod bitwise_bus_tests; #[cfg(test)] From dc1971e57044da38c90cdd1e8eaacfda0e237f96 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 16 Sep 2026 17:46:35 -0300 Subject: [PATCH 35/63] Show where the proving pass spends its time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass creates no spans of its own — the prover's live in multi_prove, which it does not use — so asking where its time goes had no answer, and twice I guessed and was wrong: once predicting a redundant Merkle tree was worth ~100s when it was 8.5, once predicting that keeping Round 1 would recover 49s when it recovered 12.6 and cost 37.6 GB of peak. Both mistakes were the same one. main's report prints costs summed over tables, and a single table already uses every core, so a per-table cost that looks huge in the sum is cheap in wall time. Instrumenting the pass itself is the only way to stop making it. What it says, on the ethrex mainnet block (ratios, not absolutes — the instrumented build runs 1.6x slower): Round 1 is 58% of the per-table work, of which the main half is 27% and duplicated with the Commit phase, and the auxiliary half is 18% and cannot be — it depends on a challenge that does not exist yet when the Commit phase runs. Rounds 2 to 4 are the other 42%, and main pays those too. So the earlier budget experiment is explained rather than contradicted: it could only ever address that 27%, and 16-way concurrency already absorbs most of it. --- bin/cli/src/main.rs | 41 ++++++++++++++++++++++++++++++++++++++ crypto/stark/src/prover.rs | 21 +++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index ca1b6eea2..afcc1b684 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1049,24 +1049,65 @@ fn run_approach_1( options: &stark::proof::options::ProofOptions, through: Stage, ) -> Result { + #[cfg(feature = "instruments")] + stark::instruments::reset_timeline(); + let t0 = std::time::Instant::now(); let committed = prover::commit_phase::run_to_end(elf, private_inputs, max_rows, options) .map_err(|e| format!("{e:?}"))?; + let t_commit = t0.elapsed(); if through == Stage::Commit { + println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); return Ok(committed.chunks.len()); } + let t1 = std::time::Instant::now(); let challenge = prover::challenge_phase::run(&committed, elf, elf_bytes, options) .map_err(|e| format!("{e:?}"))?; // The mains are committed; nothing downstream reads their traces again. drop(committed); + let t_challenge = t1.elapsed(); if through == Stage::Challenge { + println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); + println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); return Ok(challenge.roots.len()); } + let t2 = std::time::Instant::now(); let logup = prover::logup_phase::run(elf, private_inputs, max_rows, options, &challenge) .map_err(|e| format!("{e:?}"))?; + let t_prove = t2.elapsed(); + println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); + println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); + println!(" pass 3 (prove) {:>8.2}s", t_prove.as_secs_f64()); + report_span_totals(); report_fri_shape(&logup.tables); Ok(logup.tables.len()) } +/// Where the time went, summed per span label. +/// +/// The prover's own spans are per table and there are 227 of them, so the raw +/// timeline is unreadable; what answers "where is the time" is the total per +/// label. Sums exceed wall time, because tables run concurrently — the ratios +/// between labels are the point, not the absolute figures. +fn report_span_totals() { + #[cfg(feature = "instruments")] + { + use std::collections::BTreeMap; + let spans = stark::instruments::take_timeline(); + let mut by_label: BTreeMap<&str, (std::time::Duration, usize)> = BTreeMap::new(); + for s in &spans { + let e = by_label.entry(s.label).or_default(); + e.0 += s.wall; + e.1 += 1; + } + let mut rows: Vec<_> = by_label.into_iter().collect(); + rows.sort_by_key(|(_, (d, _))| std::cmp::Reverse(*d)); + println!(" --- summed over tables (concurrent, so > wall) ---"); + for (label, (d, n)) in rows.into_iter().take(12) { + println!(" {label:<28} {:>8.2}s x{n}", d.as_secs_f64()); + } + } +} + /// What one FRI per table costs, and what batching by height would collapse. /// /// Step 0 of the batched-FRI analysis: the prize is the per-table FRI data, and diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 6a21ebd85..c08fa1bc1 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1822,15 +1822,24 @@ pub trait IsStarkProver< PI: Send + Sync + Clone, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + #[cfg(feature = "instruments")] + let __r1 = crate::instruments::span("a1_round_1"); let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; - Self::prove_rounds_2_to_4( + #[cfg(feature = "instruments")] + drop(__r1); + #[cfg(feature = "instruments")] + let __r24 = crate::instruments::span("a1_rounds_2_to_4"); + let out = Self::prove_rounds_2_to_4( air, pub_inputs, &mut round_1_result, transcript, &domain, &twiddles, - ) + ); + #[cfg(feature = "instruments")] + drop(__r24); + out } /// Round 1 for one table, rebuilt from its trace. @@ -1873,11 +1882,17 @@ pub trait IsStarkProver< .map(|_| out) }; + #[cfg(feature = "instruments")] + let __m = crate::instruments::span("a1_r1_main"); let (main_src, num_main_cols) = trace.main_data_row_major(); let main_data = expand_main(main_src, num_main_cols).map_err(|_| ProvingError::EmptyCommitment)?; let main = Self::table_commit_for(air, &main_data, num_main_cols)?; + #[cfg(feature = "instruments")] + drop(__m); + #[cfg(feature = "instruments")] + let __a = crate::instruments::span("a1_r1_aux"); let (aux_data, num_aux_cols, aux) = if air.has_aux_trace() { let (aux_src, cols) = trace.aux_data_row_major(); let mut out: Vec> = Vec::with_capacity(lde_size * cols); @@ -1898,6 +1913,8 @@ pub trait IsStarkProver< (Vec::new(), 0, None) }; + #[cfg(feature = "instruments")] + drop(__a); // The fork takes the auxiliary root, then the table's bus contribution, // before round 2 samples anything. Both, in that order — the // contribution is what ties this table's share of the LogUp bus into From 6917f6b8e3bf0c6a137ad4407e713f1e0fdbca4c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 10:10:28 -0300 Subject: [PATCH 36/63] Bring the mixed-height MMCS and the batched FRI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched proof format does not need building: #951 has it, with one mixed-height MMCS per round and ONE FRI instance per epoch — which is what the spec asks for, rather than the group-by-exact-height fallback this branch was heading towards. It was deleted from another lane by #973 and #974 and is preserved at archive/batched-format-pre-deletion. So this brings the pieces over rather than reinventing them. What comes across is everything the format needs except the driver: crypto/crypto merkle_tree traits + the field-element backend they need crypto/stark fri/mmcs.rs the mixed-height tree fri/batched.rs height combination, batched commit phase, shared challenge derivation batched/round4.rs the round-4 transcript sequence par.rs par_for_each_mut_indexed, which #974 deleted 24 of their tests come with it and pass, including streaming_builder_serves_the_base_group_without_holding_it — the property this branch needs, since a builder that absorbs each table's LDE and frees it is what lets the batched rounds run without holding all of them. What is deliberately NOT brought is #951's own driver. It takes every table's trace at once, which is the residency this branch exists to remove: its floor is all 227 traces resident, measured at 41077 MB on the ethrex block, and this branch's whole proof fits in 21952 MB. The two remove different things — #951 the simultaneous LDEs, this the simultaneous traces — so the driver is the piece to write rather than to copy, and its header is the specification for it. round4's own five tests are not collected yet; they lean on parts of the format that are not across. --- .../backends/field_element_vector.rs | 136 +- crypto/crypto/src/merkle_tree/traits.rs | 71 + crypto/stark/src/batched/mod.rs | 13 + crypto/stark/src/batched/round4.rs | 936 ++++++++ crypto/stark/src/fri/batched.rs | 1134 ++++++++++ crypto/stark/src/fri/mmcs.rs | 1922 +++++++++++++++++ crypto/stark/src/fri/mod.rs | 2 + crypto/stark/src/lib.rs | 1 + crypto/stark/src/par.rs | 24 + 9 files changed, 4238 insertions(+), 1 deletion(-) create mode 100644 crypto/stark/src/batched/mod.rs create mode 100644 crypto/stark/src/batched/round4.rs create mode 100644 crypto/stark/src/fri/batched.rs create mode 100644 crypto/stark/src/fri/mmcs.rs diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index 6d0cc6491..a9f1f4b05 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -1,7 +1,7 @@ use core::marker::PhantomData; use crate::hash::poseidon::Poseidon; -use crate::merkle_tree::traits::IsMerkleTreeBackend; +use crate::merkle_tree::traits::{IsLeafHasher, IsMerkleTreeBackend, IsStreamingLeafBackend}; use alloc::vec::Vec; use digest::{Digest, Output}; use math::{ @@ -202,6 +202,140 @@ where } } +/// Exposes the streaming leaf routes to callers that reach this backend through +/// a commitment configuration rather than by name. Both bodies go through +/// [`hash_streamed`], which is where the absorbed byte layout is defined, so +/// they agree with `hash_data` by construction. +impl IsStreamingLeafBackend + for FieldElementVectorBackend +where + F: IsField, + FieldElement: AsBytes, + [u8; NUM_BYTES]: From>, + Vec>: Sync + Send, +{ + fn hash_bytes(data: &[u8]) -> [u8; NUM_BYTES] { + hash_streamed::(|sink| sink(data)) + } + + fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> [u8; NUM_BYTES] { + // A size threshold below which this streams straight through was tried + // and MEASURED NEUTRAL-TO-WORSE (963.56M vs 963.28M cycles on a blowup8 + // verify, with `verify_fri` unmoved to the cycle). The 6.9M `verify_fri` + // rise that this change costs is NOT the staging buffer — gating the + // buffer away does not recover it — so it is not worth a branch here. + // Do not re-add one without a measurement. + hash_streamed::(|sink| { + let mut stage = LeafStage::new(); + for element in a.iter().chain(b.iter()) { + element.stream_bytes(&mut |bytes| stage.push(bytes, sink)); + } + stage.flush(sink); + }) + } + + type LeafHasher = DigestLeafHasher; + + fn leaf_hasher() -> Self::LeafHasher { + DigestLeafHasher { + hasher: D::new(), + phantom: PhantomData, + } + } +} + +/// [`IsLeafHasher`] over the same digest the one-shot routes use. +/// +/// The split-invariance the trait demands is inherited rather than argued: +/// `hash_streamed` opens a fresh `D`, feeds it every element's `stream_bytes` +/// and finalizes, with no length prefix, padding or framing of its own — so +/// absorbing the same elements across several `update` calls presents `D` with +/// the identical byte stream. There is no place for a split to show. +/// +/// This is a PROVER-side construct: the guest verifier authenticates leaves it +/// receives whole, through `hash_data_from_slices`. +pub struct DigestLeafHasher { + hasher: D, + /// `fn() -> F` rather than `F`: the field is a type-level label here, never a + /// value, and the function-pointer form is unconditionally `Send`/`Sync`. The + /// bare `PhantomData` would make every leaf hasher's thread-safety hinge on + /// a marker type nobody ever moves. + phantom: PhantomData F>, +} + +impl IsLeafHasher for DigestLeafHasher +where + F: IsField, + FieldElement: AsBytes, + [u8; NUM_BYTES]: From>, +{ + type Node = [u8; NUM_BYTES]; + + fn update(&mut self, data: &[FieldElement]) { + for element in data { + element.stream_bytes(&mut |bytes| self.hasher.update(bytes)); + } + } + + fn finalize(self) -> [u8; NUM_BYTES] { + let mut result = [0u8; NUM_BYTES]; + result.copy_from_slice(&self.hasher.finalize()); + result + } +} + +/// Bytes of the leaf staging buffer. Large enough that the run reaching the +/// hasher is worth batching — 16 blocks. +const LEAF_STAGE_BYTES: usize = 1024; + +/// Coalesces a leaf's field elements into large aligned runs before they reach +/// the hasher. +/// +/// A leaf arrives one field element at a time — eight bytes per `stream_bytes` +/// call — so without staging the hasher only ever sees eight bytes at a time. +/// Coalescing presents it with fewer, larger `update` calls. +/// +/// **This cannot change any digest.** The same bytes reach the hasher in the +/// same order; only the call boundaries move, and the sponge is split-invariant +/// by construction, so it simply sees fewer, larger `update` calls. +#[repr(align(8))] +struct LeafStage { + buf: [u8; LEAF_STAGE_BYTES], + len: usize, +} + +impl LeafStage { + #[inline] + fn new() -> Self { + Self { + buf: [0u8; LEAF_STAGE_BYTES], + len: 0, + } + } + + #[inline] + fn push(&mut self, mut bytes: &[u8], sink: &mut dyn FnMut(&[u8])) { + while !bytes.is_empty() { + if self.len == LEAF_STAGE_BYTES { + sink(&self.buf[..LEAF_STAGE_BYTES]); + self.len = 0; + } + let take = (LEAF_STAGE_BYTES - self.len).min(bytes.len()); + self.buf[self.len..self.len + take].copy_from_slice(&bytes[..take]); + self.len += take; + bytes = &bytes[take..]; + } + } + + #[inline] + fn flush(&mut self, sink: &mut dyn FnMut(&[u8])) { + if self.len > 0 { + sink(&self.buf[..self.len]); + self.len = 0; + } + } +} + #[derive(Clone, Default)] pub struct BatchPoseidonTree { _poseidon: PhantomData

, diff --git a/crypto/crypto/src/merkle_tree/traits.rs b/crypto/crypto/src/merkle_tree/traits.rs index c09cff9d0..049bf5615 100644 --- a/crypto/crypto/src/merkle_tree/traits.rs +++ b/crypto/crypto/src/merkle_tree/traits.rs @@ -1,4 +1,7 @@ use alloc::vec::Vec; +use math::field::element::FieldElement; +use math::field::traits::IsField; +use math::traits::AsBytes; #[cfg(feature = "parallel")] use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; @@ -27,3 +30,71 @@ pub trait IsMerkleTreeBackend { /// It will be used in the construction of the Merkle tree. fn hash_new_parent(child_1: &Self::Node, child_2: &Self::Node) -> Self::Node; } + +/// A leaf backend that can hash a leaf without being handed one. +/// +/// [`IsMerkleTreeBackend::hash_data`] takes a `&Self::Data`, which for the +/// batched backends is a `Vec>`. Building one per leaf costs an +/// allocation per leaf — millions on a real trace — so the prover and verifier +/// never do: they serialize into a reused buffer, or hold two slices they want +/// hashed as if concatenated. These are the two shapes they use. +/// +/// Both must agree with `hash_data` on the bytes they absorb, so a leaf hashed +/// through either route is the leaf the tree was built from. That is the whole +/// contract, and it is why these live on a trait rather than staying inherent +/// methods on one concrete backend: a commitment configuration that names its +/// leaf backend generically still has to reach them. +pub trait IsStreamingLeafBackend: IsMerkleTreeBackend +where + F: IsField, + FieldElement: AsBytes, +{ + /// Hash a pre-serialized leaf buffer. Equals `hash_data` applied to the + /// elements `data` encodes, in that order. + fn hash_bytes(data: &[u8]) -> Self::Node; + + /// Hash `a ‖ b` without materializing the concatenation. Equals + /// `hash_data(&[a, b].concat())`. + fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> Self::Node; + + /// The incremental form of the same leaf hash. See [`IsLeafHasher`]. + /// + /// `Send` because there is one of these per leaf and the base layer of a real + /// epoch has millions: absorbing them is parallel across leaves, exactly as + /// the one-shot leaf hashing is. + type LeafHasher: IsLeafHasher + Send; + + /// A leaf hasher that has absorbed nothing yet. + fn leaf_hasher() -> Self::LeafHasher; +} + +/// One leaf's hash, absorbed in an arbitrary number of updates. +/// +/// [`IsStreamingLeafBackend::hash_data_from_slices`] covers the two-slice case, +/// which is every leaf the per-table trees hash. A mixed-height MMCS leaf is +/// different: it concatenates one row pair per matrix at that height, and a +/// prover that wants to produce those matrices ONE AT A TIME — absorbing each +/// into the leaves and dropping its buffer — cannot hand over all the slices at +/// once. This is the API that lets it, and the memory it costs is one hasher +/// state per leaf rather than one LDE per matrix. +/// +/// # Contract +/// +/// Splitting is free: for any partition of a leaf's elements into consecutive +/// chunks, updating with each chunk in order and finalizing must equal +/// [`IsMerkleTreeBackend::hash_data`] over the whole. A backend whose framing +/// depended on where the updates fell would produce leaves no verifier could +/// re-derive, since the verifier only ever sees the concatenation. +pub trait IsLeafHasher +where + F: IsField, + FieldElement: AsBytes, +{ + type Node; + + /// Absorb the next consecutive run of the leaf's elements. + fn update(&mut self, data: &[FieldElement]); + + /// Finish the leaf. + fn finalize(self) -> Self::Node; +} diff --git a/crypto/stark/src/batched/mod.rs b/crypto/stark/src/batched/mod.rs new file mode 100644 index 000000000..291ceddb1 --- /dev/null +++ b/crypto/stark/src/batched/mod.rs @@ -0,0 +1,13 @@ +//! The batched-commitment path: one mixed-height MMCS per round and one FRI +//! instance per epoch, instead of one tree and one FRI instance per table. +//! +//! Brought over from PR #951 piece by piece. The per-table prover and verifier +//! are untouched and produce byte-identical proofs; nothing here is reachable +//! from them. +//! +//! What is NOT brought over is #951's own driver: it takes every table's trace +//! at once, which is the residency this branch exists to remove. Its phase +//! sequence is the specification the driver here follows — each "per table" step +//! is a pass over the execution, each barrier a point where one ends. + +pub mod round4; diff --git a/crypto/stark/src/batched/round4.rs b/crypto/stark/src/batched/round4.rs new file mode 100644 index 000000000..9f5f478a8 --- /dev/null +++ b/crypto/stark/src/batched/round4.rs @@ -0,0 +1,936 @@ +//! Round 4 of the batched path: ONE FRI instance over the epoch's height-combined +//! DEEP codewords. +//! +//! # The transcript sequence, and why it has one owner +//! +//! ```text +//! shape histogram → α → standalone terminals → (β, layer root)* → β_final → terminal coeffs → grinding → iotas +//! ``` +//! +//! [`commit_batched_fri`] walks it on the prover's side; +//! [`crate::fri::batched::derive_batched_fri_challenges`] walks it on the +//! verifier's. The two are pinned to each other by +//! `prover_commit_matches_verifier_derivation`, not by review of two call sites. +//! α is sampled AFTER the shape is absorbed and BEFORE any codeword is combined, +//! which is why this function takes a `combine` closure rather than the codewords: +//! the prover cannot mix with α until the transcript has produced it, and the +//! closure is where a caller streams table by table (see +//! [`crate::fri::batched::HeightCombiner`]). +//! +//! # ★ TWO instance classes, and the index rule between them +//! +//! Not every table belongs in the batch. A table whose own FRI commits ZERO +//! layers gains nothing from being batched — there is no layer for the batch to +//! share — while it pays the full lift to the tallest domain, which is where the +//! proximity-gaps term's `|D0|^2` lives. At the measured epoch that is 13 of 28 +//! legs carrying 92% of the batch's width. [`FriInstancePlan`] partitions them, +//! and the excluded tables keep a terminal-only instance +//! ([`verify_standalone_fri_query`]) that costs one polynomial and no layers. +//! +//! The MMCS is untouched by this split — it still commits every table, so the +//! one-shared-authentication-path win survives whole. What differs is the index +//! SPACE: the batched class reads `iota` directly, a standalone table at height +//! `h` reads `iota >> (h_max - h)`. Both classes need a tamper control, since a +//! control that only touched the batched one would pass under any convention for +//! the other. +//! +//! # Query indices and the injection convention +//! +//! One `iota` per query, drawn from `[0, 2^(h_max-1))` — a row-PAIR index in the +//! TALLEST codeword's domain. Every shorter object is located by shifting it +//! down, which is what makes "one index, shared across all tables" true rather +//! than aspirational: +//! +//! - a matrix of height `h` in a round whose own tallest matrix is `h_max_round` +//! is opened at MMCS leaf `iota >> (h_max_fri - h)` — but note that +//! [`crate::fri::mmcs::MixedMmcs::verify_batch`] wants an index in ITS OWN +//! space, so a round whose `h_max_round` is below the FRI's must first reduce +//! (see that module's index-convention section, and [`reduce_iota_to_round`]). +//! - the codeword bucket at height `h` is read at position +//! [`injection_position`], which is exactly one of the two rows of the pair the +//! MMCS opened. That coincidence is not luck: both are the same row-pair +//! layout, which is why a single opening serves both the authentication and the +//! FRI join. +//! +//! # What "injection" costs the verifier +//! +//! The prover's [`crate::fri::batched::batched_commit_phase`] folds, then adds +//! `β² · bucket_h` to the running codeword before committing the layer. So the +//! verifier's per-query recursion adds the same term to the value it computed by +//! folding — and only to that value. The symmetric value at each layer comes from +//! the proof and is Merkle-authenticated against the layer root, so it already +//! carries its own injection; re-adding one would double it. + +use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; +use crypto::merkle_tree::proof::verify_merkle_path; +use math::field::element::FieldElement; +use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; +use math::traits::AsBytes; + +use crate::config::{BatchedMerkleTreeBackend, Commitment, FriLayerMerkleTreeBackend}; +use crate::fri::batched::{ + BatchedFriLayout, FriInstancePlan, absorb_shape_histogram, batched_commit_phase, + derive_batched_fri_challenges, +}; +use crate::fri::fri_commitment::FriLayer; +use crate::fri::fri_decommit::FriDecommitment; +use crate::grinding; +use crate::prover::ProvingError; + +/// What the prover produced in the batched round 4, plus the challenges it drew +/// on the way. The layers are kept so the caller can run the query phase over +/// them; everything else is what goes on the wire. +pub struct BatchedFriCommit +where + FieldElement: AsBytes + Sync + Send, +{ + pub layers: Vec>>, + pub layer_roots: Vec, + pub final_poly_coeffs: Vec>, + pub layout: BatchedFriLayout, + /// The grinding nonce, `None` when `grinding_factor == 0`. + pub nonce: Option, + /// Row-pair indices in the tallest domain, one per query. + pub iotas: Vec, + /// The mixing challenge the codewords were combined with. Kept because the + /// query phase needs it to rebuild each table's contribution. + pub alpha: FieldElement, + /// Which tables this instance carries, and which keep a terminal-only + /// instance of their own. See [`FriInstancePlan`]. + pub plan: FriInstancePlan, + /// Per table: the standalone class's terminal polynomial, `Some` exactly + /// for `plan.standalone`. Produced by `combine`, ABSORBED here (right + /// after α, before the first ζ — see `derive_batched_fri_challenges` for + /// why that absorb is load-bearing), and returned so the caller puts the + /// very coefficients the transcript bound onto the wire. + pub standalone_coeffs: Vec>>>, +} + +/// Prover side of the batched round-4 sequence. +/// +/// `heights[t]` is `log2` of table `t`'s LDE length and `widths[t]` its committed +/// column count, both in the epoch's canonical table order — the same order the +/// verifier rebuilds from the AIR set, and the same order `combine` must absorb +/// codewords in, since absorption order is what defines the α powers. +/// +/// `combine` receives α and returns the per-height buckets (see +/// [`crate::fri::batched::HeightCombiner::finish`]) TOGETHER WITH the +/// standalone class's terminal polynomials, per table (`Some` exactly for +/// `plan.standalone`). It is a closure rather than a materialized `Vec` so a +/// caller can produce one table's DEEP codeword, absorb it and drop it: +/// holding all of them at once is the memory cost batching exists to remove. +/// +/// Returns `Err` only when the device FRI commit is selected and a CUDA op +/// fails — a hard abort, the same no-silent-fallback policy as the device MMCS +/// commits (see the `batched/prover.rs` module header). The host build never +/// errors. +#[allow(clippy::too_many_arguments)] +pub fn commit_batched_fri( + transcript: &mut T, + heights: &[usize], + widths: &[usize], + combine: C, + coset_offset: &FieldElement, + blowup_log: u32, + final_poly_log_degree: u32, + grinding_factor: u8, + num_queries: usize, +) -> Result, ProvingError> +where + F: IsFFTField + IsSubFieldOf + 'static, + E: IsField + 'static + Send + Sync, + T: IsStarkTranscript + Clone, + C: FnOnce( + &FieldElement, + &FriInstancePlan, + ) -> ( + Vec>>>, + Vec>>>, + ), + FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send, +{ + // Derived from the shape, exactly as the verifier derives it — the partition + // is never sent. The tables whose own FRI commits no layer are left out of the + // batch: they gain nothing from it and pay the full lift to the tallest + // domain, which is where the proximity-gaps term's `|D0|^2` lives. + let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree) + .expect("commit_batched_fri: the epoch's shape is the prover's own"); + let h_max = plan.h_max; + + absorb_shape_histogram::(transcript, heights, widths); + let alpha = transcript.sample_field_element(); + + let (combined, standalone_coeffs) = combine(&alpha, &plan); + + // Bind the standalone class's terminal polynomials BEFORE the first ζ — + // the same walk `derive_batched_fri_challenges` replays, and the reason it + // does (its doc): a polynomial not bound here could be chosen after the + // query indices are known. + for (table, coeffs) in standalone_coeffs.iter().enumerate() { + assert_eq!( + coeffs.is_some(), + plan.standalone.contains(&table), + "the standalone terminals exist for exactly the standalone class" + ); + if let Some(coeffs) = coeffs { + for c in coeffs.iter() { + transcript.append_field_element(c); + } + } + } + + // Device fast path vs host build. Selecting here (rather than inside + // `batched_commit_phase`) keeps `crate::fri` free of the prover's error type + // and lets a device error be a hard abort: `?` propagates it instead of the + // fold loop silently falling back. `Ok(None)` = device not selected (off + // cuda, wrong field, or below the GPU threshold) → host build. + let (final_poly_coeffs, layers) = { + #[cfg(feature = "cuda")] + { + let (h_min, h_max_folds) = crate::fri::batched::bucket_height_range(&combined) + .expect("commit_batched_fri: combined has at least one occupied bucket"); + let inv_twiddles = crate::fri::fri_functions::compute_coset_twiddles_inv( + coset_offset, + 1usize << h_max_folds, + ); + match crate::gpu_lde::try_batched_fri_commit_gpu::( + &combined, + transcript, + coset_offset, + blowup_log, + final_poly_log_degree, + &inv_twiddles, + h_min, + h_max_folds, + )? { + Some(result) => result, + None => batched_commit_phase::( + combined, + transcript, + coset_offset, + blowup_log, + final_poly_log_degree, + ), + } + } + #[cfg(not(feature = "cuda"))] + { + batched_commit_phase::( + combined, + transcript, + coset_offset, + blowup_log, + final_poly_log_degree, + ) + } + }; + let layer_roots: Vec = layers.iter().map(|layer| layer.merkle_tree.root).collect(); + + // Grinding runs on the CONFIGURATION's transcript hash, not a hard-wired + // one — the same rule the unbatched `prover.rs` follows. `H` names both the + // commitment family and the Fiat-Shamir hash, so a batched proof committed + // with BLAKE3 grinds with BLAKE3 and one committed with keccak grinds with + // keccak, without either side being told twice. + let nonce = (grinding_factor > 0).then(|| { + let value = grinding::generate_nonce(&transcript.state(), grinding_factor) + .expect("nonce not found"); + transcript.append_bytes(&value.to_be_bytes()); + value + }); + + let iotas = (0..num_queries) + .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) + .collect(); + + Ok(BatchedFriCommit { + layers, + layer_roots, + final_poly_coeffs, + layout: BatchedFriLayout::new(plan.h_max, plan.h_min, blowup_log, final_poly_log_degree), + nonce, + iotas, + alpha, + plan, + standalone_coeffs, + }) +} + +/// Verify one query against a STANDALONE table's terminal-only instance. +/// +/// A table whose own FRI commits no layer has a terminal codeword that IS its +/// deep-composition codeword, so there is nothing to fold and nothing to +/// authenticate: the check is that the value the query opened is the value the +/// sent terminal polynomial encodes at that position. +/// +/// ★ `iota` is the SHARED batched query index and is reduced here — the two +/// instance classes read the same index in different spaces (see +/// [`FriInstancePlan`]). `deep` is the table's own deep-composition pair at its +/// reduced row pair, which the caller reconstructs from authenticated openings. +/// +/// Returns `false` on every malformed input; it never panics. +pub fn verify_standalone_fri_query( + iota: usize, + h_max_fri: usize, + h_table: usize, + deep: (&FieldElement, &FieldElement), + terminal_codeword: &[FieldElement], +) -> bool +where + E: IsField + 'static, +{ + let Some(reduced) = reduce_iota_to_round(iota, h_max_fri, h_table) else { + return false; + }; + terminal_codeword + .get(reduced * 2) + .is_some_and(|t| deep.0 == t) + && terminal_codeword + .get(reduced * 2 + 1) + .is_some_and(|t| deep.1 == t) +} + +/// Position, inside the codeword of height `h`, that query `iota` reads. +/// +/// `iota` is a row-pair index in the tallest domain (height `h_max`); the layer +/// whose codeword has height `h` is reached after `h_max - h` folds, and the +/// query's position there is `iota >> (h_max - h - 1)`. Both rows of the pair a +/// height-`h` MMCS opening returns — leaf `iota >> (h_max - h)`, i.e. LDE rows +/// `2k` and `2k+1` — are candidates, and the low bit of this position picks +/// between them; see [`injected_value_at_query`]. +/// +/// Not defined at `h == h_max`: the tallest codeword is the FRI's layer 0, which +/// the query reads as a PAIR (`2·iota`, `2·iota+1`) rather than at one position. +#[inline] +pub fn injection_position(iota: usize, h_max: usize, h: usize) -> usize { + debug_assert!( + h < h_max, + "the tallest codeword is read as a pair, not at a position" + ); + iota >> (h_max - h - 1) +} + +/// The value a height-`h` matrix contributes to its injection layer, chosen from +/// the row pair its MMCS opening returned. +/// +/// `evaluation` is the opening's row `2k` and `evaluation_sym` its row `2k+1`, +/// with `k = iota >> (h_max - h)`. The pair straddles the injection position, so +/// the choice is exactly that position's low bit. +#[inline] +pub fn injected_value_at_query<'a, E: IsField>( + iota: usize, + h_max: usize, + h: usize, + evaluation: &'a FieldElement, + evaluation_sym: &'a FieldElement, +) -> &'a FieldElement { + if injection_position(iota, h_max, h) & 1 == 0 { + evaluation + } else { + evaluation_sym + } +} + +/// Reduce a FRI query index to the index space of a round whose tallest matrix +/// is shorter than the FRI's. +/// +/// [`crate::fri::mmcs::MixedMmcs::verify_batch`] walks its path with the LOW bits +/// of the index it is given, while it locates a short matrix inside the tree by +/// the HIGH bits — consistent only when the index comes from that tree's own +/// `h_max`. The batched preprocessed round is the case that breaks it (its +/// tallest matrix sits below the FRI's), so every caller reduces here rather than +/// each writing the shift out. Returns `None` when the round claims to be TALLER +/// than the FRI, which no honest shape can be. +#[inline] +pub fn reduce_iota_to_round(iota: usize, h_max_fri: usize, h_max_round: usize) -> Option { + (h_max_round <= h_max_fri).then(|| iota >> (h_max_fri - h_max_round)) +} + +/// Verify one query of the batched FRI: the fold-with-injection recursion, every +/// committed layer's opening, and the terminal check. +/// +/// `p0` is the query's pair of values in the tallest codeword — the α-mixed DEEP +/// evaluations of the tables at height `h_max`, at LDE positions `2·iota` and +/// `2·iota + 1`. `bucket_at_height[h]` is `Some(v)` when at least one table has +/// height `h < h_max`, with `v` that height group's α-mixed value at +/// [`injection_position`]; `None` when no table sits at `h`. Both are the +/// caller's to reconstruct from authenticated openings — this function does no +/// authentication of trace data, only of FRI layers. +/// +/// Returns `false` on every malformed input; it never panics. +#[allow(clippy::too_many_arguments)] +pub fn verify_batched_fri_query( + layer_roots: &[Commitment], + betas: &[FieldElement], + layout: &BatchedFriLayout, + h_max: usize, + iota: usize, + decommitment: &FriDecommitment, + evaluation_point_inv: &FieldElement, + p0: (&FieldElement, &FieldElement), + bucket_at_height: &[Option>], + terminal_codeword: &[FieldElement], +) -> bool +where + F: IsFFTField + IsSubFieldOf + 'static, + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send, +{ + // The decommitment vectors are prover-supplied and are NOT bound into the + // transcript, so their lengths are pinned here before anything zips them — + // the same reason `step_3_verify_fri` pins them in the unbatched path. A + // short vector would make the fold loop run fewer rounds and accept the query + // without ever reaching the terminal. + if layer_roots.len() != layout.num_committed + || decommitment.layers_auth_paths.len() != layout.num_committed + || decommitment.layers_evaluations_sym.len() != layout.num_committed + || betas.len() != layout.num_committed + usize::from(layout.total_folds > 0) + { + return false; + } + if h_max == 0 || h_max >= usize::BITS as usize || iota >= 1usize << (h_max - 1) { + return false; + } + if bucket_at_height.len() < h_max { + return false; + } + + // No-fold case: the codeword never folds, so the terminal IS the tallest + // codeword and the query's two points sit at `2·iota` and `2·iota + 1`. No + // bucket can exist below `h_max` here — `h_min == h_max` is what makes + // `total_folds` zero — so there is nothing to inject. + if layout.total_folds == 0 { + return terminal_codeword.get(iota * 2).is_some_and(|t| p0.0 == t) + && terminal_codeword + .get(iota * 2 + 1) + .is_some_and(|t| p0.1 == t); + } + + // First fold: layer 0 (the tallest codeword) is not committed, so this fold + // consumes `p0` rather than an authenticated opening. Then the height just + // below joins, exactly as `batched_commit_phase` does before it commits. + let mut point_inv = evaluation_point_inv.clone(); + let mut v = (p0.0 + p0.1) + &point_inv * &betas[0] * (p0.0 - p0.1); + let mut index = iota; + inject(&mut v, &betas[0], bucket_at_height, h_max - 1); + + let mut openings_ok = true; + for i in 0..layout.num_committed { + let evaluation_sym = &decommitment.layers_evaluations_sym[i]; + openings_ok &= verify_layer_opening::( + &layer_roots[i], + decommitment.layers_auth_paths[i].merkle_path.as_slice(), + &v, + evaluation_sym, + index, + ); + + point_inv = point_inv.square(); + v = (&v + evaluation_sym) + &point_inv * &betas[i + 1] * (&v - evaluation_sym); + index >>= 1; + // The injection height descends with the running codeword. `checked_sub` + // rather than `h_max - 2 - i`: `layout`'s fields are only consistent with + // `h_max` when the layout was DERIVED from the same heights, and this + // function is on the verifier's path, where an overflow panic is not a + // rejection. An inconsistent layout simply injects nothing and fails at + // the terminal. + if let Some(height) = (h_max - 1).checked_sub(i + 1) { + inject(&mut v, &betas[i + 1], bucket_at_height, height); + } + } + + // `v` is now the query's value in the terminal codeword and `index` its + // position there. `.get` fails closed on an out-of-range index. + openings_ok & terminal_codeword.get(index).is_some_and(|t| &v == t) +} + +/// `running += β² · bucket_h` for the height the running codeword has just +/// reached. A no-op when no table sits at that height, and when the height is +/// below the terminal (`bucket_at_height` is indexed by height, so a fold that +/// runs past index 0 has nothing to read). +fn inject( + value: &mut FieldElement, + beta: &FieldElement, + bucket_at_height: &[Option>], + height: usize, +) { + if let Some(Some(contribution)) = bucket_at_height.get(height) { + *value = &*value + &(beta.square() * contribution); + } +} + +/// Authenticate a committed FRI layer's row pair against its root. `index` is the +/// query's position in that layer; the leaf is the pair at `index >> 1`, ordered +/// by `index`'s low bit — the same convention the unbatched +/// `verify_fri_layer_openings` uses, and the same one `query_phase` opens with. +fn verify_layer_opening( + root: &Commitment, + auth_path: &[Commitment], + evaluation: &FieldElement, + evaluation_sym: &FieldElement, + index: usize, +) -> bool +where + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, +{ + let leaf = if index % 2 == 1 { + vec![evaluation_sym.clone(), evaluation.clone()] + } else { + vec![evaluation.clone(), evaluation_sym.clone()] + }; + verify_merkle_path::>(auth_path, root, index >> 1, &leaf) +} + +/// Replay the batched round-4 transcript sequence and return the challenges, +/// or `None` when the proof's shape contradicts the epoch's. +/// +/// A thin alias for [`derive_batched_fri_challenges`], re-exported here so the +/// verifier reaches the sequence through the same module the prover's +/// [`commit_batched_fri`] lives in — the two are one protocol, and splitting them +/// across modules is how they drift. +#[allow(clippy::too_many_arguments)] +pub fn replay_batched_fri( + transcript: &mut T, + heights: &[usize], + widths: &[usize], + layer_roots: &[Commitment], + final_poly_coeffs: &[FieldElement], + standalone_coeffs: &[Option<&[FieldElement]>], + blowup_log: u32, + final_poly_log_degree: u32, + grinding_factor: u8, + nonce: Option, + num_queries: usize, +) -> Option> +where + E: IsField, + T: IsTranscript, +{ + derive_batched_fri_challenges( + transcript, + heights, + widths, + layer_roots, + final_poly_coeffs, + standalone_coeffs, + blowup_log, + final_poly_log_degree, + grinding_factor, + nonce, + num_queries, + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::fri::batched::{HeightCombiner, combine_by_height}; + use crate::fri::terminal::terminal_codeword_from_coeffs; + use crypto::fiat_shamir::default_transcript::DefaultTranscript; + use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; + use math::field::goldilocks::GoldilocksField; + use math::polynomial::Polynomial; + + pub(crate) type F = GoldilocksField; + pub(crate) type FE = FieldElement; + pub(crate) type Transcript = DefaultTranscript; + + pub(crate) const BLOWUP_LOG: u32 = 1; + pub(crate) const FINAL_POLY_LOG_DEGREE: u32 = 1; + pub(crate) const COSET_OFFSET: u64 = 3; + + /// One synthetic table: a genuinely low-degree codeword at its own height. + pub(crate) struct FakeTable { + pub height: usize, + pub width: usize, + pub codeword: Vec, + } + + /// A codeword of height `h` that IS a Reed-Solomon word of rate `2^-BLOWUP_LOG` + /// on the coset the batched FRI will read it at. + /// + /// The coset matters and is the one thing easy to get wrong here: folding + /// squares the offset, so the layer a height-`h` bucket is injected into lives + /// on `offset^(2^(h_max-h))·⟨ω⟩`, not on `offset·⟨ω⟩`. A word built on the + /// wrong coset is still low degree — the map is a rescaling of the argument — + /// so it would pass a degree check while making the terminal reconstruction + /// disagree, which is exactly the failure the honest-path test has to be able + /// to see. + pub(crate) fn low_degree_codeword(h: usize, h_max: usize, seed: u64) -> Vec { + let num_coeffs = 1usize << (h as u32 - BLOWUP_LOG); + let coeffs: Vec = (0..num_coeffs) + .map(|i| FE::from(seed.wrapping_mul(97).wrapping_add(i as u64 * 31 + 1))) + .collect(); + let offset = FE::from(COSET_OFFSET).pow(1u64 << (h_max - h)); + let mut natural = Polynomial::evaluate_offset_fft::( + &Polynomial::new(&coeffs), + 1usize << BLOWUP_LOG, + Some(num_coeffs), + &offset, + ) + .expect("coset evaluation"); + in_place_bit_reverse_permute(&mut natural); + natural + } + + /// Four tables over three heights, the shape the batched path has to handle: + /// several tables sharing the tallest height (so the base group batches), one + /// at an intermediate height (so an injection lands on a committed layer) and + /// one at the terminal height (so the FINAL fold's injection is exercised — + /// the case #768's loop missed). + pub(crate) fn fixture() -> Vec { + let h_max = 5; + vec![ + FakeTable { + height: 5, + width: 3, + codeword: low_degree_codeword(5, h_max, 11), + }, + FakeTable { + height: 4, + width: 2, + codeword: low_degree_codeword(4, h_max, 23), + }, + FakeTable { + height: 5, + width: 7, + codeword: low_degree_codeword(5, h_max, 41), + }, + FakeTable { + height: 2, + width: 1, + codeword: low_degree_codeword(2, h_max, 59), + }, + ] + } + + pub(crate) fn heights_of(tables: &[FakeTable]) -> Vec { + tables.iter().map(|t| t.height).collect() + } + + pub(crate) fn widths_of(tables: &[FakeTable]) -> Vec { + tables.iter().map(|t| t.width).collect() + } + + /// The per-table standalone slices a replay call takes, off a commit. + pub(crate) fn standalone_refs(commit: &BatchedFriCommit) -> Vec> { + commit + .standalone_coeffs + .iter() + .map(|c| c.as_deref()) + .collect() + } + + /// Run the prover's batched round 4 over `tables`, streaming the codewords + /// into the combiner one at a time — the shape a real prover uses. + pub(crate) fn commit_fixture( + tables: &[FakeTable], + transcript: &mut Transcript, + grinding_factor: u8, + num_queries: usize, + ) -> BatchedFriCommit { + let heights = heights_of(tables); + let widths = widths_of(tables); + commit_batched_fri::( + transcript, + &heights, + &widths, + |alpha, plan| { + // Only the batched class is mixed in, and in the plan's order — + // absorption order is what defines the alpha powers, so a caller + // that absorbed the standalone tables too would shift every + // power and agree with no verifier. The standalone tables hand + // back their terminal polynomials instead, exactly as the real + // prover does. + let mut combiner = HeightCombiner::new(*alpha); + for &t in &plan.batched { + combiner.absorb(&tables[t].codeword, tables[t].height); + } + let standalone = tables + .iter() + .enumerate() + .map(|(t, table)| { + plan.standalone.contains(&t).then(|| { + crate::fri::terminal::coeffs_from_terminal_codeword::( + &table.codeword, + &FE::from(COSET_OFFSET), + table.height as u32 - BLOWUP_LOG, + ) + }) + }) + .collect(); + (combiner.finish(), standalone) + }, + &FE::from(COSET_OFFSET), + BLOWUP_LOG, + FINAL_POLY_LOG_DEGREE, + grinding_factor, + num_queries, + ) + .expect("the host batched FRI commit never errors") + } + + /// υ⁻¹ for query `iota`: the inverse of the tallest coset's element at + /// FRI-order position `2·iota`, matching the unbatched verifier's + /// `query_challenge_to_evaluation_point`. + pub(crate) fn evaluation_point_inv(iota: usize, h_max: usize) -> FE { + let n = 1usize << h_max; + let omega = F::get_primitive_root_of_unity(h_max as u64).expect("root of unity"); + let point = FE::from(COSET_OFFSET) * omega.pow(reverse_index(iota * 2, n as u64)); + point.inv().expect("query point is never zero") + } + + /// What the verifier must reconstruct from authenticated openings: the α-mixed + /// value of every height group at this query's position. Here it is read + /// straight off the combined buckets, which is the oracle — `combine_by_height` + /// has its own tests, and the point of this one is the fold recursion. + pub(crate) fn query_inputs( + tables: &[FakeTable], + alpha: &FE, + iota: usize, + ) -> ((FE, FE), Vec>) { + let plan = FriInstancePlan::new(&heights_of(tables), BLOWUP_LOG, FINAL_POLY_LOG_DEGREE) + .expect("the fixture's shape partitions"); + let h_max = plan.h_max; + let inputs: Vec<(Vec, usize)> = plan + .batched + .iter() + .map(|&t| (tables[t].codeword.clone(), tables[t].height)) + .collect(); + let combined = combine_by_height(&inputs, alpha); + + let tallest = combined[h_max].as_ref().expect("tallest bucket exists"); + let p0 = (tallest[iota * 2], tallest[iota * 2 + 1]); + + let buckets = (0..h_max) + .map(|h| { + combined + .get(h) + .and_then(|slot| slot.as_ref()) + .map(|codeword| codeword[injection_position(iota, h_max, h)]) + }) + .collect(); + (p0, buckets) + } + + /// Verify one query end to end against the committed layers. + #[allow(clippy::too_many_arguments)] + pub(crate) fn verify_one_query( + commit: &BatchedFriCommit, + betas: &[FE], + h_max: usize, + iota: usize, + decommitment: &FriDecommitment, + p0: (&FE, &FE), + buckets: &[Option], + layer_roots: &[Commitment], + final_poly_coeffs: &[FE], + ) -> bool { + let terminal_offset = FE::from(COSET_OFFSET).pow(1u64 << commit.layout.total_folds); + let terminal = terminal_codeword_from_coeffs::( + final_poly_coeffs, + &terminal_offset, + commit.layout.terminal_len, + ); + verify_batched_fri_query::( + layer_roots, + betas, + &commit.layout, + h_max, + iota, + decommitment, + &evaluation_point_inv(iota, h_max), + p0, + buckets, + &terminal, + ) + } + + /// The prover's inline sequence and the verifier's replay are ONE protocol; + /// this is what pins them together. Every challenge, not only the iotas — + /// α gates the height combination and the βs gate every fold, so an + /// agreement that held only at the query indices would still be a broken + /// proof system. + #[test] + fn prover_commit_matches_verifier_derivation() { + let tables = fixture(); + let mut prover_transcript = Transcript::new(b"batched_round4"); + let mut verifier_transcript = prover_transcript.clone(); + + let commit = commit_fixture(&tables, &mut prover_transcript, 4, 6); + + let replay = replay_batched_fri::( + &mut verifier_transcript, + &heights_of(&tables), + &widths_of(&tables), + &commit.layer_roots, + &commit.final_poly_coeffs, + &standalone_refs(&commit), + BLOWUP_LOG, + FINAL_POLY_LOG_DEGREE, + 4, + commit.nonce, + 6, + ) + .expect("an honest shape must derive"); + + assert_eq!(replay.alpha, commit.alpha, "α must agree"); + assert_eq!(replay.layout, commit.layout, "the fold layout must agree"); + assert_eq!(replay.iotas, commit.iotas, "the query indices must agree"); + assert_eq!( + replay.betas.len(), + commit.layout.num_committed + 1, + "one β per committed layer plus the final fold" + ); + assert!( + crate::grinding::is_valid_nonce( + &replay.grinding_seed, + commit.nonce.expect("grinding was requested"), + 4 + ), + "the replayed grinding seed must accept the prover's nonce" + ); + assert_eq!( + prover_transcript.state(), + verifier_transcript.state(), + "both sides must end in the same transcript state" + ); + } + + /// The honest path, and it is not vacuous: the fixture spans three heights, + /// so this exercises the base group, an injection into a committed layer and + /// an injection at the final fold. If the injection convention or the + /// position derivation were wrong, the terminal check would fail. + #[test] + fn honest_batched_queries_verify() { + let tables = fixture(); + let h_max = 5; + let mut transcript = Transcript::new(b"batched_round4"); + let commit = commit_fixture(&tables, &mut transcript, 0, 8); + + let decommitments = crate::fri::query_phase::(&commit.layers, &commit.iotas); + + let mut verifier_transcript = Transcript::new(b"batched_round4"); + let replay = replay_batched_fri::( + &mut verifier_transcript, + &heights_of(&tables), + &widths_of(&tables), + &commit.layer_roots, + &commit.final_poly_coeffs, + &standalone_refs(&commit), + BLOWUP_LOG, + FINAL_POLY_LOG_DEGREE, + 0, + None, + 8, + ) + .expect("an honest shape must derive"); + + assert!(commit.layout.num_committed >= 1, "the fixture must fold"); + for (query, &iota) in commit.iotas.iter().enumerate() { + let (p0, buckets) = query_inputs(&tables, &replay.alpha, iota); + assert!( + verify_one_query( + &commit, + &replay.betas, + h_max, + iota, + &decommitments[query], + (&p0.0, &p0.1), + &buckets, + &commit.layer_roots, + &commit.final_poly_coeffs, + ), + "honest query {query} (iota {iota}) must verify" + ); + } + } + + /// The MMCS row pair a query opens at height `h` and the FRI position the + /// injection reads must be the SAME two rows. That coincidence is what lets + /// one opening serve both the authentication and the FRI join, and it is a + /// property of the two index derivations, so it is worth pinning exhaustively + /// rather than sampling. + #[test] + fn injection_position_lands_inside_the_mmcs_row_pair() { + let h_max = 6; + for iota in 0..(1usize << (h_max - 1)) { + for h in 1..h_max { + let position = injection_position(iota, h_max, h); + let mmcs_leaf = iota >> (h_max - h); + assert_eq!( + position >> 1, + mmcs_leaf, + "height {h}, iota {iota}: the injection position must sit in the opened leaf" + ); + assert!( + position < (1usize << h), + "height {h}, iota {iota}: position must stay inside the codeword" + ); + } + } + } + + /// `reduce_iota_to_round` is the documented remedy for the one case where a + /// round's tallest matrix is below the FRI's. Pin both that it is the shift + /// the MMCS wants and that it refuses the impossible direction rather than + /// shifting by a negative amount. + #[test] + fn reduce_iota_to_round_matches_the_mmcs_index_space() { + let h_max_fri = 6; + for iota in 0..(1usize << (h_max_fri - 1)) { + for h_max_round in 1..=h_max_fri { + let reduced = + reduce_iota_to_round(iota, h_max_fri, h_max_round).expect("round is shorter"); + assert!( + reduced < (1usize << (h_max_round - 1)), + "the reduced index must land in the round's own leaf range" + ); + } + } + assert!( + reduce_iota_to_round(0, 4, 5).is_none(), + "a round taller than the FRI is not a shape any honest epoch has" + ); + } + + /// A width the epoch did not commit to moves α, and therefore every fold and + /// every query index. This is the shape binding doing its job one level up + /// from the leaf: the leaf header binds a mis-parse, this binds a mis-shaped + /// epoch. + #[test] + fn a_tampered_shape_moves_the_derived_challenges() { + let tables = fixture(); + let mut prover_transcript = Transcript::new(b"batched_round4"); + let commit = commit_fixture(&tables, &mut prover_transcript, 0, 4); + + let mut widths = widths_of(&tables); + widths[1] += 1; + let mut verifier_transcript = Transcript::new(b"batched_round4"); + let replay = replay_batched_fri::( + &mut verifier_transcript, + &heights_of(&tables), + &widths, + &commit.layer_roots, + &commit.final_poly_coeffs, + &standalone_refs(&commit), + BLOWUP_LOG, + FINAL_POLY_LOG_DEGREE, + 0, + None, + 4, + ) + .expect("the shape is still structurally consistent"); + + assert_ne!( + replay.alpha, commit.alpha, + "a width the prover did not commit to must move α" + ); + assert_ne!( + replay.iotas, commit.iotas, + "a width the prover did not commit to must move the query indices" + ); + } +} diff --git a/crypto/stark/src/fri/batched.rs b/crypto/stark/src/fri/batched.rs new file mode 100644 index 000000000..996f46da5 --- /dev/null +++ b/crypto/stark/src/fri/batched.rs @@ -0,0 +1,1134 @@ +//! Batched FRI: one FRI instance over an epoch's DEEP codewords instead of one +//! per table. +//! +//! Codewords are bucketed by height, mixed within a bucket with powers of a +//! single `alpha`, and then folded from the tallest bucket downward, each +//! shorter bucket being *injected* into the running codeword at the layer whose +//! length matches it. One set of query indices, drawn from the tallest domain, +//! tests the whole chain. +//! +//! # Termination +//! +//! Folding stops at the same terminal the unbatched +//! [`crate::fri::commit_phase_from_evaluations`] stops at — the codeword that +//! encodes a polynomial of degree `< 2^fri_final_poly_log_degree` — and sends +//! that polynomial's coefficients, rather than folding all the way down to a +//! scalar. [`BatchedFriLayout`] derives the fold count through the shared +//! [`FriFoldLayout`], with one batched-only floor: the terminal may not sit +//! above the SHORTEST injected codeword, or that codeword would never reach the +//! running word. So the early stop is `min(blowup_log + k, h_min)`. + +use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; +use crypto::merkle_tree::merkle::MerkleTree; +use math::field::element::FieldElement; +use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; +use math::traits::AsBytes; +#[cfg(feature = "parallel")] +use rayon::prelude::*; + +use crate::config::FriLayerMerkleTreeBackend; +use crate::fri::fri_commitment::FriLayer; +use crate::fri::fri_functions::{ + compute_coset_twiddles_inv, fold_evaluations_in_place, update_twiddles_in_place, +}; +use crate::fri::terminal::{FriFoldLayout, coeffs_from_terminal_codeword}; + +/// Accumulates DEEP codewords into per-height buckets as they are produced, +/// mixing the `i`-th absorbed codeword with `alpha^i`. +/// +/// The point of absorbing one codeword at a time is memory: a caller that +/// produces a table's quotient, absorbs it and drops it retains only one bucket +/// per distinct height (`O(2^h_max)` in total), where handing +/// [`combine_by_height`] a fully-materialized `Vec` of every table's codeword +/// retains `O(N_tables · 2^h)`. The result is identical either way — absorption +/// order defines the `alpha` powers, so the caller must absorb in the same +/// canonical per-epoch order the verifier assumes. +pub struct HeightCombiner { + buckets: Vec>>>, + alpha: FieldElement, + /// `alpha^i` for the next codeword to be absorbed. + next_power: FieldElement, +} + +impl HeightCombiner { + pub fn new(alpha: FieldElement) -> Self { + Self { + buckets: Vec::new(), + alpha, + next_power: FieldElement::one(), + } + } + + /// Absorb one codeword of length `2^height`, scaled by the next power of + /// `alpha`. + pub fn absorb(&mut self, codeword: &[FieldElement], height: usize) { + let expected_len = 1usize << height; + assert_eq!( + codeword.len(), + expected_len, + "codeword has length {} but height {height} expects {expected_len}", + codeword.len() + ); + + if self.buckets.len() <= height { + self.buckets.resize_with(height + 1, || None); + } + let scaled = &self.next_power; + // Data-parallel under `parallel`: the scale and the scale-accumulate + // are elementwise over up to 2^h_max elements, and this loop has no + // per-table overlap to hide behind — it was serial wall time once per + // absorbed table. Same arithmetic in both arms, identical result. + #[cfg(feature = "parallel")] + match &mut self.buckets[height] { + None => { + self.buckets[height] = Some( + codeword + .par_iter() + .map(|x| scaled * x) + .collect::>>(), + ); + } + Some(acc) => { + acc.par_iter_mut() + .zip(codeword.par_iter()) + .for_each(|(a, x)| { + *a = &*a + &(scaled * x); + }); + } + } + #[cfg(not(feature = "parallel"))] + match &mut self.buckets[height] { + None => { + self.buckets[height] = Some(codeword.iter().map(|x| scaled * x).collect()); + } + Some(acc) => { + for (a, x) in acc.iter_mut().zip(codeword.iter()) { + *a = &*a + &(scaled * x); + } + } + } + self.next_power = &self.next_power * &self.alpha; + } + + /// The per-height buckets. Index `h` is `Some(combined)` when at least one + /// codeword of height `h` was absorbed, `None` otherwise; the `Vec` is + /// `max_absorbed_height + 1` long, or empty if nothing was absorbed. + pub fn finish(self) -> Vec>>> { + self.buckets + } +} + +/// Combine DEEP polynomial codewords by their FRI height for batched FRI. +/// +/// Each element of `inputs` is a pair `(codeword, height)` where `height` is +/// the log₂ of the codeword length (i.e. `codeword.len() == 2^height`). +/// The global index `i` into `inputs` is used to derive the mixing power +/// `alpha^i` (index 0 → alpha^0 = 1, index 1 → alpha^1, …). +/// +/// Returns a `Vec` of length `max_height + 1`. Index `h` contains +/// `Some(combined)` where `combined[j] = Σ_{i : height_i == h} alpha^i * codeword_i[j]`, +/// or `None` when no input has height `h`. +/// +/// This is [`HeightCombiner`] with every codeword already materialized. Prefer +/// the combiner in the prover, where holding all of them at once is the whole +/// memory cost the batching is meant to remove. +pub fn combine_by_height( + inputs: &[(Vec>, usize)], + alpha: &FieldElement, +) -> Vec>>> +where + E: IsField, +{ + let mut combiner = HeightCombiner::new(alpha.clone()); + for (codeword, height) in inputs { + combiner.absorb(codeword, *height); + } + combiner.finish() +} + +/// How far a batched FRI instance folds, and what it sends at the end. +/// +/// Mirrors [`FriFoldLayout`] — same early stop, same terminal codeword, same +/// coefficient count — with the one difference batching forces: the terminal is +/// additionally floored at the SHORTEST injected codeword's height, since a +/// bucket below the terminal would never be folded into the running word. In a +/// real epoch the shortest table is normally well above `blowup_log + k`, so the +/// floor is inert and the layout is exactly the unbatched one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BatchedFriLayout { + /// Folds from the tallest bucket down to the terminal codeword. + pub total_folds: u32, + /// Committed (Merkle-rooted) FRI layers. + pub num_committed: usize, + /// Terminal codeword length. + pub terminal_len: usize, + /// `log2` of the terminal polynomial's degree bound — the number of + /// coefficients sent is `2^effective_k`. + pub effective_k: u32, +} + +impl BatchedFriLayout { + /// Derive the layout from the epoch's codeword heights. + /// + /// * `h_max` / `h_min` — the tallest and shortest codeword heights present. + /// * `blowup_log` — log2 of the LDE blowup factor. + /// * `final_poly_log_degree` — the requested `fri_final_poly_log_degree`. + /// + /// Panics if `h_min < blowup_log` (a codeword shorter than the blowup is not + /// a Reed-Solomon word of any positive rate) or if `h_min > h_max`. + pub fn new(h_max: usize, h_min: usize, blowup_log: u32, final_poly_log_degree: u32) -> Self { + assert!(h_min <= h_max, "h_min {h_min} exceeds h_max {h_max}"); + assert!( + h_min as u32 >= blowup_log, + "codeword height {h_min} is below the blowup {blowup_log}" + ); + // Deriving at `h_min` is what applies the floor: `FriFoldLayout` clamps + // the terminal to its `lde_log` argument, so the terminal comes out at + // `min(blowup_log + k, h_min)`. Its terminal_len / effective_k are then + // exactly what the unbatched prover would send for that codeword. + let shortest = FriFoldLayout::new(h_min as u32, blowup_log, final_poly_log_degree); + let terminal_log = shortest.terminal_len.trailing_zeros(); + // The running codeword starts at h_max, not h_min, so the fold count is + // re-derived from where folding actually begins. + let total_folds = h_max as u32 - terminal_log; + Self { + total_folds, + num_committed: total_folds.saturating_sub(1) as usize, + terminal_len: shortest.terminal_len, + effective_k: shortest.effective_k, + } + } +} + +/// Which of an epoch's tables enter the ONE batched FRI instance, and which keep +/// a terminal-only instance of their own. +/// +/// # Why there are two classes +/// +/// A table whose own FRI would commit ZERO layers gains nothing from being +/// batched — there is no layer for the batch to share — while it pays the full +/// cost of being lifted to the tallest domain, which is where the proximity-gaps +/// term's `|D0|²` lives. At the measured epoch that is 13 of 28 legs carrying 92% +/// of the batch's width, so excluding them is a correction rather than a +/// compromise: it recovers ~3.6 bits of soundness AND removes work. +/// +/// A zero-layer table's FRI is degenerate in the useful sense — its terminal +/// codeword IS its deep-composition codeword — so its "own instance" is one +/// terminal polynomial and no layers at all. +/// +/// # ★ The index rule BETWEEN the classes — a hard precondition +/// +/// Both classes are opened at the SAME query indices, because the mixed-height +/// MMCS is unaffected by this split: it still commits every table, and the point +/// of one shared authentication path survives whole. What differs is the index +/// SPACE each class reads them in: +/// +/// ```text +/// batched class: iota, used directly (it is an index in the tallest domain) +/// standalone table: iota >> (h_max - h_t) +/// ``` +/// +/// This is the same reduction [`crate::fri::mmcs`]'s index-convention section +/// documents for a short round, and it fails the same silent way: prover and +/// verifier derive it from the shape, so a wrong shift is self-consistent — +/// honest proofs still verify while the short tables end up checked at positions +/// the FRI join never reaches. `each_instance_class_is_tamper_checked` is the +/// control, and it tampers a table of EACH class, because a control that only +/// touched the batched class would pass under any convention for the other. +/// +/// # Determinism +/// +/// The plan is a pure function of `(heights, blowup_log, final_poly_log_degree)`, +/// all of which the transcript has bound before any challenge that depends on it. +/// Prover and verifier therefore derive the SAME partition without it being sent, +/// which is why the split adds nothing to the wire and nothing to the shape +/// binding. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FriInstancePlan { + /// Table indices whose codewords are mixed into the batched instance, in + /// input order — the order that defines the `alpha` powers. + pub batched: Vec, + /// Table indices that keep a terminal-only instance, in input order. + pub standalone: Vec, + /// Tallest and shortest height WITHIN the batched class — the layout is + /// derived from these, not from the whole epoch. + pub h_max: usize, + pub h_min: usize, +} + +impl FriInstancePlan { + /// Partition an epoch's tables. `None` when `heights` is empty or carries a + /// height that cannot be a codeword length — both are proof-supplied, so both + /// are rejections rather than panics. + /// + /// The TALLEST table is always batched, even if it would classify as + /// standalone on its own. That keeps the batched class non-empty, so the + /// layout is always well defined; an epoch whose tallest table folds nothing + /// degenerates to a single terminal-only instance, which is what it should be. + pub fn new(heights: &[usize], blowup_log: u32, final_poly_log_degree: u32) -> Option { + if heights.is_empty() { + return None; + } + let &h_max_epoch = heights.iter().max()?; + if h_max_epoch == 0 || h_max_epoch >= u32::BITS as usize { + return None; + } + let tallest = heights.iter().position(|h| *h == h_max_epoch)?; + + let mut batched = Vec::with_capacity(heights.len()); + let mut standalone = Vec::new(); + for (t, &h) in heights.iter().enumerate() { + if h < blowup_log as usize { + return None; + } + let folds_a_layer = + FriFoldLayout::new(h as u32, blowup_log, final_poly_log_degree).num_committed > 0; + if folds_a_layer || t == tallest { + batched.push(t); + } else { + standalone.push(t); + } + } + + let h_max = batched.iter().map(|&t| heights[t]).max()?; + let h_min = batched.iter().map(|&t| heights[t]).min()?; + Some(Self { + batched, + standalone, + h_max, + h_min, + }) + } +} + +/// FRI commit phase over the bucketed output of [`combine_by_height`] / +/// [`HeightCombiner::finish`]. +/// +/// `combined[h]` is `Some(codeword)` when there are DEEP contributions at height +/// `h` (codeword length `2^h`), or `None` otherwise. +/// +/// Folding starts from the tallest bucket. After each fold to height `h`, the +/// bucket at `combined[h]` is injected into the running codeword with +/// coefficient `β²` (β being the fold challenge just used), before the layer is +/// committed. Termination follows [`BatchedFriLayout`]: the running codeword is +/// folded to the terminal length and the terminal polynomial's coefficients are +/// appended to the transcript, exactly as +/// [`crate::fri::commit_phase_from_evaluations`] does — not folded down to a +/// single scalar. +/// +/// Layer trees are built with `FriLayerMerkleTreeBackend`, the same commitment +/// backend the unbatched [`crate::fri::commit_phase_from_evaluations`] uses — so a +/// batched prover and the verifier that authenticates its openings through +/// `BatchedMerkleTreeBackend` agree on the hash by naming one backend, not by two +/// call sites coinciding. +#[allow(clippy::type_complexity)] +pub fn batched_commit_phase( + mut combined: Vec>>>, + transcript: &mut T, + coset_offset: &FieldElement, + blowup_log: u32, + final_poly_log_degree: u32, +) -> ( + Vec>, + Vec>>, +) +where + F: IsFFTField + IsSubFieldOf + 'static, + E: IsField + 'static + Send + Sync, + T: IsStarkTranscript + Clone, + FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send, +{ + let (h_min, h_max) = bucket_height_range(&combined) + .expect("batched_commit_phase: combined must have at least one Some entry"); + + let domain_size = 1usize << h_max; + // Inverse twiddle factors for the initial domain size. + let inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); + + // The device fast path is selected one layer up, in + // `crate::batched::round4::commit_batched_fri`, so this stays a pure host + // build and a device error there is a hard abort rather than a fallback into + // this function. `h_min` is still consumed by the terminal-floor layout. + + // Take the starting codeword — NOT committed; it plays the role of layer 0. + let mut running = combined[h_max] + .take() + .expect("combined[h_max] is Some by construction"); + + debug_assert_eq!( + running.len(), + domain_size, + "starting codeword length must equal 2^h_max" + ); + + let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); + + let mut inv_twiddles = inv_twiddles; + + let mut fri_layer_list = Vec::with_capacity(layout.num_committed); + + for _ in 0..layout.num_committed { + // <<<< Receive challenge β + let beta = transcript.sample_field_element(); + + // Fold evaluations in-place; running halves in length. + fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); + inject_bucket(&mut running, &mut combined, &beta); + + // Build the row-pair Merkle tree over the current running codeword. + let leaves: Vec<[FieldElement; 2]> = running + .chunks_exact(2) + .map(|chunk| [chunk[0].clone(), chunk[1].clone()]) + .collect(); + let merkle_tree = MerkleTree::>::build(&leaves) + .expect("FRI batched commit: Merkle tree construction must succeed"); + let root = merkle_tree.root; + fri_layer_list.push(FriLayer::new(&running, merkle_tree)); + + // >>>> Send commitment: append root to transcript. + transcript.append_bytes(&root); + + // Update twiddles for the next (halved) level. + update_twiddles_in_place(&mut inv_twiddles); + } + + // One final fold to reach the terminal codeword, unless already there. The + // bucket AT the terminal height is injected here: it is the last one that can + // still enter the running word, which is why the layout floors the terminal + // at the shortest height rather than at `blowup_log + k` alone. + if layout.total_folds > 0 { + let beta = transcript.sample_field_element(); + fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); + inject_bucket(&mut running, &mut combined, &beta); + } + debug_assert_eq!( + running.len(), + layout.terminal_len, + "terminal codeword size mismatch" + ); + debug_assert!( + combined.iter().all(Option::is_none), + "every bucket must have been injected before the terminal" + ); + + // Recover the terminal polynomial's coefficients and send them, mirroring + // `commit_phase_from_evaluations`: the coefficient count follows + // `layout.effective_k` (the actual terminal), and the terminal coset offset + // is `coset_offset^(2^total_folds)`. + let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); + let final_poly_coeffs = + coeffs_from_terminal_codeword::(&running, &terminal_offset, layout.effective_k); + for c in &final_poly_coeffs { + transcript.append_field_element(c); + } + + (final_poly_coeffs, fri_layer_list) +} + +/// The `(h_min, h_max)` of the occupied buckets, or `None` when none are. +/// `pub(crate)` so `commit_batched_fri` can size the device FRI's twiddles from +/// the same range this host build uses. +pub(crate) fn bucket_height_range( + combined: &[Option>>], +) -> Option<(usize, usize)> { + let mut occupied = combined + .iter() + .enumerate() + .filter_map(|(h, slot)| slot.as_ref().map(|_| h)); + let first = occupied.next()?; + Some((first, occupied.next_back().unwrap_or(first))) +} + +/// `running += β² · combined[h]` for the running codeword's current height `h`, +/// consuming that bucket. A no-op when the bucket is empty. +fn inject_bucket( + running: &mut [FieldElement], + combined: &mut [Option>>], + beta: &FieldElement, +) { + let h = running.len().trailing_zeros() as usize; + let Some(bucket) = combined.get_mut(h).and_then(Option::take) else { + return; + }; + debug_assert_eq!( + bucket.len(), + running.len(), + "a bucket at height {h} must match the running codeword's length" + ); + let beta_sq = beta.square(); + for (val, contribution) in running.iter_mut().zip(bucket.iter()) { + *val = &*val + &(&beta_sq * contribution); + } +} + +/// Canonical, order-deterministic absorption of an epoch's table-SHAPE histogram +/// into the transcript. Single source of truth for the structural binding. +/// +/// The multiset of `lde_log_height`s across an epoch's tables fully determines +/// the fold order and injection points of the batched FRI (arity is uniformly +/// 2), so binding the heights binds the whole injection schedule. The widths are +/// bound alongside them because they are what makes the mixed-height MMCS leaf +/// parse unambiguous (see [`crate::fri::mmcs`]'s width-binding section) — the +/// verifier derives widths from the AIR set rather than the proof, so this is +/// defence in depth rather than the primary binding, and it costs one field per +/// table. +/// +/// Encoding (fixed-width, length-prefixed, order-preserving): +/// `u64::to_le_bytes(len)` followed by `u64::to_le_bytes(h)`, `u64::to_le_bytes(w)` +/// for each `(h, w)` pair, in the exact order given. Caller (prover and verifier +/// alike) must pass the shape in the same canonical per-epoch table order — this +/// function does not sort or deduplicate. +/// +/// Panics if `heights` and `widths` differ in length; both sides construct them +/// from the same table list. +pub fn absorb_shape_histogram(transcript: &mut T, heights: &[usize], widths: &[usize]) +where + E: IsField, + T: IsTranscript, +{ + assert_eq!( + heights.len(), + widths.len(), + "the shape histogram needs one width per height" + ); + transcript.append_bytes(&(heights.len() as u64).to_le_bytes()); + for (h, w) in heights.iter().zip(widths.iter()) { + transcript.append_bytes(&(*h as u64).to_le_bytes()); + transcript.append_bytes(&(*w as u64).to_le_bytes()); + } +} + +/// Challenges derived from replaying the shared batched round-4 transcript +/// sequence. See [`derive_batched_fri_challenges`]. +#[derive(Debug, Clone)] +pub struct BatchedFriChallenges { + /// Sampled once after the shape histogram (and, at the call site, after all + /// per-table OOD evaluations have been absorbed). + pub alpha: FieldElement, + /// One per committed layer, plus one for the final fold when there is one: + /// `betas.len() == layout.num_committed + (layout.total_folds > 0) as usize`. + pub betas: Vec>, + /// The layout the betas and the terminal were derived under. + pub layout: BatchedFriLayout, + /// Transcript state right before the grinding nonce bytes are appended. + /// All-zero when `grinding_factor == 0` or `nonce` is `None`. + pub grinding_seed: [u8; 32], + /// One `sample_u64(2^(h_max - 1))` draw per query — a row-PAIR index in the + /// tallest domain. A round whose own `h_max` is lower must reduce these; see + /// [`crate::fri::mmcs`]'s index-convention section. + pub iotas: Vec, + /// Which tables the batched instance carries and which keep a terminal-only + /// instance of their own. Derived from the shape, never sent. + pub plan: FriInstancePlan, +} + +/// Replays the shared batched round-4 transcript sequence (shape histogram, +/// alpha, per-layer beta/root, final beta, terminal coefficients, grinding, query +/// iotas) and returns the derived challenges. The one routine the prover and the +/// verifier both call, so they provably derive identical challenges. +/// +/// `standalone_coeffs[t]` is table `t`'s terminal-only polynomial, `Some` +/// exactly for the standalone class — presence is checked against the derived +/// plan and every coefficient is ABSORBED, right after `α` and before the +/// first `ζ`. That absorb is load-bearing: the standalone check evaluates the +/// sent polynomial at the query indices drawn BELOW, so a polynomial that +/// were not bound here could be chosen after the indices are known, and each +/// query's proximity test would bind nothing until the queries saturate the +/// table's domain. The unbatched path absorbs its terminal before sampling +/// queries for the same reason; this keeps the batched path's binding equal. +/// +/// Returns `None` when the proof's layer-root count disagrees with the layout the +/// epoch's shape implies, when the terminal coefficient count is wrong, or when +/// a standalone polynomial is present for the wrong class — all prover-supplied, +/// all rejections, not panics. +#[allow(clippy::too_many_arguments)] +pub fn derive_batched_fri_challenges( + transcript: &mut T, + heights: &[usize], + widths: &[usize], + layer_roots: &[[u8; 32]], + final_poly_coeffs: &[FieldElement], + standalone_coeffs: &[Option<&[FieldElement]>], + blowup_log: u32, + final_poly_log_degree: u32, + grinding_factor: u8, + nonce: Option, + num_queries: usize, +) -> Option> +where + E: IsField, + T: IsTranscript, +{ + // The partition is derived, not sent: it is a pure function of the shape the + // histogram below binds, so both sides reach the same one. `None` on any + // height that cannot be a codeword length — heights come from proof-supplied + // trace lengths, so a bogus one is a rejection, never a panic on the + // verifier's path. + let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree)?; + let (h_max, h_min) = (plan.h_max, plan.h_min); + let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); + if layer_roots.len() != layout.num_committed + || final_poly_coeffs.len() != 1usize << layout.effective_k + || standalone_coeffs.len() != heights.len() + { + return None; + } + + absorb_shape_histogram(transcript, heights, widths); + + let alpha = transcript.sample_field_element(); + + // The standalone class's terminal polynomials, bound before any query can + // depend on them — per table ascending, each coefficient in order. The + // length pin (`2^(h_t − blowup_log)`, exactly) stays with + // `verify_epoch_commitments`. + for (table, coeffs) in standalone_coeffs.iter().enumerate() { + if coeffs.is_some() != plan.standalone.contains(&table) { + return None; + } + if let Some(coeffs) = coeffs { + for c in coeffs.iter() { + transcript.append_field_element(c); + } + } + } + + let mut betas = Vec::with_capacity(layout.num_committed + 1); + for root in layer_roots { + let beta = transcript.sample_field_element(); + transcript.append_bytes(root); + betas.push(beta); + } + + if layout.total_folds > 0 { + betas.push(transcript.sample_field_element()); + } + for c in final_poly_coeffs { + transcript.append_field_element(c); + } + + let mut grinding_seed = [0u8; 32]; + if grinding_factor > 0 + && let Some(nonce_value) = nonce + { + grinding_seed = transcript.state(); + transcript.append_bytes(&nonce_value.to_be_bytes()); + } + + let iotas = (0..num_queries) + .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) + .collect(); + + Some(BatchedFriChallenges { + alpha, + betas, + layout, + grinding_seed, + iotas, + plan, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fri::commit_phase_from_evaluations; + use crate::fri::fri_functions::{compute_coset_twiddles_inv, fold_evaluations_in_place}; + use crypto::fiat_shamir::default_transcript::DefaultTranscript; + use crypto::fiat_shamir::is_transcript::IsTranscript; + use math::field::element::FieldElement; + use math::field::goldilocks::GoldilocksField; + + type FE = FieldElement; + type Transcript = DefaultTranscript; + + #[test] + fn combine_by_height_two_height3_one_height2() { + // Three codewords: indices 0, 1 have height 3 (length 8); + // index 2 has height 2 (length 4). + let cw0: Vec = (1u64..=8).map(FE::from).collect(); + let cw1: Vec = (10u64..=17).map(FE::from).collect(); + let cw2: Vec = (100u64..=103).map(FE::from).collect(); + + let alpha = FE::from(7u64); + + let inputs: Vec<(Vec, usize)> = + vec![(cw0.clone(), 3), (cw1.clone(), 3), (cw2.clone(), 2)]; + + let out = combine_by_height(&inputs, &alpha); + + // Output vec length = max_height + 1 = 4 (indices 0..=3 only). + assert_eq!(out.len(), 4, "output length should be max_height+1 = 4"); + + // Heights 0 and 1 have no inputs. + assert!(out[0].is_none(), "height 0 should be None"); + assert!(out[1].is_none(), "height 1 should be None"); + + // Height 3: combined[j] = alpha^0 * cw0[j] + alpha^1 * cw1[j] + let alpha0 = FE::one(); + let alpha1 = alpha; + let expected3: Vec = cw0 + .iter() + .zip(cw1.iter()) + .map(|(a, b)| &(&alpha0 * a) + &(&alpha1 * b)) + .collect(); + + let got3 = out[3].as_ref().expect("height 3 should be Some"); + assert_eq!( + got3.len(), + 8, + "height-3 combined codeword should have length 8" + ); + assert_eq!(got3, &expected3, "height-3 combined values mismatch"); + + // Height 2: combined[j] = alpha^2 * cw2[j] + let alpha2 = &alpha * α + let expected2: Vec = cw2.iter().map(|x| &alpha2 * x).collect(); + + let got2 = out[2].as_ref().expect("height 2 should be Some"); + assert_eq!( + got2.len(), + 4, + "height-2 combined codeword should have length 4" + ); + assert_eq!(got2, &expected2, "height-2 combined values mismatch"); + } + + /// Absorbing codewords one at a time — the shape a prover uses so it never + /// holds every table's quotient at once — must land on the same buckets as + /// handing them all over materialized. + #[test] + fn streaming_absorption_matches_materialized_combine() { + let inputs: Vec<(Vec, usize)> = vec![ + ((1u64..=16).map(FE::from).collect(), 4), + ((50u64..=57).map(FE::from).collect(), 3), + ((90u64..=105).map(FE::from).collect(), 4), + ((200u64..=203).map(FE::from).collect(), 2), + ((300u64..=307).map(FE::from).collect(), 3), + ]; + let alpha = FE::from(11u64); + + let eager = combine_by_height(&inputs, &alpha); + + let mut combiner = HeightCombiner::new(alpha); + for (codeword, height) in &inputs { + combiner.absorb(codeword, *height); + } + assert_eq!( + combiner.finish(), + eager, + "streaming absorption must equal the materialized combine" + ); + } + + /// After the first fold in `batched_commit_phase`, the committed layer[0] + /// evaluation must equal `fold(combined[4], β₀) + β₀² · combined[3]`. + #[test] + fn batched_commit_phase_first_layer_matches_manual_fold_and_inject() { + // Build synthetic codewords for h=4 (len 16) and h=3 (len 8). + let data_h4: Vec = (1u64..=16).map(FE::from).collect(); + let data_h3: Vec = (101u64..=108).map(FE::from).collect(); + + // combined = [None, None, None, Some(data_h3), Some(data_h4)] + let combined: Vec>> = vec![ + None, + None, + None, + Some(data_h3.clone()), + Some(data_h4.clone()), + ]; + + let coset_offset = FE::from(3u64); + let (blowup_log, k) = (1u32, 1u32); + + // Create transcript; clone before mutating so we can replay independently. + let mut transcript = Transcript::new(b"batched_fri_test"); + let mut transcript_check = transcript.clone(); + + let (_coeffs, layers) = batched_commit_phase::<_, _, _>( + combined, + &mut transcript, + &coset_offset, + blowup_log, + k, + ); + + // Terminal at min(blowup_log + k, h_min) = min(2, 3) = 2, so folds run + // 4 -> 2: two folds, one committed layer. + let layout = BatchedFriLayout::new(4, 3, blowup_log, k); + assert_eq!(layout.total_folds, 2); + assert_eq!( + layers.len(), + layout.num_committed, + "committed layers must follow the layout" + ); + + // --- Independent recomputation of layer[0] --- + let beta_0 = transcript_check.sample_field_element(); + + let inv_twiddles_h4 = compute_coset_twiddles_inv::(&coset_offset, 16); + let mut expected = data_h4.clone(); + fold_evaluations_in_place(&mut expected, &beta_0, &inv_twiddles_h4); + // expected now has length 8 (height 3) + + // Inject combined[3]: expected[j] += beta_0² · data_h3[j] + let beta_0_sq = beta_0.square(); + for (j, val) in data_h3.iter().enumerate() { + expected[j] = &expected[j] + &(&beta_0_sq * val); + } + + assert_eq!( + layers[0].evaluation, expected, + "layer[0] evaluation does not match manual fold+inject" + ); + } + + /// ★ M-12: the batched commit phase must terminate where the unbatched one + /// does. With a single bucket the two are the same protocol, so they must + /// agree on the committed-layer count, the terminal coefficients, and the + /// resulting transcript state — pinning that batching did not silently switch + /// to folding all the way to a scalar (which for this input would commit + /// `h_max - 1 = 9` layers instead of 4). + #[test] + fn single_bucket_terminal_matches_the_unbatched_commit_phase() { + let h = 10usize; + let (blowup_log, k) = (1u32, 5u32); + let coset_offset = FE::from(3u64); + let evals: Vec = (0..(1u64 << h)).map(|i| FE::from(i * 7 + 1)).collect(); + let inv_twiddles = compute_coset_twiddles_inv::(&coset_offset, 1 << h); + + let mut t_unbatched = Transcript::new(b"terminal_parity"); + let (unbatched_coeffs, unbatched_layers) = + commit_phase_from_evaluations::( + evals.clone(), + &mut t_unbatched, + &coset_offset, + 1 << h, + blowup_log, + k, + &inv_twiddles, + ); + + let mut combined: Vec>> = vec![None; h + 1]; + combined[h] = Some(evals); + let mut t_batched = Transcript::new(b"terminal_parity"); + let (batched_coeffs, batched_layers) = + batched_commit_phase::<_, _, _>(combined, &mut t_batched, &coset_offset, blowup_log, k); + + // total_folds = 10 - (1 + 5) = 4, so 3 committed layers — not h_max-1 = 9. + assert_eq!(unbatched_layers.len(), 3); + assert_eq!( + batched_layers.len(), + unbatched_layers.len(), + "batched and unbatched must commit the same number of layers" + ); + assert_eq!( + batched_coeffs.len(), + 1usize << k, + "the terminal polynomial must carry 2^k coefficients" + ); + assert_eq!( + batched_coeffs, unbatched_coeffs, + "batched and unbatched must send the same terminal polynomial" + ); + for (b, u) in batched_layers.iter().zip(unbatched_layers.iter()) { + assert_eq!(b.merkle_tree.root, u.merkle_tree.root); + } + assert_eq!( + t_batched.state(), + t_unbatched.state(), + "the two commit phases must leave the transcript in the same state" + ); + } + + /// The batched-only floor: the terminal may not sit above the shortest + /// injected codeword, or that bucket would never enter the running word. + #[test] + fn terminal_is_floored_at_the_shortest_codeword() { + let (blowup_log, k) = (1u32, 5u32); + + // Shortest codeword above blowup_log + k = 6: the floor is inert and the + // layout is the unbatched one for h_max. + let inert = BatchedFriLayout::new(10, 8, blowup_log, k); + assert_eq!(inert.total_folds, 4, "10 -> 6"); + assert_eq!(inert.effective_k, k); + + // Shortest codeword BELOW blowup_log + k: folding must continue down to + // it, and the terminal polynomial shrinks accordingly. + let floored = BatchedFriLayout::new(10, 4, blowup_log, k); + assert_eq!(floored.total_folds, 6, "10 -> 4"); + assert_eq!(floored.effective_k, 3, "terminal_log 4 - blowup_log 1"); + + // And the commit phase really does consume that low bucket. + let coset_offset = FE::from(3u64); + let mut combined: Vec>> = vec![None; 8]; + combined[7] = Some((0..128u64).map(|i| FE::from(i + 1)).collect()); + combined[4] = Some((0..16u64).map(|i| FE::from(i * 3 + 5)).collect()); + let mut transcript = Transcript::new(b"floor_test"); + let (coeffs, layers) = batched_commit_phase::<_, _, _>( + combined, + &mut transcript, + &coset_offset, + blowup_log, + k, + ); + let layout = BatchedFriLayout::new(7, 4, blowup_log, k); + assert_eq!(layers.len(), layout.num_committed); + assert_eq!(coeffs.len(), 1usize << layout.effective_k); + } + + /// The prover, by hand, runs exactly the round-4 sequence; the shared replay + /// routine must reproduce byte-identical outputs from the same start state. + #[test] + fn batched_round4_prover_inline_matches_verifier_replay() { + let heights: Vec = vec![10, 10, 8, 8, 8, 7]; + let widths: Vec = vec![3, 5, 2, 2, 9, 1]; + let (blowup_log, k) = (1u32, 5u32); + // total_folds = 10 - 6 = 4 -> 3 committed layers, 4 betas. + let layout = BatchedFriLayout::new(10, 7, blowup_log, k); + assert_eq!((layout.num_committed, layout.total_folds), (3, 4)); + + let layer_roots: Vec<[u8; 32]> = (0u8..3).map(|i| [i; 32]).collect(); + let final_poly_coeffs: Vec = (0..(1u64 << layout.effective_k)).map(FE::from).collect(); + // Height 7 folds no layer at these parameters, so table 5 is standalone + // and its terminal polynomial is part of the round-4 sequence. + let standalone_terminal: Vec = (0..(1u64 << (7 - blowup_log))).map(FE::from).collect(); + + let grinding_factor: u8 = 4; + let num_queries = 3; + + let seed_transcript = Transcript::new(b"batched_round4_test"); + let mut transcript_a = seed_transcript.clone(); + let mut transcript_b = seed_transcript.clone(); + + // --- Clone A: prover-inline sequence, by hand --- + absorb_shape_histogram(&mut transcript_a, &heights, &widths); + let alpha_a = transcript_a.sample_field_element(); + for c in &standalone_terminal { + transcript_a.append_field_element(c); + } + + let mut betas_a = Vec::with_capacity(layer_roots.len() + 1); + for root in &layer_roots { + let beta = transcript_a.sample_field_element(); + transcript_a.append_bytes(root); + betas_a.push(beta); + } + betas_a.push(transcript_a.sample_field_element()); + for c in &final_poly_coeffs { + transcript_a.append_field_element(c); + } + assert_eq!( + betas_a.len(), + layout.total_folds as usize, + "one beta per fold, matching batched_commit_phase" + ); + + let grinding_seed_a = transcript_a.state(); + // Test-only: derive a real PoW nonce so the grinding step is exercised + // identically by both sides (the nonce search itself is not under test). + let nonce = crate::grinding::generate_nonce(&grinding_seed_a, grinding_factor) + .expect("a valid grinding nonce exists for this small grinding_factor"); + transcript_a.append_bytes(&nonce.to_be_bytes()); + + let iotas_a: Vec = (0..num_queries) + .map(|_| transcript_a.sample_u64(1u64 << 9) as usize) + .collect(); + + // --- Clone B: shared replay routine --- + let standalone: Vec> = vec![ + None, + None, + None, + None, + None, + Some(standalone_terminal.as_slice()), + ]; + let result = derive_batched_fri_challenges( + &mut transcript_b, + &heights, + &widths, + &layer_roots, + &final_poly_coeffs, + &standalone, + blowup_log, + k, + grinding_factor, + Some(nonce), + num_queries, + ) + .expect("a well-formed layer-root and coefficient count"); + + assert_eq!(result.alpha, alpha_a, "alpha mismatch"); + assert_eq!(result.betas, betas_a, "beta vector mismatch"); + assert_eq!(result.layout, layout, "layout mismatch"); + assert_eq!( + result.grinding_seed, grinding_seed_a, + "grinding seed mismatch" + ); + assert_eq!(result.iotas, iotas_a, "iotas mismatch"); + assert!( + result.iotas.iter().all(|&i| i < 1usize << 9), + "iotas must be row-pair indices in the tallest domain" + ); + } + + /// A layer-root or coefficient count that disagrees with the shape's layout is + /// prover-supplied, so it is a rejection rather than a panic. + #[test] + fn derive_rejects_a_layer_count_that_contradicts_the_shape() { + let heights: Vec = vec![10, 8]; + let widths: Vec = vec![2, 3]; + let (blowup_log, k) = (1u32, 5u32); + let layout = BatchedFriLayout::new(10, 8, blowup_log, k); + let coeffs: Vec = vec![FE::one(); 1usize << layout.effective_k]; + let roots: Vec<[u8; 32]> = vec![[0u8; 32]; layout.num_committed]; + + let no_standalone: Vec> = vec![None; heights.len()]; + let mut ok = Transcript::new(b"reject"); + assert!( + derive_batched_fri_challenges( + &mut ok, + &heights, + &widths, + &roots, + &coeffs, + &no_standalone, + blowup_log, + k, + 0, + None, + 1 + ) + .is_some() + ); + + let mut too_few = Transcript::new(b"reject"); + assert!( + derive_batched_fri_challenges( + &mut too_few, + &heights, + &widths, + &roots[..roots.len() - 1], + &coeffs, + &no_standalone, + blowup_log, + k, + 0, + None, + 1 + ) + .is_none(), + "one fewer layer root than the shape implies must be rejected" + ); + + let mut bad_coeffs = Transcript::new(b"reject"); + assert!( + derive_batched_fri_challenges( + &mut bad_coeffs, + &heights, + &widths, + &roots, + &coeffs[..coeffs.len() - 1], + &no_standalone, + blowup_log, + k, + 0, + None, + 1 + ) + .is_none(), + "a short terminal polynomial must be rejected" + ); + } + + /// `heights` comes from proof-supplied trace lengths, so every out-of-range + /// value is a rejection rather than a shift overflow or a layout assert. + #[test] + fn derive_rejects_out_of_range_heights_without_panicking() { + let widths = vec![2usize, 3]; + let (blowup_log, k) = (1u32, 5u32); + let coeffs: Vec = vec![FE::one(); 1usize << k]; + let roots: Vec<[u8; 32]> = vec![[0u8; 32]; 3]; + + let derive = |heights: &[usize]| { + let no_standalone: Vec> = vec![None; heights.len()]; + derive_batched_fri_challenges( + &mut Transcript::new(b"range"), + heights, + &widths, + &roots, + &coeffs, + &no_standalone, + blowup_log, + k, + 0, + None, + 1, + ) + .is_some() + }; + + assert!(derive(&[10, 8]), "a well-formed shape is accepted"); + assert!(!derive(&[0, 0]), "a zero height must be rejected"); + assert!( + !derive(&[10, 0]), + "a height below the blowup must be rejected" + ); + assert!( + !derive(&[u32::BITS as usize, 8]), + "a height at the shift width must be rejected" + ); + assert!( + !derive(&[usize::MAX, 8]), + "an absurd height must be rejected, not wrapped by the u32 cast" + ); + let empty: [usize; 0] = []; + assert!(!derive(&empty), "an empty epoch must be rejected"); + } + + /// Tampering the shape histogram (without changing anything else) must change + /// the derived batching challenge α — the structural binding that protects the + /// fold/injection schedule. Heights and widths are both bound (M-13a), so a + /// change to either alone must move α. + #[test] + fn absorb_shape_histogram_binds_heights_and_widths_into_alpha() { + let heights: Vec = vec![10, 10, 8, 8, 8, 5]; + let widths: Vec = vec![4, 4, 2, 2, 2, 1]; + + let alpha_of = |h: &[usize], w: &[usize]| { + let mut t = Transcript::new(b"histogram_binding_test"); + absorb_shape_histogram(&mut t, h, w); + t.sample_field_element() + }; + + let base = alpha_of(&heights, &widths); + + let mut other_height = heights.clone(); + other_height[5] = 6; + assert_ne!( + base, + alpha_of(&other_height, &widths), + "different height histograms must yield different alpha" + ); + + let mut other_width = widths.clone(); + other_width[5] = 2; + assert_ne!( + base, + alpha_of(&heights, &other_width), + "different width histograms must yield different alpha" + ); + + // The length prefix plus fixed-width fields make the encoding injective: + // swapping a (height, width) pair between tables also moves alpha. + let swapped_h = vec![10, 10, 8, 8, 5, 8]; + let swapped_w = vec![4, 4, 2, 2, 1, 2]; + assert_ne!( + base, + alpha_of(&swapped_h, &swapped_w), + "table order must be bound, not just the multiset" + ); + } +} diff --git a/crypto/stark/src/fri/mmcs.rs b/crypto/stark/src/fri/mmcs.rs new file mode 100644 index 000000000..22fffb910 --- /dev/null +++ b/crypto/stark/src/fri/mmcs.rs @@ -0,0 +1,1922 @@ +//! Mixed-height, row-pair MMCS (Merkle Mixed Commitment Scheme). +//! +//! Commits ALL of an epoch's matrices (one per table, of possibly different +//! heights) into ONE mixed-height Merkle tree, so a single query opens ONE +//! authentication path that covers every table's row at that query — the +//! proof-size / opening-path win of the unified-shard design (SP1 / OpenVM / +//! Plonky3). Mirrors Plonky3's `MerkleTreeMmcs`, adapted to the concrete keccak +//! commitment backends and to the row-pair `(x, -x)` leaf layout (#735). +//! +//! This is a standalone primitive: the prover and verifier do not build epoch +//! commitments with it yet. The leaf and injection layout documented below is +//! the single source of truth for whoever wires it in. +//! +//! # Inputs +//! +//! [`MixedMmcs::commit`] reads matrices through a [`LeafSource`], which reports +//! each matrix's `(log_height, width)` and serves its rows on demand: +//! - `log_height`: `log2` of the row count; the matrix has `2^log_height` rows. +//! - `width`: number of committed columns. +//! - rows are addressed by **bit-reversed** LDE position (the same layout the +//! per-table trace commit produces internally). +//! +//! # Row-pair leaves +//! +//! Leaf `k` of a matrix groups LDE positions `2k` and `2k+1` (the FRI fold pair +//! `x` and `-x`), all `width` columns batched. A matrix of `log_height h` has +//! `2^(h-1)` leaves. In [`MixedMmcs::open_batch`] / [`PolynomialOpenings`]: +//! `evaluations` = row `2k`, `evaluations_sym` = row `2k+1`. +//! +//! # Tree layout (the soundness-relevant contract) +//! +//! Let `h_max = max(log_height)`. The base digest layer (layer 0) has +//! `N0 = 2^(h_max-1)` nodes. Layer `i` has `N0 >> i` nodes; the root is the sole +//! node of layer `h_max-1`. A matrix of `log_height h` is *injected* at layer +//! index `i = h_max - h` (so the tallest matrices, `h == h_max`, populate the +//! base layer; shorter matrices enter where the layer width matches their leaf +//! count `2^(h-1)`). +//! +//! Hashing (`H = >::hash_data` over a `Vec` of field +//! elements; `C = >::hash_new_parent`, the 2-input +//! compression — the same two functions, on the same backend, that the existing +//! per-table tree uses): +//! +//! - **Base layer** node `k` (`k in [0, N0)`): +//! `layer0[k] = H( CONCAT_{m : h_m == h_max} (row_m(2k) || row_m(2k+1)) )` +//! where matrices of height `h_max` are concatenated in INPUT order. +//! - **Climb** from layer `i` to layer `i+1` (`j in [0, N_{i+1})`): +//! `parent = C(layer_i[2j], layer_i[2j+1])`. Let `inject_h = h_max - 1 - i`. If +//! any matrix has `h_m == inject_h`, then +//! `layer_{i+1}[j] = C( parent, H( CONCAT_{m : h_m == inject_h} (row_m(2j) || row_m(2j+1)) ) )` +//! (injecting matrices concatenated in INPUT order); otherwise +//! `layer_{i+1}[j] = parent`. +//! - `root = layer_{h_max-1}[0]`. +//! +//! Because the leaf and parent hashes come from `BatchedMerkleTreeBackend` — +//! the backend the per-table row-pair tree already commits with — a single-matrix `MixedMmcs` is +//! byte-identical to that tree by construction, not by coincidence. There is no +//! second encoding of a leaf to keep in step. +//! +//! # Query opening +//! +//! For query `iota in [0, N0)`, matrix `m` is opened at leaf +//! `k_m = iota >> (h_max - h_m)` (`= iota >> i_m`). The shared authentication +//! path holds, for each level `level in [0, h_max-1)`, the sibling +//! `layer_level[(iota >> level) ^ 1]`. ONE path authenticates all matrices. +//! The per-matrix [`PolynomialOpenings::proof`] fields are empty; the single +//! [`MixedOpening::proof`] is the authenticator. +//! +//! # ★ Index convention — a HARD PRECONDITION on the caller +//! +//! `iota` is a leaf index **in THIS tree**: it must be drawn from +//! `[0, 2^(h_max-1))` where `h_max` is *this MMCS's* tallest matrix. +//! [`MixedMmcs::verify_batch`] walks the path with `(iota >> level) & 1`, i.e. it +//! consumes the **low** `h_max - 1` bits, while a shorter matrix inside the tree +//! is located by `iota >> (h_max - h_m)`, i.e. by the **high** bits. Both are +//! consistent only when the two `h_max` agree. +//! +//! A caller that batches several rounds under one shared FRI query index must +//! therefore reduce a global index before calling in: +//! +//! ```text +//! iota_round = iota_fri >> (h_max_fri - h_max_round) +//! ``` +//! +//! Passing the un-reduced `iota_fri` to a round whose `h_max` is below the FRI's +//! is not a loud error — prover and verifier share this routine, so a wrong +//! convention is self-consistent: honest proofs still verify and the failure is +//! that short matrices end up authenticated at positions the FRI join never +//! checks. [`MixedMmcs::verify_batch`] rejects an `iota` outside `[0, 2^(h_max-1))` +//! to turn most of that class of misuse into a rejection rather than a silent +//! mis-binding, but the reduction remains the caller's obligation: an index that +//! happens to land in range is accepted at the wrong leaf. +//! `short_round_low_bit_convention_is_exercised` is the control on this. +//! +//! # Width binding (soundness) +//! +//! [`MixedMmcs::verify_batch`] takes per-matrix `widths` alongside `heights`. +//! Within a height group the leaf hash is over the FLAT concatenation of every +//! matrix's opened row pair (`A.eval ‖ A.eval_sym ‖ B.eval ‖ B.eval_sym ‖ …`), +//! which does NOT by itself record where each matrix's columns end. Fixing +//! `widths[m]` (matrix `m`'s column count) makes those boundaries unambiguous: +//! without it a prover could shift a boundary — e.g. lengthen one matrix's +//! `evaluations` by one element and shorten its `evaluations_sym` by one — +//! leaving the flat bytes (and therefore the group hash) identical while feeding +//! a corrupted row downstream. Consumers MUST pass the committed public +//! per-table column counts, in the same INPUT order as `heights`, derived from +//! the AIR set rather than read out of the proof. +//! +//! `heights` and `widths` must ALSO be bound into the Fiat-Shamir transcript by +//! the consumer, before any challenge that depends on the epoch's shape — see +//! [`crate::fri::batched::absorb_shape_histogram`], which is the canonical +//! encoding of that binding. +//! +//! # Determinism +//! +//! The tree is a pure function of `(matrices, input order)`. Grouping within a +//! height (base batching and injection) follows INPUT order; the prover and +//! verifier MUST pass matrices and `heights` in the same per-epoch order. +//! +//! # Memory: what the caller may drop, and when +//! +//! The MMCS owns no evaluations. It stores the digest layers +//! (`O(2^(h_max-1))` nodes) plus each matrix's `(log_height, width)`; rows are +//! pulled through [`LeafSource`] both at commit and at open time. Two properties +//! follow, and `commit_reads_each_height_group_in_one_contiguous_phase` is the +//! control on the second: +//! +//! - `commit` reads matrix `m`'s rows **only while building level +//! `h_max - h_m`**, and levels are built in descending height order. A caller +//! may therefore produce a height group's LDEs, commit, and drop them before +//! the next group is needed. +//! - Within one height group the leaf is a single `hash_data` over the group's +//! concatenated rows, so `commit` reads every matrix of that height at every +//! leaf: their access windows overlap, and a caller serving them from in-RAM +//! LDE buffers holds the whole group at once. Since the tallest group is most +//! of a real epoch's tables, that is `O(N)` resident at the base layer. +//! +//! A `LeafSource` serving rows from disk, device memory or recomputation is the +//! escape for that base-group residency: it lets a caller stream a matrix in, +//! hash it, and drop it without holding the whole group in RAM at once. +//! +//! (A streaming, incremental-leaf-hasher builder that keeps one hasher per leaf +//! and absorbs matrices as they arrive is planned but not part of this phase.) + +use core::marker::PhantomData; + +use crypto::merkle_tree::proof::Proof; +use crypto::merkle_tree::traits::{IsLeafHasher, IsMerkleTreeBackend, IsStreamingLeafBackend}; +use math::fft::bit_reversing::reverse_index; +use math::field::element::FieldElement; +use math::field::traits::IsField; +use math::traits::AsBytes; + +use crate::config::{BatchedMerkleTreeBackend, Commitment}; +use crate::proof::stark::PolynomialOpenings; + +/// On-demand supplier of committed matrix rows, so [`MixedMmcs`] builds its +/// digests and serves openings WITHOUT owning a copy of the (large) LDE buffers. +/// Both [`MixedMmcs::commit`] and [`MixedMmcs::open_batch`] read every leaf +/// through this trait, so the root and opened rows are byte-identical to those a +/// matrix-owning MMCS would produce — the prover keeps only the LDE buffers it +/// already retains for DEEP, and each MMCS stores just digests. +/// +/// Rows are addressed in each matrix's committed row-pair layout: `append_row(m, +/// r, out)` appends matrix `m`'s row at **bit-reversed** LDE position `r` (its +/// `width(m)` committed columns, in column order). This is the same `r`-indexing +/// the module's "Tree layout" section uses; an implementor holding the +/// natural-order LDE maps `r` to `reverse_index(r, 2^log_height(m))`. +pub trait LeafSource { + /// Number of committed matrices, in canonical input order. + fn num_matrices(&self) -> usize; + /// `log2` of matrix `m`'s row count. Row-pair leaves require `>= 1`. + fn log_height(&self, m: usize) -> usize; + /// Matrix `m`'s committed column count. + fn width(&self, m: usize) -> usize; + /// Append matrix `m`'s bit-reversed LDE row `bitrev_row` (its `width(m)` + /// committed columns) to `out`. `bitrev_row in [0, 2^log_height(m))`. + fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>); +} + +/// One committed matrix borrowed from a retained LDE buffer. Resolves each +/// bit-reversed row on demand (mapping through `reverse_index`) so the MMCS owns +/// no copy of the evaluations. See [`LeafSource`]. +pub enum BorrowedMatrix<'a, E: IsField> { + /// A `stride`-wide, row-major, NATURAL-order LDE buffer (the main / aux LDE + /// retained in `Round1::lde_trace`). This matrix occupies columns + /// `[col_start, col_start + width)`; its bit-reversed row `r` lives at + /// natural-order row `reverse_index(r, 2^log_height)`. + RowMajorNatural { + data: &'a [FieldElement], + stride: usize, + col_start: usize, + width: usize, + log_height: usize, + }, + /// Column-major NATURAL-order columns (the composition-poly LDE retained in + /// `Round2::lde_composition_poly_evaluations`): `cols[c][nat]` is column `c` + /// at natural-order row `nat`. Every committed column is used. + ColMajorNatural { + cols: &'a [Vec>], + log_height: usize, + }, +} + +impl BorrowedMatrix<'_, E> { + fn log_height(&self) -> usize { + match self { + BorrowedMatrix::RowMajorNatural { log_height, .. } + | BorrowedMatrix::ColMajorNatural { log_height, .. } => *log_height, + } + } + + fn width(&self) -> usize { + match self { + BorrowedMatrix::RowMajorNatural { width, .. } => *width, + BorrowedMatrix::ColMajorNatural { cols, .. } => cols.len(), + } + } + + fn append_row(&self, bitrev_row: usize, out: &mut Vec>) { + match self { + BorrowedMatrix::RowMajorNatural { + data, + stride, + col_start, + width, + log_height, + } => { + let nat = reverse_index(bitrev_row, 1u64 << log_height); + let base = nat * stride + col_start; + out.extend_from_slice(&data[base..base + width]); + } + BorrowedMatrix::ColMajorNatural { cols, log_height } => { + let nat = reverse_index(bitrev_row, 1u64 << log_height); + for col in cols.iter() { + out.push(col[nat].clone()); + } + } + } + } +} + +impl LeafSource for Vec> { + fn num_matrices(&self) -> usize { + self.len() + } + fn log_height(&self, m: usize) -> usize { + self[m].log_height() + } + fn width(&self, m: usize) -> usize { + self[m].width() + } + fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { + self[m].append_row(bitrev_row, out); + } +} + +/// A committed mixed-height, row-pair MMCS under the concrete keccak commitment +/// backends. Stores ONLY the digest layers (to serve the shared authentication path) +/// plus each matrix's `(log_height, width)` (to locate leaves). The row DATA is +/// served on demand by the caller's [`LeafSource`] — the MMCS never owns a copy +/// of the LDE. +pub struct MixedMmcs { + root: Commitment, + /// `layers[0]` is the base digest layer; `layers[h_max-1] == [root]`. + layers: Vec>, + /// Per committed matrix, in input order: `(log_height, width)`. + dims: Vec<(usize, usize)>, + h_max: usize, + _marker: PhantomData, +} + +/// The opening of ALL matrices at one query index, authenticated by a single +/// shared Merkle path. +#[derive( + Debug, + Clone, + serde::Serialize, + serde::Deserialize, + rkyv::Archive, + rkyv::Serialize, + rkyv::Deserialize, +)] +#[serde(bound = "")] +pub struct MixedOpening { + /// The one authentication path covering every matrix's row at the query. + pub proof: Proof, + /// Per-matrix row pair (in the same INPUT order as `commit`). Each entry's + /// own `proof` is empty — [`MixedOpening::proof`] is the authenticator. + pub per_matrix: Vec>, +} + +/// Hash the row pair `(row(2*leaf), row(2*leaf+1))` of every matrix whose index +/// is in `group` (in the given order), all columns batched, into one digest. +/// Rows are pulled from `source` — the MMCS owns no copy. +fn hash_group_leaf(source: &S, group: &[usize], leaf: usize) -> Commitment +where + E: IsField + 'static, + S: LeafSource, + FieldElement: AsBytes + Sync + Send, +{ + let mut buf: Vec> = Vec::new(); + for &m in group { + source.append_row(m, 2 * leaf, &mut buf); + source.append_row(m, 2 * leaf + 1, &mut buf); + } + as IsMerkleTreeBackend>::hash_data(&buf) +} + +/// Verifier-side analogue of [`hash_group_leaf`]: hash the opened row pairs of a +/// group of openings (in the given order) into one digest. +fn hash_group_openings(group: &[&PolynomialOpenings]) -> Commitment +where + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, +{ + let mut buf: Vec> = Vec::new(); + for o in group { + buf.extend_from_slice(&o.evaluations); + buf.extend_from_slice(&o.evaluations_sym); + } + as IsMerkleTreeBackend>::hash_data(&buf) +} + +#[inline] +fn compress(left: &Commitment, right: &Commitment) -> Commitment +where + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, +{ + as IsMerkleTreeBackend>::hash_new_parent(left, right) +} + +impl MixedMmcs +where + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, +{ + /// Commit the matrices supplied by `source` into one mixed-height row-pair + /// tree, storing only the digest layers. See the module docs for the exact + /// leaf/injection layout. `source` provides each matrix's dimensions and its + /// bit-reversed rows on demand; no copy of the evaluations is retained. + /// + /// Leaf hashing (the base layer and each injected climb layer) is parallel + /// across leaves via [`crate::par::par_map_collect`]; the per-level output is + /// index-ordered, so the root and layers are byte-identical to a sequential + /// build. `S: Sync` lets leaf closures read `source` from worker threads. + /// + /// Levels are built in descending height order and matrix `m` is read only + /// while its own level is built, so the caller may release a height group's + /// buffers once the next level starts — see the module's memory section. + pub fn commit + Sync>(source: &S) -> Self { + let num_matrices = source.num_matrices(); + assert!( + num_matrices > 0, + "MixedMmcs::commit requires at least one matrix" + ); + + let dims: Vec<(usize, usize)> = (0..num_matrices) + .map(|m| { + let log_height = source.log_height(m); + assert!( + log_height >= 1, + "log_height must be >= 1 (row-pair leaves need at least 2 rows)" + ); + (log_height, source.width(m)) + }) + .collect(); + + let h_max = dims + .iter() + .map(|(log_height, _)| *log_height) + .max() + .expect("dims is non-empty"); + + // Per-height group leaf digests, built in descending height order — the + // order that makes the memory claim in the module header true. Index `h` + // is `Some` exactly when some matrix has that height. + let mut group_digests: Vec>> = vec![None; h_max + 1]; + for h in (1..=h_max).rev() { + let group: Vec = (0..num_matrices).filter(|&m| dims[m].0 == h).collect(); + if group.is_empty() { + continue; + } + // 2^(h-1) independent group-leaf hashes; at `h == h_max` that is the + // bulk of the tree's hashing (half of all nodes). Parallel across + // leaves. + group_digests[h] = Some(crate::par::par_map_collect(0..1usize << (h - 1), |k| { + hash_group_leaf::(source, &group, k) + })); + } + + Self::from_group_digests(dims, h_max, group_digests) + } + + /// Build the tree from each height group's already-hashed leaf digests. + /// + /// The single climb implementation. [`Self::commit`] reaches it having hashed + /// every group leaf in one pass; a future streaming builder would reach it + /// having hashed them incrementally, matrix by matrix. That the two produce + /// the same tree is therefore a property of calling one function, not a + /// coincidence two code paths have to be shown to share. + fn from_group_digests( + dims: Vec<(usize, usize)>, + h_max: usize, + mut group_digests: Vec>>, + ) -> Self { + let mut layers: Vec> = Vec::with_capacity(h_max); + layers.push( + group_digests[h_max] + .take() + .expect("the tallest height group is occupied by construction"), + ); + + // Climb, compressing pairs and injecting shorter matrices where the layer + // width matches their leaf count. Each level's nodes are independent + // (they read only the previous, already-materialized layer), so parallel + // across nodes; levels stay sequential. + let mut i = 0usize; + while layers[i].len() > 1 { + let next_len = layers[i].len() / 2; + let injected = group_digests[h_max - 1 - i].take(); + + let cur = &layers[i]; + let next: Vec = crate::par::par_map_collect(0..next_len, |j| { + let parent = compress::(&cur[2 * j], &cur[2 * j + 1]); + match &injected { + Some(digests) => compress::(&parent, &digests[j]), + None => parent, + } + }); + layers.push(next); + i += 1; + } + + let root = layers.last().expect("at least the base layer exists")[0]; + + MixedMmcs { + root, + layers, + dims, + h_max, + _marker: PhantomData, + } + } + + /// Reconstruct the tree from a STANDARD HEAP node array — the layout the GPU + /// commit (`math_cuda::mmcs::build_mmcs_tree_on_device`) produces: `2*L-1` + /// nodes of 32 bytes, root at index 0, inner nodes in `[0, L-1)`, the `L = + /// 2^(h_max-1)` leaves in the tail `[L-1, 2L-1)`, with leaf `j` at `L-1+j`. + /// + /// This is what makes a GPU-built tree serve the SAME [`Self::auth_path`] / + /// [`Self::open_batch`] a host-built one does: the keccak (leaf + climb) runs + /// on the device, only the digest layers come back, and every downstream + /// opening reads them unchanged. The heap ordering matches + /// `merkle_gather_paths` (validated by `mmcs_tree_parity`'s + /// `paths_match_the_host_at_every_query`), so `layers[level]` here is exactly + /// the level that kernel walks. + pub fn from_heap_nodes(dims: Vec<(usize, usize)>, h_max: usize, nodes: &[u8]) -> Self { + let leaves_len = 1usize << (h_max - 1); + assert_eq!( + nodes.len(), + (2 * leaves_len - 1) * 32, + "heap node array must be (2*L-1) 32-byte digests for L = 2^(h_max-1)" + ); + let node = |i: usize| -> Commitment { + let mut c = [0u8; 32]; + c.copy_from_slice(&nodes[i * 32..i * 32 + 32]); + c + }; + // `layers[k]` is heap level `h_max-1-k`: `2^(h_max-1-k)` nodes starting at + // heap index `2^(h_max-1-k) - 1`. `layers[0]` is the leaf tail; the last + // layer is the single root at index 0. + let mut layers: Vec> = Vec::with_capacity(h_max); + for k in 0..h_max { + let level_size = leaves_len >> k; + let start = level_size - 1; + layers.push((0..level_size).map(|j| node(start + j)).collect()); + } + let root = layers.last().expect("at least the base layer exists")[0]; + + MixedMmcs { + root, + layers, + dims, + h_max, + _marker: PhantomData, + } + } + + /// The committed root. + pub fn root(&self) -> Commitment { + self.root + } + + /// Serialize the digest layers back into the standard heap byte array that + /// [`Self::from_heap_nodes`] parses — the inverse round-trip a device build + /// produces directly on the GPU. Test-only; used to corrupt a single node + /// and confirm the device-commit canary fires. + // Its consumer is #951's device-commit canary test, which this branch does + // not bring over. Kept so the port stays a copy of the original. + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn heap_bytes(&self) -> Vec { + let leaves_len = 1usize << (self.h_max - 1); + let mut heap = vec![0u8; (2 * leaves_len - 1) * 32]; + for (k, layer) in self.layers.iter().enumerate() { + let level_size = leaves_len >> k; + let start = level_size - 1; + for (j, digest) in layer.iter().enumerate() { + heap[(start + j) * 32..(start + j) * 32 + 32].copy_from_slice(digest); + } + } + heap + } + + /// `log2` of the tallest committed matrix. The query index this MMCS accepts + /// lives in `[0, 2^(h_max-1))` — see the module's index-convention section. + pub fn h_max(&self) -> usize { + self.h_max + } + + /// Per committed matrix, in input order: `(log_height, width)`. The verifier + /// is expected to rebuild these from the AIR set rather than read them here; + /// this accessor exists so a prover can bind the shape it actually committed. + pub fn dims(&self) -> &[(usize, usize)] { + &self.dims + } + + /// The leaf of matrix `m` that query `iota` opens: `iota >> (h_max - h_m)`. + /// `None` when `m` is not a committed matrix or `iota` is out of this tree's + /// index space. + /// + /// Exposed alongside [`Self::auth_path`] so a prover can assemble a + /// [`MixedOpening`] ONE MATRIX AT A TIME. [`Self::open_batch`] wants a + /// `LeafSource` describing the whole round, which means every matrix's rows + /// readable at once — the same `O(N)` residency a streaming commit path + /// exists to keep out of the commit. Query indices are only known after the + /// FRI, so without these two the win would be given back at opening time. + pub fn row_pair_leaf(&self, iota: usize, m: usize) -> Option { + if iota >= 1usize << (self.h_max - 1) { + return None; + } + let (log_height, _) = *self.dims.get(m)?; + Some(iota >> (self.h_max - log_height)) + } + + /// The shared authentication path for `iota`, reading no matrix rows at all. + /// `None` when `iota` is outside this tree's index space. + pub fn auth_path(&self, iota: usize) -> Option> { + if iota >= 1usize << (self.h_max - 1) { + return None; + } + let mut merkle_path = Vec::with_capacity(self.h_max - 1); + for level in 0..(self.h_max - 1) { + merkle_path.push(self.layers[level][(iota >> level) ^ 1]); + } + Some(Proof { merkle_path }) + } + + /// Open all matrices at query `iota in [0, 2^(h_max-1))`, returning each + /// matrix's row pair plus one shared authentication path. Row data is served + /// by `source`, which MUST describe the same matrices (same order and + /// dimensions) as the one passed to [`Self::commit`]. + pub fn open_batch>(&self, iota: usize, source: &S) -> MixedOpening { + let n0 = 1usize << (self.h_max - 1); + assert!(iota < n0, "iota {iota} out of range (n0 = {n0})"); + debug_assert_eq!( + source.num_matrices(), + self.dims.len(), + "leaf source matrix count must match the committed tree" + ); + + let per_matrix: Vec> = (0..self.dims.len()) + .map(|m| { + let (log_height, width) = self.dims[m]; + debug_assert_eq!(source.log_height(m), log_height); + debug_assert_eq!(source.width(m), width); + let k = iota >> (self.h_max - log_height); + let mut evaluations = Vec::with_capacity(width); + source.append_row(m, 2 * k, &mut evaluations); + let mut evaluations_sym = Vec::with_capacity(width); + source.append_row(m, 2 * k + 1, &mut evaluations_sym); + PolynomialOpenings { + proof: Proof { + merkle_path: Vec::new(), + }, + evaluations, + evaluations_sym, + } + }) + .collect(); + + let mut merkle_path = Vec::with_capacity(self.h_max - 1); + for level in 0..(self.h_max - 1) { + let sibling = (iota >> level) ^ 1; + merkle_path.push(self.layers[level][sibling]); + } + + MixedOpening { + proof: Proof { merkle_path }, + per_matrix, + } + } + + /// Verify a batched opening at `iota` against `root`. `heights[m]` is the + /// `log_height` of matrix `m` and `widths[m]` its column count, both in the + /// SAME order as `opening.per_matrix`, and both supplied by the verifier from + /// the AIR set rather than read out of the proof. + /// + /// `widths` binds each matrix's boundary inside the per-height-group leaf + /// hash (see the module `# Width binding` section): the group leaf hashes the + /// FLAT concatenation of every matrix's `evaluations ‖ evaluations_sym`, so + /// without fixed widths a prover could shift a matrix boundary while keeping + /// the flat bytes — and thus the hash — identical. Pinning `widths` makes the + /// boundaries unambiguous and closes that forgery. + /// + /// `iota` must already be reduced to this tree's index space — see the + /// module's index-convention section. Out-of-range indices are rejected here, + /// but that check is a backstop, not a substitute for the reduction. + /// + /// Returns `false` on every malformed input; it never panics, so a verifier + /// can call it on adversarial data. + pub fn verify_batch( + root: &Commitment, + iota: usize, + opening: &MixedOpening, + heights: &[usize], + widths: &[usize], + ) -> bool { + if opening.per_matrix.len() != heights.len() + || heights.len() != widths.len() + || heights.is_empty() + { + return false; + } + // Bind per-matrix boundaries: every opened matrix must present exactly + // `widths[m]` columns in BOTH rows of its pair. A boundary shift keeps the + // flat per-group concatenation identical but changes these lengths. + for (o, w) in opening.per_matrix.iter().zip(widths.iter()) { + if o.evaluations.len() != *w || o.evaluations_sym.len() != *w { + return false; + } + } + let Some(&h_max) = heights.iter().max() else { + return false; + }; + // Honest heights are >= 1 (row-pair leaves need >= 2 rows) and far below + // the shift width; guard both ends rather than trust the proof's shape. + if h_max == 0 || h_max >= usize::BITS as usize { + return false; + } + // Only the low `h_max - 1` bits of `iota` are consumed (one per level), so + // an index from a taller domain would authenticate the short matrices at a + // position nothing else checks. Reject it instead. + if iota >= 1usize << (h_max - 1) { + return false; + } + if opening.proof.merkle_path.len() != h_max - 1 { + return false; + } + + // Base node: batch all tallest matrices' opened row pairs (input order). + let base_group: Vec<&PolynomialOpenings> = opening + .per_matrix + .iter() + .zip(heights.iter()) + .filter(|(_, h)| **h == h_max) + .map(|(o, _)| o) + .collect(); + let mut acc = hash_group_openings::(&base_group); + + for level in 0..(h_max - 1) { + let sibling = &opening.proof.merkle_path[level]; + let bit = (iota >> level) & 1; + let mut parent = if bit == 0 { + compress::(&acc, sibling) + } else { + compress::(sibling, &acc) + }; + + // Inject matrices whose leaf count matches this (halved) layer, in + // INPUT order — mirroring `commit`'s climb exactly. + let inject_h = h_max - 1 - level; + let inject_group: Vec<&PolynomialOpenings> = opening + .per_matrix + .iter() + .zip(heights.iter()) + .filter(|(_, h)| **h == inject_h) + .map(|(o, _)| o) + .collect(); + if !inject_group.is_empty() { + let inj = hash_group_openings::(&inject_group); + parent = compress::(&parent, &inj); + } + acc = parent; + } + + &acc == root + } +} + +/// One leaf hasher of the batched (keccak) leaf backend. +type LeafHasherOf = as IsStreamingLeafBackend>::LeafHasher; + +/// Builds a [`MixedMmcs`] by absorbing matrices ONE AT A TIME, so a prover never +/// has to hold a height group's LDE buffers simultaneously. +/// +/// # Why this exists +/// +/// [`MixedMmcs::commit`] reads matrix `m` only while building level +/// `h_max - h_m`, so a caller may drop a height group before the next is needed. +/// That is not enough for the group that matters. Within one height the leaf is a +/// single hash over the concatenation of every matrix's row pair, so `commit` +/// needs them all readable at once — and the tallest group is most of an epoch's +/// tables. A caller serving those rows from full in-RAM LDE buffers is back to +/// `O(N)` at the base layer, which is the whole memory win given back. +/// +/// This builder inverts the loop: it keeps one incremental leaf hasher per leaf +/// ([`IsLeafHasher`]) and absorbs matrices into them as they arrive, so the +/// caller produces one matrix's LDE, absorbs it, and drops it. Retained state is +/// `O(leaves × hasher_state)` — bounded by the epoch's tallest height and +/// independent of how many matrices there are or how wide they get. +/// +/// # Contract +/// +/// The shape is declared up front and matrices arrive in that order: the leaf +/// concatenation binds input order (see the module's determinism section), and a +/// builder that let matrices arrive out of order would commit a different tree +/// than [`MixedMmcs::commit`] over the same input. The resulting tree IS that +/// tree — both finish through one climb — which is what makes the two +/// interchangeable rather than merely tested to agree. +pub struct StreamingMmcsBuilder +where + FieldElement: AsBytes + Sync + Send, +{ + dims: Vec<(usize, usize)>, + h_max: usize, + /// Indexed by height: the in-progress leaf hashers of that height group, + /// present from construction until the group's last matrix is absorbed. + pending: Vec>>>, + /// Indexed by height: the group's finalized leaf digests. + group_digests: Vec>>, + /// Matrices of each height still to arrive. A height reaching zero is what + /// releases that group's hashers. + remaining: Vec, + next: usize, +} + +impl StreamingMmcsBuilder +where + E: IsField + 'static, + FieldElement: AsBytes + Sync + Send, +{ + /// Declare the epoch's shape: `(log_height, width)` per matrix, in the order + /// the matrices will be absorbed and in the order the verifier will present + /// their openings. + pub fn new(dims: &[(usize, usize)]) -> Self { + assert!( + !dims.is_empty(), + "StreamingMmcsBuilder requires at least one matrix" + ); + assert!( + dims.iter().all(|(log_height, _)| *log_height >= 1), + "log_height must be >= 1 (row-pair leaves need at least 2 rows)" + ); + let h_max = dims + .iter() + .map(|(log_height, _)| *log_height) + .max() + .expect("dims is non-empty"); + + let mut remaining = vec![0usize; h_max + 1]; + for (log_height, _) in dims { + remaining[*log_height] += 1; + } + + let pending = (0..=h_max) + .map(|h| { + (remaining[h] > 0).then(|| { + (0..1usize << (h - 1)) + .map(|_| { + as IsStreamingLeafBackend>::leaf_hasher( + ) + }) + .collect() + }) + }) + .collect(); + + Self { + dims: dims.to_vec(), + h_max, + pending, + group_digests: vec![None; h_max + 1], + remaining, + next: 0, + } + } + + /// Absorb the next declared matrix, reading its rows from `source` at index + /// `m`. The caller may drop that matrix's buffers as soon as this returns. + /// + /// Panics when the arriving matrix's shape disagrees with what was declared — + /// a prover-side programming error, not proof data. + pub fn absorb + Sync>(&mut self, source: &S, m: usize) { + let index = self.next; + assert!( + index < self.dims.len(), + "absorbed more matrices ({}) than were declared ({})", + index + 1, + self.dims.len() + ); + let (log_height, width) = self.dims[index]; + assert_eq!( + (source.log_height(m), source.width(m)), + (log_height, width), + "matrix {index} arrived with a shape the builder was not declared for" + ); + + let hashers = self.pending[log_height] + .as_mut() + .expect("a height with matrices outstanding still holds its hashers"); + // One update per leaf, parallel across leaves — the same shape, and the + // same cost, as `commit`'s one-shot group hash. + crate::par::par_for_each_mut_indexed(hashers, |leaf, hasher| { + let mut row_pair = Vec::with_capacity(2 * width); + source.append_row(m, 2 * leaf, &mut row_pair); + source.append_row(m, 2 * leaf + 1, &mut row_pair); + hasher.update(&row_pair); + }); + + self.next += 1; + self.remaining[log_height] -= 1; + if self.remaining[log_height] == 0 { + let hashers = self.pending[log_height] + .take() + .expect("the group was present a moment ago"); + self.group_digests[log_height] = + Some(hashers.into_iter().map(IsLeafHasher::finalize).collect()); + } + } + + /// Finish the tree. Panics if a declared matrix never arrived — the digests + /// would silently commit to a leaf that absorbed less than it claims. + pub fn finish(self) -> MixedMmcs { + assert_eq!( + self.next, + self.dims.len(), + "{} of {} declared matrices were absorbed", + self.next, + self.dims.len() + ); + MixedMmcs::from_group_digests(self.dims, self.h_max, self.group_digests) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commitment::commit_bit_reversed; + use math::field::element::FieldElement; + use math::field::goldilocks::GoldilocksField; + use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + type FE = FieldElement; + type Mmcs = MixedMmcs; + + /// Reference [`LeafSource`] owning bit-reversed row-major matrices. Every + /// test commits/opens through this, so the byte-parity assertion against + /// `commit_bit_reversed` pins the tree contract; `borrowed_sources_match_ + /// owned_reference` cross-checks it against the borrowed (natural-order) + /// sources a prover would use. + struct OwnedMatrices { + /// Each entry: `(bit-reversed row-major data, log_height, width)`. + mats: Vec<(Vec>, usize, usize)>, + } + + impl LeafSource for OwnedMatrices { + fn num_matrices(&self) -> usize { + self.mats.len() + } + fn log_height(&self, m: usize) -> usize { + self.mats[m].1 + } + fn width(&self, m: usize) -> usize { + self.mats[m].2 + } + fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { + let (data, _log_height, width) = &self.mats[m]; + out.extend_from_slice(&data[bitrev_row * width..(bitrev_row + 1) * width]); + } + } + + fn owned(mats: Vec<(Vec, usize, usize)>) -> OwnedMatrices { + OwnedMatrices { mats } + } + + /// Build a row-major, bit-reversed flat vec from column-major natural-order + /// `columns`, matching the layout the existing trace commit consumes: row `j` + /// of the output = `[col_0[br(j)], ..., col_{w-1}[br(j)]]` with + /// `br = reverse_index(., num_rows)`. + fn row_major_bit_reversed(columns: &[Vec], num_rows: usize) -> Vec { + let width = columns.len(); + let mut out = vec![FE::from(0u64); num_rows * width]; + for (r, chunk) in out.chunks_exact_mut(width).enumerate() { + let br = reverse_index(r, num_rows as u64); + for (c, col) in columns.iter().enumerate() { + chunk[c] = col[br]; + } + } + out + } + + /// Build a row-major flat vec in NATURAL order (no bit reversal): row `r` = + /// `[col_0[r], ..., col_{w-1}[r]]`. This is the layout the prover's + /// `BorrowedMatrix::RowMajorNatural` reads (the retained main/aux LDE buffer). + fn row_major_natural(columns: &[Vec], num_rows: usize) -> Vec { + let width = columns.len(); + let mut out = vec![FE::from(0u64); num_rows * width]; + for (r, chunk) in out.chunks_exact_mut(width).enumerate() { + for (c, col) in columns.iter().enumerate() { + chunk[c] = col[r]; + } + } + out + } + + fn make_columns(width: usize, num_rows: usize, seed: u64) -> Vec> { + (0..width) + .map(|c| { + (0..num_rows) + .map(|r| { + FE::from(seed.wrapping_mul(31) + (c as u64) * 1009 + (r as u64) * 7 + 1) + }) + .collect() + }) + .collect() + } + + #[test] + fn single_matrix_commit_open_verify_and_tamper() { + let log_height = 2usize; + let num_rows = 1usize << log_height; + let width = 3usize; + let columns = make_columns(width, num_rows, 5); + let data = row_major_bit_reversed(&columns, num_rows); + + let src = owned(vec![(data.clone(), log_height, width)]); + let mmcs = Mmcs::commit(&src); + let heights = [log_height]; + let widths = [width]; + let n0 = 1usize << (log_height - 1); + + for iota in 0..n0 { + let opening = mmcs.open_batch(iota, &src); + assert_eq!(opening.per_matrix.len(), 1); + let k = iota; + let row_2k = data[(2 * k) * width..(2 * k + 1) * width].to_vec(); + let row_2k1 = data[(2 * k + 1) * width..(2 * k + 2) * width].to_vec(); + assert_eq!(opening.per_matrix[0].evaluations, row_2k); + assert_eq!(opening.per_matrix[0].evaluations_sym, row_2k1); + assert!(Mmcs::verify_batch( + &mmcs.root(), + iota, + &opening, + &heights, + &widths + )); + } + + let mut opening = mmcs.open_batch(0, &src); + opening.per_matrix[0].evaluations[0] = + &opening.per_matrix[0].evaluations[0] + &FE::from(1u64); + assert!(!Mmcs::verify_batch( + &mmcs.root(), + 0, + &opening, + &heights, + &widths + )); + } + + /// ★ The backward-compatibility statement: a single-matrix MMCS IS the + /// existing per-table row-pair tree. It holds by construction — both go + /// through `BatchedMerkleTreeBackend`'s `hash_data` / `hash_new_parent` — + /// and this pins that no second leaf encoding crept in. + /// + /// Both sides use the SAME concrete keccak backend, which is what makes the + /// comparison meaningful: `commit_bit_reversed` commits through + /// `BatchedMerkleTreeBackend`, so a hash difference here would be a layout + /// difference, not a hash-configuration mismatch. + #[test] + fn single_matrix_root_matches_existing_row_pair_tree() { + let log_height = 3usize; + let num_rows = 1usize << log_height; + let width = 4usize; + let columns = make_columns(width, num_rows, 9); + + let (_, existing_root) = + commit_bit_reversed(&columns, 2).expect("non-empty columns build a tree"); + + let data = row_major_bit_reversed(&columns, num_rows); + let mmcs = Mmcs::commit(&owned(vec![(data, log_height, width)])); + + assert_eq!(mmcs.root(), existing_root); + } + + #[test] + fn mixed_height_open_positions_verify_and_tamper() { + // Three matrices, log_heights {5, 5, 3}, widths {2, 1, 4}. + let (ha, hb, hc) = (5usize, 5usize, 3usize); + let (wa, wb, wc) = (2usize, 1usize, 4usize); + let a = row_major_bit_reversed(&make_columns(wa, 1 << ha, 1), 1 << ha); + let b = row_major_bit_reversed(&make_columns(wb, 1 << hb, 2), 1 << hb); + let c = row_major_bit_reversed(&make_columns(wc, 1 << hc, 3), 1 << hc); + + let src = owned(vec![ + (a.clone(), ha, wa), + (b.clone(), hb, wb), + (c.clone(), hc, wc), + ]); + let mmcs = Mmcs::commit(&src); + let heights = [ha, hb, hc]; + let widths = [wa, wb, wc]; + let h_max = 5usize; + let n0 = 1usize << (h_max - 1); // 16 + + let row = |data: &[FE], w: usize, r: usize| data[r * w..(r + 1) * w].to_vec(); + + for iota in [0usize, 1, 2, 3, 7, 8, 13, n0 - 1] { + let opening = mmcs.open_batch(iota, &src); + assert_eq!(opening.per_matrix.len(), 3); + + // Tall matrices open at k = iota >> 0 = iota. + assert_eq!(opening.per_matrix[0].evaluations, row(&a, wa, 2 * iota)); + assert_eq!( + opening.per_matrix[0].evaluations_sym, + row(&a, wa, 2 * iota + 1) + ); + assert_eq!(opening.per_matrix[1].evaluations, row(&b, wb, 2 * iota)); + + // Height-3 matrix opens at k = iota >> (5 - 3) = iota >> 2. + let kc = iota >> (h_max - hc); + assert_eq!(opening.per_matrix[2].evaluations, row(&c, wc, 2 * kc)); + assert_eq!( + opening.per_matrix[2].evaluations_sym, + row(&c, wc, 2 * kc + 1) + ); + + assert!( + Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), + "honest opening at iota={iota} must verify" + ); + } + + // Tamper the height-3 matrix's opened row -> rejection (proves the short + // matrix is bound by the shared path via injection). + let iota = 6usize; + let mut opening = mmcs.open_batch(iota, &src); + opening.per_matrix[2].evaluations[0] = + &opening.per_matrix[2].evaluations[0] + &FE::from(1u64); + assert!( + !Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), + "tampered height-3 row must be rejected" + ); + + // Tamper a tall-matrix row too -> rejection. + let mut opening2 = mmcs.open_batch(iota, &src); + opening2.per_matrix[0].evaluations[0] = + &opening2.per_matrix[0].evaluations[0] + &FE::from(1u64); + assert!( + !Mmcs::verify_batch(&mmcs.root(), iota, &opening2, &heights, &widths), + "tampered tall-matrix row must be rejected" + ); + } + + /// Vector test: hand-compute the root for `{log_height 2, log_height 1}` + /// matrices per the documented layout and assert equality. Pins the + /// leaf/injection contract, plus determinism. + #[test] + fn vector_root_layout_contract_and_determinism() { + // A: log_height 2 (4 rows), width 2 ; B: log_height 1 (2 rows), width 3. + let a_data = row_major_bit_reversed(&make_columns(2, 4, 3), 4); + let b_data = row_major_bit_reversed(&make_columns(3, 2, 8), 2); + + let src = owned(vec![(a_data.clone(), 2, 2), (b_data.clone(), 1, 3)]); + let mmcs = Mmcs::commit(&src); + + // Hand recomputation via the backend primitives, in the documented order. + let arow = |r: usize| a_data[r * 2..(r + 1) * 2].to_vec(); + let brow = |r: usize| b_data[r * 3..(r + 1) * 3].to_vec(); + let h = |v: Vec| { + as IsMerkleTreeBackend>::hash_data(&v) + }; + + // Base layer (matrix A only): leaf k = H(A.row(2k) || A.row(2k+1)). + let mut leaf0 = arow(0); + leaf0.extend(arow(1)); + let mut leaf1 = arow(2); + leaf1.extend(arow(3)); + let l00 = h(leaf0); + let l01 = h(leaf1); + + // Climb to layer 1 (root): compress the base pair, then inject B (h=1). + let parent = compress::(&l00, &l01); + let mut binj = brow(0); + binj.extend(brow(1)); + let inj = h(binj); + let expected_root = compress::(&parent, &inj); + + assert_eq!( + mmcs.root(), + expected_root, + "root must match the hand-computed mixed-height layout" + ); + + // Determinism: a second commit over the same inputs yields the same root. + let mmcs2 = Mmcs::commit(&owned(vec![(a_data, 2, 2), (b_data, 1, 3)])); + assert_eq!(mmcs.root(), mmcs2.root(), "commit must be deterministic"); + + for iota in 0..2usize { + let opening = mmcs.open_batch(iota, &src); + // heights {2, 1}, widths {2, 3}. + assert!(Mmcs::verify_batch( + &mmcs.root(), + iota, + &opening, + &[2, 1], + &[2, 3] + )); + } + } + + /// Two SAME-HEIGHT matrices share one base-group leaf, whose hash is over the + /// FLAT concatenation `A.eval ‖ A.eval_sym ‖ B.eval ‖ B.eval_sym`. A malicious + /// prover can shift the A|A_sym boundary (move one element from A's + /// `evaluations_sym` into A's `evaluations`) leaving that flat concatenation — + /// and hence the leaf hash — byte-identical, so a width-blind `verify_batch` + /// would accept it. The per-matrix width binding rejects the shift. + #[test] + fn boundary_shift_forgery_rejected() { + let h = 2usize; + let num_rows = 1usize << h; + let (wa, wb) = (2usize, 1usize); // wA >= 2 so we can steal one column. + let a = row_major_bit_reversed(&make_columns(wa, num_rows, 11), num_rows); + let b = row_major_bit_reversed(&make_columns(wb, num_rows, 22), num_rows); + + let src = owned(vec![(a, h, wa), (b, h, wb)]); + let mmcs = Mmcs::commit(&src); + let heights = [h, h]; + let widths = [wa, wb]; + + let iota = 0usize; + let opening = mmcs.open_batch(iota, &src); + assert!( + Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), + "honest opening must verify" + ); + + // Forge: lengthen A.evaluations by one element taken from A.evaluations_sym. + let mut forged = mmcs.open_batch(iota, &src); + let moved = forged.per_matrix[0].evaluations_sym.remove(0); + forged.per_matrix[0].evaluations.push(moved); + + // The FLAT per-group concatenation is byte-identical to the honest one, so + // the group leaf hash is UNCHANGED — the rejection must come from the width + // check, not from a differing hash. + let flat = |o: &MixedOpening| -> Vec { + let mut v = Vec::new(); + for m in &o.per_matrix { + v.extend_from_slice(&m.evaluations); + v.extend_from_slice(&m.evaluations_sym); + } + v + }; + assert_eq!( + flat(&opening), + flat(&forged), + "the flat concatenation must be byte-identical (boundary-only shift)" + ); + + assert!( + !Mmcs::verify_batch(&mmcs.root(), iota, &forged, &heights, &widths), + "boundary-shift forgery must be rejected by the width binding" + ); + } + + /// Extension-field (Fp3) coverage: the aux and composition matrices an epoch + /// batches are cubic-extension. Byte-parity cross-check of a single Fp3 matrix + /// against the existing per-table row-pair tree, plus an open/verify/tamper + /// roundtrip over the extension path. + #[test] + fn single_matrix_fp3_root_matches_existing_row_pair_tree() { + use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField as Fp3; + type F3 = FieldElement; + + let log_height = 3usize; + let num_rows = 1usize << log_height; + let width = 3usize; + + // Populate ALL three components so the 24-byte extension serialization is + // exercised (not just the embedded-base subset). + let columns: Vec> = (0..width) + .map(|c| { + (0..num_rows) + .map(|r| { + F3::new([ + FE::from((c as u64) * 7 + r as u64 + 1), + FE::from((r as u64) * 13 + 2), + FE::from((c as u64) * 5 + (r as u64) * 3 + 4), + ]) + }) + .collect() + }) + .collect(); + + let (_, existing_root) = + commit_bit_reversed(&columns, 2).expect("non-empty columns build a tree"); + + // Row-major bit-reversed equivalent of the same column-major data. + let mut data = vec![F3::zero(); num_rows * width]; + for (r, chunk) in data.chunks_exact_mut(width).enumerate() { + let br = reverse_index(r, num_rows as u64); + for (c, col) in columns.iter().enumerate() { + chunk[c] = col[br]; + } + } + + let src = OwnedMatrices { + mats: vec![(data, log_height, width)], + }; + let mmcs = MixedMmcs::::commit(&src); + assert_eq!( + mmcs.root(), + existing_root, + "Fp3 single-matrix root must match the existing row-pair tree" + ); + + let heights = [log_height]; + let widths = [width]; + for iota in 0..(1usize << (log_height - 1)) { + let opening = mmcs.open_batch(iota, &src); + assert!(MixedMmcs::::verify_batch( + &mmcs.root(), + iota, + &opening, + &heights, + &widths + )); + } + + let mut opening = mmcs.open_batch(0, &src); + opening.per_matrix[0].evaluations[0] = &opening.per_matrix[0].evaluations[0] + &F3::one(); + assert!(!MixedMmcs::::verify_batch( + &mmcs.root(), + 0, + &opening, + &heights, + &widths + )); + } + + /// Equivalence (the soundness contract a batched prover relies on): the + /// digest-only MMCS built from borrowed, NATURAL-order leaf sources yields the + /// SAME root and the SAME opened rows as the reference owning source over the + /// bit-reversed data — for the row-major (main / aux) layout, the column-major + /// (composition) layout, AND a main-split column sub-range (`col_start > 0`). + /// Only the leaf-byte source changes; nothing the verifier sees does. + #[test] + fn borrowed_sources_match_owned_reference() { + // Mixed heights {5, 5, 3}; the height-3 matrix exercises injection. + let specs = [(5usize, 3usize, 100u64), (5, 1, 200), (3, 4, 300)]; + + // Column-major natural-order columns per matrix. + let cols: Vec>> = specs + .iter() + .map(|&(lh, w, seed)| make_columns(w, 1 << lh, seed)) + .collect(); + + // Reference: owned, bit-reversed row-major. + let owned_src = owned( + specs + .iter() + .zip(cols.iter()) + .map(|(&(lh, w, _), c)| (row_major_bit_reversed(c, 1 << lh), lh, w)) + .collect(), + ); + + // Borrowed row-major NATURAL (the retained main / aux LDE buffer). + let rm_natural: Vec> = specs + .iter() + .zip(cols.iter()) + .map(|(&(lh, _, _), c)| row_major_natural(c, 1 << lh)) + .collect(); + let rm_src: Vec> = specs + .iter() + .zip(rm_natural.iter()) + .map(|(&(lh, w, _), data)| BorrowedMatrix::RowMajorNatural { + data: data.as_slice(), + stride: w, + col_start: 0, + width: w, + log_height: lh, + }) + .collect(); + + // Borrowed column-major NATURAL (the retained composition-poly LDE). + let cm_src: Vec> = specs + .iter() + .zip(cols.iter()) + .map(|(&(lh, _, _), c)| BorrowedMatrix::ColMajorNatural { + cols: c.as_slice(), + log_height: lh, + }) + .collect(); + + let owned_mmcs = Mmcs::commit(&owned_src); + let rm_mmcs = Mmcs::commit(&rm_src); + let cm_mmcs = Mmcs::commit(&cm_src); + assert_eq!( + owned_mmcs.root(), + rm_mmcs.root(), + "row-major natural root must match the owned reference" + ); + assert_eq!( + owned_mmcs.root(), + cm_mmcs.root(), + "column-major natural root must match the owned reference" + ); + + let n0 = 1usize << (5 - 1); + for iota in 0..n0 { + let o = owned_mmcs.open_batch(iota, &owned_src); + let rm = rm_mmcs.open_batch(iota, &rm_src); + let cm = cm_mmcs.open_batch(iota, &cm_src); + assert_eq!(o.proof.merkle_path, rm.proof.merkle_path); + assert_eq!(o.proof.merkle_path, cm.proof.merkle_path); + for i in 0..specs.len() { + assert_eq!(o.per_matrix[i].evaluations, rm.per_matrix[i].evaluations); + assert_eq!( + o.per_matrix[i].evaluations_sym, + rm.per_matrix[i].evaluations_sym + ); + assert_eq!(o.per_matrix[i].evaluations, cm.per_matrix[i].evaluations); + assert_eq!( + o.per_matrix[i].evaluations_sym, + cm.per_matrix[i].evaluations_sym + ); + } + } + + // Main-split sub-range: a RowMajorNatural over a wider buffer with a + // leading prefix (`col_start = prefix`) must match an owned matrix built + // over ONLY the committed trailing columns. + let (lh, prefix, w) = (4usize, 2usize, 3usize); + let num_rows = 1usize << lh; + let full = make_columns(prefix + w, num_rows, 42); + let full_natural = row_major_natural(&full, num_rows); + let sub_cols: Vec> = full[prefix..].to_vec(); + let sub_owned = owned(vec![(row_major_bit_reversed(&sub_cols, num_rows), lh, w)]); + let split_src: Vec> = + vec![BorrowedMatrix::RowMajorNatural { + data: full_natural.as_slice(), + stride: prefix + w, + col_start: prefix, + width: w, + log_height: lh, + }]; + let sub_owned_mmcs = Mmcs::commit(&sub_owned); + let split_mmcs = Mmcs::commit(&split_src); + assert_eq!( + sub_owned_mmcs.root(), + split_mmcs.root(), + "main-split (col_start>0) root must match the owned sub-range" + ); + for iota in 0..(1usize << (lh - 1)) { + let a = sub_owned_mmcs.open_batch(iota, &sub_owned); + let b = split_mmcs.open_batch(iota, &split_src); + assert_eq!(a.per_matrix[0].evaluations, b.per_matrix[0].evaluations); + assert_eq!( + a.per_matrix[0].evaluations_sym, + b.per_matrix[0].evaluations_sym + ); + } + } + + /// ★ The index-convention control (the module's "HARD PRECONDITION" section). + /// + /// A round whose tallest matrix is SHORTER than the FRI's tallest is the case + /// where the two index conventions disagree: `verify_batch` consumes the LOW + /// `h_max_round - 1` bits of whatever index it is handed, while a matrix + /// inside the tree is located by the HIGH bits of the FRI index. This asserts + /// three things about that case: + /// + /// 1. honest-path control — the correctly reduced index verifies; + /// 2. a tampered row of a SHORT (injected) matrix is rejected, so the low-bits + /// walk really does authenticate the short matrices at the reduced index; + /// 3. handing the un-reduced FRI index straight in is rejected — the misuse is + /// detectable, not silently accepted at some other leaf. + /// + /// A tamper control on the tallest matrix alone would pass under either + /// convention and catch none of this. + #[test] + fn short_round_low_bit_convention_is_exercised() { + // A hypothetical FRI over a 2^6 domain: iota_fri in [0, 2^5). + let h_max_fri = 6usize; + // This round's matrices are shorter: heights {4, 2}. + let (h_tall, h_short) = (4usize, 2usize); + let (w_tall, w_short) = (3usize, 2usize); + let tall = row_major_bit_reversed(&make_columns(w_tall, 1 << h_tall, 77), 1 << h_tall); + let short = row_major_bit_reversed(&make_columns(w_short, 1 << h_short, 88), 1 << h_short); + + let src = owned(vec![(tall, h_tall, w_tall), (short, h_short, w_short)]); + let mmcs = Mmcs::commit(&src); + let heights = [h_tall, h_short]; + let widths = [w_tall, w_short]; + assert_eq!(mmcs.h_max(), h_tall, "the round's h_max is below the FRI's"); + + // The reduction the caller owes: iota_round = iota_fri >> (h_fri - h_round). + let shift = h_max_fri - h_tall; + // Pick a FRI index whose low bits differ from the reduced index's, so the + // two conventions genuinely disagree here. + let iota_fri = 0b10110usize; + let iota_round = iota_fri >> shift; + assert_ne!( + iota_fri & ((1 << (h_tall - 1)) - 1), + iota_round, + "the test index must distinguish the low-bit and high-bit conventions" + ); + + // (1) Honest-path control at the reduced index. + let opening = mmcs.open_batch(iota_round, &src); + assert!( + Mmcs::verify_batch(&mmcs.root(), iota_round, &opening, &heights, &widths), + "the correctly reduced index must verify" + ); + + // (2) Tamper the SHORT (injected) matrix — the matrix a tall-only control + // would never touch, and the one the disagreeing conventions move. + let mut tampered = mmcs.open_batch(iota_round, &src); + tampered.per_matrix[1].evaluations[0] = + &tampered.per_matrix[1].evaluations[0] + &FE::from(1u64); + assert!( + !Mmcs::verify_batch(&mmcs.root(), iota_round, &tampered, &heights, &widths), + "a tampered SHORT-matrix row must be rejected at the reduced index" + ); + + // (3) The misuse: hand the un-reduced FRI index in. It is out of this + // tree's range, so the range guard rejects it rather than walking to some + // unrelated leaf. + assert!( + iota_fri >= 1usize << (h_tall - 1), + "the un-reduced index is outside this round's leaf range" + ); + assert!( + !Mmcs::verify_batch(&mmcs.root(), iota_fri, &opening, &heights, &widths), + "an un-reduced FRI index must be rejected, not accepted at another leaf" + ); + + // And an in-range index that is simply the wrong leaf is rejected too, so + // the guard is not the only thing standing between the two conventions. + let wrong_but_in_range = iota_fri & ((1 << (h_tall - 1)) - 1); + assert!( + !Mmcs::verify_batch( + &mmcs.root(), + wrong_but_in_range, + &opening, + &heights, + &widths + ), + "an opening replayed at the wrong in-range leaf must be rejected" + ); + } + + /// The malformed-input surface of `verify_batch`: every shape error returns + /// `false` rather than panicking, since a verifier calls this on proof data. + #[test] + fn verify_batch_rejects_malformed_shapes_without_panicking() { + let h = 3usize; + let w = 2usize; + let data = row_major_bit_reversed(&make_columns(w, 1 << h, 4), 1 << h); + let src = owned(vec![(data, h, w)]); + let mmcs = Mmcs::commit(&src); + let root = mmcs.root(); + let opening = mmcs.open_batch(1, &src); + + assert!(Mmcs::verify_batch(&root, 1, &opening, &[h], &[w])); + // Mismatched metadata lengths. + assert!(!Mmcs::verify_batch(&root, 1, &opening, &[h, h], &[w])); + assert!(!Mmcs::verify_batch(&root, 1, &opening, &[h], &[w, w])); + // Empty metadata. + assert!(!Mmcs::verify_batch(&root, 1, &opening, &[], &[])); + // A height that would overflow the level shift. + assert!(!Mmcs::verify_batch( + &root, + 1, + &opening, + &[usize::BITS as usize], + &[w] + )); + // An index past this tree's leaf count. + assert!(!Mmcs::verify_batch( + &root, + 1usize << (h - 1), + &opening, + &[h], + &[w] + )); + // A path of the wrong length. + let mut short_path = opening.clone(); + short_path.proof.merkle_path.pop(); + assert!(!Mmcs::verify_batch(&root, 1, &short_path, &[h], &[w])); + } + + /// Wraps a source and records, per matrix, the first and last global access + /// sequence number, plus a residency model the caller drives. `Mutex` / + /// atomics (not `Cell`) because both `commit` and the streaming builder read + /// the source from rayon workers. + struct Tracing<'a, E: IsField> { + inner: &'a OwnedMatrices, + clock: AtomicUsize, + window: Mutex>, + /// The residency model: which matrices the caller says it is holding. + resident: Vec, + live: AtomicUsize, + peak: AtomicUsize, + /// Rows served for a matrix the caller had already dropped. Any nonzero + /// count means the access pattern does not fit the residency policy. + reads_while_dropped: AtomicUsize, + } + + impl<'a, E: IsField> Tracing<'a, E> { + fn new(inner: &'a OwnedMatrices) -> Self { + let n = inner.num_matrices(); + Self { + inner, + clock: AtomicUsize::new(0), + window: Mutex::new(vec![(usize::MAX, 0); n]), + resident: (0..n).map(|_| AtomicBool::new(false)).collect(), + live: AtomicUsize::new(0), + peak: AtomicUsize::new(0), + reads_while_dropped: AtomicUsize::new(0), + } + } + + /// Declare every matrix held for the whole build — the only policy + /// `MixedMmcs::commit` can be served under. + fn materialize_all(&self) { + for m in 0..self.inner.num_matrices() { + self.materialize(m); + } + } + + fn materialize(&self, m: usize) { + if !self.resident[m].swap(true, Ordering::SeqCst) { + let live = self.live.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(live, Ordering::SeqCst); + } + } + + // Retained for a future streaming-commit phase's residency tests; the + // one kept `commit` test drives residency via `materialize_all` only. + #[allow(dead_code)] + fn drop_matrix(&self, m: usize) { + if self.resident[m].swap(false, Ordering::SeqCst) { + self.live.fetch_sub(1, Ordering::SeqCst); + } + } + + fn windows(self) -> (Vec<(usize, usize)>, usize, usize) { + let peak = self.peak.load(Ordering::SeqCst); + let dropped_reads = self.reads_while_dropped.load(Ordering::SeqCst); + let windows = self.window.into_inner().expect("uncontended after commit"); + (windows, peak, dropped_reads) + } + } + + impl LeafSource for Tracing<'_, E> { + fn num_matrices(&self) -> usize { + self.inner.num_matrices() + } + fn log_height(&self, m: usize) -> usize { + self.inner.log_height(m) + } + fn width(&self, m: usize) -> usize { + self.inner.width(m) + } + fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { + if !self.resident[m].load(Ordering::SeqCst) { + self.reads_while_dropped.fetch_add(1, Ordering::SeqCst); + } + let t = self.clock.fetch_add(1, Ordering::SeqCst); + let mut w = self.window.lock().expect("no test thread panics here"); + w[m].0 = w[m].0.min(t); + w[m].1 = w[m].1.max(t); + drop(w); + self.inner.append_row(m, bitrev_row, out); + } + } + + fn residency_fixture() -> ([(usize, usize, u64); 4], OwnedMatrices) { + let specs = [(5usize, 2usize, 1u64), (5, 3, 2), (3, 1, 3), (2, 4, 4)]; + let inner = owned( + specs + .iter() + .map(|&(lh, w, seed)| { + ( + row_major_bit_reversed(&make_columns(w, 1 << lh, seed), 1 << lh), + lh, + w, + ) + }) + .collect(), + ); + (specs, inner) + } + + /// The streaming builder is not a second implementation of the tree: it + /// finishes through the same climb `commit` does. This pins the consequence — + /// same root, same layers, same openings — so a future change that forked the + /// two would fail here rather than at a verifier three modules away. + #[test] + fn streaming_builder_commits_the_same_tree_as_commit() { + let (specs, inner) = residency_fixture(); + let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); + + let mut builder = StreamingMmcsBuilder::::new(&dims); + for m in 0..dims.len() { + builder.absorb(&inner, m); + } + let streamed = builder.finish(); + let reference = Mmcs::commit(&inner); + + assert_eq!( + streamed.root(), + reference.root(), + "the streamed root must equal the one-shot root" + ); + assert_eq!(streamed.h_max(), reference.h_max()); + assert_eq!(streamed.dims(), reference.dims()); + + let heights: Vec = specs.iter().map(|&(lh, _, _)| lh).collect(); + let widths: Vec = specs.iter().map(|&(_, w, _)| w).collect(); + for iota in 0..1usize << (streamed.h_max() - 1) { + let opening = streamed.open_batch(iota, &inner); + assert!( + Mmcs::verify_batch(&streamed.root(), iota, &opening, &heights, &widths), + "an opening of the streamed tree must verify at iota {iota}" + ); + assert_eq!( + opening.proof.merkle_path, + reference.open_batch(iota, &inner).proof.merkle_path, + "the authentication path at iota {iota} must be the same path" + ); + } + } + + /// ★ The acceptance test for the batched commit's memory claim. + /// + /// `commit`'s contract is per height GROUP: it reads a group inside one + /// contiguous phase, so a caller may drop the group before the next. That is + /// not enough. Within the tallest group the leaf is one hash over every + /// matrix's concatenated row pair, so `commit` reads all of them at every + /// leaf — their access windows OVERLAP, and a caller has to hold the whole + /// group. On a real epoch the tallest group is most of the tables, so that is + /// `O(N)` resident at the base layer: the memory batching exists to remove, + /// given back. + /// + /// The streaming builder's windows are pairwise disjoint across ALL matrices, + /// same-height ones included, so the residency policy "materialize, absorb, + /// drop" serves it with exactly ONE matrix live. Both halves are traced here; + /// the second is the property the batched R1 / aux / parts commits must be + /// built on, and the first is what makes it a real difference rather than a + /// restatement. + #[test] + fn streaming_builder_serves_the_base_group_without_holding_it() { + let (specs, inner) = residency_fixture(); + let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); + let base_group: Vec = (0..specs.len()).filter(|&m| specs[m].0 == 5).collect(); + assert!( + base_group.len() > 1, + "the fixture must batch more than one matrix at the tallest height" + ); + + // --- What `commit` requires: the whole group resident at once. --- + let tracing = Tracing::new(&inner); + tracing.materialize_all(); + let commit_root = Mmcs::commit(&tracing).root(); + let (commit_windows, commit_peak, commit_dropped_reads) = tracing.windows(); + assert_eq!(commit_dropped_reads, 0, "the control held everything"); + assert_eq!( + commit_peak, + specs.len(), + "serving `commit` needs every matrix resident" + ); + for (i, &m) in base_group.iter().enumerate() { + for &n in &base_group[i + 1..] { + let (fm, lm) = commit_windows[m]; + let (fn_, ln) = commit_windows[n]; + assert!( + fm <= ln && fn_ <= lm, + "matrices {m} and {n} share the base height, so `commit` must \ + read them in OVERLAPPING windows [{fm},{lm}] / [{fn_},{ln}] — \ + if this ever stops holding, the escape below is no longer the \ + thing that buys the memory" + ); + } + } + + // --- What the streaming builder requires: one matrix at a time. --- + let tracing = Tracing::new(&inner); + let mut builder = StreamingMmcsBuilder::::new(&dims); + for m in 0..dims.len() { + tracing.materialize(m); + builder.absorb(&tracing, m); + tracing.drop_matrix(m); + } + let streamed_root = builder.finish().root(); + let (streamed_windows, streamed_peak, streamed_dropped_reads) = tracing.windows(); + + assert_eq!( + streamed_root, commit_root, + "the escape must not change what is committed" + ); + assert_eq!( + streamed_dropped_reads, 0, + "no row may be read after the caller dropped its matrix" + ); + assert_eq!( + streamed_peak, + 1, + "the base height group must be served with ONE matrix resident, not \ + {} — this is the batched commit's whole memory claim", + specs.len() + ); + for (m, &(first, last)) in streamed_windows.iter().enumerate() { + assert!(first <= last, "matrix {m} was never read"); + for (n, &(fn_, ln)) in streamed_windows.iter().enumerate().skip(m + 1) { + assert!( + last < fn_ || ln < first, + "matrices {m} and {n} were read in overlapping windows \ + [{first},{last}] / [{fn_},{ln}] — the builder must finish one \ + matrix before the next is needed, at EVERY height" + ); + } + } + } + + /// A declared matrix that never arrives would leave its group's leaves having + /// absorbed less than the shape says, committing a tree no verifier rebuilds. + /// The builder refuses rather than producing it. + #[test] + #[should_panic(expected = "of 4 declared matrices were absorbed")] + fn finishing_with_a_matrix_missing_panics() { + let (specs, inner) = residency_fixture(); + let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); + let mut builder = StreamingMmcsBuilder::::new(&dims); + for m in 0..dims.len() - 1 { + builder.absorb(&inner, m); + } + builder.finish(); + } + + /// The incremental leaf hasher's whole contract: where the updates fall must + /// not show. Checked at every split point of a leaf, and for the extension + /// field the aux and composition matrices actually use — a framing bug that + /// only appeared at an element boundary would slip past a base-field check. + #[test] + fn leaf_hasher_splits_anywhere_and_matches_hash_data() { + use crypto::merkle_tree::traits::IsLeafHasher; + use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField as Fp3; + + fn check(leaf: Vec>) + where + FieldElement: AsBytes + Sync + Send, + { + let expected = as IsMerkleTreeBackend>::hash_data(&leaf); + for split in 0..=leaf.len() { + let mut hasher = + as IsStreamingLeafBackend>::leaf_hasher(); + hasher.update(&leaf[..split]); + hasher.update(&leaf[split..]); + assert_eq!( + hasher.finalize(), + expected, + "splitting the leaf at {split} changed the digest" + ); + } + // Three updates, so an implementation that only ever saw two would not + // pass by accident. + let mut hasher = + as IsStreamingLeafBackend>::leaf_hasher(); + for element in &leaf { + hasher.update(core::slice::from_ref(element)); + } + assert_eq!(hasher.finalize(), expected, "element-at-a-time must agree"); + } + + check::((1u64..=9).map(FE::from).collect()); + check::( + (1u64..=9) + .map(|i| { + FieldElement::::new([FE::from(i), FE::from(i * 7 + 1), FE::from(i * 13)]) + }) + .collect(), + ); + } + + /// The memory contract from the module's "what the caller may drop" section, + /// made falsifiable: `commit` reads each height group's rows inside ONE + /// contiguous window of the build, and the windows run in descending height + /// order. A rewrite that materialized every matrix up front, or that revisited + /// a group after moving on, would fail here. + #[test] + fn commit_reads_each_height_group_in_one_contiguous_phase() { + // Heights {5, 5, 3, 2}: two groups sharing the base layer, two injected. + let specs = [(5usize, 2usize, 1u64), (5, 3, 2), (3, 1, 3), (2, 4, 4)]; + let inner = owned( + specs + .iter() + .map(|&(lh, w, seed)| { + ( + row_major_bit_reversed(&make_columns(w, 1 << lh, seed), 1 << lh), + lh, + w, + ) + }) + .collect(), + ); + let tracing = Tracing::new(&inner); + tracing.materialize_all(); + + let traced_root = Mmcs::commit(&tracing).root(); + assert_eq!( + traced_root, + Mmcs::commit(&inner).root(), + "tracing must not change what is committed" + ); + + let (windows, _peak, _dropped) = tracing.windows(); + for (m, (first, last)) in windows.iter().enumerate() { + assert!(*first <= *last, "matrix {m} was never read"); + } + + // Same-height matrices share a window; different heights must not overlap, + // and taller groups must come first. + for (m, &(fm, lm)) in windows.iter().enumerate() { + for (n, &(fn_, ln)) in windows.iter().enumerate() { + if specs[m].0 <= specs[n].0 { + continue; + } + assert!( + lm < fn_ || ln < fm, + "matrices {m} (h={}) and {n} (h={}) were read in overlapping \ + windows [{fm},{lm}] / [{fn_},{ln}] — a height group must be \ + readable and then droppable", + specs[m].0, + specs[n].0 + ); + assert!( + lm < fn_, + "the taller matrix {m} (h={}) must be read before the shorter \ + {n} (h={})", + specs[m].0, + specs[n].0 + ); + } + } + } + + /// The GPU commit returns a standard heap node array; `from_heap_nodes` must + /// rebuild a tree that serves the same root and authentication paths the + /// host build does — that is what lets the device tree be authoritative while + /// only the digest layers come back. Round-trips a real mixed-height tree's + /// layers through the heap layout and checks every query's path. + #[test] + fn from_heap_nodes_rebuilds_the_same_tree() { + let (specs, inner) = residency_fixture(); + let m = Mmcs::commit(&inner); + let h_max = m.h_max(); + let leaves_len = 1usize << (h_max - 1); + + // Assemble the standard heap from the host layers, exactly as the device + // build writes it: layer `k` (heap level `h_max-1-k`) into + // `[2^(h_max-1-k) - 1, ..)`, leaf `j` at `L-1+j`. + let mut heap = vec![0u8; (2 * leaves_len - 1) * 32]; + for (k, layer) in m.layers.iter().enumerate() { + let level_size = leaves_len >> k; + assert_eq!(layer.len(), level_size); + let start = level_size - 1; + for (j, digest) in layer.iter().enumerate() { + heap[(start + j) * 32..(start + j) * 32 + 32].copy_from_slice(digest); + } + } + + let rebuilt = Mmcs::from_heap_nodes(m.dims.clone(), h_max, &heap); + assert_eq!( + rebuilt.root(), + m.root(), + "root must survive the heap round-trip" + ); + assert_eq!(rebuilt.h_max(), h_max); + + let heights: Vec = specs.iter().map(|&(lh, _, _)| lh).collect(); + let widths: Vec = specs.iter().map(|&(_, w, _)| w).collect(); + for iota in 0..leaves_len { + assert_eq!( + rebuilt.auth_path(iota).unwrap().merkle_path, + m.auth_path(iota).unwrap().merkle_path, + "authentication path at iota {iota} must match the host tree" + ); + // And a full opening off the rebuilt tree still verifies. + let opening = rebuilt.open_batch(iota, &inner); + assert!( + Mmcs::verify_batch(&rebuilt.root(), iota, &opening, &heights, &widths), + "an opening from the rebuilt tree must verify at iota {iota}" + ); + } + } +} diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 1f53b51cf..cee278a16 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -1,6 +1,8 @@ +pub mod batched; pub mod fri_commitment; pub mod fri_decommit; pub(crate) mod fri_functions; +pub mod mmcs; pub(crate) mod terminal; use crypto::fiat_shamir::is_transcript::IsStarkTranscript; diff --git a/crypto/stark/src/lib.rs b/crypto/stark/src/lib.rs index 6f8e7c82e..80f6ecef1 100644 --- a/crypto/stark/src/lib.rs +++ b/crypto/stark/src/lib.rs @@ -4,6 +4,7 @@ compile_error!("the `disk-spill` feature requires memmap2, which does not compile on wasm32"); #[cfg(feature = "debug-checks")] +pub mod batched; pub mod bus_debug; pub mod commitment; pub mod constraint_ir; diff --git a/crypto/stark/src/par.rs b/crypto/stark/src/par.rs index cee693e3f..5ad4accbd 100644 --- a/crypto/stark/src/par.rs +++ b/crypto/stark/src/par.rs @@ -92,3 +92,27 @@ pub(crate) fn par_try_for_each_mut( slice.iter_mut().try_for_each(f) } } + +/// Run `f(i, &mut item)` for each element of `slice` with its index. Parallel +/// when `feature = "parallel"`, sequential otherwise. +#[cfg_attr(not(feature = "parallel"), allow(dead_code))] +pub(crate) fn par_for_each_mut_indexed( + slice: &mut [T], + f: impl Fn(usize, &mut T) + Sync + Send, +) { + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + slice + .par_iter_mut() + .enumerate() + .for_each(|(i, item)| f(i, item)); + } + #[cfg(not(feature = "parallel"))] + { + slice + .iter_mut() + .enumerate() + .for_each(|(i, item)| f(i, item)); + } +} From 3494ef7a22cbfbc261b557dd0dd5cfe86f3b3fd7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 10:20:53 -0300 Subject: [PATCH 37/63] Take the batched format back out for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brought in by 6917f6b8 and reverted unchanged: it is the next step, not this one. The spec's Approach 1 asks for a batched FRI — "accumulate FRI polys into one batch polynomial" — and that is deep_for_table plus batch_fri, which are already here and already checked against a real proof. Grouping their output by exact height gives 13 FRI instances where there are 227, which is the ~54% of proof size the histogram priced. The mixed-height MMCS is a different thing: it batches the COMMITMENTS, one tree per round instead of one per table, and takes 13 FRI instances down to 1. It is strictly more, and it costs a new proof format, a new verifier and the recursion ELFs behind them. Worth doing, after. Nothing is lost by taking it out: it is in this history, and in #951 and archive/batched-format-pre-deletion. Reverting the revert brings it back. And none of it bears on memory, which is what Approach 1 was for and which is already done — 21952 MB against main's 110261 MB, proof verified. --- .../backends/field_element_vector.rs | 136 +- crypto/crypto/src/merkle_tree/traits.rs | 71 - crypto/stark/src/batched/mod.rs | 13 - crypto/stark/src/batched/round4.rs | 936 -------- crypto/stark/src/fri/batched.rs | 1134 ---------- crypto/stark/src/fri/mmcs.rs | 1922 ----------------- crypto/stark/src/fri/mod.rs | 2 - crypto/stark/src/lib.rs | 1 - crypto/stark/src/par.rs | 24 - 9 files changed, 1 insertion(+), 4238 deletions(-) delete mode 100644 crypto/stark/src/batched/mod.rs delete mode 100644 crypto/stark/src/batched/round4.rs delete mode 100644 crypto/stark/src/fri/batched.rs delete mode 100644 crypto/stark/src/fri/mmcs.rs diff --git a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs index a9f1f4b05..6d0cc6491 100644 --- a/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs +++ b/crypto/crypto/src/merkle_tree/backends/field_element_vector.rs @@ -1,7 +1,7 @@ use core::marker::PhantomData; use crate::hash::poseidon::Poseidon; -use crate::merkle_tree::traits::{IsLeafHasher, IsMerkleTreeBackend, IsStreamingLeafBackend}; +use crate::merkle_tree::traits::IsMerkleTreeBackend; use alloc::vec::Vec; use digest::{Digest, Output}; use math::{ @@ -202,140 +202,6 @@ where } } -/// Exposes the streaming leaf routes to callers that reach this backend through -/// a commitment configuration rather than by name. Both bodies go through -/// [`hash_streamed`], which is where the absorbed byte layout is defined, so -/// they agree with `hash_data` by construction. -impl IsStreamingLeafBackend - for FieldElementVectorBackend -where - F: IsField, - FieldElement: AsBytes, - [u8; NUM_BYTES]: From>, - Vec>: Sync + Send, -{ - fn hash_bytes(data: &[u8]) -> [u8; NUM_BYTES] { - hash_streamed::(|sink| sink(data)) - } - - fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> [u8; NUM_BYTES] { - // A size threshold below which this streams straight through was tried - // and MEASURED NEUTRAL-TO-WORSE (963.56M vs 963.28M cycles on a blowup8 - // verify, with `verify_fri` unmoved to the cycle). The 6.9M `verify_fri` - // rise that this change costs is NOT the staging buffer — gating the - // buffer away does not recover it — so it is not worth a branch here. - // Do not re-add one without a measurement. - hash_streamed::(|sink| { - let mut stage = LeafStage::new(); - for element in a.iter().chain(b.iter()) { - element.stream_bytes(&mut |bytes| stage.push(bytes, sink)); - } - stage.flush(sink); - }) - } - - type LeafHasher = DigestLeafHasher; - - fn leaf_hasher() -> Self::LeafHasher { - DigestLeafHasher { - hasher: D::new(), - phantom: PhantomData, - } - } -} - -/// [`IsLeafHasher`] over the same digest the one-shot routes use. -/// -/// The split-invariance the trait demands is inherited rather than argued: -/// `hash_streamed` opens a fresh `D`, feeds it every element's `stream_bytes` -/// and finalizes, with no length prefix, padding or framing of its own — so -/// absorbing the same elements across several `update` calls presents `D` with -/// the identical byte stream. There is no place for a split to show. -/// -/// This is a PROVER-side construct: the guest verifier authenticates leaves it -/// receives whole, through `hash_data_from_slices`. -pub struct DigestLeafHasher { - hasher: D, - /// `fn() -> F` rather than `F`: the field is a type-level label here, never a - /// value, and the function-pointer form is unconditionally `Send`/`Sync`. The - /// bare `PhantomData` would make every leaf hasher's thread-safety hinge on - /// a marker type nobody ever moves. - phantom: PhantomData F>, -} - -impl IsLeafHasher for DigestLeafHasher -where - F: IsField, - FieldElement: AsBytes, - [u8; NUM_BYTES]: From>, -{ - type Node = [u8; NUM_BYTES]; - - fn update(&mut self, data: &[FieldElement]) { - for element in data { - element.stream_bytes(&mut |bytes| self.hasher.update(bytes)); - } - } - - fn finalize(self) -> [u8; NUM_BYTES] { - let mut result = [0u8; NUM_BYTES]; - result.copy_from_slice(&self.hasher.finalize()); - result - } -} - -/// Bytes of the leaf staging buffer. Large enough that the run reaching the -/// hasher is worth batching — 16 blocks. -const LEAF_STAGE_BYTES: usize = 1024; - -/// Coalesces a leaf's field elements into large aligned runs before they reach -/// the hasher. -/// -/// A leaf arrives one field element at a time — eight bytes per `stream_bytes` -/// call — so without staging the hasher only ever sees eight bytes at a time. -/// Coalescing presents it with fewer, larger `update` calls. -/// -/// **This cannot change any digest.** The same bytes reach the hasher in the -/// same order; only the call boundaries move, and the sponge is split-invariant -/// by construction, so it simply sees fewer, larger `update` calls. -#[repr(align(8))] -struct LeafStage { - buf: [u8; LEAF_STAGE_BYTES], - len: usize, -} - -impl LeafStage { - #[inline] - fn new() -> Self { - Self { - buf: [0u8; LEAF_STAGE_BYTES], - len: 0, - } - } - - #[inline] - fn push(&mut self, mut bytes: &[u8], sink: &mut dyn FnMut(&[u8])) { - while !bytes.is_empty() { - if self.len == LEAF_STAGE_BYTES { - sink(&self.buf[..LEAF_STAGE_BYTES]); - self.len = 0; - } - let take = (LEAF_STAGE_BYTES - self.len).min(bytes.len()); - self.buf[self.len..self.len + take].copy_from_slice(&bytes[..take]); - self.len += take; - bytes = &bytes[take..]; - } - } - - #[inline] - fn flush(&mut self, sink: &mut dyn FnMut(&[u8])) { - if self.len > 0 { - sink(&self.buf[..self.len]); - self.len = 0; - } - } -} - #[derive(Clone, Default)] pub struct BatchPoseidonTree { _poseidon: PhantomData

, diff --git a/crypto/crypto/src/merkle_tree/traits.rs b/crypto/crypto/src/merkle_tree/traits.rs index 049bf5615..c09cff9d0 100644 --- a/crypto/crypto/src/merkle_tree/traits.rs +++ b/crypto/crypto/src/merkle_tree/traits.rs @@ -1,7 +1,4 @@ use alloc::vec::Vec; -use math::field::element::FieldElement; -use math::field::traits::IsField; -use math::traits::AsBytes; #[cfg(feature = "parallel")] use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; @@ -30,71 +27,3 @@ pub trait IsMerkleTreeBackend { /// It will be used in the construction of the Merkle tree. fn hash_new_parent(child_1: &Self::Node, child_2: &Self::Node) -> Self::Node; } - -/// A leaf backend that can hash a leaf without being handed one. -/// -/// [`IsMerkleTreeBackend::hash_data`] takes a `&Self::Data`, which for the -/// batched backends is a `Vec>`. Building one per leaf costs an -/// allocation per leaf — millions on a real trace — so the prover and verifier -/// never do: they serialize into a reused buffer, or hold two slices they want -/// hashed as if concatenated. These are the two shapes they use. -/// -/// Both must agree with `hash_data` on the bytes they absorb, so a leaf hashed -/// through either route is the leaf the tree was built from. That is the whole -/// contract, and it is why these live on a trait rather than staying inherent -/// methods on one concrete backend: a commitment configuration that names its -/// leaf backend generically still has to reach them. -pub trait IsStreamingLeafBackend: IsMerkleTreeBackend -where - F: IsField, - FieldElement: AsBytes, -{ - /// Hash a pre-serialized leaf buffer. Equals `hash_data` applied to the - /// elements `data` encodes, in that order. - fn hash_bytes(data: &[u8]) -> Self::Node; - - /// Hash `a ‖ b` without materializing the concatenation. Equals - /// `hash_data(&[a, b].concat())`. - fn hash_data_from_slices(a: &[FieldElement], b: &[FieldElement]) -> Self::Node; - - /// The incremental form of the same leaf hash. See [`IsLeafHasher`]. - /// - /// `Send` because there is one of these per leaf and the base layer of a real - /// epoch has millions: absorbing them is parallel across leaves, exactly as - /// the one-shot leaf hashing is. - type LeafHasher: IsLeafHasher + Send; - - /// A leaf hasher that has absorbed nothing yet. - fn leaf_hasher() -> Self::LeafHasher; -} - -/// One leaf's hash, absorbed in an arbitrary number of updates. -/// -/// [`IsStreamingLeafBackend::hash_data_from_slices`] covers the two-slice case, -/// which is every leaf the per-table trees hash. A mixed-height MMCS leaf is -/// different: it concatenates one row pair per matrix at that height, and a -/// prover that wants to produce those matrices ONE AT A TIME — absorbing each -/// into the leaves and dropping its buffer — cannot hand over all the slices at -/// once. This is the API that lets it, and the memory it costs is one hasher -/// state per leaf rather than one LDE per matrix. -/// -/// # Contract -/// -/// Splitting is free: for any partition of a leaf's elements into consecutive -/// chunks, updating with each chunk in order and finalizing must equal -/// [`IsMerkleTreeBackend::hash_data`] over the whole. A backend whose framing -/// depended on where the updates fell would produce leaves no verifier could -/// re-derive, since the verifier only ever sees the concatenation. -pub trait IsLeafHasher -where - F: IsField, - FieldElement: AsBytes, -{ - type Node; - - /// Absorb the next consecutive run of the leaf's elements. - fn update(&mut self, data: &[FieldElement]); - - /// Finish the leaf. - fn finalize(self) -> Self::Node; -} diff --git a/crypto/stark/src/batched/mod.rs b/crypto/stark/src/batched/mod.rs deleted file mode 100644 index 291ceddb1..000000000 --- a/crypto/stark/src/batched/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! The batched-commitment path: one mixed-height MMCS per round and one FRI -//! instance per epoch, instead of one tree and one FRI instance per table. -//! -//! Brought over from PR #951 piece by piece. The per-table prover and verifier -//! are untouched and produce byte-identical proofs; nothing here is reachable -//! from them. -//! -//! What is NOT brought over is #951's own driver: it takes every table's trace -//! at once, which is the residency this branch exists to remove. Its phase -//! sequence is the specification the driver here follows — each "per table" step -//! is a pass over the execution, each barrier a point where one ends. - -pub mod round4; diff --git a/crypto/stark/src/batched/round4.rs b/crypto/stark/src/batched/round4.rs deleted file mode 100644 index 9f5f478a8..000000000 --- a/crypto/stark/src/batched/round4.rs +++ /dev/null @@ -1,936 +0,0 @@ -//! Round 4 of the batched path: ONE FRI instance over the epoch's height-combined -//! DEEP codewords. -//! -//! # The transcript sequence, and why it has one owner -//! -//! ```text -//! shape histogram → α → standalone terminals → (β, layer root)* → β_final → terminal coeffs → grinding → iotas -//! ``` -//! -//! [`commit_batched_fri`] walks it on the prover's side; -//! [`crate::fri::batched::derive_batched_fri_challenges`] walks it on the -//! verifier's. The two are pinned to each other by -//! `prover_commit_matches_verifier_derivation`, not by review of two call sites. -//! α is sampled AFTER the shape is absorbed and BEFORE any codeword is combined, -//! which is why this function takes a `combine` closure rather than the codewords: -//! the prover cannot mix with α until the transcript has produced it, and the -//! closure is where a caller streams table by table (see -//! [`crate::fri::batched::HeightCombiner`]). -//! -//! # ★ TWO instance classes, and the index rule between them -//! -//! Not every table belongs in the batch. A table whose own FRI commits ZERO -//! layers gains nothing from being batched — there is no layer for the batch to -//! share — while it pays the full lift to the tallest domain, which is where the -//! proximity-gaps term's `|D0|^2` lives. At the measured epoch that is 13 of 28 -//! legs carrying 92% of the batch's width. [`FriInstancePlan`] partitions them, -//! and the excluded tables keep a terminal-only instance -//! ([`verify_standalone_fri_query`]) that costs one polynomial and no layers. -//! -//! The MMCS is untouched by this split — it still commits every table, so the -//! one-shared-authentication-path win survives whole. What differs is the index -//! SPACE: the batched class reads `iota` directly, a standalone table at height -//! `h` reads `iota >> (h_max - h)`. Both classes need a tamper control, since a -//! control that only touched the batched one would pass under any convention for -//! the other. -//! -//! # Query indices and the injection convention -//! -//! One `iota` per query, drawn from `[0, 2^(h_max-1))` — a row-PAIR index in the -//! TALLEST codeword's domain. Every shorter object is located by shifting it -//! down, which is what makes "one index, shared across all tables" true rather -//! than aspirational: -//! -//! - a matrix of height `h` in a round whose own tallest matrix is `h_max_round` -//! is opened at MMCS leaf `iota >> (h_max_fri - h)` — but note that -//! [`crate::fri::mmcs::MixedMmcs::verify_batch`] wants an index in ITS OWN -//! space, so a round whose `h_max_round` is below the FRI's must first reduce -//! (see that module's index-convention section, and [`reduce_iota_to_round`]). -//! - the codeword bucket at height `h` is read at position -//! [`injection_position`], which is exactly one of the two rows of the pair the -//! MMCS opened. That coincidence is not luck: both are the same row-pair -//! layout, which is why a single opening serves both the authentication and the -//! FRI join. -//! -//! # What "injection" costs the verifier -//! -//! The prover's [`crate::fri::batched::batched_commit_phase`] folds, then adds -//! `β² · bucket_h` to the running codeword before committing the layer. So the -//! verifier's per-query recursion adds the same term to the value it computed by -//! folding — and only to that value. The symmetric value at each layer comes from -//! the proof and is Merkle-authenticated against the layer root, so it already -//! carries its own injection; re-adding one would double it. - -use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; -use crypto::merkle_tree::proof::verify_merkle_path; -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; - -use crate::config::{BatchedMerkleTreeBackend, Commitment, FriLayerMerkleTreeBackend}; -use crate::fri::batched::{ - BatchedFriLayout, FriInstancePlan, absorb_shape_histogram, batched_commit_phase, - derive_batched_fri_challenges, -}; -use crate::fri::fri_commitment::FriLayer; -use crate::fri::fri_decommit::FriDecommitment; -use crate::grinding; -use crate::prover::ProvingError; - -/// What the prover produced in the batched round 4, plus the challenges it drew -/// on the way. The layers are kept so the caller can run the query phase over -/// them; everything else is what goes on the wire. -pub struct BatchedFriCommit -where - FieldElement: AsBytes + Sync + Send, -{ - pub layers: Vec>>, - pub layer_roots: Vec, - pub final_poly_coeffs: Vec>, - pub layout: BatchedFriLayout, - /// The grinding nonce, `None` when `grinding_factor == 0`. - pub nonce: Option, - /// Row-pair indices in the tallest domain, one per query. - pub iotas: Vec, - /// The mixing challenge the codewords were combined with. Kept because the - /// query phase needs it to rebuild each table's contribution. - pub alpha: FieldElement, - /// Which tables this instance carries, and which keep a terminal-only - /// instance of their own. See [`FriInstancePlan`]. - pub plan: FriInstancePlan, - /// Per table: the standalone class's terminal polynomial, `Some` exactly - /// for `plan.standalone`. Produced by `combine`, ABSORBED here (right - /// after α, before the first ζ — see `derive_batched_fri_challenges` for - /// why that absorb is load-bearing), and returned so the caller puts the - /// very coefficients the transcript bound onto the wire. - pub standalone_coeffs: Vec>>>, -} - -/// Prover side of the batched round-4 sequence. -/// -/// `heights[t]` is `log2` of table `t`'s LDE length and `widths[t]` its committed -/// column count, both in the epoch's canonical table order — the same order the -/// verifier rebuilds from the AIR set, and the same order `combine` must absorb -/// codewords in, since absorption order is what defines the α powers. -/// -/// `combine` receives α and returns the per-height buckets (see -/// [`crate::fri::batched::HeightCombiner::finish`]) TOGETHER WITH the -/// standalone class's terminal polynomials, per table (`Some` exactly for -/// `plan.standalone`). It is a closure rather than a materialized `Vec` so a -/// caller can produce one table's DEEP codeword, absorb it and drop it: -/// holding all of them at once is the memory cost batching exists to remove. -/// -/// Returns `Err` only when the device FRI commit is selected and a CUDA op -/// fails — a hard abort, the same no-silent-fallback policy as the device MMCS -/// commits (see the `batched/prover.rs` module header). The host build never -/// errors. -#[allow(clippy::too_many_arguments)] -pub fn commit_batched_fri( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - combine: C, - coset_offset: &FieldElement, - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - num_queries: usize, -) -> Result, ProvingError> -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static + Send + Sync, - T: IsStarkTranscript + Clone, - C: FnOnce( - &FieldElement, - &FriInstancePlan, - ) -> ( - Vec>>>, - Vec>>>, - ), - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - // Derived from the shape, exactly as the verifier derives it — the partition - // is never sent. The tables whose own FRI commits no layer are left out of the - // batch: they gain nothing from it and pay the full lift to the tallest - // domain, which is where the proximity-gaps term's `|D0|^2` lives. - let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree) - .expect("commit_batched_fri: the epoch's shape is the prover's own"); - let h_max = plan.h_max; - - absorb_shape_histogram::(transcript, heights, widths); - let alpha = transcript.sample_field_element(); - - let (combined, standalone_coeffs) = combine(&alpha, &plan); - - // Bind the standalone class's terminal polynomials BEFORE the first ζ — - // the same walk `derive_batched_fri_challenges` replays, and the reason it - // does (its doc): a polynomial not bound here could be chosen after the - // query indices are known. - for (table, coeffs) in standalone_coeffs.iter().enumerate() { - assert_eq!( - coeffs.is_some(), - plan.standalone.contains(&table), - "the standalone terminals exist for exactly the standalone class" - ); - if let Some(coeffs) = coeffs { - for c in coeffs.iter() { - transcript.append_field_element(c); - } - } - } - - // Device fast path vs host build. Selecting here (rather than inside - // `batched_commit_phase`) keeps `crate::fri` free of the prover's error type - // and lets a device error be a hard abort: `?` propagates it instead of the - // fold loop silently falling back. `Ok(None)` = device not selected (off - // cuda, wrong field, or below the GPU threshold) → host build. - let (final_poly_coeffs, layers) = { - #[cfg(feature = "cuda")] - { - let (h_min, h_max_folds) = crate::fri::batched::bucket_height_range(&combined) - .expect("commit_batched_fri: combined has at least one occupied bucket"); - let inv_twiddles = crate::fri::fri_functions::compute_coset_twiddles_inv( - coset_offset, - 1usize << h_max_folds, - ); - match crate::gpu_lde::try_batched_fri_commit_gpu::( - &combined, - transcript, - coset_offset, - blowup_log, - final_poly_log_degree, - &inv_twiddles, - h_min, - h_max_folds, - )? { - Some(result) => result, - None => batched_commit_phase::( - combined, - transcript, - coset_offset, - blowup_log, - final_poly_log_degree, - ), - } - } - #[cfg(not(feature = "cuda"))] - { - batched_commit_phase::( - combined, - transcript, - coset_offset, - blowup_log, - final_poly_log_degree, - ) - } - }; - let layer_roots: Vec = layers.iter().map(|layer| layer.merkle_tree.root).collect(); - - // Grinding runs on the CONFIGURATION's transcript hash, not a hard-wired - // one — the same rule the unbatched `prover.rs` follows. `H` names both the - // commitment family and the Fiat-Shamir hash, so a batched proof committed - // with BLAKE3 grinds with BLAKE3 and one committed with keccak grinds with - // keccak, without either side being told twice. - let nonce = (grinding_factor > 0).then(|| { - let value = grinding::generate_nonce(&transcript.state(), grinding_factor) - .expect("nonce not found"); - transcript.append_bytes(&value.to_be_bytes()); - value - }); - - let iotas = (0..num_queries) - .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) - .collect(); - - Ok(BatchedFriCommit { - layers, - layer_roots, - final_poly_coeffs, - layout: BatchedFriLayout::new(plan.h_max, plan.h_min, blowup_log, final_poly_log_degree), - nonce, - iotas, - alpha, - plan, - standalone_coeffs, - }) -} - -/// Verify one query against a STANDALONE table's terminal-only instance. -/// -/// A table whose own FRI commits no layer has a terminal codeword that IS its -/// deep-composition codeword, so there is nothing to fold and nothing to -/// authenticate: the check is that the value the query opened is the value the -/// sent terminal polynomial encodes at that position. -/// -/// ★ `iota` is the SHARED batched query index and is reduced here — the two -/// instance classes read the same index in different spaces (see -/// [`FriInstancePlan`]). `deep` is the table's own deep-composition pair at its -/// reduced row pair, which the caller reconstructs from authenticated openings. -/// -/// Returns `false` on every malformed input; it never panics. -pub fn verify_standalone_fri_query( - iota: usize, - h_max_fri: usize, - h_table: usize, - deep: (&FieldElement, &FieldElement), - terminal_codeword: &[FieldElement], -) -> bool -where - E: IsField + 'static, -{ - let Some(reduced) = reduce_iota_to_round(iota, h_max_fri, h_table) else { - return false; - }; - terminal_codeword - .get(reduced * 2) - .is_some_and(|t| deep.0 == t) - && terminal_codeword - .get(reduced * 2 + 1) - .is_some_and(|t| deep.1 == t) -} - -/// Position, inside the codeword of height `h`, that query `iota` reads. -/// -/// `iota` is a row-pair index in the tallest domain (height `h_max`); the layer -/// whose codeword has height `h` is reached after `h_max - h` folds, and the -/// query's position there is `iota >> (h_max - h - 1)`. Both rows of the pair a -/// height-`h` MMCS opening returns — leaf `iota >> (h_max - h)`, i.e. LDE rows -/// `2k` and `2k+1` — are candidates, and the low bit of this position picks -/// between them; see [`injected_value_at_query`]. -/// -/// Not defined at `h == h_max`: the tallest codeword is the FRI's layer 0, which -/// the query reads as a PAIR (`2·iota`, `2·iota+1`) rather than at one position. -#[inline] -pub fn injection_position(iota: usize, h_max: usize, h: usize) -> usize { - debug_assert!( - h < h_max, - "the tallest codeword is read as a pair, not at a position" - ); - iota >> (h_max - h - 1) -} - -/// The value a height-`h` matrix contributes to its injection layer, chosen from -/// the row pair its MMCS opening returned. -/// -/// `evaluation` is the opening's row `2k` and `evaluation_sym` its row `2k+1`, -/// with `k = iota >> (h_max - h)`. The pair straddles the injection position, so -/// the choice is exactly that position's low bit. -#[inline] -pub fn injected_value_at_query<'a, E: IsField>( - iota: usize, - h_max: usize, - h: usize, - evaluation: &'a FieldElement, - evaluation_sym: &'a FieldElement, -) -> &'a FieldElement { - if injection_position(iota, h_max, h) & 1 == 0 { - evaluation - } else { - evaluation_sym - } -} - -/// Reduce a FRI query index to the index space of a round whose tallest matrix -/// is shorter than the FRI's. -/// -/// [`crate::fri::mmcs::MixedMmcs::verify_batch`] walks its path with the LOW bits -/// of the index it is given, while it locates a short matrix inside the tree by -/// the HIGH bits — consistent only when the index comes from that tree's own -/// `h_max`. The batched preprocessed round is the case that breaks it (its -/// tallest matrix sits below the FRI's), so every caller reduces here rather than -/// each writing the shift out. Returns `None` when the round claims to be TALLER -/// than the FRI, which no honest shape can be. -#[inline] -pub fn reduce_iota_to_round(iota: usize, h_max_fri: usize, h_max_round: usize) -> Option { - (h_max_round <= h_max_fri).then(|| iota >> (h_max_fri - h_max_round)) -} - -/// Verify one query of the batched FRI: the fold-with-injection recursion, every -/// committed layer's opening, and the terminal check. -/// -/// `p0` is the query's pair of values in the tallest codeword — the α-mixed DEEP -/// evaluations of the tables at height `h_max`, at LDE positions `2·iota` and -/// `2·iota + 1`. `bucket_at_height[h]` is `Some(v)` when at least one table has -/// height `h < h_max`, with `v` that height group's α-mixed value at -/// [`injection_position`]; `None` when no table sits at `h`. Both are the -/// caller's to reconstruct from authenticated openings — this function does no -/// authentication of trace data, only of FRI layers. -/// -/// Returns `false` on every malformed input; it never panics. -#[allow(clippy::too_many_arguments)] -pub fn verify_batched_fri_query( - layer_roots: &[Commitment], - betas: &[FieldElement], - layout: &BatchedFriLayout, - h_max: usize, - iota: usize, - decommitment: &FriDecommitment, - evaluation_point_inv: &FieldElement, - p0: (&FieldElement, &FieldElement), - bucket_at_height: &[Option>], - terminal_codeword: &[FieldElement], -) -> bool -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - // The decommitment vectors are prover-supplied and are NOT bound into the - // transcript, so their lengths are pinned here before anything zips them — - // the same reason `step_3_verify_fri` pins them in the unbatched path. A - // short vector would make the fold loop run fewer rounds and accept the query - // without ever reaching the terminal. - if layer_roots.len() != layout.num_committed - || decommitment.layers_auth_paths.len() != layout.num_committed - || decommitment.layers_evaluations_sym.len() != layout.num_committed - || betas.len() != layout.num_committed + usize::from(layout.total_folds > 0) - { - return false; - } - if h_max == 0 || h_max >= usize::BITS as usize || iota >= 1usize << (h_max - 1) { - return false; - } - if bucket_at_height.len() < h_max { - return false; - } - - // No-fold case: the codeword never folds, so the terminal IS the tallest - // codeword and the query's two points sit at `2·iota` and `2·iota + 1`. No - // bucket can exist below `h_max` here — `h_min == h_max` is what makes - // `total_folds` zero — so there is nothing to inject. - if layout.total_folds == 0 { - return terminal_codeword.get(iota * 2).is_some_and(|t| p0.0 == t) - && terminal_codeword - .get(iota * 2 + 1) - .is_some_and(|t| p0.1 == t); - } - - // First fold: layer 0 (the tallest codeword) is not committed, so this fold - // consumes `p0` rather than an authenticated opening. Then the height just - // below joins, exactly as `batched_commit_phase` does before it commits. - let mut point_inv = evaluation_point_inv.clone(); - let mut v = (p0.0 + p0.1) + &point_inv * &betas[0] * (p0.0 - p0.1); - let mut index = iota; - inject(&mut v, &betas[0], bucket_at_height, h_max - 1); - - let mut openings_ok = true; - for i in 0..layout.num_committed { - let evaluation_sym = &decommitment.layers_evaluations_sym[i]; - openings_ok &= verify_layer_opening::( - &layer_roots[i], - decommitment.layers_auth_paths[i].merkle_path.as_slice(), - &v, - evaluation_sym, - index, - ); - - point_inv = point_inv.square(); - v = (&v + evaluation_sym) + &point_inv * &betas[i + 1] * (&v - evaluation_sym); - index >>= 1; - // The injection height descends with the running codeword. `checked_sub` - // rather than `h_max - 2 - i`: `layout`'s fields are only consistent with - // `h_max` when the layout was DERIVED from the same heights, and this - // function is on the verifier's path, where an overflow panic is not a - // rejection. An inconsistent layout simply injects nothing and fails at - // the terminal. - if let Some(height) = (h_max - 1).checked_sub(i + 1) { - inject(&mut v, &betas[i + 1], bucket_at_height, height); - } - } - - // `v` is now the query's value in the terminal codeword and `index` its - // position there. `.get` fails closed on an out-of-range index. - openings_ok & terminal_codeword.get(index).is_some_and(|t| &v == t) -} - -/// `running += β² · bucket_h` for the height the running codeword has just -/// reached. A no-op when no table sits at that height, and when the height is -/// below the terminal (`bucket_at_height` is indexed by height, so a fold that -/// runs past index 0 has nothing to read). -fn inject( - value: &mut FieldElement, - beta: &FieldElement, - bucket_at_height: &[Option>], - height: usize, -) { - if let Some(Some(contribution)) = bucket_at_height.get(height) { - *value = &*value + &(beta.square() * contribution); - } -} - -/// Authenticate a committed FRI layer's row pair against its root. `index` is the -/// query's position in that layer; the leaf is the pair at `index >> 1`, ordered -/// by `index`'s low bit — the same convention the unbatched -/// `verify_fri_layer_openings` uses, and the same one `query_phase` opens with. -fn verify_layer_opening( - root: &Commitment, - auth_path: &[Commitment], - evaluation: &FieldElement, - evaluation_sym: &FieldElement, - index: usize, -) -> bool -where - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, -{ - let leaf = if index % 2 == 1 { - vec![evaluation_sym.clone(), evaluation.clone()] - } else { - vec![evaluation.clone(), evaluation_sym.clone()] - }; - verify_merkle_path::>(auth_path, root, index >> 1, &leaf) -} - -/// Replay the batched round-4 transcript sequence and return the challenges, -/// or `None` when the proof's shape contradicts the epoch's. -/// -/// A thin alias for [`derive_batched_fri_challenges`], re-exported here so the -/// verifier reaches the sequence through the same module the prover's -/// [`commit_batched_fri`] lives in — the two are one protocol, and splitting them -/// across modules is how they drift. -#[allow(clippy::too_many_arguments)] -pub fn replay_batched_fri( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - layer_roots: &[Commitment], - final_poly_coeffs: &[FieldElement], - standalone_coeffs: &[Option<&[FieldElement]>], - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - nonce: Option, - num_queries: usize, -) -> Option> -where - E: IsField, - T: IsTranscript, -{ - derive_batched_fri_challenges( - transcript, - heights, - widths, - layer_roots, - final_poly_coeffs, - standalone_coeffs, - blowup_log, - final_poly_log_degree, - grinding_factor, - nonce, - num_queries, - ) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::fri::batched::{HeightCombiner, combine_by_height}; - use crate::fri::terminal::terminal_codeword_from_coeffs; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; - use math::field::goldilocks::GoldilocksField; - use math::polynomial::Polynomial; - - pub(crate) type F = GoldilocksField; - pub(crate) type FE = FieldElement; - pub(crate) type Transcript = DefaultTranscript; - - pub(crate) const BLOWUP_LOG: u32 = 1; - pub(crate) const FINAL_POLY_LOG_DEGREE: u32 = 1; - pub(crate) const COSET_OFFSET: u64 = 3; - - /// One synthetic table: a genuinely low-degree codeword at its own height. - pub(crate) struct FakeTable { - pub height: usize, - pub width: usize, - pub codeword: Vec, - } - - /// A codeword of height `h` that IS a Reed-Solomon word of rate `2^-BLOWUP_LOG` - /// on the coset the batched FRI will read it at. - /// - /// The coset matters and is the one thing easy to get wrong here: folding - /// squares the offset, so the layer a height-`h` bucket is injected into lives - /// on `offset^(2^(h_max-h))·⟨ω⟩`, not on `offset·⟨ω⟩`. A word built on the - /// wrong coset is still low degree — the map is a rescaling of the argument — - /// so it would pass a degree check while making the terminal reconstruction - /// disagree, which is exactly the failure the honest-path test has to be able - /// to see. - pub(crate) fn low_degree_codeword(h: usize, h_max: usize, seed: u64) -> Vec { - let num_coeffs = 1usize << (h as u32 - BLOWUP_LOG); - let coeffs: Vec = (0..num_coeffs) - .map(|i| FE::from(seed.wrapping_mul(97).wrapping_add(i as u64 * 31 + 1))) - .collect(); - let offset = FE::from(COSET_OFFSET).pow(1u64 << (h_max - h)); - let mut natural = Polynomial::evaluate_offset_fft::( - &Polynomial::new(&coeffs), - 1usize << BLOWUP_LOG, - Some(num_coeffs), - &offset, - ) - .expect("coset evaluation"); - in_place_bit_reverse_permute(&mut natural); - natural - } - - /// Four tables over three heights, the shape the batched path has to handle: - /// several tables sharing the tallest height (so the base group batches), one - /// at an intermediate height (so an injection lands on a committed layer) and - /// one at the terminal height (so the FINAL fold's injection is exercised — - /// the case #768's loop missed). - pub(crate) fn fixture() -> Vec { - let h_max = 5; - vec![ - FakeTable { - height: 5, - width: 3, - codeword: low_degree_codeword(5, h_max, 11), - }, - FakeTable { - height: 4, - width: 2, - codeword: low_degree_codeword(4, h_max, 23), - }, - FakeTable { - height: 5, - width: 7, - codeword: low_degree_codeword(5, h_max, 41), - }, - FakeTable { - height: 2, - width: 1, - codeword: low_degree_codeword(2, h_max, 59), - }, - ] - } - - pub(crate) fn heights_of(tables: &[FakeTable]) -> Vec { - tables.iter().map(|t| t.height).collect() - } - - pub(crate) fn widths_of(tables: &[FakeTable]) -> Vec { - tables.iter().map(|t| t.width).collect() - } - - /// The per-table standalone slices a replay call takes, off a commit. - pub(crate) fn standalone_refs(commit: &BatchedFriCommit) -> Vec> { - commit - .standalone_coeffs - .iter() - .map(|c| c.as_deref()) - .collect() - } - - /// Run the prover's batched round 4 over `tables`, streaming the codewords - /// into the combiner one at a time — the shape a real prover uses. - pub(crate) fn commit_fixture( - tables: &[FakeTable], - transcript: &mut Transcript, - grinding_factor: u8, - num_queries: usize, - ) -> BatchedFriCommit { - let heights = heights_of(tables); - let widths = widths_of(tables); - commit_batched_fri::( - transcript, - &heights, - &widths, - |alpha, plan| { - // Only the batched class is mixed in, and in the plan's order — - // absorption order is what defines the alpha powers, so a caller - // that absorbed the standalone tables too would shift every - // power and agree with no verifier. The standalone tables hand - // back their terminal polynomials instead, exactly as the real - // prover does. - let mut combiner = HeightCombiner::new(*alpha); - for &t in &plan.batched { - combiner.absorb(&tables[t].codeword, tables[t].height); - } - let standalone = tables - .iter() - .enumerate() - .map(|(t, table)| { - plan.standalone.contains(&t).then(|| { - crate::fri::terminal::coeffs_from_terminal_codeword::( - &table.codeword, - &FE::from(COSET_OFFSET), - table.height as u32 - BLOWUP_LOG, - ) - }) - }) - .collect(); - (combiner.finish(), standalone) - }, - &FE::from(COSET_OFFSET), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - grinding_factor, - num_queries, - ) - .expect("the host batched FRI commit never errors") - } - - /// υ⁻¹ for query `iota`: the inverse of the tallest coset's element at - /// FRI-order position `2·iota`, matching the unbatched verifier's - /// `query_challenge_to_evaluation_point`. - pub(crate) fn evaluation_point_inv(iota: usize, h_max: usize) -> FE { - let n = 1usize << h_max; - let omega = F::get_primitive_root_of_unity(h_max as u64).expect("root of unity"); - let point = FE::from(COSET_OFFSET) * omega.pow(reverse_index(iota * 2, n as u64)); - point.inv().expect("query point is never zero") - } - - /// What the verifier must reconstruct from authenticated openings: the α-mixed - /// value of every height group at this query's position. Here it is read - /// straight off the combined buckets, which is the oracle — `combine_by_height` - /// has its own tests, and the point of this one is the fold recursion. - pub(crate) fn query_inputs( - tables: &[FakeTable], - alpha: &FE, - iota: usize, - ) -> ((FE, FE), Vec>) { - let plan = FriInstancePlan::new(&heights_of(tables), BLOWUP_LOG, FINAL_POLY_LOG_DEGREE) - .expect("the fixture's shape partitions"); - let h_max = plan.h_max; - let inputs: Vec<(Vec, usize)> = plan - .batched - .iter() - .map(|&t| (tables[t].codeword.clone(), tables[t].height)) - .collect(); - let combined = combine_by_height(&inputs, alpha); - - let tallest = combined[h_max].as_ref().expect("tallest bucket exists"); - let p0 = (tallest[iota * 2], tallest[iota * 2 + 1]); - - let buckets = (0..h_max) - .map(|h| { - combined - .get(h) - .and_then(|slot| slot.as_ref()) - .map(|codeword| codeword[injection_position(iota, h_max, h)]) - }) - .collect(); - (p0, buckets) - } - - /// Verify one query end to end against the committed layers. - #[allow(clippy::too_many_arguments)] - pub(crate) fn verify_one_query( - commit: &BatchedFriCommit, - betas: &[FE], - h_max: usize, - iota: usize, - decommitment: &FriDecommitment, - p0: (&FE, &FE), - buckets: &[Option], - layer_roots: &[Commitment], - final_poly_coeffs: &[FE], - ) -> bool { - let terminal_offset = FE::from(COSET_OFFSET).pow(1u64 << commit.layout.total_folds); - let terminal = terminal_codeword_from_coeffs::( - final_poly_coeffs, - &terminal_offset, - commit.layout.terminal_len, - ); - verify_batched_fri_query::( - layer_roots, - betas, - &commit.layout, - h_max, - iota, - decommitment, - &evaluation_point_inv(iota, h_max), - p0, - buckets, - &terminal, - ) - } - - /// The prover's inline sequence and the verifier's replay are ONE protocol; - /// this is what pins them together. Every challenge, not only the iotas — - /// α gates the height combination and the βs gate every fold, so an - /// agreement that held only at the query indices would still be a broken - /// proof system. - #[test] - fn prover_commit_matches_verifier_derivation() { - let tables = fixture(); - let mut prover_transcript = Transcript::new(b"batched_round4"); - let mut verifier_transcript = prover_transcript.clone(); - - let commit = commit_fixture(&tables, &mut prover_transcript, 4, 6); - - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths_of(&tables), - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 4, - commit.nonce, - 6, - ) - .expect("an honest shape must derive"); - - assert_eq!(replay.alpha, commit.alpha, "α must agree"); - assert_eq!(replay.layout, commit.layout, "the fold layout must agree"); - assert_eq!(replay.iotas, commit.iotas, "the query indices must agree"); - assert_eq!( - replay.betas.len(), - commit.layout.num_committed + 1, - "one β per committed layer plus the final fold" - ); - assert!( - crate::grinding::is_valid_nonce( - &replay.grinding_seed, - commit.nonce.expect("grinding was requested"), - 4 - ), - "the replayed grinding seed must accept the prover's nonce" - ); - assert_eq!( - prover_transcript.state(), - verifier_transcript.state(), - "both sides must end in the same transcript state" - ); - } - - /// The honest path, and it is not vacuous: the fixture spans three heights, - /// so this exercises the base group, an injection into a committed layer and - /// an injection at the final fold. If the injection convention or the - /// position derivation were wrong, the terminal check would fail. - #[test] - fn honest_batched_queries_verify() { - let tables = fixture(); - let h_max = 5; - let mut transcript = Transcript::new(b"batched_round4"); - let commit = commit_fixture(&tables, &mut transcript, 0, 8); - - let decommitments = crate::fri::query_phase::(&commit.layers, &commit.iotas); - - let mut verifier_transcript = Transcript::new(b"batched_round4"); - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths_of(&tables), - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 0, - None, - 8, - ) - .expect("an honest shape must derive"); - - assert!(commit.layout.num_committed >= 1, "the fixture must fold"); - for (query, &iota) in commit.iotas.iter().enumerate() { - let (p0, buckets) = query_inputs(&tables, &replay.alpha, iota); - assert!( - verify_one_query( - &commit, - &replay.betas, - h_max, - iota, - &decommitments[query], - (&p0.0, &p0.1), - &buckets, - &commit.layer_roots, - &commit.final_poly_coeffs, - ), - "honest query {query} (iota {iota}) must verify" - ); - } - } - - /// The MMCS row pair a query opens at height `h` and the FRI position the - /// injection reads must be the SAME two rows. That coincidence is what lets - /// one opening serve both the authentication and the FRI join, and it is a - /// property of the two index derivations, so it is worth pinning exhaustively - /// rather than sampling. - #[test] - fn injection_position_lands_inside_the_mmcs_row_pair() { - let h_max = 6; - for iota in 0..(1usize << (h_max - 1)) { - for h in 1..h_max { - let position = injection_position(iota, h_max, h); - let mmcs_leaf = iota >> (h_max - h); - assert_eq!( - position >> 1, - mmcs_leaf, - "height {h}, iota {iota}: the injection position must sit in the opened leaf" - ); - assert!( - position < (1usize << h), - "height {h}, iota {iota}: position must stay inside the codeword" - ); - } - } - } - - /// `reduce_iota_to_round` is the documented remedy for the one case where a - /// round's tallest matrix is below the FRI's. Pin both that it is the shift - /// the MMCS wants and that it refuses the impossible direction rather than - /// shifting by a negative amount. - #[test] - fn reduce_iota_to_round_matches_the_mmcs_index_space() { - let h_max_fri = 6; - for iota in 0..(1usize << (h_max_fri - 1)) { - for h_max_round in 1..=h_max_fri { - let reduced = - reduce_iota_to_round(iota, h_max_fri, h_max_round).expect("round is shorter"); - assert!( - reduced < (1usize << (h_max_round - 1)), - "the reduced index must land in the round's own leaf range" - ); - } - } - assert!( - reduce_iota_to_round(0, 4, 5).is_none(), - "a round taller than the FRI is not a shape any honest epoch has" - ); - } - - /// A width the epoch did not commit to moves α, and therefore every fold and - /// every query index. This is the shape binding doing its job one level up - /// from the leaf: the leaf header binds a mis-parse, this binds a mis-shaped - /// epoch. - #[test] - fn a_tampered_shape_moves_the_derived_challenges() { - let tables = fixture(); - let mut prover_transcript = Transcript::new(b"batched_round4"); - let commit = commit_fixture(&tables, &mut prover_transcript, 0, 4); - - let mut widths = widths_of(&tables); - widths[1] += 1; - let mut verifier_transcript = Transcript::new(b"batched_round4"); - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths, - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 0, - None, - 4, - ) - .expect("the shape is still structurally consistent"); - - assert_ne!( - replay.alpha, commit.alpha, - "a width the prover did not commit to must move α" - ); - assert_ne!( - replay.iotas, commit.iotas, - "a width the prover did not commit to must move the query indices" - ); - } -} diff --git a/crypto/stark/src/fri/batched.rs b/crypto/stark/src/fri/batched.rs deleted file mode 100644 index 996f46da5..000000000 --- a/crypto/stark/src/fri/batched.rs +++ /dev/null @@ -1,1134 +0,0 @@ -//! Batched FRI: one FRI instance over an epoch's DEEP codewords instead of one -//! per table. -//! -//! Codewords are bucketed by height, mixed within a bucket with powers of a -//! single `alpha`, and then folded from the tallest bucket downward, each -//! shorter bucket being *injected* into the running codeword at the layer whose -//! length matches it. One set of query indices, drawn from the tallest domain, -//! tests the whole chain. -//! -//! # Termination -//! -//! Folding stops at the same terminal the unbatched -//! [`crate::fri::commit_phase_from_evaluations`] stops at — the codeword that -//! encodes a polynomial of degree `< 2^fri_final_poly_log_degree` — and sends -//! that polynomial's coefficients, rather than folding all the way down to a -//! scalar. [`BatchedFriLayout`] derives the fold count through the shared -//! [`FriFoldLayout`], with one batched-only floor: the terminal may not sit -//! above the SHORTEST injected codeword, or that codeword would never reach the -//! running word. So the early stop is `min(blowup_log + k, h_min)`. - -use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; -use crypto::merkle_tree::merkle::MerkleTree; -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; -#[cfg(feature = "parallel")] -use rayon::prelude::*; - -use crate::config::FriLayerMerkleTreeBackend; -use crate::fri::fri_commitment::FriLayer; -use crate::fri::fri_functions::{ - compute_coset_twiddles_inv, fold_evaluations_in_place, update_twiddles_in_place, -}; -use crate::fri::terminal::{FriFoldLayout, coeffs_from_terminal_codeword}; - -/// Accumulates DEEP codewords into per-height buckets as they are produced, -/// mixing the `i`-th absorbed codeword with `alpha^i`. -/// -/// The point of absorbing one codeword at a time is memory: a caller that -/// produces a table's quotient, absorbs it and drops it retains only one bucket -/// per distinct height (`O(2^h_max)` in total), where handing -/// [`combine_by_height`] a fully-materialized `Vec` of every table's codeword -/// retains `O(N_tables · 2^h)`. The result is identical either way — absorption -/// order defines the `alpha` powers, so the caller must absorb in the same -/// canonical per-epoch order the verifier assumes. -pub struct HeightCombiner { - buckets: Vec>>>, - alpha: FieldElement, - /// `alpha^i` for the next codeword to be absorbed. - next_power: FieldElement, -} - -impl HeightCombiner { - pub fn new(alpha: FieldElement) -> Self { - Self { - buckets: Vec::new(), - alpha, - next_power: FieldElement::one(), - } - } - - /// Absorb one codeword of length `2^height`, scaled by the next power of - /// `alpha`. - pub fn absorb(&mut self, codeword: &[FieldElement], height: usize) { - let expected_len = 1usize << height; - assert_eq!( - codeword.len(), - expected_len, - "codeword has length {} but height {height} expects {expected_len}", - codeword.len() - ); - - if self.buckets.len() <= height { - self.buckets.resize_with(height + 1, || None); - } - let scaled = &self.next_power; - // Data-parallel under `parallel`: the scale and the scale-accumulate - // are elementwise over up to 2^h_max elements, and this loop has no - // per-table overlap to hide behind — it was serial wall time once per - // absorbed table. Same arithmetic in both arms, identical result. - #[cfg(feature = "parallel")] - match &mut self.buckets[height] { - None => { - self.buckets[height] = Some( - codeword - .par_iter() - .map(|x| scaled * x) - .collect::>>(), - ); - } - Some(acc) => { - acc.par_iter_mut() - .zip(codeword.par_iter()) - .for_each(|(a, x)| { - *a = &*a + &(scaled * x); - }); - } - } - #[cfg(not(feature = "parallel"))] - match &mut self.buckets[height] { - None => { - self.buckets[height] = Some(codeword.iter().map(|x| scaled * x).collect()); - } - Some(acc) => { - for (a, x) in acc.iter_mut().zip(codeword.iter()) { - *a = &*a + &(scaled * x); - } - } - } - self.next_power = &self.next_power * &self.alpha; - } - - /// The per-height buckets. Index `h` is `Some(combined)` when at least one - /// codeword of height `h` was absorbed, `None` otherwise; the `Vec` is - /// `max_absorbed_height + 1` long, or empty if nothing was absorbed. - pub fn finish(self) -> Vec>>> { - self.buckets - } -} - -/// Combine DEEP polynomial codewords by their FRI height for batched FRI. -/// -/// Each element of `inputs` is a pair `(codeword, height)` where `height` is -/// the log₂ of the codeword length (i.e. `codeword.len() == 2^height`). -/// The global index `i` into `inputs` is used to derive the mixing power -/// `alpha^i` (index 0 → alpha^0 = 1, index 1 → alpha^1, …). -/// -/// Returns a `Vec` of length `max_height + 1`. Index `h` contains -/// `Some(combined)` where `combined[j] = Σ_{i : height_i == h} alpha^i * codeword_i[j]`, -/// or `None` when no input has height `h`. -/// -/// This is [`HeightCombiner`] with every codeword already materialized. Prefer -/// the combiner in the prover, where holding all of them at once is the whole -/// memory cost the batching is meant to remove. -pub fn combine_by_height( - inputs: &[(Vec>, usize)], - alpha: &FieldElement, -) -> Vec>>> -where - E: IsField, -{ - let mut combiner = HeightCombiner::new(alpha.clone()); - for (codeword, height) in inputs { - combiner.absorb(codeword, *height); - } - combiner.finish() -} - -/// How far a batched FRI instance folds, and what it sends at the end. -/// -/// Mirrors [`FriFoldLayout`] — same early stop, same terminal codeword, same -/// coefficient count — with the one difference batching forces: the terminal is -/// additionally floored at the SHORTEST injected codeword's height, since a -/// bucket below the terminal would never be folded into the running word. In a -/// real epoch the shortest table is normally well above `blowup_log + k`, so the -/// floor is inert and the layout is exactly the unbatched one. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BatchedFriLayout { - /// Folds from the tallest bucket down to the terminal codeword. - pub total_folds: u32, - /// Committed (Merkle-rooted) FRI layers. - pub num_committed: usize, - /// Terminal codeword length. - pub terminal_len: usize, - /// `log2` of the terminal polynomial's degree bound — the number of - /// coefficients sent is `2^effective_k`. - pub effective_k: u32, -} - -impl BatchedFriLayout { - /// Derive the layout from the epoch's codeword heights. - /// - /// * `h_max` / `h_min` — the tallest and shortest codeword heights present. - /// * `blowup_log` — log2 of the LDE blowup factor. - /// * `final_poly_log_degree` — the requested `fri_final_poly_log_degree`. - /// - /// Panics if `h_min < blowup_log` (a codeword shorter than the blowup is not - /// a Reed-Solomon word of any positive rate) or if `h_min > h_max`. - pub fn new(h_max: usize, h_min: usize, blowup_log: u32, final_poly_log_degree: u32) -> Self { - assert!(h_min <= h_max, "h_min {h_min} exceeds h_max {h_max}"); - assert!( - h_min as u32 >= blowup_log, - "codeword height {h_min} is below the blowup {blowup_log}" - ); - // Deriving at `h_min` is what applies the floor: `FriFoldLayout` clamps - // the terminal to its `lde_log` argument, so the terminal comes out at - // `min(blowup_log + k, h_min)`. Its terminal_len / effective_k are then - // exactly what the unbatched prover would send for that codeword. - let shortest = FriFoldLayout::new(h_min as u32, blowup_log, final_poly_log_degree); - let terminal_log = shortest.terminal_len.trailing_zeros(); - // The running codeword starts at h_max, not h_min, so the fold count is - // re-derived from where folding actually begins. - let total_folds = h_max as u32 - terminal_log; - Self { - total_folds, - num_committed: total_folds.saturating_sub(1) as usize, - terminal_len: shortest.terminal_len, - effective_k: shortest.effective_k, - } - } -} - -/// Which of an epoch's tables enter the ONE batched FRI instance, and which keep -/// a terminal-only instance of their own. -/// -/// # Why there are two classes -/// -/// A table whose own FRI would commit ZERO layers gains nothing from being -/// batched — there is no layer for the batch to share — while it pays the full -/// cost of being lifted to the tallest domain, which is where the proximity-gaps -/// term's `|D0|²` lives. At the measured epoch that is 13 of 28 legs carrying 92% -/// of the batch's width, so excluding them is a correction rather than a -/// compromise: it recovers ~3.6 bits of soundness AND removes work. -/// -/// A zero-layer table's FRI is degenerate in the useful sense — its terminal -/// codeword IS its deep-composition codeword — so its "own instance" is one -/// terminal polynomial and no layers at all. -/// -/// # ★ The index rule BETWEEN the classes — a hard precondition -/// -/// Both classes are opened at the SAME query indices, because the mixed-height -/// MMCS is unaffected by this split: it still commits every table, and the point -/// of one shared authentication path survives whole. What differs is the index -/// SPACE each class reads them in: -/// -/// ```text -/// batched class: iota, used directly (it is an index in the tallest domain) -/// standalone table: iota >> (h_max - h_t) -/// ``` -/// -/// This is the same reduction [`crate::fri::mmcs`]'s index-convention section -/// documents for a short round, and it fails the same silent way: prover and -/// verifier derive it from the shape, so a wrong shift is self-consistent — -/// honest proofs still verify while the short tables end up checked at positions -/// the FRI join never reaches. `each_instance_class_is_tamper_checked` is the -/// control, and it tampers a table of EACH class, because a control that only -/// touched the batched class would pass under any convention for the other. -/// -/// # Determinism -/// -/// The plan is a pure function of `(heights, blowup_log, final_poly_log_degree)`, -/// all of which the transcript has bound before any challenge that depends on it. -/// Prover and verifier therefore derive the SAME partition without it being sent, -/// which is why the split adds nothing to the wire and nothing to the shape -/// binding. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FriInstancePlan { - /// Table indices whose codewords are mixed into the batched instance, in - /// input order — the order that defines the `alpha` powers. - pub batched: Vec, - /// Table indices that keep a terminal-only instance, in input order. - pub standalone: Vec, - /// Tallest and shortest height WITHIN the batched class — the layout is - /// derived from these, not from the whole epoch. - pub h_max: usize, - pub h_min: usize, -} - -impl FriInstancePlan { - /// Partition an epoch's tables. `None` when `heights` is empty or carries a - /// height that cannot be a codeword length — both are proof-supplied, so both - /// are rejections rather than panics. - /// - /// The TALLEST table is always batched, even if it would classify as - /// standalone on its own. That keeps the batched class non-empty, so the - /// layout is always well defined; an epoch whose tallest table folds nothing - /// degenerates to a single terminal-only instance, which is what it should be. - pub fn new(heights: &[usize], blowup_log: u32, final_poly_log_degree: u32) -> Option { - if heights.is_empty() { - return None; - } - let &h_max_epoch = heights.iter().max()?; - if h_max_epoch == 0 || h_max_epoch >= u32::BITS as usize { - return None; - } - let tallest = heights.iter().position(|h| *h == h_max_epoch)?; - - let mut batched = Vec::with_capacity(heights.len()); - let mut standalone = Vec::new(); - for (t, &h) in heights.iter().enumerate() { - if h < blowup_log as usize { - return None; - } - let folds_a_layer = - FriFoldLayout::new(h as u32, blowup_log, final_poly_log_degree).num_committed > 0; - if folds_a_layer || t == tallest { - batched.push(t); - } else { - standalone.push(t); - } - } - - let h_max = batched.iter().map(|&t| heights[t]).max()?; - let h_min = batched.iter().map(|&t| heights[t]).min()?; - Some(Self { - batched, - standalone, - h_max, - h_min, - }) - } -} - -/// FRI commit phase over the bucketed output of [`combine_by_height`] / -/// [`HeightCombiner::finish`]. -/// -/// `combined[h]` is `Some(codeword)` when there are DEEP contributions at height -/// `h` (codeword length `2^h`), or `None` otherwise. -/// -/// Folding starts from the tallest bucket. After each fold to height `h`, the -/// bucket at `combined[h]` is injected into the running codeword with -/// coefficient `β²` (β being the fold challenge just used), before the layer is -/// committed. Termination follows [`BatchedFriLayout`]: the running codeword is -/// folded to the terminal length and the terminal polynomial's coefficients are -/// appended to the transcript, exactly as -/// [`crate::fri::commit_phase_from_evaluations`] does — not folded down to a -/// single scalar. -/// -/// Layer trees are built with `FriLayerMerkleTreeBackend`, the same commitment -/// backend the unbatched [`crate::fri::commit_phase_from_evaluations`] uses — so a -/// batched prover and the verifier that authenticates its openings through -/// `BatchedMerkleTreeBackend` agree on the hash by naming one backend, not by two -/// call sites coinciding. -#[allow(clippy::type_complexity)] -pub fn batched_commit_phase( - mut combined: Vec>>>, - transcript: &mut T, - coset_offset: &FieldElement, - blowup_log: u32, - final_poly_log_degree: u32, -) -> ( - Vec>, - Vec>>, -) -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static + Send + Sync, - T: IsStarkTranscript + Clone, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - let (h_min, h_max) = bucket_height_range(&combined) - .expect("batched_commit_phase: combined must have at least one Some entry"); - - let domain_size = 1usize << h_max; - // Inverse twiddle factors for the initial domain size. - let inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); - - // The device fast path is selected one layer up, in - // `crate::batched::round4::commit_batched_fri`, so this stays a pure host - // build and a device error there is a hard abort rather than a fallback into - // this function. `h_min` is still consumed by the terminal-floor layout. - - // Take the starting codeword — NOT committed; it plays the role of layer 0. - let mut running = combined[h_max] - .take() - .expect("combined[h_max] is Some by construction"); - - debug_assert_eq!( - running.len(), - domain_size, - "starting codeword length must equal 2^h_max" - ); - - let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); - - let mut inv_twiddles = inv_twiddles; - - let mut fri_layer_list = Vec::with_capacity(layout.num_committed); - - for _ in 0..layout.num_committed { - // <<<< Receive challenge β - let beta = transcript.sample_field_element(); - - // Fold evaluations in-place; running halves in length. - fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); - inject_bucket(&mut running, &mut combined, &beta); - - // Build the row-pair Merkle tree over the current running codeword. - let leaves: Vec<[FieldElement; 2]> = running - .chunks_exact(2) - .map(|chunk| [chunk[0].clone(), chunk[1].clone()]) - .collect(); - let merkle_tree = MerkleTree::>::build(&leaves) - .expect("FRI batched commit: Merkle tree construction must succeed"); - let root = merkle_tree.root; - fri_layer_list.push(FriLayer::new(&running, merkle_tree)); - - // >>>> Send commitment: append root to transcript. - transcript.append_bytes(&root); - - // Update twiddles for the next (halved) level. - update_twiddles_in_place(&mut inv_twiddles); - } - - // One final fold to reach the terminal codeword, unless already there. The - // bucket AT the terminal height is injected here: it is the last one that can - // still enter the running word, which is why the layout floors the terminal - // at the shortest height rather than at `blowup_log + k` alone. - if layout.total_folds > 0 { - let beta = transcript.sample_field_element(); - fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); - inject_bucket(&mut running, &mut combined, &beta); - } - debug_assert_eq!( - running.len(), - layout.terminal_len, - "terminal codeword size mismatch" - ); - debug_assert!( - combined.iter().all(Option::is_none), - "every bucket must have been injected before the terminal" - ); - - // Recover the terminal polynomial's coefficients and send them, mirroring - // `commit_phase_from_evaluations`: the coefficient count follows - // `layout.effective_k` (the actual terminal), and the terminal coset offset - // is `coset_offset^(2^total_folds)`. - let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); - let final_poly_coeffs = - coeffs_from_terminal_codeword::(&running, &terminal_offset, layout.effective_k); - for c in &final_poly_coeffs { - transcript.append_field_element(c); - } - - (final_poly_coeffs, fri_layer_list) -} - -/// The `(h_min, h_max)` of the occupied buckets, or `None` when none are. -/// `pub(crate)` so `commit_batched_fri` can size the device FRI's twiddles from -/// the same range this host build uses. -pub(crate) fn bucket_height_range( - combined: &[Option>>], -) -> Option<(usize, usize)> { - let mut occupied = combined - .iter() - .enumerate() - .filter_map(|(h, slot)| slot.as_ref().map(|_| h)); - let first = occupied.next()?; - Some((first, occupied.next_back().unwrap_or(first))) -} - -/// `running += β² · combined[h]` for the running codeword's current height `h`, -/// consuming that bucket. A no-op when the bucket is empty. -fn inject_bucket( - running: &mut [FieldElement], - combined: &mut [Option>>], - beta: &FieldElement, -) { - let h = running.len().trailing_zeros() as usize; - let Some(bucket) = combined.get_mut(h).and_then(Option::take) else { - return; - }; - debug_assert_eq!( - bucket.len(), - running.len(), - "a bucket at height {h} must match the running codeword's length" - ); - let beta_sq = beta.square(); - for (val, contribution) in running.iter_mut().zip(bucket.iter()) { - *val = &*val + &(&beta_sq * contribution); - } -} - -/// Canonical, order-deterministic absorption of an epoch's table-SHAPE histogram -/// into the transcript. Single source of truth for the structural binding. -/// -/// The multiset of `lde_log_height`s across an epoch's tables fully determines -/// the fold order and injection points of the batched FRI (arity is uniformly -/// 2), so binding the heights binds the whole injection schedule. The widths are -/// bound alongside them because they are what makes the mixed-height MMCS leaf -/// parse unambiguous (see [`crate::fri::mmcs`]'s width-binding section) — the -/// verifier derives widths from the AIR set rather than the proof, so this is -/// defence in depth rather than the primary binding, and it costs one field per -/// table. -/// -/// Encoding (fixed-width, length-prefixed, order-preserving): -/// `u64::to_le_bytes(len)` followed by `u64::to_le_bytes(h)`, `u64::to_le_bytes(w)` -/// for each `(h, w)` pair, in the exact order given. Caller (prover and verifier -/// alike) must pass the shape in the same canonical per-epoch table order — this -/// function does not sort or deduplicate. -/// -/// Panics if `heights` and `widths` differ in length; both sides construct them -/// from the same table list. -pub fn absorb_shape_histogram(transcript: &mut T, heights: &[usize], widths: &[usize]) -where - E: IsField, - T: IsTranscript, -{ - assert_eq!( - heights.len(), - widths.len(), - "the shape histogram needs one width per height" - ); - transcript.append_bytes(&(heights.len() as u64).to_le_bytes()); - for (h, w) in heights.iter().zip(widths.iter()) { - transcript.append_bytes(&(*h as u64).to_le_bytes()); - transcript.append_bytes(&(*w as u64).to_le_bytes()); - } -} - -/// Challenges derived from replaying the shared batched round-4 transcript -/// sequence. See [`derive_batched_fri_challenges`]. -#[derive(Debug, Clone)] -pub struct BatchedFriChallenges { - /// Sampled once after the shape histogram (and, at the call site, after all - /// per-table OOD evaluations have been absorbed). - pub alpha: FieldElement, - /// One per committed layer, plus one for the final fold when there is one: - /// `betas.len() == layout.num_committed + (layout.total_folds > 0) as usize`. - pub betas: Vec>, - /// The layout the betas and the terminal were derived under. - pub layout: BatchedFriLayout, - /// Transcript state right before the grinding nonce bytes are appended. - /// All-zero when `grinding_factor == 0` or `nonce` is `None`. - pub grinding_seed: [u8; 32], - /// One `sample_u64(2^(h_max - 1))` draw per query — a row-PAIR index in the - /// tallest domain. A round whose own `h_max` is lower must reduce these; see - /// [`crate::fri::mmcs`]'s index-convention section. - pub iotas: Vec, - /// Which tables the batched instance carries and which keep a terminal-only - /// instance of their own. Derived from the shape, never sent. - pub plan: FriInstancePlan, -} - -/// Replays the shared batched round-4 transcript sequence (shape histogram, -/// alpha, per-layer beta/root, final beta, terminal coefficients, grinding, query -/// iotas) and returns the derived challenges. The one routine the prover and the -/// verifier both call, so they provably derive identical challenges. -/// -/// `standalone_coeffs[t]` is table `t`'s terminal-only polynomial, `Some` -/// exactly for the standalone class — presence is checked against the derived -/// plan and every coefficient is ABSORBED, right after `α` and before the -/// first `ζ`. That absorb is load-bearing: the standalone check evaluates the -/// sent polynomial at the query indices drawn BELOW, so a polynomial that -/// were not bound here could be chosen after the indices are known, and each -/// query's proximity test would bind nothing until the queries saturate the -/// table's domain. The unbatched path absorbs its terminal before sampling -/// queries for the same reason; this keeps the batched path's binding equal. -/// -/// Returns `None` when the proof's layer-root count disagrees with the layout the -/// epoch's shape implies, when the terminal coefficient count is wrong, or when -/// a standalone polynomial is present for the wrong class — all prover-supplied, -/// all rejections, not panics. -#[allow(clippy::too_many_arguments)] -pub fn derive_batched_fri_challenges( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - layer_roots: &[[u8; 32]], - final_poly_coeffs: &[FieldElement], - standalone_coeffs: &[Option<&[FieldElement]>], - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - nonce: Option, - num_queries: usize, -) -> Option> -where - E: IsField, - T: IsTranscript, -{ - // The partition is derived, not sent: it is a pure function of the shape the - // histogram below binds, so both sides reach the same one. `None` on any - // height that cannot be a codeword length — heights come from proof-supplied - // trace lengths, so a bogus one is a rejection, never a panic on the - // verifier's path. - let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree)?; - let (h_max, h_min) = (plan.h_max, plan.h_min); - let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); - if layer_roots.len() != layout.num_committed - || final_poly_coeffs.len() != 1usize << layout.effective_k - || standalone_coeffs.len() != heights.len() - { - return None; - } - - absorb_shape_histogram(transcript, heights, widths); - - let alpha = transcript.sample_field_element(); - - // The standalone class's terminal polynomials, bound before any query can - // depend on them — per table ascending, each coefficient in order. The - // length pin (`2^(h_t − blowup_log)`, exactly) stays with - // `verify_epoch_commitments`. - for (table, coeffs) in standalone_coeffs.iter().enumerate() { - if coeffs.is_some() != plan.standalone.contains(&table) { - return None; - } - if let Some(coeffs) = coeffs { - for c in coeffs.iter() { - transcript.append_field_element(c); - } - } - } - - let mut betas = Vec::with_capacity(layout.num_committed + 1); - for root in layer_roots { - let beta = transcript.sample_field_element(); - transcript.append_bytes(root); - betas.push(beta); - } - - if layout.total_folds > 0 { - betas.push(transcript.sample_field_element()); - } - for c in final_poly_coeffs { - transcript.append_field_element(c); - } - - let mut grinding_seed = [0u8; 32]; - if grinding_factor > 0 - && let Some(nonce_value) = nonce - { - grinding_seed = transcript.state(); - transcript.append_bytes(&nonce_value.to_be_bytes()); - } - - let iotas = (0..num_queries) - .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) - .collect(); - - Some(BatchedFriChallenges { - alpha, - betas, - layout, - grinding_seed, - iotas, - plan, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::fri::commit_phase_from_evaluations; - use crate::fri::fri_functions::{compute_coset_twiddles_inv, fold_evaluations_in_place}; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use crypto::fiat_shamir::is_transcript::IsTranscript; - use math::field::element::FieldElement; - use math::field::goldilocks::GoldilocksField; - - type FE = FieldElement; - type Transcript = DefaultTranscript; - - #[test] - fn combine_by_height_two_height3_one_height2() { - // Three codewords: indices 0, 1 have height 3 (length 8); - // index 2 has height 2 (length 4). - let cw0: Vec = (1u64..=8).map(FE::from).collect(); - let cw1: Vec = (10u64..=17).map(FE::from).collect(); - let cw2: Vec = (100u64..=103).map(FE::from).collect(); - - let alpha = FE::from(7u64); - - let inputs: Vec<(Vec, usize)> = - vec![(cw0.clone(), 3), (cw1.clone(), 3), (cw2.clone(), 2)]; - - let out = combine_by_height(&inputs, &alpha); - - // Output vec length = max_height + 1 = 4 (indices 0..=3 only). - assert_eq!(out.len(), 4, "output length should be max_height+1 = 4"); - - // Heights 0 and 1 have no inputs. - assert!(out[0].is_none(), "height 0 should be None"); - assert!(out[1].is_none(), "height 1 should be None"); - - // Height 3: combined[j] = alpha^0 * cw0[j] + alpha^1 * cw1[j] - let alpha0 = FE::one(); - let alpha1 = alpha; - let expected3: Vec = cw0 - .iter() - .zip(cw1.iter()) - .map(|(a, b)| &(&alpha0 * a) + &(&alpha1 * b)) - .collect(); - - let got3 = out[3].as_ref().expect("height 3 should be Some"); - assert_eq!( - got3.len(), - 8, - "height-3 combined codeword should have length 8" - ); - assert_eq!(got3, &expected3, "height-3 combined values mismatch"); - - // Height 2: combined[j] = alpha^2 * cw2[j] - let alpha2 = &alpha * α - let expected2: Vec = cw2.iter().map(|x| &alpha2 * x).collect(); - - let got2 = out[2].as_ref().expect("height 2 should be Some"); - assert_eq!( - got2.len(), - 4, - "height-2 combined codeword should have length 4" - ); - assert_eq!(got2, &expected2, "height-2 combined values mismatch"); - } - - /// Absorbing codewords one at a time — the shape a prover uses so it never - /// holds every table's quotient at once — must land on the same buckets as - /// handing them all over materialized. - #[test] - fn streaming_absorption_matches_materialized_combine() { - let inputs: Vec<(Vec, usize)> = vec![ - ((1u64..=16).map(FE::from).collect(), 4), - ((50u64..=57).map(FE::from).collect(), 3), - ((90u64..=105).map(FE::from).collect(), 4), - ((200u64..=203).map(FE::from).collect(), 2), - ((300u64..=307).map(FE::from).collect(), 3), - ]; - let alpha = FE::from(11u64); - - let eager = combine_by_height(&inputs, &alpha); - - let mut combiner = HeightCombiner::new(alpha); - for (codeword, height) in &inputs { - combiner.absorb(codeword, *height); - } - assert_eq!( - combiner.finish(), - eager, - "streaming absorption must equal the materialized combine" - ); - } - - /// After the first fold in `batched_commit_phase`, the committed layer[0] - /// evaluation must equal `fold(combined[4], β₀) + β₀² · combined[3]`. - #[test] - fn batched_commit_phase_first_layer_matches_manual_fold_and_inject() { - // Build synthetic codewords for h=4 (len 16) and h=3 (len 8). - let data_h4: Vec = (1u64..=16).map(FE::from).collect(); - let data_h3: Vec = (101u64..=108).map(FE::from).collect(); - - // combined = [None, None, None, Some(data_h3), Some(data_h4)] - let combined: Vec>> = vec![ - None, - None, - None, - Some(data_h3.clone()), - Some(data_h4.clone()), - ]; - - let coset_offset = FE::from(3u64); - let (blowup_log, k) = (1u32, 1u32); - - // Create transcript; clone before mutating so we can replay independently. - let mut transcript = Transcript::new(b"batched_fri_test"); - let mut transcript_check = transcript.clone(); - - let (_coeffs, layers) = batched_commit_phase::<_, _, _>( - combined, - &mut transcript, - &coset_offset, - blowup_log, - k, - ); - - // Terminal at min(blowup_log + k, h_min) = min(2, 3) = 2, so folds run - // 4 -> 2: two folds, one committed layer. - let layout = BatchedFriLayout::new(4, 3, blowup_log, k); - assert_eq!(layout.total_folds, 2); - assert_eq!( - layers.len(), - layout.num_committed, - "committed layers must follow the layout" - ); - - // --- Independent recomputation of layer[0] --- - let beta_0 = transcript_check.sample_field_element(); - - let inv_twiddles_h4 = compute_coset_twiddles_inv::(&coset_offset, 16); - let mut expected = data_h4.clone(); - fold_evaluations_in_place(&mut expected, &beta_0, &inv_twiddles_h4); - // expected now has length 8 (height 3) - - // Inject combined[3]: expected[j] += beta_0² · data_h3[j] - let beta_0_sq = beta_0.square(); - for (j, val) in data_h3.iter().enumerate() { - expected[j] = &expected[j] + &(&beta_0_sq * val); - } - - assert_eq!( - layers[0].evaluation, expected, - "layer[0] evaluation does not match manual fold+inject" - ); - } - - /// ★ M-12: the batched commit phase must terminate where the unbatched one - /// does. With a single bucket the two are the same protocol, so they must - /// agree on the committed-layer count, the terminal coefficients, and the - /// resulting transcript state — pinning that batching did not silently switch - /// to folding all the way to a scalar (which for this input would commit - /// `h_max - 1 = 9` layers instead of 4). - #[test] - fn single_bucket_terminal_matches_the_unbatched_commit_phase() { - let h = 10usize; - let (blowup_log, k) = (1u32, 5u32); - let coset_offset = FE::from(3u64); - let evals: Vec = (0..(1u64 << h)).map(|i| FE::from(i * 7 + 1)).collect(); - let inv_twiddles = compute_coset_twiddles_inv::(&coset_offset, 1 << h); - - let mut t_unbatched = Transcript::new(b"terminal_parity"); - let (unbatched_coeffs, unbatched_layers) = - commit_phase_from_evaluations::( - evals.clone(), - &mut t_unbatched, - &coset_offset, - 1 << h, - blowup_log, - k, - &inv_twiddles, - ); - - let mut combined: Vec>> = vec![None; h + 1]; - combined[h] = Some(evals); - let mut t_batched = Transcript::new(b"terminal_parity"); - let (batched_coeffs, batched_layers) = - batched_commit_phase::<_, _, _>(combined, &mut t_batched, &coset_offset, blowup_log, k); - - // total_folds = 10 - (1 + 5) = 4, so 3 committed layers — not h_max-1 = 9. - assert_eq!(unbatched_layers.len(), 3); - assert_eq!( - batched_layers.len(), - unbatched_layers.len(), - "batched and unbatched must commit the same number of layers" - ); - assert_eq!( - batched_coeffs.len(), - 1usize << k, - "the terminal polynomial must carry 2^k coefficients" - ); - assert_eq!( - batched_coeffs, unbatched_coeffs, - "batched and unbatched must send the same terminal polynomial" - ); - for (b, u) in batched_layers.iter().zip(unbatched_layers.iter()) { - assert_eq!(b.merkle_tree.root, u.merkle_tree.root); - } - assert_eq!( - t_batched.state(), - t_unbatched.state(), - "the two commit phases must leave the transcript in the same state" - ); - } - - /// The batched-only floor: the terminal may not sit above the shortest - /// injected codeword, or that bucket would never enter the running word. - #[test] - fn terminal_is_floored_at_the_shortest_codeword() { - let (blowup_log, k) = (1u32, 5u32); - - // Shortest codeword above blowup_log + k = 6: the floor is inert and the - // layout is the unbatched one for h_max. - let inert = BatchedFriLayout::new(10, 8, blowup_log, k); - assert_eq!(inert.total_folds, 4, "10 -> 6"); - assert_eq!(inert.effective_k, k); - - // Shortest codeword BELOW blowup_log + k: folding must continue down to - // it, and the terminal polynomial shrinks accordingly. - let floored = BatchedFriLayout::new(10, 4, blowup_log, k); - assert_eq!(floored.total_folds, 6, "10 -> 4"); - assert_eq!(floored.effective_k, 3, "terminal_log 4 - blowup_log 1"); - - // And the commit phase really does consume that low bucket. - let coset_offset = FE::from(3u64); - let mut combined: Vec>> = vec![None; 8]; - combined[7] = Some((0..128u64).map(|i| FE::from(i + 1)).collect()); - combined[4] = Some((0..16u64).map(|i| FE::from(i * 3 + 5)).collect()); - let mut transcript = Transcript::new(b"floor_test"); - let (coeffs, layers) = batched_commit_phase::<_, _, _>( - combined, - &mut transcript, - &coset_offset, - blowup_log, - k, - ); - let layout = BatchedFriLayout::new(7, 4, blowup_log, k); - assert_eq!(layers.len(), layout.num_committed); - assert_eq!(coeffs.len(), 1usize << layout.effective_k); - } - - /// The prover, by hand, runs exactly the round-4 sequence; the shared replay - /// routine must reproduce byte-identical outputs from the same start state. - #[test] - fn batched_round4_prover_inline_matches_verifier_replay() { - let heights: Vec = vec![10, 10, 8, 8, 8, 7]; - let widths: Vec = vec![3, 5, 2, 2, 9, 1]; - let (blowup_log, k) = (1u32, 5u32); - // total_folds = 10 - 6 = 4 -> 3 committed layers, 4 betas. - let layout = BatchedFriLayout::new(10, 7, blowup_log, k); - assert_eq!((layout.num_committed, layout.total_folds), (3, 4)); - - let layer_roots: Vec<[u8; 32]> = (0u8..3).map(|i| [i; 32]).collect(); - let final_poly_coeffs: Vec = (0..(1u64 << layout.effective_k)).map(FE::from).collect(); - // Height 7 folds no layer at these parameters, so table 5 is standalone - // and its terminal polynomial is part of the round-4 sequence. - let standalone_terminal: Vec = (0..(1u64 << (7 - blowup_log))).map(FE::from).collect(); - - let grinding_factor: u8 = 4; - let num_queries = 3; - - let seed_transcript = Transcript::new(b"batched_round4_test"); - let mut transcript_a = seed_transcript.clone(); - let mut transcript_b = seed_transcript.clone(); - - // --- Clone A: prover-inline sequence, by hand --- - absorb_shape_histogram(&mut transcript_a, &heights, &widths); - let alpha_a = transcript_a.sample_field_element(); - for c in &standalone_terminal { - transcript_a.append_field_element(c); - } - - let mut betas_a = Vec::with_capacity(layer_roots.len() + 1); - for root in &layer_roots { - let beta = transcript_a.sample_field_element(); - transcript_a.append_bytes(root); - betas_a.push(beta); - } - betas_a.push(transcript_a.sample_field_element()); - for c in &final_poly_coeffs { - transcript_a.append_field_element(c); - } - assert_eq!( - betas_a.len(), - layout.total_folds as usize, - "one beta per fold, matching batched_commit_phase" - ); - - let grinding_seed_a = transcript_a.state(); - // Test-only: derive a real PoW nonce so the grinding step is exercised - // identically by both sides (the nonce search itself is not under test). - let nonce = crate::grinding::generate_nonce(&grinding_seed_a, grinding_factor) - .expect("a valid grinding nonce exists for this small grinding_factor"); - transcript_a.append_bytes(&nonce.to_be_bytes()); - - let iotas_a: Vec = (0..num_queries) - .map(|_| transcript_a.sample_u64(1u64 << 9) as usize) - .collect(); - - // --- Clone B: shared replay routine --- - let standalone: Vec> = vec![ - None, - None, - None, - None, - None, - Some(standalone_terminal.as_slice()), - ]; - let result = derive_batched_fri_challenges( - &mut transcript_b, - &heights, - &widths, - &layer_roots, - &final_poly_coeffs, - &standalone, - blowup_log, - k, - grinding_factor, - Some(nonce), - num_queries, - ) - .expect("a well-formed layer-root and coefficient count"); - - assert_eq!(result.alpha, alpha_a, "alpha mismatch"); - assert_eq!(result.betas, betas_a, "beta vector mismatch"); - assert_eq!(result.layout, layout, "layout mismatch"); - assert_eq!( - result.grinding_seed, grinding_seed_a, - "grinding seed mismatch" - ); - assert_eq!(result.iotas, iotas_a, "iotas mismatch"); - assert!( - result.iotas.iter().all(|&i| i < 1usize << 9), - "iotas must be row-pair indices in the tallest domain" - ); - } - - /// A layer-root or coefficient count that disagrees with the shape's layout is - /// prover-supplied, so it is a rejection rather than a panic. - #[test] - fn derive_rejects_a_layer_count_that_contradicts_the_shape() { - let heights: Vec = vec![10, 8]; - let widths: Vec = vec![2, 3]; - let (blowup_log, k) = (1u32, 5u32); - let layout = BatchedFriLayout::new(10, 8, blowup_log, k); - let coeffs: Vec = vec![FE::one(); 1usize << layout.effective_k]; - let roots: Vec<[u8; 32]> = vec![[0u8; 32]; layout.num_committed]; - - let no_standalone: Vec> = vec![None; heights.len()]; - let mut ok = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut ok, - &heights, - &widths, - &roots, - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_some() - ); - - let mut too_few = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut too_few, - &heights, - &widths, - &roots[..roots.len() - 1], - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_none(), - "one fewer layer root than the shape implies must be rejected" - ); - - let mut bad_coeffs = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut bad_coeffs, - &heights, - &widths, - &roots, - &coeffs[..coeffs.len() - 1], - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_none(), - "a short terminal polynomial must be rejected" - ); - } - - /// `heights` comes from proof-supplied trace lengths, so every out-of-range - /// value is a rejection rather than a shift overflow or a layout assert. - #[test] - fn derive_rejects_out_of_range_heights_without_panicking() { - let widths = vec![2usize, 3]; - let (blowup_log, k) = (1u32, 5u32); - let coeffs: Vec = vec![FE::one(); 1usize << k]; - let roots: Vec<[u8; 32]> = vec![[0u8; 32]; 3]; - - let derive = |heights: &[usize]| { - let no_standalone: Vec> = vec![None; heights.len()]; - derive_batched_fri_challenges( - &mut Transcript::new(b"range"), - heights, - &widths, - &roots, - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1, - ) - .is_some() - }; - - assert!(derive(&[10, 8]), "a well-formed shape is accepted"); - assert!(!derive(&[0, 0]), "a zero height must be rejected"); - assert!( - !derive(&[10, 0]), - "a height below the blowup must be rejected" - ); - assert!( - !derive(&[u32::BITS as usize, 8]), - "a height at the shift width must be rejected" - ); - assert!( - !derive(&[usize::MAX, 8]), - "an absurd height must be rejected, not wrapped by the u32 cast" - ); - let empty: [usize; 0] = []; - assert!(!derive(&empty), "an empty epoch must be rejected"); - } - - /// Tampering the shape histogram (without changing anything else) must change - /// the derived batching challenge α — the structural binding that protects the - /// fold/injection schedule. Heights and widths are both bound (M-13a), so a - /// change to either alone must move α. - #[test] - fn absorb_shape_histogram_binds_heights_and_widths_into_alpha() { - let heights: Vec = vec![10, 10, 8, 8, 8, 5]; - let widths: Vec = vec![4, 4, 2, 2, 2, 1]; - - let alpha_of = |h: &[usize], w: &[usize]| { - let mut t = Transcript::new(b"histogram_binding_test"); - absorb_shape_histogram(&mut t, h, w); - t.sample_field_element() - }; - - let base = alpha_of(&heights, &widths); - - let mut other_height = heights.clone(); - other_height[5] = 6; - assert_ne!( - base, - alpha_of(&other_height, &widths), - "different height histograms must yield different alpha" - ); - - let mut other_width = widths.clone(); - other_width[5] = 2; - assert_ne!( - base, - alpha_of(&heights, &other_width), - "different width histograms must yield different alpha" - ); - - // The length prefix plus fixed-width fields make the encoding injective: - // swapping a (height, width) pair between tables also moves alpha. - let swapped_h = vec![10, 10, 8, 8, 5, 8]; - let swapped_w = vec![4, 4, 2, 2, 1, 2]; - assert_ne!( - base, - alpha_of(&swapped_h, &swapped_w), - "table order must be bound, not just the multiset" - ); - } -} diff --git a/crypto/stark/src/fri/mmcs.rs b/crypto/stark/src/fri/mmcs.rs deleted file mode 100644 index 22fffb910..000000000 --- a/crypto/stark/src/fri/mmcs.rs +++ /dev/null @@ -1,1922 +0,0 @@ -//! Mixed-height, row-pair MMCS (Merkle Mixed Commitment Scheme). -//! -//! Commits ALL of an epoch's matrices (one per table, of possibly different -//! heights) into ONE mixed-height Merkle tree, so a single query opens ONE -//! authentication path that covers every table's row at that query — the -//! proof-size / opening-path win of the unified-shard design (SP1 / OpenVM / -//! Plonky3). Mirrors Plonky3's `MerkleTreeMmcs`, adapted to the concrete keccak -//! commitment backends and to the row-pair `(x, -x)` leaf layout (#735). -//! -//! This is a standalone primitive: the prover and verifier do not build epoch -//! commitments with it yet. The leaf and injection layout documented below is -//! the single source of truth for whoever wires it in. -//! -//! # Inputs -//! -//! [`MixedMmcs::commit`] reads matrices through a [`LeafSource`], which reports -//! each matrix's `(log_height, width)` and serves its rows on demand: -//! - `log_height`: `log2` of the row count; the matrix has `2^log_height` rows. -//! - `width`: number of committed columns. -//! - rows are addressed by **bit-reversed** LDE position (the same layout the -//! per-table trace commit produces internally). -//! -//! # Row-pair leaves -//! -//! Leaf `k` of a matrix groups LDE positions `2k` and `2k+1` (the FRI fold pair -//! `x` and `-x`), all `width` columns batched. A matrix of `log_height h` has -//! `2^(h-1)` leaves. In [`MixedMmcs::open_batch`] / [`PolynomialOpenings`]: -//! `evaluations` = row `2k`, `evaluations_sym` = row `2k+1`. -//! -//! # Tree layout (the soundness-relevant contract) -//! -//! Let `h_max = max(log_height)`. The base digest layer (layer 0) has -//! `N0 = 2^(h_max-1)` nodes. Layer `i` has `N0 >> i` nodes; the root is the sole -//! node of layer `h_max-1`. A matrix of `log_height h` is *injected* at layer -//! index `i = h_max - h` (so the tallest matrices, `h == h_max`, populate the -//! base layer; shorter matrices enter where the layer width matches their leaf -//! count `2^(h-1)`). -//! -//! Hashing (`H = >::hash_data` over a `Vec` of field -//! elements; `C = >::hash_new_parent`, the 2-input -//! compression — the same two functions, on the same backend, that the existing -//! per-table tree uses): -//! -//! - **Base layer** node `k` (`k in [0, N0)`): -//! `layer0[k] = H( CONCAT_{m : h_m == h_max} (row_m(2k) || row_m(2k+1)) )` -//! where matrices of height `h_max` are concatenated in INPUT order. -//! - **Climb** from layer `i` to layer `i+1` (`j in [0, N_{i+1})`): -//! `parent = C(layer_i[2j], layer_i[2j+1])`. Let `inject_h = h_max - 1 - i`. If -//! any matrix has `h_m == inject_h`, then -//! `layer_{i+1}[j] = C( parent, H( CONCAT_{m : h_m == inject_h} (row_m(2j) || row_m(2j+1)) ) )` -//! (injecting matrices concatenated in INPUT order); otherwise -//! `layer_{i+1}[j] = parent`. -//! - `root = layer_{h_max-1}[0]`. -//! -//! Because the leaf and parent hashes come from `BatchedMerkleTreeBackend` — -//! the backend the per-table row-pair tree already commits with — a single-matrix `MixedMmcs` is -//! byte-identical to that tree by construction, not by coincidence. There is no -//! second encoding of a leaf to keep in step. -//! -//! # Query opening -//! -//! For query `iota in [0, N0)`, matrix `m` is opened at leaf -//! `k_m = iota >> (h_max - h_m)` (`= iota >> i_m`). The shared authentication -//! path holds, for each level `level in [0, h_max-1)`, the sibling -//! `layer_level[(iota >> level) ^ 1]`. ONE path authenticates all matrices. -//! The per-matrix [`PolynomialOpenings::proof`] fields are empty; the single -//! [`MixedOpening::proof`] is the authenticator. -//! -//! # ★ Index convention — a HARD PRECONDITION on the caller -//! -//! `iota` is a leaf index **in THIS tree**: it must be drawn from -//! `[0, 2^(h_max-1))` where `h_max` is *this MMCS's* tallest matrix. -//! [`MixedMmcs::verify_batch`] walks the path with `(iota >> level) & 1`, i.e. it -//! consumes the **low** `h_max - 1` bits, while a shorter matrix inside the tree -//! is located by `iota >> (h_max - h_m)`, i.e. by the **high** bits. Both are -//! consistent only when the two `h_max` agree. -//! -//! A caller that batches several rounds under one shared FRI query index must -//! therefore reduce a global index before calling in: -//! -//! ```text -//! iota_round = iota_fri >> (h_max_fri - h_max_round) -//! ``` -//! -//! Passing the un-reduced `iota_fri` to a round whose `h_max` is below the FRI's -//! is not a loud error — prover and verifier share this routine, so a wrong -//! convention is self-consistent: honest proofs still verify and the failure is -//! that short matrices end up authenticated at positions the FRI join never -//! checks. [`MixedMmcs::verify_batch`] rejects an `iota` outside `[0, 2^(h_max-1))` -//! to turn most of that class of misuse into a rejection rather than a silent -//! mis-binding, but the reduction remains the caller's obligation: an index that -//! happens to land in range is accepted at the wrong leaf. -//! `short_round_low_bit_convention_is_exercised` is the control on this. -//! -//! # Width binding (soundness) -//! -//! [`MixedMmcs::verify_batch`] takes per-matrix `widths` alongside `heights`. -//! Within a height group the leaf hash is over the FLAT concatenation of every -//! matrix's opened row pair (`A.eval ‖ A.eval_sym ‖ B.eval ‖ B.eval_sym ‖ …`), -//! which does NOT by itself record where each matrix's columns end. Fixing -//! `widths[m]` (matrix `m`'s column count) makes those boundaries unambiguous: -//! without it a prover could shift a boundary — e.g. lengthen one matrix's -//! `evaluations` by one element and shorten its `evaluations_sym` by one — -//! leaving the flat bytes (and therefore the group hash) identical while feeding -//! a corrupted row downstream. Consumers MUST pass the committed public -//! per-table column counts, in the same INPUT order as `heights`, derived from -//! the AIR set rather than read out of the proof. -//! -//! `heights` and `widths` must ALSO be bound into the Fiat-Shamir transcript by -//! the consumer, before any challenge that depends on the epoch's shape — see -//! [`crate::fri::batched::absorb_shape_histogram`], which is the canonical -//! encoding of that binding. -//! -//! # Determinism -//! -//! The tree is a pure function of `(matrices, input order)`. Grouping within a -//! height (base batching and injection) follows INPUT order; the prover and -//! verifier MUST pass matrices and `heights` in the same per-epoch order. -//! -//! # Memory: what the caller may drop, and when -//! -//! The MMCS owns no evaluations. It stores the digest layers -//! (`O(2^(h_max-1))` nodes) plus each matrix's `(log_height, width)`; rows are -//! pulled through [`LeafSource`] both at commit and at open time. Two properties -//! follow, and `commit_reads_each_height_group_in_one_contiguous_phase` is the -//! control on the second: -//! -//! - `commit` reads matrix `m`'s rows **only while building level -//! `h_max - h_m`**, and levels are built in descending height order. A caller -//! may therefore produce a height group's LDEs, commit, and drop them before -//! the next group is needed. -//! - Within one height group the leaf is a single `hash_data` over the group's -//! concatenated rows, so `commit` reads every matrix of that height at every -//! leaf: their access windows overlap, and a caller serving them from in-RAM -//! LDE buffers holds the whole group at once. Since the tallest group is most -//! of a real epoch's tables, that is `O(N)` resident at the base layer. -//! -//! A `LeafSource` serving rows from disk, device memory or recomputation is the -//! escape for that base-group residency: it lets a caller stream a matrix in, -//! hash it, and drop it without holding the whole group in RAM at once. -//! -//! (A streaming, incremental-leaf-hasher builder that keeps one hasher per leaf -//! and absorbs matrices as they arrive is planned but not part of this phase.) - -use core::marker::PhantomData; - -use crypto::merkle_tree::proof::Proof; -use crypto::merkle_tree::traits::{IsLeafHasher, IsMerkleTreeBackend, IsStreamingLeafBackend}; -use math::fft::bit_reversing::reverse_index; -use math::field::element::FieldElement; -use math::field::traits::IsField; -use math::traits::AsBytes; - -use crate::config::{BatchedMerkleTreeBackend, Commitment}; -use crate::proof::stark::PolynomialOpenings; - -/// On-demand supplier of committed matrix rows, so [`MixedMmcs`] builds its -/// digests and serves openings WITHOUT owning a copy of the (large) LDE buffers. -/// Both [`MixedMmcs::commit`] and [`MixedMmcs::open_batch`] read every leaf -/// through this trait, so the root and opened rows are byte-identical to those a -/// matrix-owning MMCS would produce — the prover keeps only the LDE buffers it -/// already retains for DEEP, and each MMCS stores just digests. -/// -/// Rows are addressed in each matrix's committed row-pair layout: `append_row(m, -/// r, out)` appends matrix `m`'s row at **bit-reversed** LDE position `r` (its -/// `width(m)` committed columns, in column order). This is the same `r`-indexing -/// the module's "Tree layout" section uses; an implementor holding the -/// natural-order LDE maps `r` to `reverse_index(r, 2^log_height(m))`. -pub trait LeafSource { - /// Number of committed matrices, in canonical input order. - fn num_matrices(&self) -> usize; - /// `log2` of matrix `m`'s row count. Row-pair leaves require `>= 1`. - fn log_height(&self, m: usize) -> usize; - /// Matrix `m`'s committed column count. - fn width(&self, m: usize) -> usize; - /// Append matrix `m`'s bit-reversed LDE row `bitrev_row` (its `width(m)` - /// committed columns) to `out`. `bitrev_row in [0, 2^log_height(m))`. - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>); -} - -/// One committed matrix borrowed from a retained LDE buffer. Resolves each -/// bit-reversed row on demand (mapping through `reverse_index`) so the MMCS owns -/// no copy of the evaluations. See [`LeafSource`]. -pub enum BorrowedMatrix<'a, E: IsField> { - /// A `stride`-wide, row-major, NATURAL-order LDE buffer (the main / aux LDE - /// retained in `Round1::lde_trace`). This matrix occupies columns - /// `[col_start, col_start + width)`; its bit-reversed row `r` lives at - /// natural-order row `reverse_index(r, 2^log_height)`. - RowMajorNatural { - data: &'a [FieldElement], - stride: usize, - col_start: usize, - width: usize, - log_height: usize, - }, - /// Column-major NATURAL-order columns (the composition-poly LDE retained in - /// `Round2::lde_composition_poly_evaluations`): `cols[c][nat]` is column `c` - /// at natural-order row `nat`. Every committed column is used. - ColMajorNatural { - cols: &'a [Vec>], - log_height: usize, - }, -} - -impl BorrowedMatrix<'_, E> { - fn log_height(&self) -> usize { - match self { - BorrowedMatrix::RowMajorNatural { log_height, .. } - | BorrowedMatrix::ColMajorNatural { log_height, .. } => *log_height, - } - } - - fn width(&self) -> usize { - match self { - BorrowedMatrix::RowMajorNatural { width, .. } => *width, - BorrowedMatrix::ColMajorNatural { cols, .. } => cols.len(), - } - } - - fn append_row(&self, bitrev_row: usize, out: &mut Vec>) { - match self { - BorrowedMatrix::RowMajorNatural { - data, - stride, - col_start, - width, - log_height, - } => { - let nat = reverse_index(bitrev_row, 1u64 << log_height); - let base = nat * stride + col_start; - out.extend_from_slice(&data[base..base + width]); - } - BorrowedMatrix::ColMajorNatural { cols, log_height } => { - let nat = reverse_index(bitrev_row, 1u64 << log_height); - for col in cols.iter() { - out.push(col[nat].clone()); - } - } - } - } -} - -impl LeafSource for Vec> { - fn num_matrices(&self) -> usize { - self.len() - } - fn log_height(&self, m: usize) -> usize { - self[m].log_height() - } - fn width(&self, m: usize) -> usize { - self[m].width() - } - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { - self[m].append_row(bitrev_row, out); - } -} - -/// A committed mixed-height, row-pair MMCS under the concrete keccak commitment -/// backends. Stores ONLY the digest layers (to serve the shared authentication path) -/// plus each matrix's `(log_height, width)` (to locate leaves). The row DATA is -/// served on demand by the caller's [`LeafSource`] — the MMCS never owns a copy -/// of the LDE. -pub struct MixedMmcs { - root: Commitment, - /// `layers[0]` is the base digest layer; `layers[h_max-1] == [root]`. - layers: Vec>, - /// Per committed matrix, in input order: `(log_height, width)`. - dims: Vec<(usize, usize)>, - h_max: usize, - _marker: PhantomData, -} - -/// The opening of ALL matrices at one query index, authenticated by a single -/// shared Merkle path. -#[derive( - Debug, - Clone, - serde::Serialize, - serde::Deserialize, - rkyv::Archive, - rkyv::Serialize, - rkyv::Deserialize, -)] -#[serde(bound = "")] -pub struct MixedOpening { - /// The one authentication path covering every matrix's row at the query. - pub proof: Proof, - /// Per-matrix row pair (in the same INPUT order as `commit`). Each entry's - /// own `proof` is empty — [`MixedOpening::proof`] is the authenticator. - pub per_matrix: Vec>, -} - -/// Hash the row pair `(row(2*leaf), row(2*leaf+1))` of every matrix whose index -/// is in `group` (in the given order), all columns batched, into one digest. -/// Rows are pulled from `source` — the MMCS owns no copy. -fn hash_group_leaf(source: &S, group: &[usize], leaf: usize) -> Commitment -where - E: IsField + 'static, - S: LeafSource, - FieldElement: AsBytes + Sync + Send, -{ - let mut buf: Vec> = Vec::new(); - for &m in group { - source.append_row(m, 2 * leaf, &mut buf); - source.append_row(m, 2 * leaf + 1, &mut buf); - } - as IsMerkleTreeBackend>::hash_data(&buf) -} - -/// Verifier-side analogue of [`hash_group_leaf`]: hash the opened row pairs of a -/// group of openings (in the given order) into one digest. -fn hash_group_openings(group: &[&PolynomialOpenings]) -> Commitment -where - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, -{ - let mut buf: Vec> = Vec::new(); - for o in group { - buf.extend_from_slice(&o.evaluations); - buf.extend_from_slice(&o.evaluations_sym); - } - as IsMerkleTreeBackend>::hash_data(&buf) -} - -#[inline] -fn compress(left: &Commitment, right: &Commitment) -> Commitment -where - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, -{ - as IsMerkleTreeBackend>::hash_new_parent(left, right) -} - -impl MixedMmcs -where - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, -{ - /// Commit the matrices supplied by `source` into one mixed-height row-pair - /// tree, storing only the digest layers. See the module docs for the exact - /// leaf/injection layout. `source` provides each matrix's dimensions and its - /// bit-reversed rows on demand; no copy of the evaluations is retained. - /// - /// Leaf hashing (the base layer and each injected climb layer) is parallel - /// across leaves via [`crate::par::par_map_collect`]; the per-level output is - /// index-ordered, so the root and layers are byte-identical to a sequential - /// build. `S: Sync` lets leaf closures read `source` from worker threads. - /// - /// Levels are built in descending height order and matrix `m` is read only - /// while its own level is built, so the caller may release a height group's - /// buffers once the next level starts — see the module's memory section. - pub fn commit + Sync>(source: &S) -> Self { - let num_matrices = source.num_matrices(); - assert!( - num_matrices > 0, - "MixedMmcs::commit requires at least one matrix" - ); - - let dims: Vec<(usize, usize)> = (0..num_matrices) - .map(|m| { - let log_height = source.log_height(m); - assert!( - log_height >= 1, - "log_height must be >= 1 (row-pair leaves need at least 2 rows)" - ); - (log_height, source.width(m)) - }) - .collect(); - - let h_max = dims - .iter() - .map(|(log_height, _)| *log_height) - .max() - .expect("dims is non-empty"); - - // Per-height group leaf digests, built in descending height order — the - // order that makes the memory claim in the module header true. Index `h` - // is `Some` exactly when some matrix has that height. - let mut group_digests: Vec>> = vec![None; h_max + 1]; - for h in (1..=h_max).rev() { - let group: Vec = (0..num_matrices).filter(|&m| dims[m].0 == h).collect(); - if group.is_empty() { - continue; - } - // 2^(h-1) independent group-leaf hashes; at `h == h_max` that is the - // bulk of the tree's hashing (half of all nodes). Parallel across - // leaves. - group_digests[h] = Some(crate::par::par_map_collect(0..1usize << (h - 1), |k| { - hash_group_leaf::(source, &group, k) - })); - } - - Self::from_group_digests(dims, h_max, group_digests) - } - - /// Build the tree from each height group's already-hashed leaf digests. - /// - /// The single climb implementation. [`Self::commit`] reaches it having hashed - /// every group leaf in one pass; a future streaming builder would reach it - /// having hashed them incrementally, matrix by matrix. That the two produce - /// the same tree is therefore a property of calling one function, not a - /// coincidence two code paths have to be shown to share. - fn from_group_digests( - dims: Vec<(usize, usize)>, - h_max: usize, - mut group_digests: Vec>>, - ) -> Self { - let mut layers: Vec> = Vec::with_capacity(h_max); - layers.push( - group_digests[h_max] - .take() - .expect("the tallest height group is occupied by construction"), - ); - - // Climb, compressing pairs and injecting shorter matrices where the layer - // width matches their leaf count. Each level's nodes are independent - // (they read only the previous, already-materialized layer), so parallel - // across nodes; levels stay sequential. - let mut i = 0usize; - while layers[i].len() > 1 { - let next_len = layers[i].len() / 2; - let injected = group_digests[h_max - 1 - i].take(); - - let cur = &layers[i]; - let next: Vec = crate::par::par_map_collect(0..next_len, |j| { - let parent = compress::(&cur[2 * j], &cur[2 * j + 1]); - match &injected { - Some(digests) => compress::(&parent, &digests[j]), - None => parent, - } - }); - layers.push(next); - i += 1; - } - - let root = layers.last().expect("at least the base layer exists")[0]; - - MixedMmcs { - root, - layers, - dims, - h_max, - _marker: PhantomData, - } - } - - /// Reconstruct the tree from a STANDARD HEAP node array — the layout the GPU - /// commit (`math_cuda::mmcs::build_mmcs_tree_on_device`) produces: `2*L-1` - /// nodes of 32 bytes, root at index 0, inner nodes in `[0, L-1)`, the `L = - /// 2^(h_max-1)` leaves in the tail `[L-1, 2L-1)`, with leaf `j` at `L-1+j`. - /// - /// This is what makes a GPU-built tree serve the SAME [`Self::auth_path`] / - /// [`Self::open_batch`] a host-built one does: the keccak (leaf + climb) runs - /// on the device, only the digest layers come back, and every downstream - /// opening reads them unchanged. The heap ordering matches - /// `merkle_gather_paths` (validated by `mmcs_tree_parity`'s - /// `paths_match_the_host_at_every_query`), so `layers[level]` here is exactly - /// the level that kernel walks. - pub fn from_heap_nodes(dims: Vec<(usize, usize)>, h_max: usize, nodes: &[u8]) -> Self { - let leaves_len = 1usize << (h_max - 1); - assert_eq!( - nodes.len(), - (2 * leaves_len - 1) * 32, - "heap node array must be (2*L-1) 32-byte digests for L = 2^(h_max-1)" - ); - let node = |i: usize| -> Commitment { - let mut c = [0u8; 32]; - c.copy_from_slice(&nodes[i * 32..i * 32 + 32]); - c - }; - // `layers[k]` is heap level `h_max-1-k`: `2^(h_max-1-k)` nodes starting at - // heap index `2^(h_max-1-k) - 1`. `layers[0]` is the leaf tail; the last - // layer is the single root at index 0. - let mut layers: Vec> = Vec::with_capacity(h_max); - for k in 0..h_max { - let level_size = leaves_len >> k; - let start = level_size - 1; - layers.push((0..level_size).map(|j| node(start + j)).collect()); - } - let root = layers.last().expect("at least the base layer exists")[0]; - - MixedMmcs { - root, - layers, - dims, - h_max, - _marker: PhantomData, - } - } - - /// The committed root. - pub fn root(&self) -> Commitment { - self.root - } - - /// Serialize the digest layers back into the standard heap byte array that - /// [`Self::from_heap_nodes`] parses — the inverse round-trip a device build - /// produces directly on the GPU. Test-only; used to corrupt a single node - /// and confirm the device-commit canary fires. - // Its consumer is #951's device-commit canary test, which this branch does - // not bring over. Kept so the port stays a copy of the original. - #[cfg(test)] - #[allow(dead_code)] - pub(crate) fn heap_bytes(&self) -> Vec { - let leaves_len = 1usize << (self.h_max - 1); - let mut heap = vec![0u8; (2 * leaves_len - 1) * 32]; - for (k, layer) in self.layers.iter().enumerate() { - let level_size = leaves_len >> k; - let start = level_size - 1; - for (j, digest) in layer.iter().enumerate() { - heap[(start + j) * 32..(start + j) * 32 + 32].copy_from_slice(digest); - } - } - heap - } - - /// `log2` of the tallest committed matrix. The query index this MMCS accepts - /// lives in `[0, 2^(h_max-1))` — see the module's index-convention section. - pub fn h_max(&self) -> usize { - self.h_max - } - - /// Per committed matrix, in input order: `(log_height, width)`. The verifier - /// is expected to rebuild these from the AIR set rather than read them here; - /// this accessor exists so a prover can bind the shape it actually committed. - pub fn dims(&self) -> &[(usize, usize)] { - &self.dims - } - - /// The leaf of matrix `m` that query `iota` opens: `iota >> (h_max - h_m)`. - /// `None` when `m` is not a committed matrix or `iota` is out of this tree's - /// index space. - /// - /// Exposed alongside [`Self::auth_path`] so a prover can assemble a - /// [`MixedOpening`] ONE MATRIX AT A TIME. [`Self::open_batch`] wants a - /// `LeafSource` describing the whole round, which means every matrix's rows - /// readable at once — the same `O(N)` residency a streaming commit path - /// exists to keep out of the commit. Query indices are only known after the - /// FRI, so without these two the win would be given back at opening time. - pub fn row_pair_leaf(&self, iota: usize, m: usize) -> Option { - if iota >= 1usize << (self.h_max - 1) { - return None; - } - let (log_height, _) = *self.dims.get(m)?; - Some(iota >> (self.h_max - log_height)) - } - - /// The shared authentication path for `iota`, reading no matrix rows at all. - /// `None` when `iota` is outside this tree's index space. - pub fn auth_path(&self, iota: usize) -> Option> { - if iota >= 1usize << (self.h_max - 1) { - return None; - } - let mut merkle_path = Vec::with_capacity(self.h_max - 1); - for level in 0..(self.h_max - 1) { - merkle_path.push(self.layers[level][(iota >> level) ^ 1]); - } - Some(Proof { merkle_path }) - } - - /// Open all matrices at query `iota in [0, 2^(h_max-1))`, returning each - /// matrix's row pair plus one shared authentication path. Row data is served - /// by `source`, which MUST describe the same matrices (same order and - /// dimensions) as the one passed to [`Self::commit`]. - pub fn open_batch>(&self, iota: usize, source: &S) -> MixedOpening { - let n0 = 1usize << (self.h_max - 1); - assert!(iota < n0, "iota {iota} out of range (n0 = {n0})"); - debug_assert_eq!( - source.num_matrices(), - self.dims.len(), - "leaf source matrix count must match the committed tree" - ); - - let per_matrix: Vec> = (0..self.dims.len()) - .map(|m| { - let (log_height, width) = self.dims[m]; - debug_assert_eq!(source.log_height(m), log_height); - debug_assert_eq!(source.width(m), width); - let k = iota >> (self.h_max - log_height); - let mut evaluations = Vec::with_capacity(width); - source.append_row(m, 2 * k, &mut evaluations); - let mut evaluations_sym = Vec::with_capacity(width); - source.append_row(m, 2 * k + 1, &mut evaluations_sym); - PolynomialOpenings { - proof: Proof { - merkle_path: Vec::new(), - }, - evaluations, - evaluations_sym, - } - }) - .collect(); - - let mut merkle_path = Vec::with_capacity(self.h_max - 1); - for level in 0..(self.h_max - 1) { - let sibling = (iota >> level) ^ 1; - merkle_path.push(self.layers[level][sibling]); - } - - MixedOpening { - proof: Proof { merkle_path }, - per_matrix, - } - } - - /// Verify a batched opening at `iota` against `root`. `heights[m]` is the - /// `log_height` of matrix `m` and `widths[m]` its column count, both in the - /// SAME order as `opening.per_matrix`, and both supplied by the verifier from - /// the AIR set rather than read out of the proof. - /// - /// `widths` binds each matrix's boundary inside the per-height-group leaf - /// hash (see the module `# Width binding` section): the group leaf hashes the - /// FLAT concatenation of every matrix's `evaluations ‖ evaluations_sym`, so - /// without fixed widths a prover could shift a matrix boundary while keeping - /// the flat bytes — and thus the hash — identical. Pinning `widths` makes the - /// boundaries unambiguous and closes that forgery. - /// - /// `iota` must already be reduced to this tree's index space — see the - /// module's index-convention section. Out-of-range indices are rejected here, - /// but that check is a backstop, not a substitute for the reduction. - /// - /// Returns `false` on every malformed input; it never panics, so a verifier - /// can call it on adversarial data. - pub fn verify_batch( - root: &Commitment, - iota: usize, - opening: &MixedOpening, - heights: &[usize], - widths: &[usize], - ) -> bool { - if opening.per_matrix.len() != heights.len() - || heights.len() != widths.len() - || heights.is_empty() - { - return false; - } - // Bind per-matrix boundaries: every opened matrix must present exactly - // `widths[m]` columns in BOTH rows of its pair. A boundary shift keeps the - // flat per-group concatenation identical but changes these lengths. - for (o, w) in opening.per_matrix.iter().zip(widths.iter()) { - if o.evaluations.len() != *w || o.evaluations_sym.len() != *w { - return false; - } - } - let Some(&h_max) = heights.iter().max() else { - return false; - }; - // Honest heights are >= 1 (row-pair leaves need >= 2 rows) and far below - // the shift width; guard both ends rather than trust the proof's shape. - if h_max == 0 || h_max >= usize::BITS as usize { - return false; - } - // Only the low `h_max - 1` bits of `iota` are consumed (one per level), so - // an index from a taller domain would authenticate the short matrices at a - // position nothing else checks. Reject it instead. - if iota >= 1usize << (h_max - 1) { - return false; - } - if opening.proof.merkle_path.len() != h_max - 1 { - return false; - } - - // Base node: batch all tallest matrices' opened row pairs (input order). - let base_group: Vec<&PolynomialOpenings> = opening - .per_matrix - .iter() - .zip(heights.iter()) - .filter(|(_, h)| **h == h_max) - .map(|(o, _)| o) - .collect(); - let mut acc = hash_group_openings::(&base_group); - - for level in 0..(h_max - 1) { - let sibling = &opening.proof.merkle_path[level]; - let bit = (iota >> level) & 1; - let mut parent = if bit == 0 { - compress::(&acc, sibling) - } else { - compress::(sibling, &acc) - }; - - // Inject matrices whose leaf count matches this (halved) layer, in - // INPUT order — mirroring `commit`'s climb exactly. - let inject_h = h_max - 1 - level; - let inject_group: Vec<&PolynomialOpenings> = opening - .per_matrix - .iter() - .zip(heights.iter()) - .filter(|(_, h)| **h == inject_h) - .map(|(o, _)| o) - .collect(); - if !inject_group.is_empty() { - let inj = hash_group_openings::(&inject_group); - parent = compress::(&parent, &inj); - } - acc = parent; - } - - &acc == root - } -} - -/// One leaf hasher of the batched (keccak) leaf backend. -type LeafHasherOf = as IsStreamingLeafBackend>::LeafHasher; - -/// Builds a [`MixedMmcs`] by absorbing matrices ONE AT A TIME, so a prover never -/// has to hold a height group's LDE buffers simultaneously. -/// -/// # Why this exists -/// -/// [`MixedMmcs::commit`] reads matrix `m` only while building level -/// `h_max - h_m`, so a caller may drop a height group before the next is needed. -/// That is not enough for the group that matters. Within one height the leaf is a -/// single hash over the concatenation of every matrix's row pair, so `commit` -/// needs them all readable at once — and the tallest group is most of an epoch's -/// tables. A caller serving those rows from full in-RAM LDE buffers is back to -/// `O(N)` at the base layer, which is the whole memory win given back. -/// -/// This builder inverts the loop: it keeps one incremental leaf hasher per leaf -/// ([`IsLeafHasher`]) and absorbs matrices into them as they arrive, so the -/// caller produces one matrix's LDE, absorbs it, and drops it. Retained state is -/// `O(leaves × hasher_state)` — bounded by the epoch's tallest height and -/// independent of how many matrices there are or how wide they get. -/// -/// # Contract -/// -/// The shape is declared up front and matrices arrive in that order: the leaf -/// concatenation binds input order (see the module's determinism section), and a -/// builder that let matrices arrive out of order would commit a different tree -/// than [`MixedMmcs::commit`] over the same input. The resulting tree IS that -/// tree — both finish through one climb — which is what makes the two -/// interchangeable rather than merely tested to agree. -pub struct StreamingMmcsBuilder -where - FieldElement: AsBytes + Sync + Send, -{ - dims: Vec<(usize, usize)>, - h_max: usize, - /// Indexed by height: the in-progress leaf hashers of that height group, - /// present from construction until the group's last matrix is absorbed. - pending: Vec>>>, - /// Indexed by height: the group's finalized leaf digests. - group_digests: Vec>>, - /// Matrices of each height still to arrive. A height reaching zero is what - /// releases that group's hashers. - remaining: Vec, - next: usize, -} - -impl StreamingMmcsBuilder -where - E: IsField + 'static, - FieldElement: AsBytes + Sync + Send, -{ - /// Declare the epoch's shape: `(log_height, width)` per matrix, in the order - /// the matrices will be absorbed and in the order the verifier will present - /// their openings. - pub fn new(dims: &[(usize, usize)]) -> Self { - assert!( - !dims.is_empty(), - "StreamingMmcsBuilder requires at least one matrix" - ); - assert!( - dims.iter().all(|(log_height, _)| *log_height >= 1), - "log_height must be >= 1 (row-pair leaves need at least 2 rows)" - ); - let h_max = dims - .iter() - .map(|(log_height, _)| *log_height) - .max() - .expect("dims is non-empty"); - - let mut remaining = vec![0usize; h_max + 1]; - for (log_height, _) in dims { - remaining[*log_height] += 1; - } - - let pending = (0..=h_max) - .map(|h| { - (remaining[h] > 0).then(|| { - (0..1usize << (h - 1)) - .map(|_| { - as IsStreamingLeafBackend>::leaf_hasher( - ) - }) - .collect() - }) - }) - .collect(); - - Self { - dims: dims.to_vec(), - h_max, - pending, - group_digests: vec![None; h_max + 1], - remaining, - next: 0, - } - } - - /// Absorb the next declared matrix, reading its rows from `source` at index - /// `m`. The caller may drop that matrix's buffers as soon as this returns. - /// - /// Panics when the arriving matrix's shape disagrees with what was declared — - /// a prover-side programming error, not proof data. - pub fn absorb + Sync>(&mut self, source: &S, m: usize) { - let index = self.next; - assert!( - index < self.dims.len(), - "absorbed more matrices ({}) than were declared ({})", - index + 1, - self.dims.len() - ); - let (log_height, width) = self.dims[index]; - assert_eq!( - (source.log_height(m), source.width(m)), - (log_height, width), - "matrix {index} arrived with a shape the builder was not declared for" - ); - - let hashers = self.pending[log_height] - .as_mut() - .expect("a height with matrices outstanding still holds its hashers"); - // One update per leaf, parallel across leaves — the same shape, and the - // same cost, as `commit`'s one-shot group hash. - crate::par::par_for_each_mut_indexed(hashers, |leaf, hasher| { - let mut row_pair = Vec::with_capacity(2 * width); - source.append_row(m, 2 * leaf, &mut row_pair); - source.append_row(m, 2 * leaf + 1, &mut row_pair); - hasher.update(&row_pair); - }); - - self.next += 1; - self.remaining[log_height] -= 1; - if self.remaining[log_height] == 0 { - let hashers = self.pending[log_height] - .take() - .expect("the group was present a moment ago"); - self.group_digests[log_height] = - Some(hashers.into_iter().map(IsLeafHasher::finalize).collect()); - } - } - - /// Finish the tree. Panics if a declared matrix never arrived — the digests - /// would silently commit to a leaf that absorbed less than it claims. - pub fn finish(self) -> MixedMmcs { - assert_eq!( - self.next, - self.dims.len(), - "{} of {} declared matrices were absorbed", - self.next, - self.dims.len() - ); - MixedMmcs::from_group_digests(self.dims, self.h_max, self.group_digests) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::commitment::commit_bit_reversed; - use math::field::element::FieldElement; - use math::field::goldilocks::GoldilocksField; - use std::sync::Mutex; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - type FE = FieldElement; - type Mmcs = MixedMmcs; - - /// Reference [`LeafSource`] owning bit-reversed row-major matrices. Every - /// test commits/opens through this, so the byte-parity assertion against - /// `commit_bit_reversed` pins the tree contract; `borrowed_sources_match_ - /// owned_reference` cross-checks it against the borrowed (natural-order) - /// sources a prover would use. - struct OwnedMatrices { - /// Each entry: `(bit-reversed row-major data, log_height, width)`. - mats: Vec<(Vec>, usize, usize)>, - } - - impl LeafSource for OwnedMatrices { - fn num_matrices(&self) -> usize { - self.mats.len() - } - fn log_height(&self, m: usize) -> usize { - self.mats[m].1 - } - fn width(&self, m: usize) -> usize { - self.mats[m].2 - } - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { - let (data, _log_height, width) = &self.mats[m]; - out.extend_from_slice(&data[bitrev_row * width..(bitrev_row + 1) * width]); - } - } - - fn owned(mats: Vec<(Vec, usize, usize)>) -> OwnedMatrices { - OwnedMatrices { mats } - } - - /// Build a row-major, bit-reversed flat vec from column-major natural-order - /// `columns`, matching the layout the existing trace commit consumes: row `j` - /// of the output = `[col_0[br(j)], ..., col_{w-1}[br(j)]]` with - /// `br = reverse_index(., num_rows)`. - fn row_major_bit_reversed(columns: &[Vec], num_rows: usize) -> Vec { - let width = columns.len(); - let mut out = vec![FE::from(0u64); num_rows * width]; - for (r, chunk) in out.chunks_exact_mut(width).enumerate() { - let br = reverse_index(r, num_rows as u64); - for (c, col) in columns.iter().enumerate() { - chunk[c] = col[br]; - } - } - out - } - - /// Build a row-major flat vec in NATURAL order (no bit reversal): row `r` = - /// `[col_0[r], ..., col_{w-1}[r]]`. This is the layout the prover's - /// `BorrowedMatrix::RowMajorNatural` reads (the retained main/aux LDE buffer). - fn row_major_natural(columns: &[Vec], num_rows: usize) -> Vec { - let width = columns.len(); - let mut out = vec![FE::from(0u64); num_rows * width]; - for (r, chunk) in out.chunks_exact_mut(width).enumerate() { - for (c, col) in columns.iter().enumerate() { - chunk[c] = col[r]; - } - } - out - } - - fn make_columns(width: usize, num_rows: usize, seed: u64) -> Vec> { - (0..width) - .map(|c| { - (0..num_rows) - .map(|r| { - FE::from(seed.wrapping_mul(31) + (c as u64) * 1009 + (r as u64) * 7 + 1) - }) - .collect() - }) - .collect() - } - - #[test] - fn single_matrix_commit_open_verify_and_tamper() { - let log_height = 2usize; - let num_rows = 1usize << log_height; - let width = 3usize; - let columns = make_columns(width, num_rows, 5); - let data = row_major_bit_reversed(&columns, num_rows); - - let src = owned(vec![(data.clone(), log_height, width)]); - let mmcs = Mmcs::commit(&src); - let heights = [log_height]; - let widths = [width]; - let n0 = 1usize << (log_height - 1); - - for iota in 0..n0 { - let opening = mmcs.open_batch(iota, &src); - assert_eq!(opening.per_matrix.len(), 1); - let k = iota; - let row_2k = data[(2 * k) * width..(2 * k + 1) * width].to_vec(); - let row_2k1 = data[(2 * k + 1) * width..(2 * k + 2) * width].to_vec(); - assert_eq!(opening.per_matrix[0].evaluations, row_2k); - assert_eq!(opening.per_matrix[0].evaluations_sym, row_2k1); - assert!(Mmcs::verify_batch( - &mmcs.root(), - iota, - &opening, - &heights, - &widths - )); - } - - let mut opening = mmcs.open_batch(0, &src); - opening.per_matrix[0].evaluations[0] = - &opening.per_matrix[0].evaluations[0] + &FE::from(1u64); - assert!(!Mmcs::verify_batch( - &mmcs.root(), - 0, - &opening, - &heights, - &widths - )); - } - - /// ★ The backward-compatibility statement: a single-matrix MMCS IS the - /// existing per-table row-pair tree. It holds by construction — both go - /// through `BatchedMerkleTreeBackend`'s `hash_data` / `hash_new_parent` — - /// and this pins that no second leaf encoding crept in. - /// - /// Both sides use the SAME concrete keccak backend, which is what makes the - /// comparison meaningful: `commit_bit_reversed` commits through - /// `BatchedMerkleTreeBackend`, so a hash difference here would be a layout - /// difference, not a hash-configuration mismatch. - #[test] - fn single_matrix_root_matches_existing_row_pair_tree() { - let log_height = 3usize; - let num_rows = 1usize << log_height; - let width = 4usize; - let columns = make_columns(width, num_rows, 9); - - let (_, existing_root) = - commit_bit_reversed(&columns, 2).expect("non-empty columns build a tree"); - - let data = row_major_bit_reversed(&columns, num_rows); - let mmcs = Mmcs::commit(&owned(vec![(data, log_height, width)])); - - assert_eq!(mmcs.root(), existing_root); - } - - #[test] - fn mixed_height_open_positions_verify_and_tamper() { - // Three matrices, log_heights {5, 5, 3}, widths {2, 1, 4}. - let (ha, hb, hc) = (5usize, 5usize, 3usize); - let (wa, wb, wc) = (2usize, 1usize, 4usize); - let a = row_major_bit_reversed(&make_columns(wa, 1 << ha, 1), 1 << ha); - let b = row_major_bit_reversed(&make_columns(wb, 1 << hb, 2), 1 << hb); - let c = row_major_bit_reversed(&make_columns(wc, 1 << hc, 3), 1 << hc); - - let src = owned(vec![ - (a.clone(), ha, wa), - (b.clone(), hb, wb), - (c.clone(), hc, wc), - ]); - let mmcs = Mmcs::commit(&src); - let heights = [ha, hb, hc]; - let widths = [wa, wb, wc]; - let h_max = 5usize; - let n0 = 1usize << (h_max - 1); // 16 - - let row = |data: &[FE], w: usize, r: usize| data[r * w..(r + 1) * w].to_vec(); - - for iota in [0usize, 1, 2, 3, 7, 8, 13, n0 - 1] { - let opening = mmcs.open_batch(iota, &src); - assert_eq!(opening.per_matrix.len(), 3); - - // Tall matrices open at k = iota >> 0 = iota. - assert_eq!(opening.per_matrix[0].evaluations, row(&a, wa, 2 * iota)); - assert_eq!( - opening.per_matrix[0].evaluations_sym, - row(&a, wa, 2 * iota + 1) - ); - assert_eq!(opening.per_matrix[1].evaluations, row(&b, wb, 2 * iota)); - - // Height-3 matrix opens at k = iota >> (5 - 3) = iota >> 2. - let kc = iota >> (h_max - hc); - assert_eq!(opening.per_matrix[2].evaluations, row(&c, wc, 2 * kc)); - assert_eq!( - opening.per_matrix[2].evaluations_sym, - row(&c, wc, 2 * kc + 1) - ); - - assert!( - Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), - "honest opening at iota={iota} must verify" - ); - } - - // Tamper the height-3 matrix's opened row -> rejection (proves the short - // matrix is bound by the shared path via injection). - let iota = 6usize; - let mut opening = mmcs.open_batch(iota, &src); - opening.per_matrix[2].evaluations[0] = - &opening.per_matrix[2].evaluations[0] + &FE::from(1u64); - assert!( - !Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), - "tampered height-3 row must be rejected" - ); - - // Tamper a tall-matrix row too -> rejection. - let mut opening2 = mmcs.open_batch(iota, &src); - opening2.per_matrix[0].evaluations[0] = - &opening2.per_matrix[0].evaluations[0] + &FE::from(1u64); - assert!( - !Mmcs::verify_batch(&mmcs.root(), iota, &opening2, &heights, &widths), - "tampered tall-matrix row must be rejected" - ); - } - - /// Vector test: hand-compute the root for `{log_height 2, log_height 1}` - /// matrices per the documented layout and assert equality. Pins the - /// leaf/injection contract, plus determinism. - #[test] - fn vector_root_layout_contract_and_determinism() { - // A: log_height 2 (4 rows), width 2 ; B: log_height 1 (2 rows), width 3. - let a_data = row_major_bit_reversed(&make_columns(2, 4, 3), 4); - let b_data = row_major_bit_reversed(&make_columns(3, 2, 8), 2); - - let src = owned(vec![(a_data.clone(), 2, 2), (b_data.clone(), 1, 3)]); - let mmcs = Mmcs::commit(&src); - - // Hand recomputation via the backend primitives, in the documented order. - let arow = |r: usize| a_data[r * 2..(r + 1) * 2].to_vec(); - let brow = |r: usize| b_data[r * 3..(r + 1) * 3].to_vec(); - let h = |v: Vec| { - as IsMerkleTreeBackend>::hash_data(&v) - }; - - // Base layer (matrix A only): leaf k = H(A.row(2k) || A.row(2k+1)). - let mut leaf0 = arow(0); - leaf0.extend(arow(1)); - let mut leaf1 = arow(2); - leaf1.extend(arow(3)); - let l00 = h(leaf0); - let l01 = h(leaf1); - - // Climb to layer 1 (root): compress the base pair, then inject B (h=1). - let parent = compress::(&l00, &l01); - let mut binj = brow(0); - binj.extend(brow(1)); - let inj = h(binj); - let expected_root = compress::(&parent, &inj); - - assert_eq!( - mmcs.root(), - expected_root, - "root must match the hand-computed mixed-height layout" - ); - - // Determinism: a second commit over the same inputs yields the same root. - let mmcs2 = Mmcs::commit(&owned(vec![(a_data, 2, 2), (b_data, 1, 3)])); - assert_eq!(mmcs.root(), mmcs2.root(), "commit must be deterministic"); - - for iota in 0..2usize { - let opening = mmcs.open_batch(iota, &src); - // heights {2, 1}, widths {2, 3}. - assert!(Mmcs::verify_batch( - &mmcs.root(), - iota, - &opening, - &[2, 1], - &[2, 3] - )); - } - } - - /// Two SAME-HEIGHT matrices share one base-group leaf, whose hash is over the - /// FLAT concatenation `A.eval ‖ A.eval_sym ‖ B.eval ‖ B.eval_sym`. A malicious - /// prover can shift the A|A_sym boundary (move one element from A's - /// `evaluations_sym` into A's `evaluations`) leaving that flat concatenation — - /// and hence the leaf hash — byte-identical, so a width-blind `verify_batch` - /// would accept it. The per-matrix width binding rejects the shift. - #[test] - fn boundary_shift_forgery_rejected() { - let h = 2usize; - let num_rows = 1usize << h; - let (wa, wb) = (2usize, 1usize); // wA >= 2 so we can steal one column. - let a = row_major_bit_reversed(&make_columns(wa, num_rows, 11), num_rows); - let b = row_major_bit_reversed(&make_columns(wb, num_rows, 22), num_rows); - - let src = owned(vec![(a, h, wa), (b, h, wb)]); - let mmcs = Mmcs::commit(&src); - let heights = [h, h]; - let widths = [wa, wb]; - - let iota = 0usize; - let opening = mmcs.open_batch(iota, &src); - assert!( - Mmcs::verify_batch(&mmcs.root(), iota, &opening, &heights, &widths), - "honest opening must verify" - ); - - // Forge: lengthen A.evaluations by one element taken from A.evaluations_sym. - let mut forged = mmcs.open_batch(iota, &src); - let moved = forged.per_matrix[0].evaluations_sym.remove(0); - forged.per_matrix[0].evaluations.push(moved); - - // The FLAT per-group concatenation is byte-identical to the honest one, so - // the group leaf hash is UNCHANGED — the rejection must come from the width - // check, not from a differing hash. - let flat = |o: &MixedOpening| -> Vec { - let mut v = Vec::new(); - for m in &o.per_matrix { - v.extend_from_slice(&m.evaluations); - v.extend_from_slice(&m.evaluations_sym); - } - v - }; - assert_eq!( - flat(&opening), - flat(&forged), - "the flat concatenation must be byte-identical (boundary-only shift)" - ); - - assert!( - !Mmcs::verify_batch(&mmcs.root(), iota, &forged, &heights, &widths), - "boundary-shift forgery must be rejected by the width binding" - ); - } - - /// Extension-field (Fp3) coverage: the aux and composition matrices an epoch - /// batches are cubic-extension. Byte-parity cross-check of a single Fp3 matrix - /// against the existing per-table row-pair tree, plus an open/verify/tamper - /// roundtrip over the extension path. - #[test] - fn single_matrix_fp3_root_matches_existing_row_pair_tree() { - use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField as Fp3; - type F3 = FieldElement; - - let log_height = 3usize; - let num_rows = 1usize << log_height; - let width = 3usize; - - // Populate ALL three components so the 24-byte extension serialization is - // exercised (not just the embedded-base subset). - let columns: Vec> = (0..width) - .map(|c| { - (0..num_rows) - .map(|r| { - F3::new([ - FE::from((c as u64) * 7 + r as u64 + 1), - FE::from((r as u64) * 13 + 2), - FE::from((c as u64) * 5 + (r as u64) * 3 + 4), - ]) - }) - .collect() - }) - .collect(); - - let (_, existing_root) = - commit_bit_reversed(&columns, 2).expect("non-empty columns build a tree"); - - // Row-major bit-reversed equivalent of the same column-major data. - let mut data = vec![F3::zero(); num_rows * width]; - for (r, chunk) in data.chunks_exact_mut(width).enumerate() { - let br = reverse_index(r, num_rows as u64); - for (c, col) in columns.iter().enumerate() { - chunk[c] = col[br]; - } - } - - let src = OwnedMatrices { - mats: vec![(data, log_height, width)], - }; - let mmcs = MixedMmcs::::commit(&src); - assert_eq!( - mmcs.root(), - existing_root, - "Fp3 single-matrix root must match the existing row-pair tree" - ); - - let heights = [log_height]; - let widths = [width]; - for iota in 0..(1usize << (log_height - 1)) { - let opening = mmcs.open_batch(iota, &src); - assert!(MixedMmcs::::verify_batch( - &mmcs.root(), - iota, - &opening, - &heights, - &widths - )); - } - - let mut opening = mmcs.open_batch(0, &src); - opening.per_matrix[0].evaluations[0] = &opening.per_matrix[0].evaluations[0] + &F3::one(); - assert!(!MixedMmcs::::verify_batch( - &mmcs.root(), - 0, - &opening, - &heights, - &widths - )); - } - - /// Equivalence (the soundness contract a batched prover relies on): the - /// digest-only MMCS built from borrowed, NATURAL-order leaf sources yields the - /// SAME root and the SAME opened rows as the reference owning source over the - /// bit-reversed data — for the row-major (main / aux) layout, the column-major - /// (composition) layout, AND a main-split column sub-range (`col_start > 0`). - /// Only the leaf-byte source changes; nothing the verifier sees does. - #[test] - fn borrowed_sources_match_owned_reference() { - // Mixed heights {5, 5, 3}; the height-3 matrix exercises injection. - let specs = [(5usize, 3usize, 100u64), (5, 1, 200), (3, 4, 300)]; - - // Column-major natural-order columns per matrix. - let cols: Vec>> = specs - .iter() - .map(|&(lh, w, seed)| make_columns(w, 1 << lh, seed)) - .collect(); - - // Reference: owned, bit-reversed row-major. - let owned_src = owned( - specs - .iter() - .zip(cols.iter()) - .map(|(&(lh, w, _), c)| (row_major_bit_reversed(c, 1 << lh), lh, w)) - .collect(), - ); - - // Borrowed row-major NATURAL (the retained main / aux LDE buffer). - let rm_natural: Vec> = specs - .iter() - .zip(cols.iter()) - .map(|(&(lh, _, _), c)| row_major_natural(c, 1 << lh)) - .collect(); - let rm_src: Vec> = specs - .iter() - .zip(rm_natural.iter()) - .map(|(&(lh, w, _), data)| BorrowedMatrix::RowMajorNatural { - data: data.as_slice(), - stride: w, - col_start: 0, - width: w, - log_height: lh, - }) - .collect(); - - // Borrowed column-major NATURAL (the retained composition-poly LDE). - let cm_src: Vec> = specs - .iter() - .zip(cols.iter()) - .map(|(&(lh, _, _), c)| BorrowedMatrix::ColMajorNatural { - cols: c.as_slice(), - log_height: lh, - }) - .collect(); - - let owned_mmcs = Mmcs::commit(&owned_src); - let rm_mmcs = Mmcs::commit(&rm_src); - let cm_mmcs = Mmcs::commit(&cm_src); - assert_eq!( - owned_mmcs.root(), - rm_mmcs.root(), - "row-major natural root must match the owned reference" - ); - assert_eq!( - owned_mmcs.root(), - cm_mmcs.root(), - "column-major natural root must match the owned reference" - ); - - let n0 = 1usize << (5 - 1); - for iota in 0..n0 { - let o = owned_mmcs.open_batch(iota, &owned_src); - let rm = rm_mmcs.open_batch(iota, &rm_src); - let cm = cm_mmcs.open_batch(iota, &cm_src); - assert_eq!(o.proof.merkle_path, rm.proof.merkle_path); - assert_eq!(o.proof.merkle_path, cm.proof.merkle_path); - for i in 0..specs.len() { - assert_eq!(o.per_matrix[i].evaluations, rm.per_matrix[i].evaluations); - assert_eq!( - o.per_matrix[i].evaluations_sym, - rm.per_matrix[i].evaluations_sym - ); - assert_eq!(o.per_matrix[i].evaluations, cm.per_matrix[i].evaluations); - assert_eq!( - o.per_matrix[i].evaluations_sym, - cm.per_matrix[i].evaluations_sym - ); - } - } - - // Main-split sub-range: a RowMajorNatural over a wider buffer with a - // leading prefix (`col_start = prefix`) must match an owned matrix built - // over ONLY the committed trailing columns. - let (lh, prefix, w) = (4usize, 2usize, 3usize); - let num_rows = 1usize << lh; - let full = make_columns(prefix + w, num_rows, 42); - let full_natural = row_major_natural(&full, num_rows); - let sub_cols: Vec> = full[prefix..].to_vec(); - let sub_owned = owned(vec![(row_major_bit_reversed(&sub_cols, num_rows), lh, w)]); - let split_src: Vec> = - vec![BorrowedMatrix::RowMajorNatural { - data: full_natural.as_slice(), - stride: prefix + w, - col_start: prefix, - width: w, - log_height: lh, - }]; - let sub_owned_mmcs = Mmcs::commit(&sub_owned); - let split_mmcs = Mmcs::commit(&split_src); - assert_eq!( - sub_owned_mmcs.root(), - split_mmcs.root(), - "main-split (col_start>0) root must match the owned sub-range" - ); - for iota in 0..(1usize << (lh - 1)) { - let a = sub_owned_mmcs.open_batch(iota, &sub_owned); - let b = split_mmcs.open_batch(iota, &split_src); - assert_eq!(a.per_matrix[0].evaluations, b.per_matrix[0].evaluations); - assert_eq!( - a.per_matrix[0].evaluations_sym, - b.per_matrix[0].evaluations_sym - ); - } - } - - /// ★ The index-convention control (the module's "HARD PRECONDITION" section). - /// - /// A round whose tallest matrix is SHORTER than the FRI's tallest is the case - /// where the two index conventions disagree: `verify_batch` consumes the LOW - /// `h_max_round - 1` bits of whatever index it is handed, while a matrix - /// inside the tree is located by the HIGH bits of the FRI index. This asserts - /// three things about that case: - /// - /// 1. honest-path control — the correctly reduced index verifies; - /// 2. a tampered row of a SHORT (injected) matrix is rejected, so the low-bits - /// walk really does authenticate the short matrices at the reduced index; - /// 3. handing the un-reduced FRI index straight in is rejected — the misuse is - /// detectable, not silently accepted at some other leaf. - /// - /// A tamper control on the tallest matrix alone would pass under either - /// convention and catch none of this. - #[test] - fn short_round_low_bit_convention_is_exercised() { - // A hypothetical FRI over a 2^6 domain: iota_fri in [0, 2^5). - let h_max_fri = 6usize; - // This round's matrices are shorter: heights {4, 2}. - let (h_tall, h_short) = (4usize, 2usize); - let (w_tall, w_short) = (3usize, 2usize); - let tall = row_major_bit_reversed(&make_columns(w_tall, 1 << h_tall, 77), 1 << h_tall); - let short = row_major_bit_reversed(&make_columns(w_short, 1 << h_short, 88), 1 << h_short); - - let src = owned(vec![(tall, h_tall, w_tall), (short, h_short, w_short)]); - let mmcs = Mmcs::commit(&src); - let heights = [h_tall, h_short]; - let widths = [w_tall, w_short]; - assert_eq!(mmcs.h_max(), h_tall, "the round's h_max is below the FRI's"); - - // The reduction the caller owes: iota_round = iota_fri >> (h_fri - h_round). - let shift = h_max_fri - h_tall; - // Pick a FRI index whose low bits differ from the reduced index's, so the - // two conventions genuinely disagree here. - let iota_fri = 0b10110usize; - let iota_round = iota_fri >> shift; - assert_ne!( - iota_fri & ((1 << (h_tall - 1)) - 1), - iota_round, - "the test index must distinguish the low-bit and high-bit conventions" - ); - - // (1) Honest-path control at the reduced index. - let opening = mmcs.open_batch(iota_round, &src); - assert!( - Mmcs::verify_batch(&mmcs.root(), iota_round, &opening, &heights, &widths), - "the correctly reduced index must verify" - ); - - // (2) Tamper the SHORT (injected) matrix — the matrix a tall-only control - // would never touch, and the one the disagreeing conventions move. - let mut tampered = mmcs.open_batch(iota_round, &src); - tampered.per_matrix[1].evaluations[0] = - &tampered.per_matrix[1].evaluations[0] + &FE::from(1u64); - assert!( - !Mmcs::verify_batch(&mmcs.root(), iota_round, &tampered, &heights, &widths), - "a tampered SHORT-matrix row must be rejected at the reduced index" - ); - - // (3) The misuse: hand the un-reduced FRI index in. It is out of this - // tree's range, so the range guard rejects it rather than walking to some - // unrelated leaf. - assert!( - iota_fri >= 1usize << (h_tall - 1), - "the un-reduced index is outside this round's leaf range" - ); - assert!( - !Mmcs::verify_batch(&mmcs.root(), iota_fri, &opening, &heights, &widths), - "an un-reduced FRI index must be rejected, not accepted at another leaf" - ); - - // And an in-range index that is simply the wrong leaf is rejected too, so - // the guard is not the only thing standing between the two conventions. - let wrong_but_in_range = iota_fri & ((1 << (h_tall - 1)) - 1); - assert!( - !Mmcs::verify_batch( - &mmcs.root(), - wrong_but_in_range, - &opening, - &heights, - &widths - ), - "an opening replayed at the wrong in-range leaf must be rejected" - ); - } - - /// The malformed-input surface of `verify_batch`: every shape error returns - /// `false` rather than panicking, since a verifier calls this on proof data. - #[test] - fn verify_batch_rejects_malformed_shapes_without_panicking() { - let h = 3usize; - let w = 2usize; - let data = row_major_bit_reversed(&make_columns(w, 1 << h, 4), 1 << h); - let src = owned(vec![(data, h, w)]); - let mmcs = Mmcs::commit(&src); - let root = mmcs.root(); - let opening = mmcs.open_batch(1, &src); - - assert!(Mmcs::verify_batch(&root, 1, &opening, &[h], &[w])); - // Mismatched metadata lengths. - assert!(!Mmcs::verify_batch(&root, 1, &opening, &[h, h], &[w])); - assert!(!Mmcs::verify_batch(&root, 1, &opening, &[h], &[w, w])); - // Empty metadata. - assert!(!Mmcs::verify_batch(&root, 1, &opening, &[], &[])); - // A height that would overflow the level shift. - assert!(!Mmcs::verify_batch( - &root, - 1, - &opening, - &[usize::BITS as usize], - &[w] - )); - // An index past this tree's leaf count. - assert!(!Mmcs::verify_batch( - &root, - 1usize << (h - 1), - &opening, - &[h], - &[w] - )); - // A path of the wrong length. - let mut short_path = opening.clone(); - short_path.proof.merkle_path.pop(); - assert!(!Mmcs::verify_batch(&root, 1, &short_path, &[h], &[w])); - } - - /// Wraps a source and records, per matrix, the first and last global access - /// sequence number, plus a residency model the caller drives. `Mutex` / - /// atomics (not `Cell`) because both `commit` and the streaming builder read - /// the source from rayon workers. - struct Tracing<'a, E: IsField> { - inner: &'a OwnedMatrices, - clock: AtomicUsize, - window: Mutex>, - /// The residency model: which matrices the caller says it is holding. - resident: Vec, - live: AtomicUsize, - peak: AtomicUsize, - /// Rows served for a matrix the caller had already dropped. Any nonzero - /// count means the access pattern does not fit the residency policy. - reads_while_dropped: AtomicUsize, - } - - impl<'a, E: IsField> Tracing<'a, E> { - fn new(inner: &'a OwnedMatrices) -> Self { - let n = inner.num_matrices(); - Self { - inner, - clock: AtomicUsize::new(0), - window: Mutex::new(vec![(usize::MAX, 0); n]), - resident: (0..n).map(|_| AtomicBool::new(false)).collect(), - live: AtomicUsize::new(0), - peak: AtomicUsize::new(0), - reads_while_dropped: AtomicUsize::new(0), - } - } - - /// Declare every matrix held for the whole build — the only policy - /// `MixedMmcs::commit` can be served under. - fn materialize_all(&self) { - for m in 0..self.inner.num_matrices() { - self.materialize(m); - } - } - - fn materialize(&self, m: usize) { - if !self.resident[m].swap(true, Ordering::SeqCst) { - let live = self.live.fetch_add(1, Ordering::SeqCst) + 1; - self.peak.fetch_max(live, Ordering::SeqCst); - } - } - - // Retained for a future streaming-commit phase's residency tests; the - // one kept `commit` test drives residency via `materialize_all` only. - #[allow(dead_code)] - fn drop_matrix(&self, m: usize) { - if self.resident[m].swap(false, Ordering::SeqCst) { - self.live.fetch_sub(1, Ordering::SeqCst); - } - } - - fn windows(self) -> (Vec<(usize, usize)>, usize, usize) { - let peak = self.peak.load(Ordering::SeqCst); - let dropped_reads = self.reads_while_dropped.load(Ordering::SeqCst); - let windows = self.window.into_inner().expect("uncontended after commit"); - (windows, peak, dropped_reads) - } - } - - impl LeafSource for Tracing<'_, E> { - fn num_matrices(&self) -> usize { - self.inner.num_matrices() - } - fn log_height(&self, m: usize) -> usize { - self.inner.log_height(m) - } - fn width(&self, m: usize) -> usize { - self.inner.width(m) - } - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec>) { - if !self.resident[m].load(Ordering::SeqCst) { - self.reads_while_dropped.fetch_add(1, Ordering::SeqCst); - } - let t = self.clock.fetch_add(1, Ordering::SeqCst); - let mut w = self.window.lock().expect("no test thread panics here"); - w[m].0 = w[m].0.min(t); - w[m].1 = w[m].1.max(t); - drop(w); - self.inner.append_row(m, bitrev_row, out); - } - } - - fn residency_fixture() -> ([(usize, usize, u64); 4], OwnedMatrices) { - let specs = [(5usize, 2usize, 1u64), (5, 3, 2), (3, 1, 3), (2, 4, 4)]; - let inner = owned( - specs - .iter() - .map(|&(lh, w, seed)| { - ( - row_major_bit_reversed(&make_columns(w, 1 << lh, seed), 1 << lh), - lh, - w, - ) - }) - .collect(), - ); - (specs, inner) - } - - /// The streaming builder is not a second implementation of the tree: it - /// finishes through the same climb `commit` does. This pins the consequence — - /// same root, same layers, same openings — so a future change that forked the - /// two would fail here rather than at a verifier three modules away. - #[test] - fn streaming_builder_commits_the_same_tree_as_commit() { - let (specs, inner) = residency_fixture(); - let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); - - let mut builder = StreamingMmcsBuilder::::new(&dims); - for m in 0..dims.len() { - builder.absorb(&inner, m); - } - let streamed = builder.finish(); - let reference = Mmcs::commit(&inner); - - assert_eq!( - streamed.root(), - reference.root(), - "the streamed root must equal the one-shot root" - ); - assert_eq!(streamed.h_max(), reference.h_max()); - assert_eq!(streamed.dims(), reference.dims()); - - let heights: Vec = specs.iter().map(|&(lh, _, _)| lh).collect(); - let widths: Vec = specs.iter().map(|&(_, w, _)| w).collect(); - for iota in 0..1usize << (streamed.h_max() - 1) { - let opening = streamed.open_batch(iota, &inner); - assert!( - Mmcs::verify_batch(&streamed.root(), iota, &opening, &heights, &widths), - "an opening of the streamed tree must verify at iota {iota}" - ); - assert_eq!( - opening.proof.merkle_path, - reference.open_batch(iota, &inner).proof.merkle_path, - "the authentication path at iota {iota} must be the same path" - ); - } - } - - /// ★ The acceptance test for the batched commit's memory claim. - /// - /// `commit`'s contract is per height GROUP: it reads a group inside one - /// contiguous phase, so a caller may drop the group before the next. That is - /// not enough. Within the tallest group the leaf is one hash over every - /// matrix's concatenated row pair, so `commit` reads all of them at every - /// leaf — their access windows OVERLAP, and a caller has to hold the whole - /// group. On a real epoch the tallest group is most of the tables, so that is - /// `O(N)` resident at the base layer: the memory batching exists to remove, - /// given back. - /// - /// The streaming builder's windows are pairwise disjoint across ALL matrices, - /// same-height ones included, so the residency policy "materialize, absorb, - /// drop" serves it with exactly ONE matrix live. Both halves are traced here; - /// the second is the property the batched R1 / aux / parts commits must be - /// built on, and the first is what makes it a real difference rather than a - /// restatement. - #[test] - fn streaming_builder_serves_the_base_group_without_holding_it() { - let (specs, inner) = residency_fixture(); - let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); - let base_group: Vec = (0..specs.len()).filter(|&m| specs[m].0 == 5).collect(); - assert!( - base_group.len() > 1, - "the fixture must batch more than one matrix at the tallest height" - ); - - // --- What `commit` requires: the whole group resident at once. --- - let tracing = Tracing::new(&inner); - tracing.materialize_all(); - let commit_root = Mmcs::commit(&tracing).root(); - let (commit_windows, commit_peak, commit_dropped_reads) = tracing.windows(); - assert_eq!(commit_dropped_reads, 0, "the control held everything"); - assert_eq!( - commit_peak, - specs.len(), - "serving `commit` needs every matrix resident" - ); - for (i, &m) in base_group.iter().enumerate() { - for &n in &base_group[i + 1..] { - let (fm, lm) = commit_windows[m]; - let (fn_, ln) = commit_windows[n]; - assert!( - fm <= ln && fn_ <= lm, - "matrices {m} and {n} share the base height, so `commit` must \ - read them in OVERLAPPING windows [{fm},{lm}] / [{fn_},{ln}] — \ - if this ever stops holding, the escape below is no longer the \ - thing that buys the memory" - ); - } - } - - // --- What the streaming builder requires: one matrix at a time. --- - let tracing = Tracing::new(&inner); - let mut builder = StreamingMmcsBuilder::::new(&dims); - for m in 0..dims.len() { - tracing.materialize(m); - builder.absorb(&tracing, m); - tracing.drop_matrix(m); - } - let streamed_root = builder.finish().root(); - let (streamed_windows, streamed_peak, streamed_dropped_reads) = tracing.windows(); - - assert_eq!( - streamed_root, commit_root, - "the escape must not change what is committed" - ); - assert_eq!( - streamed_dropped_reads, 0, - "no row may be read after the caller dropped its matrix" - ); - assert_eq!( - streamed_peak, - 1, - "the base height group must be served with ONE matrix resident, not \ - {} — this is the batched commit's whole memory claim", - specs.len() - ); - for (m, &(first, last)) in streamed_windows.iter().enumerate() { - assert!(first <= last, "matrix {m} was never read"); - for (n, &(fn_, ln)) in streamed_windows.iter().enumerate().skip(m + 1) { - assert!( - last < fn_ || ln < first, - "matrices {m} and {n} were read in overlapping windows \ - [{first},{last}] / [{fn_},{ln}] — the builder must finish one \ - matrix before the next is needed, at EVERY height" - ); - } - } - } - - /// A declared matrix that never arrives would leave its group's leaves having - /// absorbed less than the shape says, committing a tree no verifier rebuilds. - /// The builder refuses rather than producing it. - #[test] - #[should_panic(expected = "of 4 declared matrices were absorbed")] - fn finishing_with_a_matrix_missing_panics() { - let (specs, inner) = residency_fixture(); - let dims: Vec<(usize, usize)> = specs.iter().map(|&(lh, w, _)| (lh, w)).collect(); - let mut builder = StreamingMmcsBuilder::::new(&dims); - for m in 0..dims.len() - 1 { - builder.absorb(&inner, m); - } - builder.finish(); - } - - /// The incremental leaf hasher's whole contract: where the updates fall must - /// not show. Checked at every split point of a leaf, and for the extension - /// field the aux and composition matrices actually use — a framing bug that - /// only appeared at an element boundary would slip past a base-field check. - #[test] - fn leaf_hasher_splits_anywhere_and_matches_hash_data() { - use crypto::merkle_tree::traits::IsLeafHasher; - use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField as Fp3; - - fn check(leaf: Vec>) - where - FieldElement: AsBytes + Sync + Send, - { - let expected = as IsMerkleTreeBackend>::hash_data(&leaf); - for split in 0..=leaf.len() { - let mut hasher = - as IsStreamingLeafBackend>::leaf_hasher(); - hasher.update(&leaf[..split]); - hasher.update(&leaf[split..]); - assert_eq!( - hasher.finalize(), - expected, - "splitting the leaf at {split} changed the digest" - ); - } - // Three updates, so an implementation that only ever saw two would not - // pass by accident. - let mut hasher = - as IsStreamingLeafBackend>::leaf_hasher(); - for element in &leaf { - hasher.update(core::slice::from_ref(element)); - } - assert_eq!(hasher.finalize(), expected, "element-at-a-time must agree"); - } - - check::((1u64..=9).map(FE::from).collect()); - check::( - (1u64..=9) - .map(|i| { - FieldElement::::new([FE::from(i), FE::from(i * 7 + 1), FE::from(i * 13)]) - }) - .collect(), - ); - } - - /// The memory contract from the module's "what the caller may drop" section, - /// made falsifiable: `commit` reads each height group's rows inside ONE - /// contiguous window of the build, and the windows run in descending height - /// order. A rewrite that materialized every matrix up front, or that revisited - /// a group after moving on, would fail here. - #[test] - fn commit_reads_each_height_group_in_one_contiguous_phase() { - // Heights {5, 5, 3, 2}: two groups sharing the base layer, two injected. - let specs = [(5usize, 2usize, 1u64), (5, 3, 2), (3, 1, 3), (2, 4, 4)]; - let inner = owned( - specs - .iter() - .map(|&(lh, w, seed)| { - ( - row_major_bit_reversed(&make_columns(w, 1 << lh, seed), 1 << lh), - lh, - w, - ) - }) - .collect(), - ); - let tracing = Tracing::new(&inner); - tracing.materialize_all(); - - let traced_root = Mmcs::commit(&tracing).root(); - assert_eq!( - traced_root, - Mmcs::commit(&inner).root(), - "tracing must not change what is committed" - ); - - let (windows, _peak, _dropped) = tracing.windows(); - for (m, (first, last)) in windows.iter().enumerate() { - assert!(*first <= *last, "matrix {m} was never read"); - } - - // Same-height matrices share a window; different heights must not overlap, - // and taller groups must come first. - for (m, &(fm, lm)) in windows.iter().enumerate() { - for (n, &(fn_, ln)) in windows.iter().enumerate() { - if specs[m].0 <= specs[n].0 { - continue; - } - assert!( - lm < fn_ || ln < fm, - "matrices {m} (h={}) and {n} (h={}) were read in overlapping \ - windows [{fm},{lm}] / [{fn_},{ln}] — a height group must be \ - readable and then droppable", - specs[m].0, - specs[n].0 - ); - assert!( - lm < fn_, - "the taller matrix {m} (h={}) must be read before the shorter \ - {n} (h={})", - specs[m].0, - specs[n].0 - ); - } - } - } - - /// The GPU commit returns a standard heap node array; `from_heap_nodes` must - /// rebuild a tree that serves the same root and authentication paths the - /// host build does — that is what lets the device tree be authoritative while - /// only the digest layers come back. Round-trips a real mixed-height tree's - /// layers through the heap layout and checks every query's path. - #[test] - fn from_heap_nodes_rebuilds_the_same_tree() { - let (specs, inner) = residency_fixture(); - let m = Mmcs::commit(&inner); - let h_max = m.h_max(); - let leaves_len = 1usize << (h_max - 1); - - // Assemble the standard heap from the host layers, exactly as the device - // build writes it: layer `k` (heap level `h_max-1-k`) into - // `[2^(h_max-1-k) - 1, ..)`, leaf `j` at `L-1+j`. - let mut heap = vec![0u8; (2 * leaves_len - 1) * 32]; - for (k, layer) in m.layers.iter().enumerate() { - let level_size = leaves_len >> k; - assert_eq!(layer.len(), level_size); - let start = level_size - 1; - for (j, digest) in layer.iter().enumerate() { - heap[(start + j) * 32..(start + j) * 32 + 32].copy_from_slice(digest); - } - } - - let rebuilt = Mmcs::from_heap_nodes(m.dims.clone(), h_max, &heap); - assert_eq!( - rebuilt.root(), - m.root(), - "root must survive the heap round-trip" - ); - assert_eq!(rebuilt.h_max(), h_max); - - let heights: Vec = specs.iter().map(|&(lh, _, _)| lh).collect(); - let widths: Vec = specs.iter().map(|&(_, w, _)| w).collect(); - for iota in 0..leaves_len { - assert_eq!( - rebuilt.auth_path(iota).unwrap().merkle_path, - m.auth_path(iota).unwrap().merkle_path, - "authentication path at iota {iota} must match the host tree" - ); - // And a full opening off the rebuilt tree still verifies. - let opening = rebuilt.open_batch(iota, &inner); - assert!( - Mmcs::verify_batch(&rebuilt.root(), iota, &opening, &heights, &widths), - "an opening from the rebuilt tree must verify at iota {iota}" - ); - } - } -} diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index cee278a16..1f53b51cf 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -1,8 +1,6 @@ -pub mod batched; pub mod fri_commitment; pub mod fri_decommit; pub(crate) mod fri_functions; -pub mod mmcs; pub(crate) mod terminal; use crypto::fiat_shamir::is_transcript::IsStarkTranscript; diff --git a/crypto/stark/src/lib.rs b/crypto/stark/src/lib.rs index 80f6ecef1..6f8e7c82e 100644 --- a/crypto/stark/src/lib.rs +++ b/crypto/stark/src/lib.rs @@ -4,7 +4,6 @@ compile_error!("the `disk-spill` feature requires memmap2, which does not compile on wasm32"); #[cfg(feature = "debug-checks")] -pub mod batched; pub mod bus_debug; pub mod commitment; pub mod constraint_ir; diff --git a/crypto/stark/src/par.rs b/crypto/stark/src/par.rs index 5ad4accbd..cee693e3f 100644 --- a/crypto/stark/src/par.rs +++ b/crypto/stark/src/par.rs @@ -92,27 +92,3 @@ pub(crate) fn par_try_for_each_mut( slice.iter_mut().try_for_each(f) } } - -/// Run `f(i, &mut item)` for each element of `slice` with its index. Parallel -/// when `feature = "parallel"`, sequential otherwise. -#[cfg_attr(not(feature = "parallel"), allow(dead_code))] -pub(crate) fn par_for_each_mut_indexed( - slice: &mut [T], - f: impl Fn(usize, &mut T) + Sync + Send, -) { - #[cfg(feature = "parallel")] - { - use rayon::prelude::*; - slice - .par_iter_mut() - .enumerate() - .for_each(|(i, item)| f(i, item)); - } - #[cfg(not(feature = "parallel"))] - { - slice - .iter_mut() - .enumerate() - .for_each(|(i, item)| f(i, item)); - } -} From 02e7681bd42533d76a979a1697b3248af377f2fc Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 10:26:30 -0300 Subject: [PATCH 38/63] Draw the batch coefficient from every table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batched fold is only binding if its coefficient depends on everything it folds; otherwise a table could be swapped after the coefficient was fixed and the fold would still check out. So alpha comes from the shared transcript as it stood before the per-table forks, plus every table's round-3 data in AIR order: the bus contribution when there is one, the composition root, the two out-of-domain blocks column by column, then the composition parts. That reads public data, not the forks, and the difference is the whole point. The first design absorbed each fork's state, which cannot work: the verifier has no forks, only a proof. Every field here is one the proof carries, so the verifier rebuilds the same seed from the same bytes. The byte order is #647's, which documented it as prover-and-verifier-must-match. The test moves one field of one table at a time — all five, in both tables — and requires alpha to move for each, then swaps the two tables and requires it to move again, since the order is part of what the verifier replays. Dropping any one absorption fails it by name. --- crypto/stark/src/prover.rs | 87 ++++++++++++++++++++++----- prover/src/tests/batched_fri_tests.rs | 87 +++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 14 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index c08fa1bc1..ab5fa7e61 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -669,6 +669,7 @@ pub struct MainRoots { } /// One table's contribution to a batched FRI. +#[derive(Clone)] pub struct TableDeep { /// The domain the codeword lives on. Only tables that agree on this can be /// folded together: the fold squares the coset offset each layer, so a @@ -682,10 +683,17 @@ pub struct TableDeep { pub trace_rows: usize, /// The DEEP composition codeword, `lde_size` long. pub deep: Vec>, - /// The fork's state once this table is done with it. The batch's - /// coefficient is drawn from every one of these, in AIR order, which is - /// what binds the fold to all the data it folds. - pub fork_state: Vec, + /// What the batch's coefficient is drawn from: this table's public round-3 + /// data, in the order a transcript absorbs it. + /// + /// Not the fork's state, which was the first design and is unusable — the + /// verifier has no forks, only a proof. Everything here is carried in the + /// proof, so the verifier rebuilds the same seed from the same bytes. + pub bus_contribution: Option>, + pub composition_poly_root: Commitment, + pub trace_ood: Table, + pub trace_ood_next: Table, + pub parts_ood: Vec>, } /// Source of truth for a table whose *trace* has been retired. @@ -1962,6 +1970,8 @@ pub trait IsStarkProver< Round2, Round3, FieldElement, + Table, + Table, ), ProvingError, > @@ -2029,7 +2039,7 @@ pub trait IsStarkProver< transcript.append_field_element(element); } - Ok((round_2_result, round_3_result, z)) + Ok((round_2_result, round_3_result, z, ood_block0, ood_block1)) } /// One table taken as far as a batched FRI lets it go on its own. @@ -2058,14 +2068,15 @@ pub trait IsStarkProver< { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; - let (mut round_2_result, round_3_result, z) = Self::rounds_2_and_3( - air, - pub_inputs, - &mut round_1_result, - transcript, - &domain, - &twiddles, - )?; + let (mut round_2_result, round_3_result, z, trace_ood, trace_ood_next) = + Self::rounds_2_and_3( + air, + pub_inputs, + &mut round_1_result, + transcript, + &domain, + &twiddles, + )?; // Round 4's opening move, up to the point where the batch takes over: // gamma is this table's own, sampled from its own fork. @@ -2102,10 +2113,58 @@ pub trait IsStarkProver< lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, deep, - fork_state: transcript.state().to_vec(), + bus_contribution: round_1_result + .bus_public_inputs + .as_ref() + .map(|b| b.table_contribution.clone()), + composition_poly_root: round_2_result.composition_poly_root, + trace_ood, + trace_ood_next, + parts_ood: round_3_result.composition_poly_parts_ood_evaluation.clone(), }) } + /// The batch's coefficient, drawn from every table's round-3 data. + /// + /// `pre_fork` is the shared transcript as it stood before the per-table + /// forks — after the LogUp challenges and nothing else. On top of it go, per + /// table in AIR order: the bus contribution when there is one, the + /// composition root, the two out-of-domain blocks column by column, and the + /// composition parts. That byte order is the protocol, and the verifier + /// walks it from the same fields the proof carries, which is why this reads + /// public data rather than the forks — the verifier has no forks. + /// + /// Drawing `alpha` from all of it is what makes the fold binding: a table + /// cannot be swapped after the fact without moving the coefficient that + /// folded it. + fn batch_alpha( + pre_fork: &(impl IsStarkTranscript + Clone), + tables: &[TableDeep], + ) -> FieldElement + where + FieldElement: AsBytes, + FieldElement: AsBytes, + { + let mut seed = pre_fork.clone(); + for t in tables { + if let Some(ref c) = t.bus_contribution { + seed.append_field_element(c); + } + seed.append_bytes(&t.composition_poly_root); + for block in [&t.trace_ood, &t.trace_ood_next] { + for col in block.columns().iter() { + for elem in col.iter() { + seed.append_field_element(elem); + } + } + } + for elem in t.parts_ood.iter() { + seed.append_field_element(elem); + } + } + seed.sample_field_element() + } + /// One FRI over a whole group of tables. /// /// The members share a domain, so their codewords add directly: the batch diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index f9e914be2..fdc0a48b1 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -5,6 +5,7 @@ use crate::tables::MaxRowsConfig; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use executor::elf::Elf; use stark::prover::IsStarkProver; +use stark::prover::TableDeep; /// A batch of one must reproduce the proof's FRI exactly. /// @@ -87,3 +88,89 @@ fn a_batch_of_one_matches_the_unbatched_fri() { "a batch of one folded to a different FRI than the proof carries" ); } + +/// The batch's coefficient must depend on every table it folds. +/// +/// `alpha` is what makes a batched fold binding: if it could be drawn without +/// some table's round-3 data, that table could be swapped after the coefficient +/// was fixed and the fold would still check out. So the property to pin is not +/// that the derivation runs — it is that moving any single field any table +/// contributes moves the result. +/// +/// Built from data rather than from a proof on purpose: this is about the byte +/// order, and a synthetic table exercises every field including the ones a real +/// fixture might leave empty. +#[test] +fn alpha_moves_when_any_table_moves() { + use crypto::fiat_shamir::default_transcript::DefaultTranscript; + use math::field::element::FieldElement; + use stark::table::Table; + type P = stark::prover::Prover; + type E = GoldilocksExtension; + + let table = |seed: u64| TableDeep:: { + lde_size: 8, + trace_rows: 4, + deep: Vec::new(), + bus_contribution: Some(FieldElement::::from(seed)), + composition_poly_root: [seed as u8; 32], + trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), + trace_ood_next: Table::new(vec![FieldElement::::from(seed + 2)], 1), + parts_ood: vec![FieldElement::::from(seed + 3)], + }; + + let pre_fork = DefaultTranscript::::new(&[7, 7, 7]); + let base = vec![table(1), table(2)]; + let alpha =

>::batch_alpha(&pre_fork, &base); + + // Every field of every table, one at a time. + type Mutation = (&'static str, Box>)>); + let mutate: Vec = vec![ + ( + "bus", + Box::new(|t: &mut Vec>| { + t[0].bus_contribution = Some(FieldElement::::from(99)) + }), + ), + ( + "root", + Box::new(|t: &mut Vec>| t[1].composition_poly_root = [9u8; 32]), + ), + ( + "ood", + Box::new(|t: &mut Vec>| { + t[0].trace_ood = Table::new(vec![FieldElement::::from(99)], 1) + }), + ), + ( + "ood_next", + Box::new(|t: &mut Vec>| { + t[1].trace_ood_next = Table::new(vec![FieldElement::::from(99)], 1) + }), + ), + ( + "parts", + Box::new(|t: &mut Vec>| { + t[0].parts_ood = vec![FieldElement::::from(99)] + }), + ), + ]; + for (what, f) in mutate { + let mut moved = base.clone(); + f(&mut moved); + assert_ne!( + alpha, +

>::batch_alpha(&pre_fork, &moved), + "alpha ignores {what}, so that data is not bound to the fold" + ); + } + + // And order is part of it: the same tables the other way round are a + // different batch. + let swapped = vec![base[1].clone(), base[0].clone()]; + assert_ne!( + alpha, +

>::batch_alpha(&pre_fork, &swapped), + "alpha ignores the table order, which the verifier replays" + ); +} From 57b5f126f53b620ff6dcb0de501fd7f5a6879491 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 12:03:55 -0300 Subject: [PATCH 39/63] Fold every table into one FRI per domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk produces the chunked tables and the end-of-run step the rest; this takes all of them to their DEEP codeword, groups them by exact domain, and runs one FRI per group. On the ethrex mainnet block that is 227 instances collapsing into 13, and the per-table FRI data is 57.9% of the proof. The codewords are held, not the LDEs they came from — one extension element per row instead of every column, about 6.5 GB against tens — which is what lets the fold wait until every table is done without walking the execution a third time. Grouping is by exact domain and can only be: the fold squares the coset offset each layer, so a short codeword over offset* never lines up with a tall fold over offset^2*. Any AIR serves a group, since domain_and_twiddles keys on the proof options alone and every table in a prove shares them. Two things can go silently wrong here and the test pins both: a table can go missing, and a batch that skips one is not smaller but wrong, so the count is checked against a real proof's; and two domains can land in one group, which the fold cannot express, so every group is checked to be a single domain. It also pins something that looks like a bug and is not: a group commits FRI layers exactly when its codeword is longer than the terminal one, which is the blowup times the final polynomial's degree bound. The short tables — one row blown up to two, and the 256-long ones under a 2^7 bound at blowup 2 — are already terminal and fold zero times. --- prover/src/logup_phase.rs | 202 ++++++++++++++++++++++++++ prover/src/tests/batched_fri_tests.rs | 74 ++++++++++ 2 files changed, 276 insertions(+) diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index ba7ce4358..5f24da378 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -281,3 +281,205 @@ fn assemble( }) .collect() } + +/// One FRI per height group, instead of one per table. +pub struct Batched { + /// Per group, in ascending domain size: the domain and its FRI layer roots. + pub groups: Vec<(usize, Vec)>, + /// How many tables each group folded, so the collapse is visible. + pub members: Vec, + pub resident: Resident, +} + +type Deep = stark::prover::TableDeep; +type Deeps = std::sync::Mutex>; + +struct BuildDeep<'a> { + batch: pass::Batched<'a, Item>, +} + +impl<'a> BuildDeep<'a> { + fn new(airs: &'a ChunkAirs, challenge: &'a Challenge, done: &'a Deeps) -> Self { + Self { + batch: pass::Batched::new(move |items| deep_batch(airs, challenge, done, items)), + } + } +} + +fn deep_batch( + airs: &ChunkAirs, + challenge: &Challenge, + done: &Deeps, + items: Vec, +) -> Result<(), Error> { + use rayon::prelude::*; + let order = &challenge.order; + let n = order.len(); + let built: Result, Error> = items + .into_par_iter() + .map(|(kind, chunk, mut trace)| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "batched phase: {kind:?} chunk {chunk} is not in the layout" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let deep = deep_of( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + ) + .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, deep)) + }) + .collect(); + done.lock().expect("deeps").extend(built?); + Ok(()) +} + +fn deep_of( + air: &dyn stark::traits::AIR< + Field = GoldilocksField, + FieldExtension = GoldilocksExtension, + PublicInputs = (), + >, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut DefaultTranscript, +) -> Result { + type P = stark::prover::Prover; +

>::deep_for_table(air, &(), trace, challenges, transcript) + .map_err(|e| format!("{e:?}")) +} + +impl Visitor for BuildDeep<'_> { + fn table( + &mut self, + kind: TableKind, + chunk: usize, + trace: TraceTable, + ) -> Result<(), Error> { + self.batch.push((kind, chunk, trace)) + } + + fn flush(&mut self) -> Result<(), Error> { + self.batch.drain() + } +} + +/// Walk the execution and fold every table into one FRI per height group. +/// +/// The codewords are held, not the LDEs they came from — one extension element +/// per row instead of every column — which is what lets the fold wait until +/// every table is done without walking the execution a third time. +/// +/// Grouping is by exact domain and can only be: the fold squares the coset +/// offset each layer, so a short codeword never lines up with a tall fold. +pub fn run_batched( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, + challenge: &Challenge, +) -> Result { + use std::collections::BTreeMap; + type P = stark::prover::Prover; + + let chunk_airs = ChunkAirs::new(proof_options); + let done = std::sync::Mutex::new(Vec::new()); + let mut visitor = BuildDeep::new(&chunk_airs, challenge, &done); + let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; + drop(visitor); + let mut deeps = done.into_inner().expect("deeps"); + + // The tables the walk could not retire, in the same AIR order, exactly as + // `assemble` walks them for the per-table path. + let order = &challenge.order; + let airs = crate::VmAirs::new( + elf, + proof_options, + false, + &resident.page_configs, + order.counts(), + None, + true, + None, + None, + None, + ); + let n = order.len(); + let build = |idx: usize, + air: &crate::VmAir, + trace: &mut TraceTable| + -> Result<(usize, Deep), Error> { + let mut transcript = fork(&challenge.transcript, idx, n); + let deep = deep_of(air.as_ref(), trace, &challenge.challenges, &mut transcript) + .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; + Ok((idx, deep)) + }; + let fixed: [( + &crate::VmAir, + &mut TraceTable, + ); NUM_FIXED_AIRS] = [ + (&airs.bitwise, &mut resident.bitwise), + (&airs.decode, &mut resident.decode), + (&airs.commit, &mut resident.accumulated.commit), + (&airs.keccak, &mut resident.accumulated.keccak), + (&airs.keccak_rnd, &mut resident.accumulated.keccak_rnd), + (&airs.keccak_rc, &mut resident.accumulated.keccak_rc), + (&airs.ecsm, &mut resident.accumulated.ecsm), + (&airs.ecdas, &mut resident.accumulated.ecdas), + (&airs.hint, &mut resident.accumulated.hint), + (&airs.register, &mut resident.register), + ]; + for (idx, (air, trace)) in fixed.into_iter().enumerate() { + deeps.push(build(idx, air, trace)?); + } + if airs.include_halt { + deeps.push(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); + } + for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { + let idx = order.page_index(i).ok_or_else(|| { + Error::Prover(format!("batched phase: page {i} is not in the layout")) + })?; + deeps.push(build(idx, air, trace)?); + } + + // AIR order: the seed is absorbed in it and the verifier replays it. + deeps.sort_by_key(|(idx, _)| *idx); + if deeps.len() != n { + return Err(Error::Prover(format!( + "batched phase: {} tables for a layout of {n}", + deeps.len() + ))); + } + let ordered: Vec = deeps.into_iter().map(|(_, d)| d).collect(); + let alpha =

>::batch_alpha(&challenge.transcript, &ordered); + + let mut by_height: BTreeMap> = BTreeMap::new(); + for d in ordered { + by_height.entry(d.lde_size).or_default().push(d); + } + + // Any AIR serves for a group: `domain_and_twiddles` keys on the proof + // options alone, and every table in a prove shares them. + let any = chunk_airs.get(TableKind::Cpu).as_ref(); + let mut transcript = challenge.transcript.clone(); + let (mut groups, mut members) = (Vec::new(), Vec::new()); + for (lde_size, group) in by_height { + let count = group.len(); + let roots =

>::batch_fri(any, group, &alpha, &mut transcript) + .ok_or_else(|| { + Error::Prover(format!("batched phase: no FRI for size {lde_size}")) + })?; + groups.push((lde_size, roots)); + members.push(count); + } + + Ok(Batched { + groups, + members, + resident, + }) +} diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index fdc0a48b1..e1986c5b3 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -174,3 +174,77 @@ fn alpha_moves_when_any_table_moves() { "alpha ignores the table order, which the verifier replays" ); } + +/// The driver must fold every table, and fold them by domain. +/// +/// Two things can silently go wrong at once here. A table can go missing — the +/// walk produces the chunked ones and the end-of-run step the rest, and a batch +/// that skips one is not a smaller batch, it is a wrong one. And tables of +/// different domains can end up in the same group, which the fold cannot +/// express: it squares the coset offset each layer, so a short codeword never +/// lines up with a tall fold. +/// +/// So this checks the count against a real proof's table count, and that every +/// group is one domain with at least one member, and that the collapse actually +/// happened. +#[test] +fn the_driver_folds_every_table_grouped_by_domain() { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let batched = crate::logup_phase::run_batched(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("batched phase"); + + let folded: usize = batched.members.iter().sum(); + assert_eq!( + folded, + vm_proof.proof.proofs.len(), + "the driver folded {folded} tables but the proof has {}", + vm_proof.proof.proofs.len() + ); + assert!( + batched.groups.len() < folded, + "{} groups for {folded} tables is no collapse at all", + batched.groups.len() + ); + // A group commits layers exactly when there is something to fold. FRI stops + // at the final polynomial, whose CODEWORD is the blowup times its degree + // bound — so with blowup 2 and a degree bound of 2^7, a 256-long codeword is + // already terminal and folds zero times. The short tables (one row blown up + // to two) are terminal for the same reason. An empty group there is correct, + // not a group that failed. + let terminal = + (1usize << proof_options.fri_final_poly_log_degree) * proof_options.blowup_factor as usize; + for ((lde_size, roots), count) in batched.groups.iter().zip(batched.members.iter()) { + assert!(*count > 0, "a group of {lde_size} folded nothing"); + assert_eq!( + !roots.is_empty(), + *lde_size > terminal, + "a group of {lde_size} committed {} layers against a terminal of {terminal}", + roots.len() + ); + } + // Domains are distinct: a repeated one would mean two groups that should + // have been one, which is a fold that did not happen. + let mut sizes: Vec = batched.groups.iter().map(|(s, _)| *s).collect(); + let before = sizes.len(); + sizes.dedup(); + assert_eq!(before, sizes.len(), "two groups share a domain"); +} From bbe6aa64d5d005a6826229e078a209ac8c9fdd73 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 12:18:55 -0300 Subject: [PATCH 40/63] Give each group the queries its members open at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Open pass cannot start without them: a member's openings are taken at its group's query indices, and those do not exist until that group's FRI is over and ground. So the batched FRI now carries through to them — layers, final polynomial, grinding nonce, the shared indices and their decommitments — instead of stopping at the layer roots. The sharing is the whole substance of batching on the opening side. One set of indices for a group of tables is what makes their openings a group's worth of work rather than a table's each. The batch-of-one test grows to cover it, and stops exactly where determinism does. Layers and final polynomial must equal the ordinary prover's, and do. The nonce must not be compared: grinding searches in parallel and returns whichever nonce it finds first, so two runs over the same transcript state produce different valid ones — and since the indices are sampled after the nonce is absorbed, they move with it. What can be checked there is that the group sampled as many indices as it decommitted, and as many as the proof carries. --- crypto/stark/src/prover.rs | 38 ++++++++++++++++++++++++--- prover/src/logup_phase.rs | 6 +++-- prover/src/tests/batched_fri_tests.rs | 38 +++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index ab5fa7e61..212a45a4d 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -668,6 +668,17 @@ pub struct MainRoots { pub main: Commitment, } +/// One height group's FRI: the instance every member of the group folds into. +pub struct GroupFri { + pub layer_roots: Vec, + pub final_poly_coeffs: Vec>, + /// The query indices the whole group answers, and which each member's + /// openings are taken at. + pub iotas: Vec, + pub query_list: Vec>, + pub nonce: Option, +} + /// One table's contribution to a batched FRI. #[derive(Clone)] pub struct TableDeep { @@ -2180,7 +2191,7 @@ pub trait IsStarkProver< members: Vec>, alpha: &FieldElement, transcript: &mut (impl IsStarkTranscript + Clone), - ) -> Option> + ) -> Option> where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, @@ -2205,7 +2216,7 @@ pub trait IsStarkProver< let (domain, _) = domain_and_twiddles(air, trace_rows); let coset_offset = FieldElement::::from(air.context().proof_options.coset_offset); - let (_, layers) = fri::commit_phase_from_evaluations( + let (final_poly_coeffs, layers) = fri::commit_phase_from_evaluations( acc, transcript, &coset_offset, @@ -2214,7 +2225,28 @@ pub trait IsStarkProver< air.options().fri_final_poly_log_degree as u32, domain.fri_inv_twiddles(), ); - Some(layers.iter().map(|l| l.merkle_tree.root).collect()) + + // Grinding, then the queries — both from the batch's transcript, so the + // whole group answers the same indices. That sharing is the point: the + // openings a member owes are at the group's iotas, not at its own. + let grinding_factor = air.context().proof_options.grinding_factor; + let mut nonce = None; + if grinding_factor > 0 { + let value = grinding::generate_nonce_maybe_gpu(&transcript.state(), grinding_factor)?; + transcript.append_bytes(&value.to_be_bytes()); + nonce = Some(value); + } + let iotas = + Self::sample_query_indexes(air.options().fri_number_of_queries, &domain, transcript); + let query_list = fri::query_phase(&layers, &iotas); + + Some(GroupFri { + layer_roots: layers.iter().map(|l| l.merkle_tree.root).collect(), + final_poly_coeffs, + iotas, + query_list, + nonce, + }) } /// The main commitment of an already-expanded LDE, split when the AIR is diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 5f24da378..bf032a761 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -284,8 +284,10 @@ fn assemble( /// One FRI per height group, instead of one per table. pub struct Batched { - /// Per group, in ascending domain size: the domain and its FRI layer roots. - pub groups: Vec<(usize, Vec)>, + /// Per group, in ascending domain size: the domain and its FRI instance — + /// layers, final polynomial, the shared query indices and their + /// decommitments. + pub groups: Vec<(usize, stark::prover::GroupFri)>, /// How many tables each group folded, so the collapse is visible. pub members: Vec, pub resident: Resident, diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index e1986c5b3..0ef4a5bc5 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -75,7 +75,7 @@ fn a_batch_of_one_matches_the_unbatched_fri() { .expect("deep"); let one = math::field::element::FieldElement::::one(); - let roots =

>::batch_fri( + let fri =

>::batch_fri( airs.bitwise.as_ref(), vec![deep], &one, @@ -83,10 +83,34 @@ fn a_batch_of_one_matches_the_unbatched_fri() { ) .expect("batched fri"); + // Everything round 4 would have produced for this table on its own: the + // layers, the final polynomial, the ground nonce, and the queries its + // openings answer. + let want = &vm_proof.proof.proofs[idx]; assert_eq!( - roots, vm_proof.proof.proofs[idx].fri_layers_merkle_roots, + fri.layer_roots, want.fri_layers_merkle_roots, "a batch of one folded to a different FRI than the proof carries" ); + assert_eq!( + fri.final_poly_coeffs, want.fri_final_poly_coeffs, + "a batch of one folded to a different final polynomial" + ); + // And there the comparison stops. Grinding searches for a nonce in parallel + // and finds whichever one it finds first, so two runs over the same + // transcript state produce different valid nonces — and the queries are + // sampled after the nonce is absorbed, so they differ with it. What is + // deterministic is everything up to that point, which is what is checked + // above; the queries are checked instead by the count they produce. + assert_eq!( + fri.query_list.len(), + want.query_list.len(), + "a batch of one answered a different number of queries" + ); + assert_eq!( + fri.iotas.len(), + want.query_list.len(), + "the group sampled a different number of query indices than it decommitted" + ); } /// The batch's coefficient must depend on every table it folds. @@ -232,13 +256,17 @@ fn the_driver_folds_every_table_grouped_by_domain() { // not a group that failed. let terminal = (1usize << proof_options.fri_final_poly_log_degree) * proof_options.blowup_factor as usize; - for ((lde_size, roots), count) in batched.groups.iter().zip(batched.members.iter()) { + for ((lde_size, fri), count) in batched.groups.iter().zip(batched.members.iter()) { assert!(*count > 0, "a group of {lde_size} folded nothing"); assert_eq!( - !roots.is_empty(), + !fri.layer_roots.is_empty(), *lde_size > terminal, "a group of {lde_size} committed {} layers against a terminal of {terminal}", - roots.len() + fri.layer_roots.len() + ); + assert!( + !fri.iotas.is_empty(), + "a group of {lde_size} sampled no queries for its members to open at" ); } // Domains are distinct: a repeated one would mean two groups that should From 053660ab6d8160b44af01bbe466b121a925ba4e3 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 12:29:39 -0300 Subject: [PATCH 41/63] Open a table at the indices its group decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of Approach 1's five passes is the one that opens, and it can only run once the batched FRI has fixed a group's query indices. open_for_table is the per-table half: the table is rebuilt — round 1 for the trace commitments, round 2 for the composition ones — its rows are taken at the group's indices, and it dies with the call, which is the trade this approach makes everywhere. For that to be possible a table has to know which group folded it, and after the codewords are sorted by domain they no longer sit in AIR order. So a codeword carries its AIR index, and the batch reports the group of each table. The test checks the mapping both ways: every table points at a group that exists, and the groups' own member counts agree with what the tables claim. A table pointing at the wrong group would open against a FRI that never folded it, which is the kind of thing that verifies fine right up until it does not. What is still missing is the walk that drives this over every table, and the proof shape that falls out of it. --- crypto/stark/src/prover.rs | 46 +++++++++++++++++++++++++++ prover/src/logup_phase.rs | 18 ++++++++++- prover/src/tests/batched_fri_tests.rs | 22 +++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 212a45a4d..a185e54f4 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -694,6 +694,10 @@ pub struct TableDeep { pub trace_rows: usize, /// The DEEP composition codeword, `lde_size` long. pub deep: Vec>, + /// Where the table sits in the AIR order, carried so the batch can say + /// which group each table ended up in once the codewords are sorted by + /// domain rather than by position. + pub air_index: usize, /// What the batch's coefficient is drawn from: this table's public round-3 /// data, in the order a transcript absorbs it. /// @@ -2124,6 +2128,7 @@ pub trait IsStarkProver< lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, deep, + air_index: usize::MAX, bus_contribution: round_1_result .bus_public_inputs .as_ref() @@ -2176,6 +2181,47 @@ pub trait IsStarkProver< seed.sample_field_element() } + /// One table's openings, at the indices its group decided. + /// + /// The Open pass: the batched FRI fixed the query indices for a whole + /// group, and every member owes its rows at those indices. The table is + /// rebuilt to serve them — round 1 for the trace commitments and round 2 + /// for the composition ones — and dies with the call, which is the trade + /// the approach makes everywhere else too. + /// + /// `transcript` is the table's own fork again, in the same state round 1 + /// expects, because rebuilding walks the same rounds it walked before. + fn open_for_table( + air: &dyn AIR, + pub_inputs: &PI, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut (impl IsStarkTranscript + Clone), + iotas: &[usize], + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + PI: Send + Sync + Clone, + { + let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + let (round_2_result, _, _, _, _) = Self::rounds_2_and_3( + air, + pub_inputs, + &mut round_1_result, + transcript, + &domain, + &twiddles, + )?; + Ok(Self::open_deep_composition_poly( + &domain, + &round_1_result, + &round_2_result, + iotas, + )) + } + /// One FRI over a whole group of tables. /// /// The members share a domain, so their codewords add directly: the batch diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index bf032a761..dce291a94 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -290,6 +290,9 @@ pub struct Batched { pub groups: Vec<(usize, stark::prover::GroupFri)>, /// How many tables each group folded, so the collapse is visible. pub members: Vec, + /// Which group each table belongs to, by AIR index — the group whose query + /// indices its openings answer. + pub group_of: Vec, pub resident: Resident, } @@ -456,7 +459,13 @@ pub fn run_batched( deeps.len() ))); } - let ordered: Vec = deeps.into_iter().map(|(_, d)| d).collect(); + let ordered: Vec = deeps + .into_iter() + .map(|(idx, mut d)| { + d.air_index = idx; + d + }) + .collect(); let alpha =

>::batch_alpha(&challenge.transcript, &ordered); let mut by_height: BTreeMap> = BTreeMap::new(); @@ -469,6 +478,12 @@ pub fn run_batched( let any = chunk_airs.get(TableKind::Cpu).as_ref(); let mut transcript = challenge.transcript.clone(); let (mut groups, mut members) = (Vec::new(), Vec::new()); + let mut group_of = vec![usize::MAX; n]; + for (g, (_, group)) in by_height.iter().enumerate() { + for d in group.iter() { + group_of[d.air_index] = g; + } + } for (lde_size, group) in by_height { let count = group.len(); let roots =

>::batch_fri(any, group, &alpha, &mut transcript) @@ -482,6 +497,7 @@ pub fn run_batched( Ok(Batched { groups, members, + group_of, resident, }) } diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 0ef4a5bc5..7acecf020 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -136,6 +136,7 @@ fn alpha_moves_when_any_table_moves() { lde_size: 8, trace_rows: 4, deep: Vec::new(), + air_index: seed as usize, bus_contribution: Some(FieldElement::::from(seed)), composition_poly_root: [seed as u8; 32], trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), @@ -269,6 +270,27 @@ fn the_driver_folds_every_table_grouped_by_domain() { "a group of {lde_size} sampled no queries for its members to open at" ); } + // Every table knows its group, and it is the group of its own domain. The + // Open pass reads this to take a table's openings at the right indices, so + // a table pointing at the wrong group opens against a FRI that never folded + // it. + assert_eq!(batched.group_of.len(), folded, "a table has no group"); + for (idx, g) in batched.group_of.iter().enumerate() { + assert!( + *g < batched.groups.len(), + "table {idx} points at group {g}, past the {} there are", + batched.groups.len() + ); + } + let mut per_group = vec![0usize; batched.groups.len()]; + for g in &batched.group_of { + per_group[*g] += 1; + } + assert_eq!( + per_group, batched.members, + "the tables' groups disagree with what each group says it folded" + ); + // Domains are distinct: a repeated one would mean two groups that should // have been one, which is a fold that did not happen. let mut sizes: Vec = batched.groups.iter().map(|(s, _)| *s).collect(); From bf8adb879a682c721e72676b679772077ea21c18 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 12:36:46 -0300 Subject: [PATCH 42/63] Walk once more and open every table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1's fifth pass, and the last. The query indices do not exist until the batched FRI is over, so nothing here could have been folded into an earlier walk — which is exactly why the spec puts it last. Same shape as the three walks before it: a Visitor over the execution, batched k at a time, with the end-of-run tables served afterwards in AIR order. What is new is that each table is opened at ITS GROUP's indices, looked up through the mapping the batch reported. The test caught its own weakness, which is worth recording. Checking that a table opened as many rows as its group asked for proves less than it appears: the number of queries is a global option, so every group asks the same number and a table handed the wrong group's indices still opens the right count — pointing every table at group 0 does not fail it. What separates the groups is the indices themselves, addressed against different domains, so the test also requires every group's indices to be rows that group actually has. An index from a taller group is not a row a shorter one has. --- prover/src/logup_phase.rs | 198 ++++++++++++++++++++++++++ prover/src/tests/batched_fri_tests.rs | 66 +++++++++ 2 files changed, 264 insertions(+) diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index dce291a94..157373f40 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -501,3 +501,201 @@ pub fn run_batched( resident, }) } + +/// What the Open pass produced: every table's rows at its group's indices. +pub struct Opened { + /// One entry per table, in AIR order. + pub openings: + Vec>, + pub resident: Resident, +} + +type Open = stark::proof::stark::DeepPolynomialOpenings; +type Opens = std::sync::Mutex>; + +struct OpenTables<'a> { + batch: pass::Batched<'a, Item>, +} + +impl<'a> OpenTables<'a> { + fn new( + airs: &'a ChunkAirs, + challenge: &'a Challenge, + batched: &'a Batched, + done: &'a Opens, + ) -> Self { + Self { + batch: pass::Batched::new(move |items| { + open_batch(airs, challenge, batched, done, items) + }), + } + } +} + +fn iotas_of(batched: &Batched, idx: usize) -> Result<&[usize], Error> { + let g = *batched + .group_of + .get(idx) + .ok_or_else(|| Error::Prover(format!("open pass: table {idx} has no group")))?; + let (_, fri) = batched + .groups + .get(g) + .ok_or_else(|| Error::Prover(format!("open pass: table {idx} points at group {g}")))?; + Ok(&fri.iotas) +} + +fn open_batch( + airs: &ChunkAirs, + challenge: &Challenge, + batched: &Batched, + done: &Opens, + items: Vec, +) -> Result<(), Error> { + use rayon::prelude::*; + let order = &challenge.order; + let n = order.len(); + let built: Result, Error> = items + .into_par_iter() + .map(|(kind, chunk, mut trace)| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "open pass: {kind:?} chunk {chunk} is not in the layout" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let opening = open_of( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + iotas_of(batched, idx)?, + ) + .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, opening)) + }) + .collect(); + done.lock().expect("openings").extend(built?); + Ok(()) +} + +fn open_of( + air: &dyn stark::traits::AIR< + Field = GoldilocksField, + FieldExtension = GoldilocksExtension, + PublicInputs = (), + >, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut DefaultTranscript, + iotas: &[usize], +) -> Result { + type P = stark::prover::Prover; +

>::open_for_table(air, &(), trace, challenges, transcript, iotas) + .map_err(|e| format!("{e:?}")) +} + +impl Visitor for OpenTables<'_> { + fn table( + &mut self, + kind: TableKind, + chunk: usize, + trace: TraceTable, + ) -> Result<(), Error> { + self.batch.push((kind, chunk, trace)) + } + + fn flush(&mut self) -> Result<(), Error> { + self.batch.drain() + } +} + +/// Approach 1's fifth pass: walk the execution once more and open every table +/// at the indices its group settled on. +/// +/// The last walk, and the one the spec puts last for a reason — the indices do +/// not exist until the batched FRI is over, so nothing here could have been +/// folded into an earlier pass. +pub fn run_open( + elf: &Elf, + private_input: &[u8], + max_rows: &MaxRowsConfig, + proof_options: &ProofOptions, + challenge: &Challenge, + batched: &Batched, +) -> Result { + let chunk_airs = ChunkAirs::new(proof_options); + let done = std::sync::Mutex::new(Vec::new()); + let mut visitor = OpenTables::new(&chunk_airs, challenge, batched, &done); + let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; + drop(visitor); + let mut opens = done.into_inner().expect("openings"); + + let order = &challenge.order; + let airs = crate::VmAirs::new( + elf, + proof_options, + false, + &resident.page_configs, + order.counts(), + None, + true, + None, + None, + None, + ); + let n = order.len(); + let build = |idx: usize, + air: &crate::VmAir, + trace: &mut TraceTable| + -> Result<(usize, Open), Error> { + let mut transcript = fork(&challenge.transcript, idx, n); + let opening = open_of( + air.as_ref(), + trace, + &challenge.challenges, + &mut transcript, + iotas_of(batched, idx)?, + ) + .map_err(|e| Error::Prover(format!("open pass: table {idx}: {e}")))?; + Ok((idx, opening)) + }; + let fixed: [( + &crate::VmAir, + &mut TraceTable, + ); NUM_FIXED_AIRS] = [ + (&airs.bitwise, &mut resident.bitwise), + (&airs.decode, &mut resident.decode), + (&airs.commit, &mut resident.accumulated.commit), + (&airs.keccak, &mut resident.accumulated.keccak), + (&airs.keccak_rnd, &mut resident.accumulated.keccak_rnd), + (&airs.keccak_rc, &mut resident.accumulated.keccak_rc), + (&airs.ecsm, &mut resident.accumulated.ecsm), + (&airs.ecdas, &mut resident.accumulated.ecdas), + (&airs.hint, &mut resident.accumulated.hint), + (&airs.register, &mut resident.register), + ]; + for (idx, (air, trace)) in fixed.into_iter().enumerate() { + opens.push(build(idx, air, trace)?); + } + if airs.include_halt { + opens.push(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); + } + for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { + let idx = order + .page_index(i) + .ok_or_else(|| Error::Prover(format!("open pass: page {i} is not in the layout")))?; + opens.push(build(idx, air, trace)?); + } + + opens.sort_by_key(|(idx, _)| *idx); + if opens.len() != n { + return Err(Error::Prover(format!( + "open pass: {} tables for a layout of {n}", + opens.len() + ))); + } + Ok(Opened { + openings: opens.into_iter().map(|(_, o)| o).collect(), + resident, + }) +} diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 7acecf020..4a3986c59 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -298,3 +298,69 @@ fn the_driver_folds_every_table_grouped_by_domain() { sizes.dedup(); assert_eq!(before, sizes.len(), "two groups share a domain"); } + +/// The Open pass must serve every table, at its own group's indices. +/// +/// This is Approach 1's fifth pass and the last one: the query indices do not +/// exist until the batched FRI is over, so it could not have been folded into +/// an earlier walk. What it produces is what a verifier will authenticate, so +/// the two things that matter are that no table is missing and that each opened +/// as many rows as its group asked for — a table opening the wrong count is a +/// table answering a different FRI. +#[test] +fn the_open_pass_serves_every_table_at_its_group() { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let batched = crate::logup_phase::run_batched(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("batched phase"); + let opened = + crate::logup_phase::run_open(&elf, &[], &max_rows, &proof_options, &challenge, &batched) + .expect("open pass"); + + assert_eq!( + opened.openings.len(), + batched.group_of.len(), + "the Open pass served a different number of tables than the batch folded" + ); + for (idx, opening) in opened.openings.iter().enumerate() { + let g = batched.group_of[idx]; + let wanted = batched.groups[g].1.iotas.len(); + assert_eq!( + opening.len(), + wanted, + "table {idx} opened {} rows for a group that asked {wanted}", + opening.len() + ); + } + + // The count above is weaker than it looks: the number of queries is a + // global option, so every group asks for the same number and a table given + // the wrong group's indices still opens the right count. What separates the + // groups is the indices themselves, which are sampled from different + // transcript states and — this is the part that must hold — addressed + // against different domains. An index from a taller group is simply not a + // row a shorter one has. + for (lde_size, fri) in batched.groups.iter() { + for iota in fri.iotas.iter() { + assert!( + iota < lde_size, + "a group of {lde_size} sampled index {iota}, which is not a row it has" + ); + } + } +} From e50a46e0cdd7ec981c73eeca26a1af89d12e0111 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 12:55:59 -0300 Subject: [PATCH 43/63] Assemble what the five passes produce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pieces existed and nothing joined them: roots and out-of-domain values from the batched pass, rows from the Open pass, one FRI per domain from the fold. BatchedProof is that join. It is additive. StarkProof and multi_verify are untouched and still produce byte-identical proofs; this is a second format beside them, for the path that folds one FRI per domain instead of one per table. Nothing has to be migrated for it to exist. The split is the claim: a table keeps what only it can answer for, and a group carries the FRI its tables share. So the test compares every table against a real per-table proof — main, auxiliary and precomputed roots, the composition root, the parts at z, the out-of-domain evaluations, the trace length — and requires them equal. What is absent per table is exactly the four things that became the group's: the layers, the final polynomial, the queries and the nonce. On the ethrex block those are 448 MB of 775. --- crypto/stark/src/prover.rs | 11 +++ prover/src/logup_phase.rs | 84 +++++++++++++++++++++++ prover/src/tests/batched_fri_tests.rs | 97 +++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index a185e54f4..f79f7fa46 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -705,6 +705,11 @@ pub struct TableDeep { /// verifier has no forks, only a proof. Everything here is carried in the /// proof, so the verifier rebuilds the same seed from the same bytes. pub bus_contribution: Option>, + /// The round 1 roots and the full bus inputs, which the proof carries even + /// though only the contribution goes into the seed. + pub main_roots: MainRoots, + pub aux_root: Option, + pub bus_public_inputs: Option>, pub composition_poly_root: Commitment, pub trace_ood: Table, pub trace_ood_next: Table, @@ -2133,6 +2138,12 @@ pub trait IsStarkProver< .bus_public_inputs .as_ref() .map(|b| b.table_contribution.clone()), + main_roots: MainRoots { + precomputed: round_1_result.main.precomputed_root, + main: round_1_result.main.root, + }, + aux_root: round_1_result.aux.as_ref().map(|c| c.root), + bus_public_inputs: round_1_result.bus_public_inputs.clone(), composition_poly_root: round_2_result.composition_poly_root, trace_ood, trace_ood_next, diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 157373f40..5a4c5e5b2 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -282,8 +282,28 @@ fn assemble( .collect() } +/// A table's half of a batched proof: everything it contributes that is not a +/// FRI, which is now its group's business. +/// +/// This is what the per-table `StarkProof` keeps once the layers, the final +/// polynomial, the queries and the nonce move to the group — the 57.9% of the +/// proof that stops being paid once per table. +pub struct TablePublic { + pub trace_rows: usize, + pub main_root: stark::config::Commitment, + pub precomputed_root: Option, + pub aux_root: Option, + pub composition_poly_root: stark::config::Commitment, + pub trace_ood: stark::table::Table, + pub trace_ood_next: stark::table::Table, + pub parts_ood: Vec>, + pub bus_public_inputs: Option>, +} + /// One FRI per height group, instead of one per table. pub struct Batched { + /// Per table, in AIR order: what it contributes besides its codeword. + pub tables: Vec, /// Per group, in ascending domain size: the domain and its FRI instance — /// layers, final polynomial, the shared query indices and their /// decommitments. @@ -468,6 +488,23 @@ pub fn run_batched( .collect(); let alpha =

>::batch_alpha(&challenge.transcript, &ordered); + // Taken before the fold, which consumes the codewords: this is the half of + // each table that survives into the proof. + let tables: Vec = ordered + .iter() + .map(|d| TablePublic { + trace_rows: d.trace_rows, + main_root: d.main_roots.main, + precomputed_root: d.main_roots.precomputed, + aux_root: d.aux_root, + composition_poly_root: d.composition_poly_root, + trace_ood: d.trace_ood.clone(), + trace_ood_next: d.trace_ood_next.clone(), + parts_ood: d.parts_ood.clone(), + bus_public_inputs: d.bus_public_inputs.clone(), + }) + .collect(); + let mut by_height: BTreeMap> = BTreeMap::new(); for d in ordered { by_height.entry(d.lde_size).or_default().push(d); @@ -495,6 +532,7 @@ pub fn run_batched( } Ok(Batched { + tables, groups, members, group_of, @@ -699,3 +737,49 @@ pub fn run_open( resident, }) } + +/// A batched proof: what the five passes produce, assembled. +/// +/// Additive, not a replacement. `StarkProof` and `multi_verify` are untouched +/// and still produce byte-identical proofs; this is a second format alongside +/// them, for the path that folds one FRI per domain instead of one per table. +/// +/// The split is the whole point. A table keeps what only it can answer for — +/// its roots, its out-of-domain values, its openings — and a group carries the +/// FRI those tables share. That is the 57.9% of a per-table proof that stops +/// being paid 227 times. +pub struct BatchedProof { + /// Per table, in AIR order. + pub tables: Vec, + /// Per table, in AIR order: its rows at its group's indices. + pub openings: Vec, + /// Which group each table belongs to. + pub group_of: Vec, + /// Per group, in ascending domain: the FRI they share. + pub groups: Vec<(usize, stark::prover::GroupFri)>, + /// The statement, which the verifier binds before absorbing any root. + pub public_output: Vec, + pub page_configs: Vec, +} + +/// Assemble what the batched and Open passes produced. +/// +/// Takes them rather than running them, so the two walks stay independently +/// testable and a caller can measure either on its own. +pub fn assemble_batched_proof(batched: Batched, opened: Opened) -> Result { + if batched.tables.len() != opened.openings.len() { + return Err(Error::Prover(format!( + "assemble: {} tables against {} openings", + batched.tables.len(), + opened.openings.len() + ))); + } + Ok(BatchedProof { + tables: batched.tables, + openings: opened.openings, + group_of: batched.group_of, + groups: batched.groups, + public_output: opened.resident.public_output, + page_configs: opened.resident.page_configs, + }) +} diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 4a3986c59..ef9e98b14 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -138,6 +138,12 @@ fn alpha_moves_when_any_table_moves() { deep: Vec::new(), air_index: seed as usize, bus_contribution: Some(FieldElement::::from(seed)), + main_roots: stark::prover::MainRoots { + precomputed: None, + main: [0u8; 32], + }, + aux_root: None, + bus_public_inputs: None, composition_poly_root: [seed as u8; 32], trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), trace_ood_next: Table::new(vec![FieldElement::::from(seed + 2)], 1), @@ -364,3 +370,94 @@ fn the_open_pass_serves_every_table_at_its_group() { } } } + +/// The batched proof must carry, per table, what the per-table proof carries — +/// minus exactly the FRI. +/// +/// The claim behind the whole format is that nothing is lost by moving the FRI +/// to the group: a table still answers for its own roots, its own out-of-domain +/// values and its own rows. So every one of those is compared against a real +/// per-table proof, table by table. What is absent is the four things that +/// became the group's, and those are what the size saving is made of. +#[test] +fn the_batched_proof_keeps_everything_but_the_fri() { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let batched = crate::logup_phase::run_batched(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("batched phase"); + let opened = + crate::logup_phase::run_open(&elf, &[], &max_rows, &proof_options, &challenge, &batched) + .expect("open pass"); + let proof = crate::logup_phase::assemble_batched_proof(batched, opened).expect("assemble"); + + assert_eq!( + proof.tables.len(), + vm_proof.proof.proofs.len(), + "the batched proof covers a different number of tables" + ); + for (idx, (got, want)) in proof + .tables + .iter() + .zip(vm_proof.proof.proofs.iter()) + .enumerate() + { + assert_eq!( + got.main_root, want.lde_trace_main_merkle_root, + "table {idx}: a different main root" + ); + assert_eq!( + got.aux_root, want.lde_trace_aux_merkle_root, + "table {idx}: a different auxiliary root" + ); + assert_eq!( + got.precomputed_root, want.lde_trace_precomputed_merkle_root, + "table {idx}: a different precomputed root" + ); + assert_eq!( + got.composition_poly_root, want.composition_poly_root, + "table {idx}: a different composition root" + ); + assert_eq!( + got.parts_ood, want.composition_poly_parts_ood_evaluation, + "table {idx}: different composition parts at z" + ); + assert_eq!( + (got.trace_ood.width, got.trace_ood.columns()), + ( + want.trace_ood_evaluations.width, + want.trace_ood_evaluations.columns() + ), + "table {idx}: different out-of-domain evaluations at z" + ); + assert_eq!( + got.trace_rows, want.trace_length, + "table {idx}: a different trace length" + ); + } + + // And the FRI is where it should be: nowhere per table, once per domain. + assert!( + proof.groups.len() < proof.tables.len(), + "{} groups for {} tables is no collapse", + proof.groups.len(), + proof.tables.len() + ); +} From 91e177a2549e21b48ad29e65615e2848701ae1c4 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 13:09:46 -0300 Subject: [PATCH 44/63] Weigh the batched proof against the per-table one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prize was priced before any of this was built — the per-table FRI data was 57.9% of the proof, and collapsing 227 instances into 13 was estimated at ~54% off. This measures it instead: 338 MB against 775, so 56.4% off, which is where the estimate said it would be. The split is 326 MB still per table — roots, out-of-domain values, openings — against 11 MB for all 13 groups. Those 11 MB are what used to be paid 227 times. It costs, against the per-table path, +95s and +8.5 GB: two extra walks to fold and to open, and the 227 DEEP codewords held until alpha is known. Against main it is still 3.6x less memory, at 2.57x the time, with a proof 56% smaller. The first run of this said 349.9s and 39.6 GB because the stage ran the per-table prove AND then the fold, which measures neither. The batched path replaces that pass rather than following it. --- bin/cli/src/main.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index afcc1b684..cdd5ef052 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -283,6 +283,9 @@ enum Stage { Challenge, /// Also walk again to build and commit the auxiliary columns. Logup, + /// Instead of one FRI per table, fold them by domain, open at the group's + /// indices, and assemble the batched proof. + Batched, } fn main() -> ExitCode { @@ -1070,6 +1073,36 @@ fn run_approach_1( println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); return Ok(challenge.roots.len()); } + // The batched path replaces the per-table prove; running both would measure + // neither. + if through == Stage::Batched { + let t3 = std::time::Instant::now(); + let batched = + prover::logup_phase::run_batched(elf, private_inputs, max_rows, options, &challenge) + .map_err(|e| format!("{e:?}"))?; + let opened = prover::logup_phase::run_open( + elf, + private_inputs, + max_rows, + options, + &challenge, + &batched, + ) + .map_err(|e| format!("{e:?}"))?; + let tables = batched.tables.len(); + let groups = batched.groups.len(); + let proof = prover::logup_phase::assemble_batched_proof(batched, opened) + .map_err(|e| format!("{e:?}"))?; + println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); + println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); + println!( + " passes 3-5 (fold+open) {:>6.2}s", + t3.elapsed().as_secs_f64() + ); + report_batched_size(&proof, tables, groups); + return Ok(tables); + } + let t2 = std::time::Instant::now(); let logup = prover::logup_phase::run(elf, private_inputs, max_rows, options, &challenge) .map_err(|e| format!("{e:?}"))?; @@ -1082,6 +1115,48 @@ fn run_approach_1( Ok(logup.tables.len()) } +/// What the batched proof weighs, against what the per-table one weighs. +/// +/// The prize was priced before any of this was built: the per-table FRI data +/// was 57.9% of the proof. This is the same measurement on the other side — +/// what a table still carries once the layers, the final polynomial, the +/// queries and the nonce belong to its group. +fn report_batched_size(proof: &prover::logup_phase::BatchedProof, tables: usize, groups: usize) { + let mut per_table = 0usize; + for (t, o) in proof.tables.iter().zip(proof.openings.iter()) { + per_table += serde_cbor::to_vec(&t.trace_ood) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&t.trace_ood_next) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&t.parts_ood) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(o).map(|v| v.len()).unwrap_or(0) + + 32 * 4; + } + let mut per_group = 0usize; + for (_, fri) in proof.groups.iter() { + per_group += serde_cbor::to_vec(&fri.layer_roots) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&fri.final_poly_coeffs) + .map(|v| v.len()) + .unwrap_or(0) + + serde_cbor::to_vec(&fri.query_list) + .map(|v| v.len()) + .unwrap_or(0); + } + let total = per_table + per_group; + println!( + "Batched: {tables} tables over {groups} groups; {} MB per table + {} MB per group = {} MB", + per_table / (1024 * 1024), + per_group / (1024 * 1024), + total / (1024 * 1024), + ); +} + /// Where the time went, summed per span label. /// /// The prover's own spans are per table and there are 227 of them, so the raw From 3527f51e933498363f416d1757845ef46cac4e47 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 15:15:02 -0300 Subject: [PATCH 45/63] Stop rebuilding roots the Commit phase already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A perf profile of the batched path on the ethrex block puts keccak at 17.6%, the hottest thing in it by a wide margin — Merkle hashing, not arithmetic. That matches what the prover's own profile has always said and points at the same place: a tree built for nothing is the most expensive nothing available. The fold walk was building one. It rebuilt every table's main Merkle tree, but the Commit phase computed all 227 of those roots already and hands them over in AIR order, rounds 2 and 3 never read the commitment, and the fold does not open against the tree — the opening walk does. So the roots are passed in and the tree is not built: 103.24s to 96.79s for that walk, at no cost in memory. Correctness is not taken on trust. The assembled proof's main roots are compared against a real per-table proof's, table by table, which is the test that would fail first if a reused root were the wrong one. What the profile leaves on the table is bigger and not chased here: about 18% of the time is rayon and crossbeam plumbing, plus 2.2% of kernel spinlock. Sixteen concurrent tables each parallelising over 96 cores is oversubscription, and the k sweep measures the net effect without separating work from plumbing. --- bin/cli/src/main.rs | 9 ++++--- crypto/stark/src/prover.rs | 35 ++++++++++++++++++++++++--- prover/src/logup_phase.rs | 23 +++++++++++++++--- prover/src/tests/batched_fri_tests.rs | 1 + 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index cdd5ef052..cd36d4267 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1080,6 +1080,8 @@ fn run_approach_1( let batched = prover::logup_phase::run_batched(elf, private_inputs, max_rows, options, &challenge) .map_err(|e| format!("{e:?}"))?; + let t_fold = t3.elapsed(); + let t4 = std::time::Instant::now(); let opened = prover::logup_phase::run_open( elf, private_inputs, @@ -1091,14 +1093,13 @@ fn run_approach_1( .map_err(|e| format!("{e:?}"))?; let tables = batched.tables.len(); let groups = batched.groups.len(); + let t_open = t4.elapsed(); let proof = prover::logup_phase::assemble_batched_proof(batched, opened) .map_err(|e| format!("{e:?}"))?; println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); - println!( - " passes 3-5 (fold+open) {:>6.2}s", - t3.elapsed().as_secs_f64() - ); + println!(" pass 3-4 (deep+fold) {:>7.2}s", t_fold.as_secs_f64()); + println!(" pass 5 (open) {:>8.2}s", t_open.as_secs_f64()); report_batched_size(&proof, tables, groups); return Ok(tables); } diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index f79f7fa46..3c45ee0da 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -132,6 +132,20 @@ where FieldElement: AsBytes, { /// Build a `TableCommit` for a plain (non-preprocessed) table. + /// Roots without a tree, for a pass that will not open against it. The + /// tree is the expensive half; a caller that already knows the roots and + /// only needs them to travel should not pay for one. + fn known_roots(root: Commitment, precomputed: Option) -> Self { + Self { + tree: Arc::new(BatchedMerkleTree::from_root(root)), + root, + precomputed_tree: None, + precomputed_root: precomputed, + num_precomputed_cols: 0, + leaves_dropped: None, + } + } + fn plain(#[allow(unused_mut)] mut tree: BatchedMerkleTree, root: Commitment) -> Self { let leaves_dropped = Self::retire_leaves(&mut tree); Self { @@ -1852,7 +1866,8 @@ pub trait IsStarkProver< let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); #[cfg(feature = "instruments")] let __r1 = crate::instruments::span("a1_round_1"); - let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + let mut round_1_result = + Self::round_1_from_trace(air, trace, challenges, transcript, None)?; #[cfg(feature = "instruments")] drop(__r1); #[cfg(feature = "instruments")] @@ -1882,6 +1897,7 @@ pub trait IsStarkProver< trace: &mut TraceTable, challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), + known_main: Option, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -1915,7 +1931,15 @@ pub trait IsStarkProver< let (main_src, num_main_cols) = trace.main_data_row_major(); let main_data = expand_main(main_src, num_main_cols).map_err(|_| ProvingError::EmptyCommitment)?; - let main = Self::table_commit_for(air, &main_data, num_main_cols)?; + // A caller that already has this table's main roots — the Commit phase + // computed every one of them — and that will not open against the tree + // can hand them over instead. Building the tree is the hottest thing in + // a prove (keccak is 17.6% of the profile), so not building one that + // nothing will ask a question of is the cheapest saving there is. + let main = match known_main { + Some(roots) => TableCommit::known_roots(roots.main, roots.precomputed), + None => Self::table_commit_for(air, &main_data, num_main_cols)?, + }; #[cfg(feature = "instruments")] drop(__m); @@ -2080,6 +2104,7 @@ pub trait IsStarkProver< trace: &mut TraceTable, challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), + known_main: Option, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -2087,7 +2112,8 @@ pub trait IsStarkProver< PI: Send + Sync + Clone, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); - let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + let mut round_1_result = + Self::round_1_from_trace(air, trace, challenges, transcript, known_main)?; let (mut round_2_result, round_3_result, z, trace_ood, trace_ood_next) = Self::rounds_2_and_3( air, @@ -2216,7 +2242,8 @@ pub trait IsStarkProver< PI: Send + Sync + Clone, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); - let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript)?; + let mut round_1_result = + Self::round_1_from_trace(air, trace, challenges, transcript, None)?; let (round_2_result, _, _, _, _) = Self::rounds_2_and_3( air, pub_inputs, diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 5a4c5e5b2..e3ebb43d9 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -354,6 +354,7 @@ fn deep_batch( &mut trace, &challenge.challenges, &mut transcript, + challenge.roots.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, deep)) @@ -372,10 +373,18 @@ fn deep_of( trace: &mut TraceTable, challenges: &[FieldElement], transcript: &mut DefaultTranscript, + known_main: Option, ) -> Result { type P = stark::prover::Prover; -

>::deep_for_table(air, &(), trace, challenges, transcript) - .map_err(|e| format!("{e:?}")) +

>::deep_for_table( + air, + &(), + trace, + challenges, + transcript, + known_main, + ) + .map_err(|e| format!("{e:?}")) } impl Visitor for BuildDeep<'_> { @@ -439,8 +448,14 @@ pub fn run_batched( trace: &mut TraceTable| -> Result<(usize, Deep), Error> { let mut transcript = fork(&challenge.transcript, idx, n); - let deep = deep_of(air.as_ref(), trace, &challenge.challenges, &mut transcript) - .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; + let deep = deep_of( + air.as_ref(), + trace, + &challenge.challenges, + &mut transcript, + challenge.roots.get(idx).cloned(), + ) + .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; Ok((idx, deep)) }; let fixed: [( diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index ef9e98b14..5fa3ac994 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -71,6 +71,7 @@ fn a_batch_of_one_matches_the_unbatched_fri() { &mut resident.bitwise, &challenge.challenges, &mut fork, + None, ) .expect("deep"); From 228ac2de52c89bd1140706d250f15a7a7787ac03 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 15:57:17 -0300 Subject: [PATCH 46/63] Open against the trees, instead of hashing them twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the one optimization the spec names for Approach 1's Open phase: "keeping the internal nodes of the merkle tree in memory, obviating the need to recompute it; while still dropping the biggest memory cost (the leaves)". It had been available since the first day of this branch — the leaf-dropping tree was ported with everything else — and nothing used it. The Open pass was rebuilding every table's main and auxiliary trees purely to have paths to open with. Now the fold walk hands its trees over. A perf profile of this path puts keccak at 17.6%, the hottest symbol in it, so hashing a tree once instead of twice is where the time was. It costs the earlier reuse of the Commit phase's roots: a table whose main commitment is roots-only cannot answer an opening, and handing one to the Open pass panics in get_proof_by_pos, which is how this was found. The fold walk builds real trees again, and the saving moves from that walk to the one after it, where both trees are saved rather than one. Also splits the batch seed out of the codeword. The seed is a function of public data alone, so a caller reasoning about it should not have to build a codeword or a tree to do so — SeedData carries exactly what the transcript absorbs. --- crypto/stark/src/prover.rs | 122 +++++++++++++++++++++++--- prover/src/logup_phase.rs | 32 +++++-- prover/src/tests/batched_fri_tests.rs | 28 ++---- 3 files changed, 146 insertions(+), 36 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 3c45ee0da..d4a9e84fc 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -695,7 +695,11 @@ pub struct GroupFri { /// One table's contribution to a batched FRI. #[derive(Clone)] -pub struct TableDeep { +pub struct TableDeep +where + FieldElement: AsBytes, + FieldElement: AsBytes, +{ /// The domain the codeword lives on. Only tables that agree on this can be /// folded together: the fold squares the coset offset each layer, so a /// short codeword over `offset·` never lines up with a tall fold over @@ -708,6 +712,9 @@ pub struct TableDeep { pub trace_rows: usize, /// The DEEP composition codeword, `lde_size` long. pub deep: Vec>, + /// The trees this table's Round 1 built, for the Open pass to use instead + /// of hashing them a second time. The spec's one named optimization. + pub kept: KeptCommits, /// Where the table sits in the AIR order, carried so the batch can say /// which group each table ended up in once the codewords are sorted by /// domain rather than by position. @@ -730,6 +737,97 @@ pub struct TableDeep { pub parts_ood: Vec>, } +impl Round1 +where + Field: IsSubFieldOf + IsFFTField, + FieldExtension: IsField, + FieldElement: AsBytes, + FieldElement: AsBytes, +{ + /// The trees this round built, to hand to the pass that opens. + pub fn keep_commits(&self) -> KeptCommits { + KeptCommits { + main: self.main.share(), + aux: self.aux.as_ref().map(TableCommit::share), + } + } +} + +/// What a table contributes to the batch's seed. +/// +/// Split out of [`TableDeep`] because the seed is a function of public data +/// only — the codeword and the trees have no business in it, and a caller that +/// wants to reason about the seed alone should not have to build either. +#[derive(Clone)] +pub struct SeedData { + pub bus_contribution: Option>, + pub composition_poly_root: Commitment, + pub trace_ood: Table, + pub trace_ood_next: Table, + pub parts_ood: Vec>, +} + +/// A table's Round 1 trees, kept so a later pass opens against them instead of +/// rebuilding them. +/// +/// The spec's one named optimization for the Open phase: "keeping the internal +/// nodes of the merkle tree in memory, obviating the need to recompute it; +/// while still dropping the biggest memory cost (the leaves)". `TableCommit` +/// already drops the leaves where it can, so what is held here is the inner +/// nodes — and a profile says Merkle hashing is the single hottest thing in a +/// prove, so not hashing it twice is the saving the spec was pointing at. +impl TableDeep +where + FieldElement: AsBytes, + FieldElement: AsBytes, +{ + /// This table's share of the batch seed. + pub fn seed_data(&self) -> SeedData { + SeedData { + bus_contribution: self.bus_contribution.clone(), + composition_poly_root: self.composition_poly_root, + trace_ood: self.trace_ood.clone(), + trace_ood_next: self.trace_ood_next.clone(), + parts_ood: self.parts_ood.clone(), + } + } +} + +impl Clone for KeptCommits +where + FieldElement: AsBytes, + FieldElement: AsBytes, +{ + /// Cheap: `TableCommit::share` only bumps refcounts. + fn clone(&self) -> Self { + Self { + main: self.main.share(), + aux: self.aux.as_ref().map(TableCommit::share), + } + } +} + +pub struct KeptCommits +where + FieldElement: AsBytes, + FieldElement: AsBytes, +{ + main: TableCommit, + aux: Option>, +} + +impl KeptCommits +where + FieldElement: AsBytes, + FieldElement: AsBytes, +{ + /// Roughly what holding this costs — the inner nodes of both trees. + pub fn bytes(&self) -> usize { + self.main.tree.nodes().len() * 32 + + self.aux.as_ref().map_or(0, |c| c.tree.nodes().len() * 32) + } +} + /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1867,7 +1965,7 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let __r1 = crate::instruments::span("a1_round_1"); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, None)?; + Self::round_1_from_trace(air, trace, challenges, transcript, None, None)?; #[cfg(feature = "instruments")] drop(__r1); #[cfg(feature = "instruments")] @@ -1898,6 +1996,7 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), known_main: Option, + kept: Option>, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -1936,9 +2035,10 @@ pub trait IsStarkProver< // can hand them over instead. Building the tree is the hottest thing in // a prove (keccak is 17.6% of the profile), so not building one that // nothing will ask a question of is the cheapest saving there is. - let main = match known_main { - Some(roots) => TableCommit::known_roots(roots.main, roots.precomputed), - None => Self::table_commit_for(air, &main_data, num_main_cols)?, + let main = match (&kept, known_main) { + (Some(k), _) => k.main.share(), + (None, Some(roots)) => TableCommit::known_roots(roots.main, roots.precomputed), + (None, None) => Self::table_commit_for(air, &main_data, num_main_cols)?, }; #[cfg(feature = "instruments")] @@ -2105,7 +2205,7 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), known_main: Option, - ) -> Result, ProvingError> + ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, FieldElement: AsBytes + math::traits::ByteConversion, @@ -2113,7 +2213,7 @@ pub trait IsStarkProver< { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, known_main)?; + Self::round_1_from_trace(air, trace, challenges, transcript, known_main, None)?; let (mut round_2_result, round_3_result, z, trace_ood, trace_ood_next) = Self::rounds_2_and_3( air, @@ -2159,6 +2259,7 @@ pub trait IsStarkProver< lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, deep, + kept: round_1_result.keep_commits(), air_index: usize::MAX, bus_contribution: round_1_result .bus_public_inputs @@ -2192,7 +2293,7 @@ pub trait IsStarkProver< /// folded it. fn batch_alpha( pre_fork: &(impl IsStarkTranscript + Clone), - tables: &[TableDeep], + tables: &[SeedData], ) -> FieldElement where FieldElement: AsBytes, @@ -2235,6 +2336,7 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), iotas: &[usize], + kept: Option>, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -2243,7 +2345,7 @@ pub trait IsStarkProver< { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, None)?; + Self::round_1_from_trace(air, trace, challenges, transcript, None, kept)?; let (round_2_result, _, _, _, _) = Self::rounds_2_and_3( air, pub_inputs, @@ -2272,7 +2374,7 @@ pub trait IsStarkProver< /// binds the fold to all the data it folds. fn batch_fri( air: &dyn AIR, - members: Vec>, + members: Vec>, alpha: &FieldElement, transcript: &mut (impl IsStarkTranscript + Clone), ) -> Option> diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index e3ebb43d9..dffc7fcde 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -313,10 +313,13 @@ pub struct Batched { /// Which group each table belongs to, by AIR index — the group whose query /// indices its openings answer. pub group_of: Vec, + /// Per table, in AIR order: the trees the fold walk built, so the Open pass + /// opens against them instead of hashing them again. + pub kept: Vec>, pub resident: Resident, } -type Deep = stark::prover::TableDeep; +type Deep = stark::prover::TableDeep; type Deeps = std::sync::Mutex>; struct BuildDeep<'a> { @@ -354,7 +357,10 @@ fn deep_batch( &mut trace, &challenge.challenges, &mut transcript, - challenge.roots.get(idx).cloned(), + // The tree this builds is handed to the Open pass, so it has + // to be a real one — reusing the Commit phase's roots would + // keep a root-only tree that cannot answer an opening. + None, ) .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, deep)) @@ -453,7 +459,7 @@ pub fn run_batched( trace, &challenge.challenges, &mut transcript, - challenge.roots.get(idx).cloned(), + None, ) .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; Ok((idx, deep)) @@ -501,10 +507,12 @@ pub fn run_batched( d }) .collect(); - let alpha =

>::batch_alpha(&challenge.transcript, &ordered); + let seed: Vec<_> = ordered.iter().map(|d| d.seed_data()).collect(); + let alpha =

>::batch_alpha(&challenge.transcript, &seed); // Taken before the fold, which consumes the codewords: this is the half of // each table that survives into the proof. + let kept: Vec<_> = ordered.iter().map(|d| d.kept.clone()).collect(); let tables: Vec = ordered .iter() .map(|d| TablePublic { @@ -548,6 +556,7 @@ pub fn run_batched( Ok(Batched { tables, + kept, groups, members, group_of, @@ -622,6 +631,7 @@ fn open_batch( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, + batched.kept.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, opening)) @@ -641,10 +651,19 @@ fn open_of( challenges: &[FieldElement], transcript: &mut DefaultTranscript, iotas: &[usize], + kept: Option>, ) -> Result { type P = stark::prover::Prover; -

>::open_for_table(air, &(), trace, challenges, transcript, iotas) - .map_err(|e| format!("{e:?}")) +

>::open_for_table( + air, + &(), + trace, + challenges, + transcript, + iotas, + kept, + ) + .map_err(|e| format!("{e:?}")) } impl Visitor for OpenTables<'_> { @@ -708,6 +727,7 @@ pub fn run_open( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, + batched.kept.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("open pass: table {idx}: {e}")))?; Ok((idx, opening)) diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 5fa3ac994..784f5178c 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -5,7 +5,7 @@ use crate::tables::MaxRowsConfig; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use executor::elf::Elf; use stark::prover::IsStarkProver; -use stark::prover::TableDeep; +use stark::prover::SeedData; /// A batch of one must reproduce the proof's FRI exactly. /// @@ -133,18 +133,8 @@ fn alpha_moves_when_any_table_moves() { type P = stark::prover::Prover; type E = GoldilocksExtension; - let table = |seed: u64| TableDeep:: { - lde_size: 8, - trace_rows: 4, - deep: Vec::new(), - air_index: seed as usize, + let table = |seed: u64| SeedData:: { bus_contribution: Some(FieldElement::::from(seed)), - main_roots: stark::prover::MainRoots { - precomputed: None, - main: [0u8; 32], - }, - aux_root: None, - bus_public_inputs: None, composition_poly_root: [seed as u8; 32], trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), trace_ood_next: Table::new(vec![FieldElement::::from(seed + 2)], 1), @@ -156,35 +146,33 @@ fn alpha_moves_when_any_table_moves() { let alpha =

>::batch_alpha(&pre_fork, &base); // Every field of every table, one at a time. - type Mutation = (&'static str, Box>)>); + type Mutation = (&'static str, Box>)>); let mutate: Vec = vec![ ( "bus", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[0].bus_contribution = Some(FieldElement::::from(99)) }), ), ( "root", - Box::new(|t: &mut Vec>| t[1].composition_poly_root = [9u8; 32]), + Box::new(|t: &mut Vec>| t[1].composition_poly_root = [9u8; 32]), ), ( "ood", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[0].trace_ood = Table::new(vec![FieldElement::::from(99)], 1) }), ), ( "ood_next", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[1].trace_ood_next = Table::new(vec![FieldElement::::from(99)], 1) }), ), ( "parts", - Box::new(|t: &mut Vec>| { - t[0].parts_ood = vec![FieldElement::::from(99)] - }), + Box::new(|t: &mut Vec>| t[0].parts_ood = vec![FieldElement::::from(99)]), ), ]; for (what, f) in mutate { From 2f505b96cb1842c453d6b7dcfaf2433dc79474ec Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 16:35:57 -0300 Subject: [PATCH 47/63] Take the kept trees back out: measured, bad trade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied properly — inner nodes kept, leaves dropped, which is what the spec describes — it costs 9.1 GB and buys 3.8s. Applied the way it first went in, with the leaves still there, it cost 17.4 GB. Either way the peak passes the 41 GB that main needs merely to hold its traces, which is the advantage this branch exists for. Instrumenting the Open pass says why, and it is not what any of us guessed. Summed over the 227 tables: round 1 is 415s, rounds 2-3 are 172s, and the opening itself is 0.27s. The pass is essentially all reconstruction, and keeping trees removes only the Merkle part of round 1 — the LDE behind it is untouched and is the larger half. It also settles the idea of opening only the queried rows instead of rebuilding the LDE: there is nothing there to win. Opening already costs 0.27s of 90. What costs is rebuilding the inputs so that it can happen. So the reconstruction is the approach, not an inefficiency in it. Two experiments have now priced not paying for it — 37.6 GB for 12.6s, and 9.1 GB for 3.8s — and both are worse than paying. The sub-step spans stay. They are what closed this, and they name the next thing to look at: the auxiliary half of round 1 is 392s summed, the most expensive item in the pipeline, and nobody has looked at it yet. --- crypto/stark/src/prover.rs | 122 +++----------------------- prover/src/logup_phase.rs | 32 ++----- prover/src/tests/batched_fri_tests.rs | 28 ++++-- 3 files changed, 36 insertions(+), 146 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index d4a9e84fc..3c45ee0da 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -695,11 +695,7 @@ pub struct GroupFri { /// One table's contribution to a batched FRI. #[derive(Clone)] -pub struct TableDeep -where - FieldElement: AsBytes, - FieldElement: AsBytes, -{ +pub struct TableDeep { /// The domain the codeword lives on. Only tables that agree on this can be /// folded together: the fold squares the coset offset each layer, so a /// short codeword over `offset·` never lines up with a tall fold over @@ -712,9 +708,6 @@ where pub trace_rows: usize, /// The DEEP composition codeword, `lde_size` long. pub deep: Vec>, - /// The trees this table's Round 1 built, for the Open pass to use instead - /// of hashing them a second time. The spec's one named optimization. - pub kept: KeptCommits, /// Where the table sits in the AIR order, carried so the batch can say /// which group each table ended up in once the codewords are sorted by /// domain rather than by position. @@ -737,97 +730,6 @@ where pub parts_ood: Vec>, } -impl Round1 -where - Field: IsSubFieldOf + IsFFTField, - FieldExtension: IsField, - FieldElement: AsBytes, - FieldElement: AsBytes, -{ - /// The trees this round built, to hand to the pass that opens. - pub fn keep_commits(&self) -> KeptCommits { - KeptCommits { - main: self.main.share(), - aux: self.aux.as_ref().map(TableCommit::share), - } - } -} - -/// What a table contributes to the batch's seed. -/// -/// Split out of [`TableDeep`] because the seed is a function of public data -/// only — the codeword and the trees have no business in it, and a caller that -/// wants to reason about the seed alone should not have to build either. -#[derive(Clone)] -pub struct SeedData { - pub bus_contribution: Option>, - pub composition_poly_root: Commitment, - pub trace_ood: Table, - pub trace_ood_next: Table, - pub parts_ood: Vec>, -} - -/// A table's Round 1 trees, kept so a later pass opens against them instead of -/// rebuilding them. -/// -/// The spec's one named optimization for the Open phase: "keeping the internal -/// nodes of the merkle tree in memory, obviating the need to recompute it; -/// while still dropping the biggest memory cost (the leaves)". `TableCommit` -/// already drops the leaves where it can, so what is held here is the inner -/// nodes — and a profile says Merkle hashing is the single hottest thing in a -/// prove, so not hashing it twice is the saving the spec was pointing at. -impl TableDeep -where - FieldElement: AsBytes, - FieldElement: AsBytes, -{ - /// This table's share of the batch seed. - pub fn seed_data(&self) -> SeedData { - SeedData { - bus_contribution: self.bus_contribution.clone(), - composition_poly_root: self.composition_poly_root, - trace_ood: self.trace_ood.clone(), - trace_ood_next: self.trace_ood_next.clone(), - parts_ood: self.parts_ood.clone(), - } - } -} - -impl Clone for KeptCommits -where - FieldElement: AsBytes, - FieldElement: AsBytes, -{ - /// Cheap: `TableCommit::share` only bumps refcounts. - fn clone(&self) -> Self { - Self { - main: self.main.share(), - aux: self.aux.as_ref().map(TableCommit::share), - } - } -} - -pub struct KeptCommits -where - FieldElement: AsBytes, - FieldElement: AsBytes, -{ - main: TableCommit, - aux: Option>, -} - -impl KeptCommits -where - FieldElement: AsBytes, - FieldElement: AsBytes, -{ - /// Roughly what holding this costs — the inner nodes of both trees. - pub fn bytes(&self) -> usize { - self.main.tree.nodes().len() * 32 - + self.aux.as_ref().map_or(0, |c| c.tree.nodes().len() * 32) - } -} - /// Source of truth for a table whose *trace* has been retired. /// /// The retire-LDE mode ([`streaming_retire_lde`]) drops a table's LDE and @@ -1965,7 +1867,7 @@ pub trait IsStarkProver< #[cfg(feature = "instruments")] let __r1 = crate::instruments::span("a1_round_1"); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, None, None)?; + Self::round_1_from_trace(air, trace, challenges, transcript, None)?; #[cfg(feature = "instruments")] drop(__r1); #[cfg(feature = "instruments")] @@ -1996,7 +1898,6 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), known_main: Option, - kept: Option>, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -2035,10 +1936,9 @@ pub trait IsStarkProver< // can hand them over instead. Building the tree is the hottest thing in // a prove (keccak is 17.6% of the profile), so not building one that // nothing will ask a question of is the cheapest saving there is. - let main = match (&kept, known_main) { - (Some(k), _) => k.main.share(), - (None, Some(roots)) => TableCommit::known_roots(roots.main, roots.precomputed), - (None, None) => Self::table_commit_for(air, &main_data, num_main_cols)?, + let main = match known_main { + Some(roots) => TableCommit::known_roots(roots.main, roots.precomputed), + None => Self::table_commit_for(air, &main_data, num_main_cols)?, }; #[cfg(feature = "instruments")] @@ -2205,7 +2105,7 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), known_main: Option, - ) -> Result, ProvingError> + ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, FieldElement: AsBytes + math::traits::ByteConversion, @@ -2213,7 +2113,7 @@ pub trait IsStarkProver< { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, known_main, None)?; + Self::round_1_from_trace(air, trace, challenges, transcript, known_main)?; let (mut round_2_result, round_3_result, z, trace_ood, trace_ood_next) = Self::rounds_2_and_3( air, @@ -2259,7 +2159,6 @@ pub trait IsStarkProver< lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, deep, - kept: round_1_result.keep_commits(), air_index: usize::MAX, bus_contribution: round_1_result .bus_public_inputs @@ -2293,7 +2192,7 @@ pub trait IsStarkProver< /// folded it. fn batch_alpha( pre_fork: &(impl IsStarkTranscript + Clone), - tables: &[SeedData], + tables: &[TableDeep], ) -> FieldElement where FieldElement: AsBytes, @@ -2336,7 +2235,6 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), iotas: &[usize], - kept: Option>, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -2345,7 +2243,7 @@ pub trait IsStarkProver< { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); let mut round_1_result = - Self::round_1_from_trace(air, trace, challenges, transcript, None, kept)?; + Self::round_1_from_trace(air, trace, challenges, transcript, None)?; let (round_2_result, _, _, _, _) = Self::rounds_2_and_3( air, pub_inputs, @@ -2374,7 +2272,7 @@ pub trait IsStarkProver< /// binds the fold to all the data it folds. fn batch_fri( air: &dyn AIR, - members: Vec>, + members: Vec>, alpha: &FieldElement, transcript: &mut (impl IsStarkTranscript + Clone), ) -> Option> diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index dffc7fcde..e3ebb43d9 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -313,13 +313,10 @@ pub struct Batched { /// Which group each table belongs to, by AIR index — the group whose query /// indices its openings answer. pub group_of: Vec, - /// Per table, in AIR order: the trees the fold walk built, so the Open pass - /// opens against them instead of hashing them again. - pub kept: Vec>, pub resident: Resident, } -type Deep = stark::prover::TableDeep; +type Deep = stark::prover::TableDeep; type Deeps = std::sync::Mutex>; struct BuildDeep<'a> { @@ -357,10 +354,7 @@ fn deep_batch( &mut trace, &challenge.challenges, &mut transcript, - // The tree this builds is handed to the Open pass, so it has - // to be a real one — reusing the Commit phase's roots would - // keep a root-only tree that cannot answer an opening. - None, + challenge.roots.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, deep)) @@ -459,7 +453,7 @@ pub fn run_batched( trace, &challenge.challenges, &mut transcript, - None, + challenge.roots.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; Ok((idx, deep)) @@ -507,12 +501,10 @@ pub fn run_batched( d }) .collect(); - let seed: Vec<_> = ordered.iter().map(|d| d.seed_data()).collect(); - let alpha =

>::batch_alpha(&challenge.transcript, &seed); + let alpha =

>::batch_alpha(&challenge.transcript, &ordered); // Taken before the fold, which consumes the codewords: this is the half of // each table that survives into the proof. - let kept: Vec<_> = ordered.iter().map(|d| d.kept.clone()).collect(); let tables: Vec = ordered .iter() .map(|d| TablePublic { @@ -556,7 +548,6 @@ pub fn run_batched( Ok(Batched { tables, - kept, groups, members, group_of, @@ -631,7 +622,6 @@ fn open_batch( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, - batched.kept.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, opening)) @@ -651,19 +641,10 @@ fn open_of( challenges: &[FieldElement], transcript: &mut DefaultTranscript, iotas: &[usize], - kept: Option>, ) -> Result { type P = stark::prover::Prover; -

>::open_for_table( - air, - &(), - trace, - challenges, - transcript, - iotas, - kept, - ) - .map_err(|e| format!("{e:?}")) +

>::open_for_table(air, &(), trace, challenges, transcript, iotas) + .map_err(|e| format!("{e:?}")) } impl Visitor for OpenTables<'_> { @@ -727,7 +708,6 @@ pub fn run_open( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, - batched.kept.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("open pass: table {idx}: {e}")))?; Ok((idx, opening)) diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 784f5178c..5fa3ac994 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -5,7 +5,7 @@ use crate::tables::MaxRowsConfig; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use executor::elf::Elf; use stark::prover::IsStarkProver; -use stark::prover::SeedData; +use stark::prover::TableDeep; /// A batch of one must reproduce the proof's FRI exactly. /// @@ -133,8 +133,18 @@ fn alpha_moves_when_any_table_moves() { type P = stark::prover::Prover; type E = GoldilocksExtension; - let table = |seed: u64| SeedData:: { + let table = |seed: u64| TableDeep:: { + lde_size: 8, + trace_rows: 4, + deep: Vec::new(), + air_index: seed as usize, bus_contribution: Some(FieldElement::::from(seed)), + main_roots: stark::prover::MainRoots { + precomputed: None, + main: [0u8; 32], + }, + aux_root: None, + bus_public_inputs: None, composition_poly_root: [seed as u8; 32], trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), trace_ood_next: Table::new(vec![FieldElement::::from(seed + 2)], 1), @@ -146,33 +156,35 @@ fn alpha_moves_when_any_table_moves() { let alpha =

>::batch_alpha(&pre_fork, &base); // Every field of every table, one at a time. - type Mutation = (&'static str, Box>)>); + type Mutation = (&'static str, Box>)>); let mutate: Vec = vec![ ( "bus", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[0].bus_contribution = Some(FieldElement::::from(99)) }), ), ( "root", - Box::new(|t: &mut Vec>| t[1].composition_poly_root = [9u8; 32]), + Box::new(|t: &mut Vec>| t[1].composition_poly_root = [9u8; 32]), ), ( "ood", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[0].trace_ood = Table::new(vec![FieldElement::::from(99)], 1) }), ), ( "ood_next", - Box::new(|t: &mut Vec>| { + Box::new(|t: &mut Vec>| { t[1].trace_ood_next = Table::new(vec![FieldElement::::from(99)], 1) }), ), ( "parts", - Box::new(|t: &mut Vec>| t[0].parts_ood = vec![FieldElement::::from(99)]), + Box::new(|t: &mut Vec>| { + t[0].parts_ood = vec![FieldElement::::from(99)] + }), ), ]; for (what, f) in mutate { From 43c2cbb0b5aaf6f4b275b8fb6377aa91abde07c7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 17:03:08 -0300 Subject: [PATCH 48/63] Fold each codeword as it appears, and drop it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's sentence was read wrong all along. "All FRI polynomials generated during this phase can already be accumulated in a single batch polynomial, by sampling the batching coefficients after commiting to the polynomials they randomize" does not mean one coefficient drawn at the end. It means one per polynomial, drawn after that polynomial's data goes into the seed — which is what lets a codeword be folded and dropped the moment it exists. So the fold walk no longer holds 227 codewords waiting for a single alpha. It holds one accumulator per distinct domain, thirteen of them, and each table is absorbed, weighted, added and dropped. That makes the fold order part of the protocol: a table's coefficient depends on every table folded before it. The walk produces tables in the order they close, not in AIR order, so the proof carries the order it used — 227 indices against 338 MB of proof. Within a batch the order is fixed by AIR index so it is a function of the walk and not of which thread finished first. Measured on this branch only in that the five batched tests still pass, which covers correctness: the batch of one still reproduces the unbatched FRI, the coefficients still move when any table's data or position moves, and the assembled proof still matches a real one table by table. NOT measured for speed, and there is a signal against it: the test suite went from 58s to 190s locally. fold_one does an O(domain) accumulate while holding the lock, so a batch's sixteen tables accumulate in series with the walk stalled behind them. That needs the accumulate moved out of the critical section — the coefficients must be drawn in order, the additions need not be. --- bin/cli/src/main.rs | 1 + crypto/stark/src/prover.rs | 142 +++++++++------ prover/src/logup_phase.rs | 237 +++++++++++++++----------- prover/src/tests/batched_fri_tests.rs | 38 +++-- 4 files changed, 253 insertions(+), 165 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index cd36d4267..2835d37f7 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1100,6 +1100,7 @@ fn run_approach_1( println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); println!(" pass 3-4 (deep+fold) {:>7.2}s", t_fold.as_secs_f64()); println!(" pass 5 (open) {:>8.2}s", t_open.as_secs_f64()); + report_span_totals(); report_batched_size(&proof, tables, groups); return Ok(tables); } diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 3c45ee0da..8ab1d683b 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1958,8 +1958,12 @@ pub trait IsStarkProver< &twiddles.two_half_fwd, ) .map_err(|_| ProvingError::EmptyCommitment)?; + #[cfg(feature = "instruments")] + let __am = crate::instruments::span("a1_aux_merkle"); let (tree, root) = Self::commit_rows_bit_reversed(&out, cols).ok_or(ProvingError::EmptyCommitment)?; + #[cfg(feature = "instruments")] + drop(__am); (out, cols, Some(TableCommit::plain(tree, root))) } else { (Vec::new(), 0, None) @@ -2112,8 +2116,14 @@ pub trait IsStarkProver< PI: Send + Sync + Clone, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + #[cfg(feature = "instruments")] + let __f1 = crate::instruments::span("a1_fold_r1"); let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript, known_main)?; + #[cfg(feature = "instruments")] + drop(__f1); + #[cfg(feature = "instruments")] + let __f23 = crate::instruments::span("a1_fold_r23"); let (mut round_2_result, round_3_result, z, trace_ood, trace_ood_next) = Self::rounds_2_and_3( air, @@ -2124,6 +2134,10 @@ pub trait IsStarkProver< &twiddles, )?; + #[cfg(feature = "instruments")] + drop(__f23); + #[cfg(feature = "instruments")] + let __fd = crate::instruments::span("a1_fold_deep"); // Round 4's opening move, up to the point where the batch takes over: // gamma is this table's own, sampled from its own fork. let gamma = transcript.sample_field_element(); @@ -2155,6 +2169,8 @@ pub trait IsStarkProver< let mut deep = deep; in_place_bit_reverse_permute(&mut deep); + #[cfg(feature = "instruments")] + drop(__fd); Ok(TableDeep { lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, @@ -2190,34 +2206,69 @@ pub trait IsStarkProver< /// Drawing `alpha` from all of it is what makes the fold binding: a table /// cannot be swapped after the fact without moving the coefficient that /// folded it. - fn batch_alpha( - pre_fork: &(impl IsStarkTranscript + Clone), - tables: &[TableDeep], + fn fold_coefficient( + seed: &mut (impl IsStarkTranscript + Clone), + table: &TableDeep, ) -> FieldElement where FieldElement: AsBytes, FieldElement: AsBytes, { - let mut seed = pre_fork.clone(); - for t in tables { - if let Some(ref c) = t.bus_contribution { - seed.append_field_element(c); - } - seed.append_bytes(&t.composition_poly_root); - for block in [&t.trace_ood, &t.trace_ood_next] { - for col in block.columns().iter() { - for elem in col.iter() { - seed.append_field_element(elem); - } + if let Some(ref c) = table.bus_contribution { + seed.append_field_element(c); + } + seed.append_bytes(&table.composition_poly_root); + for block in [&table.trace_ood, &table.trace_ood_next] { + for col in block.columns().iter() { + for elem in col.iter() { + seed.append_field_element(elem); } } - for elem in t.parts_ood.iter() { - seed.append_field_element(elem); - } + } + for elem in table.parts_ood.iter() { + seed.append_field_element(elem); } seed.sample_field_element() } + /// Every table's coefficient, in the order they are folded. + /// + /// Only for reasoning about the sequence as a whole; the prover draws them + /// one at a time, as it folds. + fn fold_coefficients( + pre_fork: &(impl IsStarkTranscript + Clone), + tables: &[TableDeep], + ) -> Vec> + where + FieldElement: AsBytes, + FieldElement: AsBytes, + { + let mut seed = pre_fork.clone(); + tables + .iter() + .map(|t| Self::fold_coefficient(&mut seed, t)) + .collect() + } + + /// Add `coefficient * codeword` into a group's running accumulator. + /// + /// The accumulator is the batch polynomial the spec describes. A member is + /// added and dropped, so what is held is one codeword per distinct domain + /// rather than one per table — which on the ethrex block is 13 instead of + /// 227, and about a gigabyte instead of eight and a half. + fn accumulate( + acc: &mut Vec>, + coefficient: &FieldElement, + codeword: &[FieldElement], + ) { + if acc.is_empty() { + acc.resize(codeword.len(), FieldElement::::zero()); + } + for (dst, src) in acc.iter_mut().zip(codeword.iter()) { + *dst = &*dst + coefficient * src; + } + } + /// One table's openings, at the indices its group decided. /// /// The Open pass: the batched FRI fixed the query indices for a whole @@ -2242,8 +2293,14 @@ pub trait IsStarkProver< PI: Send + Sync + Clone, { let (domain, twiddles) = domain_and_twiddles(air, trace.num_rows()); + #[cfg(feature = "instruments")] + let __o1 = crate::instruments::span("a1_open_r1"); let mut round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript, None)?; + #[cfg(feature = "instruments")] + drop(__o1); + #[cfg(feature = "instruments")] + let __o23 = crate::instruments::span("a1_open_r23"); let (round_2_result, _, _, _, _) = Self::rounds_2_and_3( air, pub_inputs, @@ -2252,52 +2309,34 @@ pub trait IsStarkProver< &domain, &twiddles, )?; - Ok(Self::open_deep_composition_poly( - &domain, - &round_1_result, - &round_2_result, - iotas, - )) + #[cfg(feature = "instruments")] + drop(__o23); + #[cfg(feature = "instruments")] + let __od = crate::instruments::span("a1_open_deep"); + let out = + Self::open_deep_composition_poly(&domain, &round_1_result, &round_2_result, iotas); + #[cfg(feature = "instruments")] + drop(__od); + Ok(out) } - /// One FRI over a whole group of tables. - /// - /// The members share a domain, so their codewords add directly: the batch - /// is `Σ αᵏ·deepₖ` with `k` running in AIR order. Accumulating in place is - /// the point — a member is folded in and dropped, so what this holds is one - /// codeword, not the group's worth of them. + /// One FRI over a group's finished accumulator. /// - /// `transcript` is the batch's, not any member's, and `alpha` must have - /// been drawn from it after every member's fork state went in. That is what - /// binds the fold to all the data it folds. + /// The members were folded in as they were produced, so by here the group + /// is a single codeword and nothing of the tables remains. fn batch_fri( air: &dyn AIR, - members: Vec>, - alpha: &FieldElement, + acc: Vec>, + trace_rows: usize, transcript: &mut (impl IsStarkTranscript + Clone), ) -> Option> where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { - let lde_size = members.first()?.lde_size; - let trace_rows = members.first()?.trace_rows; - if members - .iter() - .any(|m| m.lde_size != lde_size || m.trace_rows != trace_rows) - { + if acc.is_empty() { return None; } - let mut acc = vec![FieldElement::::zero(); lde_size]; - let mut power = FieldElement::::one(); - - for member in members { - for (dst, src) in acc.iter_mut().zip(member.deep.iter()) { - *dst = &*dst + &power * src; - } - power *= alpha; - } - let (domain, _) = domain_and_twiddles(air, trace_rows); let coset_offset = FieldElement::::from(air.context().proof_options.coset_offset); let (final_poly_coeffs, layers) = fri::commit_phase_from_evaluations( @@ -2310,9 +2349,6 @@ pub trait IsStarkProver< domain.fri_inv_twiddles(), ); - // Grinding, then the queries — both from the batch's transcript, so the - // whole group answers the same indices. That sharing is the point: the - // openings a member owes are at the group's iotas, not at its own. let grinding_factor = air.context().proof_options.grinding_factor; let mut nonce = None; if grinding_factor > 0 { diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index e3ebb43d9..d05ebe5bc 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -313,11 +313,31 @@ pub struct Batched { /// Which group each table belongs to, by AIR index — the group whose query /// indices its openings answer. pub group_of: Vec, + /// The AIR indices in the order they were folded. The coefficient of a + /// table depends on every table folded before it, so the verifier has to + /// replay this order and the proof carries it. + pub fold_order: Vec, pub resident: Resident, } type Deep = stark::prover::TableDeep; -type Deeps = std::sync::Mutex>; +/// What the fold walk carries between batches. +/// +/// The seed and the accumulators are advanced sequentially — the coefficient of +/// a table depends on every table folded before it — while the codewords that +/// feed them are computed in parallel. So the expensive half stays concurrent +/// and only the folding is serialised. +struct FoldState { + seed: DefaultTranscript, + /// One accumulator per distinct domain, and the rows behind it. + acc: std::collections::BTreeMap>, usize, usize)>, + /// The AIR indices in the order they were folded, which the proof carries + /// so a verifier can replay the same sequence of coefficients. + order: Vec, + tables: Vec<(usize, TablePublic)>, +} + +type Deeps = std::sync::Mutex; struct BuildDeep<'a> { batch: pass::Batched<'a, Item>, @@ -360,10 +380,45 @@ fn deep_batch( Ok((idx, deep)) }) .collect(); - done.lock().expect("deeps").extend(built?); + // Folded in a fixed order within the batch, so the sequence is a function + // of the walk and not of which thread finished first. + let mut built = built?; + built.sort_by_key(|(idx, _)| *idx); + let mut state = done.lock().expect("fold state"); + for (idx, deep) in built { + fold_one(&mut state, idx, deep); + } Ok(()) } +/// Absorb a table, draw its coefficient, add it to its group, drop it. +fn fold_one(state: &mut FoldState, idx: usize, deep: Deep) { + type P = stark::prover::Prover; + let coefficient =

>::fold_coefficient(&mut state.seed, &deep); + let entry = state + .acc + .entry(deep.lde_size) + .or_insert_with(|| (Vec::new(), deep.trace_rows, 0)); +

>::accumulate(&mut entry.0, &coefficient, &deep.deep); + entry.2 += 1; + state.order.push(idx); + state.tables.push(( + idx, + TablePublic { + trace_rows: deep.trace_rows, + main_root: deep.main_roots.main, + precomputed_root: deep.main_roots.precomputed, + aux_root: deep.aux_root, + composition_poly_root: deep.composition_poly_root, + trace_ood: deep.trace_ood, + trace_ood_next: deep.trace_ood_next, + parts_ood: deep.parts_ood, + bus_public_inputs: deep.bus_public_inputs, + }, + )); + // `deep` dies here, which is the whole point. +} + fn deep_of( air: &dyn stark::traits::AIR< Field = GoldilocksField, @@ -417,18 +472,20 @@ pub fn run_batched( proof_options: &ProofOptions, challenge: &Challenge, ) -> Result { - use std::collections::BTreeMap; type P = stark::prover::Prover; let chunk_airs = ChunkAirs::new(proof_options); - let done = std::sync::Mutex::new(Vec::new()); + let done = std::sync::Mutex::new(FoldState { + seed: challenge.transcript.clone(), + acc: std::collections::BTreeMap::new(), + order: Vec::new(), + tables: Vec::new(), + }); let mut visitor = BuildDeep::new(&chunk_airs, challenge, &done); let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; drop(visitor); - let mut deeps = done.into_inner().expect("deeps"); - // The tables the walk could not retire, in the same AIR order, exactly as - // `assemble` walks them for the per-table path. + // The tables the walk could not retire, folded after it in AIR order. let order = &challenge.order; let airs = crate::VmAirs::new( elf, @@ -443,111 +500,97 @@ pub fn run_batched( None, ); let n = order.len(); - let build = |idx: usize, - air: &crate::VmAir, - trace: &mut TraceTable| - -> Result<(usize, Deep), Error> { - let mut transcript = fork(&challenge.transcript, idx, n); - let deep = deep_of( - air.as_ref(), - trace, - &challenge.challenges, - &mut transcript, - challenge.roots.get(idx).cloned(), - ) - .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; - Ok((idx, deep)) - }; - let fixed: [( - &crate::VmAir, - &mut TraceTable, - ); NUM_FIXED_AIRS] = [ - (&airs.bitwise, &mut resident.bitwise), - (&airs.decode, &mut resident.decode), - (&airs.commit, &mut resident.accumulated.commit), - (&airs.keccak, &mut resident.accumulated.keccak), - (&airs.keccak_rnd, &mut resident.accumulated.keccak_rnd), - (&airs.keccak_rc, &mut resident.accumulated.keccak_rc), - (&airs.ecsm, &mut resident.accumulated.ecsm), - (&airs.ecdas, &mut resident.accumulated.ecdas), - (&airs.hint, &mut resident.accumulated.hint), - (&airs.register, &mut resident.register), - ]; - for (idx, (air, trace)) in fixed.into_iter().enumerate() { - deeps.push(build(idx, air, trace)?); - } - if airs.include_halt { - deeps.push(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); - } - for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { - let idx = order.page_index(i).ok_or_else(|| { - Error::Prover(format!("batched phase: page {i} is not in the layout")) - })?; - deeps.push(build(idx, air, trace)?); + { + let mut state = done.lock().expect("fold state"); + let build = |state: &mut FoldState, + idx: usize, + air: &crate::VmAir, + trace: &mut TraceTable| + -> Result<(), Error> { + let mut transcript = fork(&challenge.transcript, idx, n); + let deep = deep_of( + air.as_ref(), + trace, + &challenge.challenges, + &mut transcript, + challenge.roots.get(idx).cloned(), + ) + .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; + fold_one(state, idx, deep); + Ok(()) + }; + let fixed: [( + &crate::VmAir, + &mut TraceTable, + ); NUM_FIXED_AIRS] = [ + (&airs.bitwise, &mut resident.bitwise), + (&airs.decode, &mut resident.decode), + (&airs.commit, &mut resident.accumulated.commit), + (&airs.keccak, &mut resident.accumulated.keccak), + (&airs.keccak_rnd, &mut resident.accumulated.keccak_rnd), + (&airs.keccak_rc, &mut resident.accumulated.keccak_rc), + (&airs.ecsm, &mut resident.accumulated.ecsm), + (&airs.ecdas, &mut resident.accumulated.ecdas), + (&airs.hint, &mut resident.accumulated.hint), + (&airs.register, &mut resident.register), + ]; + for (idx, (air, trace)) in fixed.into_iter().enumerate() { + build(&mut state, idx, air, trace)?; + } + if airs.include_halt { + build(&mut state, NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?; + } + for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { + let idx = order.page_index(i).ok_or_else(|| { + Error::Prover(format!("batched phase: page {i} is not in the layout")) + })?; + build(&mut state, idx, air, trace)?; + } } - // AIR order: the seed is absorbed in it and the verifier replays it. - deeps.sort_by_key(|(idx, _)| *idx); - if deeps.len() != n { + let FoldState { + mut seed, + acc, + order: fold_order, + mut tables, + } = done.into_inner().expect("fold state"); + if tables.len() != n { return Err(Error::Prover(format!( "batched phase: {} tables for a layout of {n}", - deeps.len() + tables.len() ))); } - let ordered: Vec = deeps - .into_iter() - .map(|(idx, mut d)| { - d.air_index = idx; - d - }) - .collect(); - let alpha =

>::batch_alpha(&challenge.transcript, &ordered); - - // Taken before the fold, which consumes the codewords: this is the half of - // each table that survives into the proof. - let tables: Vec = ordered - .iter() - .map(|d| TablePublic { - trace_rows: d.trace_rows, - main_root: d.main_roots.main, - precomputed_root: d.main_roots.precomputed, - aux_root: d.aux_root, - composition_poly_root: d.composition_poly_root, - trace_ood: d.trace_ood.clone(), - trace_ood_next: d.trace_ood_next.clone(), - parts_ood: d.parts_ood.clone(), - bus_public_inputs: d.bus_public_inputs.clone(), - }) - .collect(); - let mut by_height: BTreeMap> = BTreeMap::new(); - for d in ordered { - by_height.entry(d.lde_size).or_default().push(d); + // One FRI per accumulator, and the mapping from table to group. + let mut group_of = vec![usize::MAX; n]; + let sizes: Vec = acc.keys().copied().collect(); + for (&idx, _) in tables.iter().map(|(i, t)| (i, t)) { + let rows = tables + .iter() + .find(|(i, _)| *i == idx) + .map(|(_, t)| t.trace_rows) + .expect("table just listed"); + let lde = rows * proof_options.blowup_factor as usize; + group_of[idx] = sizes.iter().position(|s| *s == lde).ok_or_else(|| { + Error::Prover(format!( + "batched phase: table {idx} has no group of size {lde}" + )) + })?; } - // Any AIR serves for a group: `domain_and_twiddles` keys on the proof - // options alone, and every table in a prove shares them. let any = chunk_airs.get(TableKind::Cpu).as_ref(); - let mut transcript = challenge.transcript.clone(); let (mut groups, mut members) = (Vec::new(), Vec::new()); - let mut group_of = vec![usize::MAX; n]; - for (g, (_, group)) in by_height.iter().enumerate() { - for d in group.iter() { - group_of[d.air_index] = g; - } - } - for (lde_size, group) in by_height { - let count = group.len(); - let roots =

>::batch_fri(any, group, &alpha, &mut transcript) - .ok_or_else(|| { - Error::Prover(format!("batched phase: no FRI for size {lde_size}")) - })?; - groups.push((lde_size, roots)); + for (lde_size, (codeword, trace_rows, count)) in acc { + let fri =

>::batch_fri(any, codeword, trace_rows, &mut seed) + .ok_or_else(|| Error::Prover(format!("batched phase: no FRI for size {lde_size}")))?; + groups.push((lde_size, fri)); members.push(count); } + tables.sort_by_key(|(idx, _)| *idx); Ok(Batched { - tables, + tables: tables.into_iter().map(|(_, t)| t).collect(), + fold_order, groups, members, group_of, diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 5fa3ac994..36bc3946d 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -75,11 +75,15 @@ fn a_batch_of_one_matches_the_unbatched_fri() { ) .expect("deep"); + // A batch of one: the accumulator is the codeword itself, folded with a + // coefficient of one. let one = math::field::element::FieldElement::::one(); + let mut acc = Vec::new(); +

>::accumulate(&mut acc, &one, &deep.deep); let fri =

>::batch_fri( airs.bitwise.as_ref(), - vec![deep], - &one, + acc, + deep.trace_rows, &mut fork, ) .expect("batched fri"); @@ -114,13 +118,17 @@ fn a_batch_of_one_matches_the_unbatched_fri() { ); } -/// The batch's coefficient must depend on every table it folds. +/// Every table's coefficient must depend on every table folded before it. /// -/// `alpha` is what makes a batched fold binding: if it could be drawn without -/// some table's round-3 data, that table could be swapped after the coefficient -/// was fixed and the fold would still check out. So the property to pin is not -/// that the derivation runs — it is that moving any single field any table -/// contributes moves the result. +/// The coefficients are what make a batched fold binding: if one could be drawn +/// without some table's round-3 data, that table could be swapped after the +/// coefficient was fixed and the fold would still check out. So the property to +/// pin is not that the derivation runs — it is that moving any single field any +/// table contributes moves the sequence. +/// +/// Drawn one per table, after that table's data goes in, which is what the spec +/// asks for and what lets a codeword be folded and dropped as it is produced +/// instead of every codeword being held to the end. /// /// Built from data rather than from a proof on purpose: this is about the byte /// order, and a synthetic table exercises every field including the ones a real @@ -153,7 +161,7 @@ fn alpha_moves_when_any_table_moves() { let pre_fork = DefaultTranscript::::new(&[7, 7, 7]); let base = vec![table(1), table(2)]; - let alpha =

>::batch_alpha(&pre_fork, &base); + let alphas =

>::fold_coefficients(&pre_fork, &base); // Every field of every table, one at a time. type Mutation = (&'static str, Box>)>); @@ -191,9 +199,9 @@ fn alpha_moves_when_any_table_moves() { let mut moved = base.clone(); f(&mut moved); assert_ne!( - alpha, -

>::batch_alpha(&pre_fork, &moved), - "alpha ignores {what}, so that data is not bound to the fold" + alphas, +

>::fold_coefficients(&pre_fork, &moved), + "the coefficients ignore {what}, so that data is not bound to the fold" ); } @@ -201,9 +209,9 @@ fn alpha_moves_when_any_table_moves() { // different batch. let swapped = vec![base[1].clone(), base[0].clone()]; assert_ne!( - alpha, -

>::batch_alpha(&pre_fork, &swapped), - "alpha ignores the table order, which the verifier replays" + alphas, +

>::fold_coefficients(&pre_fork, &swapped), + "the coefficients ignore the table order, which the verifier replays" ); } From 26631c1cc0bbe2eb81608be742598659ceb71e3c Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 17:23:44 -0300 Subject: [PATCH 49/63] Derive the batched proof's challenges from the proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first piece of the batched verifier, and the floor the rest stands on: the transcript replay. It reads a proof it has never seen a prover produce and derives every challenge from it — the statement, then the round 1 roots in AIR order for the shared LogUp challenge, then each table's fold coefficient in the order the proof says it was folded. That order is in the proof because it has to be. A table's coefficient depends on every table folded before it, and the walk produces tables as they close rather than in AIR order, so the sequence is part of what the verifier replays. Pinned against the prover's own values rather than against a second implementation of the same idea, which would only agree with itself. Dropping the precomputed root of a preprocessed table — one line, the kind of thing that looks like a detail — moves the shared challenge and fails it. What this is not: deriving the right challenges does not make a proof valid. It makes the questions right. Whether the answers are is what the pieces after this decide — the openings against the roots, the constraint identity at each z, the bus balance, and the fold against the terminal polynomial. It also changes nothing about proving time, which is worth saying plainly: the batched path costs 232.3s against the per-table path's 143.5s, and buys a proof of 338 MB against 775. This is the toll for collecting that, not a way to lower the other number. --- prover/src/batched_verifier.rs | 108 ++++++++++++++++++++++++++ prover/src/lib.rs | 1 + prover/src/logup_phase.rs | 4 + prover/src/tests/batched_fri_tests.rs | 62 +++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 prover/src/batched_verifier.rs diff --git a/prover/src/batched_verifier.rs b/prover/src/batched_verifier.rs new file mode 100644 index 000000000..0bef9a21d --- /dev/null +++ b/prover/src/batched_verifier.rs @@ -0,0 +1,108 @@ +//! The batched proof's verifier. +//! +//! Alongside `multi_verify`, never in place of it: the per-table path is +//! untouched and its proofs are byte-identical to what they always were. +//! +//! What a verifier decides is not "did the prover follow its own steps" — it is +//! whether a proof it has never seen a prover produce is valid. That is built in +//! pieces, and this is the first: the transcript replay, which derives every +//! challenge from the proof alone. Nothing below it can be checked until the +//! challenges are the prover's, and if they are, a forged proof has to be wrong +//! about something the later pieces test rather than about which questions were +//! asked. + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use crypto::fiat_shamir::is_transcript::IsTranscript; +use math::field::element::FieldElement; + +use crate::Error; +use crate::logup_phase::BatchedProof; +use crate::tables::types::GoldilocksExtension; + +/// Every challenge a batched proof's verification needs, derived from the proof. +pub struct Replay { + /// The one challenge the whole execution shares. + pub logup: Vec>, + /// Per table, in AIR order: its fold coefficient. + pub coefficients: Vec>, + /// Per group, in the order the prover ran them: the query indices. + pub iotas: Vec>, +} + +/// Replay the transcript the prover walked, from the proof. +/// +/// `elf_bytes` and the statement come from outside the proof on purpose: a +/// proof that could choose its own statement would prove nothing. +pub fn replay( + proof: &BatchedProof, + elf_bytes: &[u8], + table_counts: &crate::TableCounts, + proof_options: &stark::proof::options::ProofOptions, +) -> Result { + let mut transcript = DefaultTranscript::::new(&[]); + crate::statement::absorb_statement( + &mut transcript, + crate::statement::StatementKind::Monolithic, + elf_bytes, + &proof.public_output, + table_counts, + proof + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(), + &crate::tables::trace_builder::runtime_page_ranges(&proof.page_configs), + proof_options.fri_final_poly_log_degree, + ); + + // Round 1, in AIR order: a preprocessed table's precomputed root first. + for t in proof.tables.iter() { + if let Some(ref pre) = t.precomputed_root { + transcript.append_bytes(pre); + } + transcript.append_bytes(&t.main_root); + } + let logup: Vec<_> = (0..stark::lookup::LOGUP_NUM_CHALLENGES) + .map(|_| transcript.sample_field_element()) + .collect(); + + // The fold coefficients, in the order the prover folded — which the proof + // carries because a table's coefficient depends on every table before it. + let mut seed = transcript.clone(); + let mut coefficients = vec![FieldElement::::zero(); proof.tables.len()]; + for &idx in proof.fold_order.iter() { + let t = proof.tables.get(idx).ok_or_else(|| { + Error::Prover(format!("batched verify: fold order names table {idx}")) + })?; + if let Some(ref bpi) = t.bus_public_inputs { + seed.append_field_element(&bpi.table_contribution); + } + seed.append_bytes(&t.composition_poly_root); + let blocks: [&stark::table::Table; 2] = + [&t.trace_ood, &t.trace_ood_next]; + for block in blocks { + for col in block.columns().iter() { + for elem in col.iter() { + seed.append_field_element(elem); + } + } + } + for elem in t.parts_ood.iter() { + seed.append_field_element(elem); + } + coefficients[idx] = seed.sample_field_element(); + } + if proof.fold_order.len() != proof.tables.len() { + return Err(Error::Prover(format!( + "batched verify: {} tables folded of {}", + proof.fold_order.len(), + proof.tables.len() + ))); + } + + Ok(Replay { + logup, + coefficients, + iotas: Vec::new(), + }) +} diff --git a/prover/src/lib.rs b/prover/src/lib.rs index ab9ac4c5e..68822cdba 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -12,6 +12,7 @@ #[cfg(feature = "disk-spill")] pub mod auto_storage; +pub mod batched_verifier; pub mod challenge_phase; pub mod commit_phase; pub mod constraints; diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index d05ebe5bc..34b7063bf 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -813,6 +813,9 @@ pub struct BatchedProof { pub openings: Vec, /// Which group each table belongs to. pub group_of: Vec, + /// The AIR indices in the order they were folded, which the verifier + /// replays because a table's coefficient depends on every table before it. + pub fold_order: Vec, /// Per group, in ascending domain: the FRI they share. pub groups: Vec<(usize, stark::prover::GroupFri)>, /// The statement, which the verifier binds before absorbing any root. @@ -834,6 +837,7 @@ pub fn assemble_batched_proof(batched: Batched, opened: Opened) -> Result Date: Thu, 17 Sep 2026 19:19:38 -0300 Subject: [PATCH 50/63] Keep walking while the batch is proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk re-executes the program and is serial; proving a batch of tables is not. Batched processed each full batch inline, so the walk stood still while the batch was proved and the workers stood still while the walk built the next one. Measured at k=1, where summed spans equal wall time, the walk is ~22s of a 246s pass — time that was spent with one side idle either way. Now a full batch goes down a bounded channel to a worker thread and the walk keeps going. The bound on live tables moves from k to k per batch in flight, with A1_INFLIGHT choosing how many may wait; the default is a rendezvous, so at most one batch is being proved while one is being built. On the ethrex mainnet block, per-table path, k=16: 148.25s to 129.74s, with the peak unchanged at 21952 MB — not by a byte, because the peak is set by the resident tables at the end of the run and a batch of chunks is small beside them. Lowering k to 8 under the pipeline gives 135.12s, so k stays. That is 12.5% off, against a prediction of about 25%. The rest is not recoverable by slack: A1_INFLIGHT=1 gives 130.56s and costs 0.9 GB, with the peak moving to mid-run as the queued batch becomes it. The walk and the worker already overlap as far as they can, and the worker is the long pole; hiding the walk saves only what was not already hidden behind it. Rendezvous stays the default. The batched path, with its two extra walks, gains the same way: 232.3s to 202.4s, each of the fold and open walks from ~100s to 85s, peak unchanged at 30434 MB. A1_PIPELINE=0 keeps the inline behaviour so the two can be compared in one binary; both pass the same eight phase tests. --- prover/src/commit_phase.rs | 32 ++++++----- prover/src/logup_phase.rs | 44 ++++++++++----- prover/src/pass.rs | 108 ++++++++++++++++++++++++++++++++----- 3 files changed, 144 insertions(+), 40 deletions(-) diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index c18accc0d..9bb154f45 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -52,9 +52,13 @@ struct CommitMain<'a> { } impl<'a> CommitMain<'a> { - fn new(airs: &'a ChunkAirs, roots: &'a std::sync::Mutex>) -> Self { + fn new( + scope: &'a std::thread::Scope<'a, '_>, + airs: &'a ChunkAirs, + roots: &'a std::sync::Mutex>, + ) -> Self { Self { - batch: pass::Batched::new(move |items| commit_batch(airs, roots, items)), + batch: pass::Batched::new(scope, move |items| commit_batch(airs, roots, items)), } } } @@ -110,10 +114,12 @@ pub fn run( ) -> Result { let airs = ChunkAirs::new(proof_options); let roots = std::sync::Mutex::new(Vec::new()); - let mut visitor = CommitMain::new(&airs, &roots); - let walked = pass::walk(elf, private_input, max_rows, &mut visitor)?; - visitor.flush()?; - drop(visitor); + let walked = std::thread::scope(|s| { + let mut visitor = CommitMain::new(s, &airs, &roots); + let walked = pass::walk(elf, private_input, max_rows, &mut visitor)?; + visitor.flush()?; + Ok::<_, Error>(walked) + })?; Ok(CommitPhase { closed: roots.into_inner().expect("roots"), walked, @@ -134,9 +140,10 @@ pub fn run_to_end( ) -> Result { let airs = ChunkAirs::new(proof_options); let roots = std::sync::Mutex::new(Vec::new()); - let mut visitor = CommitMain::new(&airs, &roots); - let remaining = pass::run(elf, private_input, max_rows, &mut visitor)?; - drop(visitor); + let remaining = std::thread::scope(|s| { + let mut visitor = CommitMain::new(s, &airs, &roots); + pass::run(elf, private_input, max_rows, &mut visitor) + })?; Ok(Committed { chunks: roots.into_inner().expect("roots"), remaining, @@ -155,9 +162,10 @@ pub fn commit_remaining( ) -> Result<(Vec, Resident), Error> { let airs = ChunkAirs::new(proof_options); let roots = std::sync::Mutex::new(Vec::new()); - let mut visitor = CommitMain::new(&airs, &roots); - let resident = pass::finish(walked, private_input, max_rows, &mut visitor)?; - drop(visitor); + let resident = std::thread::scope(|s| { + let mut visitor = CommitMain::new(s, &airs, &roots); + pass::finish(walked, private_input, max_rows, &mut visitor) + })?; Ok((roots.into_inner().expect("roots"), resident)) } diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 34b7063bf..f85ae881b 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -54,9 +54,16 @@ struct BuildAux<'a> { } impl<'a> BuildAux<'a> { - fn new(airs: &'a ChunkAirs, challenge: &'a Challenge, done: &'a Proofs) -> Self { + fn new( + scope: &'a std::thread::Scope<'a, '_>, + airs: &'a ChunkAirs, + challenge: &'a Challenge, + done: &'a Proofs, + ) -> Self { Self { - batch: pass::Batched::new(move |items| rounds_batch(airs, challenge, done, items)), + batch: pass::Batched::new(scope, move |items| { + rounds_batch(airs, challenge, done, items) + }), } } } @@ -185,9 +192,10 @@ pub fn run( ) -> Result { let airs = ChunkAirs::new(proof_options); let done = std::sync::Mutex::new(Vec::new()); - let mut visitor = BuildAux::new(&airs, challenge, &done); - let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; - drop(visitor); + let mut resident = std::thread::scope(|s| { + let mut visitor = BuildAux::new(s, &airs, challenge, &done); + pass::run(elf, private_input, max_rows, &mut visitor) + })?; let chunks = done.into_inner().expect("logup results"); let tables = assemble(chunks, &mut resident, elf, proof_options, challenge)?; @@ -344,9 +352,14 @@ struct BuildDeep<'a> { } impl<'a> BuildDeep<'a> { - fn new(airs: &'a ChunkAirs, challenge: &'a Challenge, done: &'a Deeps) -> Self { + fn new( + scope: &'a std::thread::Scope<'a, '_>, + airs: &'a ChunkAirs, + challenge: &'a Challenge, + done: &'a Deeps, + ) -> Self { Self { - batch: pass::Batched::new(move |items| deep_batch(airs, challenge, done, items)), + batch: pass::Batched::new(scope, move |items| deep_batch(airs, challenge, done, items)), } } } @@ -481,9 +494,10 @@ pub fn run_batched( order: Vec::new(), tables: Vec::new(), }); - let mut visitor = BuildDeep::new(&chunk_airs, challenge, &done); - let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; - drop(visitor); + let mut resident = std::thread::scope(|s| { + let mut visitor = BuildDeep::new(s, &chunk_airs, challenge, &done); + pass::run(elf, private_input, max_rows, &mut visitor) + })?; // The tables the walk could not retire, folded after it in AIR order. let order = &challenge.order; @@ -615,13 +629,14 @@ struct OpenTables<'a> { impl<'a> OpenTables<'a> { fn new( + scope: &'a std::thread::Scope<'a, '_>, airs: &'a ChunkAirs, challenge: &'a Challenge, batched: &'a Batched, done: &'a Opens, ) -> Self { Self { - batch: pass::Batched::new(move |items| { + batch: pass::Batched::new(scope, move |items| { open_batch(airs, challenge, batched, done, items) }), } @@ -721,9 +736,10 @@ pub fn run_open( ) -> Result { let chunk_airs = ChunkAirs::new(proof_options); let done = std::sync::Mutex::new(Vec::new()); - let mut visitor = OpenTables::new(&chunk_airs, challenge, batched, &done); - let mut resident = pass::run(elf, private_input, max_rows, &mut visitor)?; - drop(visitor); + let mut resident = std::thread::scope(|s| { + let mut visitor = OpenTables::new(s, &chunk_airs, challenge, batched, &done); + pass::run(elf, private_input, max_rows, &mut visitor) + })?; let mut opens = done.into_inner().expect("openings"); let order = &challenge.order; diff --git a/prover/src/pass.rs b/prover/src/pass.rs index 11535f2c8..e1b14c924 100644 --- a/prover/src/pass.rs +++ b/prover/src/pass.rs @@ -80,41 +80,121 @@ pub fn table_parallelism() -> usize { .unwrap_or(1) } -/// Collects tables until there are `k` of them, then hands the batch over. +/// How many full batches may wait in the channel between the walk and the +/// worker, beyond the one being processed. `A1_INFLIGHT`, default 0: the walk +/// hands a batch over and is free the moment the worker takes it, so at most +/// `k` tables are being processed while `k` more are being built. +fn inflight() -> usize { + std::env::var("A1_INFLIGHT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +/// `A1_PIPELINE=0` processes each batch inline, blocking the walk — the +/// pre-pipeline behaviour, kept so the two can be measured against each other +/// in one binary. +fn pipelined() -> bool { + std::env::var("A1_PIPELINE") + .map(|v| v != "0") + .unwrap_or(true) +} + +/// Collects tables until there are `k` of them, then hands the batch to a +/// worker thread and keeps walking. +/// +/// The walk is serial — it re-executes the program — and a batch's proving is +/// not, so the two should overlap: while the worker proves batch N the walk +/// builds batch N+1. Measured at k=1 the walk is ~22s of a 246s pass, and with +/// the old inline processing every one of those seconds was spent with the +/// worker idle and vice versa. The bound on how many tables are alive moves +/// from `k` to `k` times the batches in flight, which is the knob a caller has. /// -/// Sits between the walk and a pass so the pass only says what to do with one -/// batch; the batching, and the bound on how many tables are alive, live here -/// once. The action is boxed so a pass names `Batched<'_, Item>` and not a -/// closure type. -pub struct Batched<'a, T> { +/// The action is boxed so a pass names `Batched<'_, Item>` and not a closure +/// type, and it is `Send` because it runs on the worker. +pub struct Batched<'scope, T> { batch: Vec, k: usize, + tx: Option>>, + worker: Option>>, #[allow(clippy::type_complexity)] - run: Box) -> Result<(), Error> + 'a>, + inline: Option) -> Result<(), Error> + Send + 'scope>>, } -impl<'a, T> Batched<'a, T> { - pub fn new(run: impl FnMut(Vec) -> Result<(), Error> + 'a) -> Self { +impl<'scope, T: Send + 'scope> Batched<'scope, T> { + pub fn new( + scope: &'scope std::thread::Scope<'scope, '_>, + run: impl FnMut(Vec) -> Result<(), Error> + Send + 'scope, + ) -> Self { + let k = table_parallelism(); + if !pipelined() { + return Self { + batch: Vec::new(), + k, + tx: None, + worker: None, + inline: Some(Box::new(run)), + }; + } + let (tx, rx) = std::sync::mpsc::sync_channel::>(inflight()); + let worker = scope.spawn(move || { + let mut run = run; + for batch in rx { + run(batch)?; + } + Ok(()) + }); Self { batch: Vec::new(), - k: table_parallelism(), - run: Box::new(run), + k, + tx: Some(tx), + worker: Some(worker), + inline: None, } } pub fn push(&mut self, item: T) -> Result<(), Error> { self.batch.push(item); if self.batch.len() >= self.k { - return self.drain(); + return self.hand_over(); } Ok(()) } - pub fn drain(&mut self) -> Result<(), Error> { + /// Give the current batch to whoever processes it, without waiting for + /// the result. + fn hand_over(&mut self) -> Result<(), Error> { if self.batch.is_empty() { return Ok(()); } - (self.run)(std::mem::take(&mut self.batch)) + let batch = std::mem::take(&mut self.batch); + if let Some(run) = self.inline.as_mut() { + return run(batch); + } + match self.tx.as_ref() { + Some(tx) => tx.send(batch).map_err(|_| { + // The worker is gone, which means it failed; the real error is + // what `join` returns. + Error::Prover("batched: the worker stopped early".into()) + }), + None => Err(Error::Prover("batched: pushed after finishing".into())), + } + } + + /// Hand over what is left and wait for the worker to finish everything. + pub fn drain(&mut self) -> Result<(), Error> { + let handed = self.hand_over(); + // Closing the channel is what ends the worker's loop. + drop(self.tx.take()); + let joined = match self.worker.take() { + Some(w) => w + .join() + .unwrap_or_else(|_| Err(Error::Prover("batched: the worker panicked".into()))), + None => Ok(()), + }; + // A send failure only ever means the worker had already failed; report + // the worker's error, which says why. + joined.and(handed) } } From 4147862ecb767fd02b5ae9a47620b810489d606e Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 19:50:19 -0300 Subject: [PATCH 51/63] Measure the walk on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass is the walk plus what it does to each table, and until now the two could only be told apart by inference: summed spans at k=1 minus the wall time, which is where several wrong guesses this week came from. This adds a stage that runs the walk with a visitor that does nothing, so the floor every pass pays is a number rather than a residual. On the ethrex mainnet block it is 14.28s. That settles two things. Pass 1 is walk-bound: its own work, main LDE plus Merkle, is ~14s serial at k=1 and far less at k=16, so a fully overlapped pass 1 would cost the walk and no more — and it costs 22.68s, which puts ~8.4s of interference between the walk and the worker that the pipeline is not hiding. And the walk itself is 1.6x main's execute-plus-trace-build (8.9s): about 5s of rebuilding tables chunk by chunk, with the six deduplicating tables deduplicated per chunk where main does it once over the logs. Those are the two levers left in the time of this path, each with a ceiling measured rather than estimated — roughly 8s and 5s of 129.7. Neither closes the gap to main's 92.7s, and the spec's Approach 1 says outright that it costs "extra interpolations and Merkle tree evaluations" for its memory. The walk-only helper existed as a test fixture; it is now a public function. --- bin/cli/src/main.rs | 9 +++++++++ prover/src/logup_phase.rs | 6 ++++-- prover/src/tests/batched_fri_tests.rs | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 2835d37f7..d229f53ce 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -286,6 +286,9 @@ enum Stage { /// Instead of one FRI per table, fold them by domain, open at the group's /// indices, and assemble the batched proof. Batched, + /// Only the walk: replay the execution and rebuild every table, proving + /// nothing. The floor each pass pays. + Walk, } fn main() -> ExitCode { @@ -1055,6 +1058,12 @@ fn run_approach_1( #[cfg(feature = "instruments")] stark::instruments::reset_timeline(); let t0 = std::time::Instant::now(); + if through == Stage::Walk { + let resident = prover::logup_phase::walk_only(elf, private_inputs, max_rows) + .map_err(|e| format!("{e:?}"))?; + println!(" walk only {:>8.2}s", t0.elapsed().as_secs_f64()); + return Ok(resident.pages.len()); + } let committed = prover::commit_phase::run_to_end(elf, private_inputs, max_rows, options) .map_err(|e| format!("{e:?}"))?; let t_commit = t0.elapsed(); diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index f85ae881b..8fe3a2ba7 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -131,8 +131,10 @@ pub(crate) fn fork_for( /// Rebuild only the tables a pass cannot retire, for a caller that wants one of /// them without proving the run. -#[cfg(test)] -pub(crate) fn resident_tables( +/// The walk with nothing done to any table: what the execution costs to +/// replay and rebuild, on its own. This is the floor every pass pays, and the +/// number the pipeline can at best hide behind the proving. +pub fn walk_only( elf: &Elf, private_input: &[u8], max_rows: &MaxRowsConfig, diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 3eb46e2fe..570062647 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -58,7 +58,7 @@ fn a_batch_of_one_matches_the_unbatched_fri() { None, None, ); - let mut resident = crate::logup_phase::resident_tables(&elf, &[], &max_rows).expect("resident"); + let mut resident = crate::logup_phase::walk_only(&elf, &[], &max_rows).expect("resident"); // BITWISE is table 0 and the largest resident one, so it exercises a real // domain rather than a one-row corner. From 9abbdae3e489fb679318af421341c31347964cd7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 22:53:06 -0300 Subject: [PATCH 52/63] Keep large buffers warm in jemalloc jemalloc serves allocations of 8 MiB and up from a dedicated arena and purges each of them the moment it is freed, whatever the decay says, unless that arena's decay is disabled (extent_may_force_decay, extent.c:941). Every trace, LDE and composition buffer of the prover is that size, and the streaming prover allocates and drops one after another, so each chunk refaulted and re-zeroed the same pages. On the ethrex block the walk profile had the kernel's clear_page_erms as its top symbol at 12.9%. Disable the huge arena's decay at startup and purge it from a thread every 10 s instead. Hot buffers are reused across threads; cold ones still go back to the OS within the same window jemalloc would have used. Ethrex on 96 cores: per-table streaming 131.2 -> 117.4 s (-10.5%), batched 202.4 -> 181.5 s (-10.3%), RSS +2.8 GB (peak heap unchanged); main's prove gains ~2% since it barely churns. A long decay, a background thread, or oversize_threshold:0 do not reach this: the first two leave the eager purge active, the last scatters the buffers over 384 per-thread arenas. Linux only: elsewhere jemalloc is built without background threads and the decay mallctl for the huge arena traps. MALLOC_CONF in the environment still overrides the defaults. --- Cargo.lock | 1 + bin/cli/Cargo.toml | 5 +++-- bin/cli/src/main.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bcbda1c2c..4cbef8dbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,7 @@ dependencies = [ "stark", "tempfile", "tikv-jemalloc-ctl", + "tikv-jemalloc-sys", "tikv-jemallocator", ] diff --git a/bin/cli/Cargo.toml b/bin/cli/Cargo.toml index 776d8acd0..816b34e9c 100644 --- a/bin/cli/Cargo.toml +++ b/bin/cli/Cargo.toml @@ -14,11 +14,12 @@ clap = { version = "4.3.10", features = ["derive"] } rkyv = { version = "0.8.10", default-features = false, features = ["alloc", "bytecheck", "aligned", "pointer_width_64"] } tempfile = "3" tikv-jemallocator = "0.6" -tikv-jemalloc-ctl = { version = "0.6", features = ["stats"], optional = true } +tikv-jemalloc-ctl = { version = "0.6", features = ["stats"] } +tikv-jemalloc-sys = "0.6" env_logger = "0.11" [features] -jemalloc-stats = ["dep:tikv-jemalloc-ctl"] +jemalloc-stats = [] disk-spill = ["prover/disk-spill"] instruments = ["prover/instruments", "stark/instruments"] # GPU profiling build (Nsight): CUDA prover + instruments spans + NVTX ranges. diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index d229f53ce..5bc5827b5 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -10,6 +10,54 @@ use clap::{Parser, Subcommand, ValueHint}; #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +/// jemalloc serves allocations of 8 MiB and up from one shared arena and purges each of +/// them the moment it is freed, whatever the decay says, unless decay is disabled for that +/// arena (`extent_may_force_decay`). A prover that allocates and drops one trace-sized +/// buffer after another then refaults and re-zeroes the same pages for every chunk. +/// Disable the arena's decay and purge it on our own clock instead: hot buffers are +/// reused across threads, cold ones still go back to the OS. +/// +/// Linux only: elsewhere jemalloc is built without background threads and the decay +/// mallctl traps. +fn keep_large_buffers_warm() { + #[cfg(target_os = "linux")] + { + use std::ffi::CString; + use std::ptr::null_mut; + use std::time::Duration; + use tikv_jemalloc_ctl::raw; + + const PURGE_EVERY: Duration = Duration::from_secs(10); + + // The arena only exists after the first large allocation. + std::hint::black_box(vec![0u8; 16 << 20]); + // SAFETY: `opt.narenas` is `unsigned`, the decay knob is `ssize_t`, and `purge` + // takes no value. + unsafe { + let Ok(huge_arena) = raw::read::(b"opt.narenas\0") else { + return; + }; + let decay = format!("arena.{huge_arena}.dirty_decay_ms\0"); + if raw::write(decay.as_bytes(), -1i64).is_err() { + return; + } + let purge = CString::new(format!("arena.{huge_arena}.purge")).unwrap(); + std::thread::spawn(move || { + loop { + std::thread::sleep(PURGE_EVERY); + tikv_jemalloc_sys::mallctl( + purge.as_ptr(), + null_mut(), + null_mut(), + null_mut(), + 0, + ); + } + }); + } + } +} use executor::vm::instruction::decoding::Instruction; use executor::vm::instruction::execution::{Accelerator, SyscallNumbers}; use executor::{elf::Elf, flamegraph::FlamegraphGenerator, vm::execution::Executor}; @@ -292,6 +340,7 @@ enum Stage { } fn main() -> ExitCode { + keep_large_buffers_warm(); env_logger::init(); let cli = Cli::parse(); From fdffe8d2b4428ec99cb96417993371f28561fb1a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 22:53:25 -0300 Subject: [PATCH 53/63] Build the AIRs once for all the passes The Challenge pass built every AIR to assemble the round-1 roots in AIR order, and the LogUp, fold and open passes each built them again from the same inputs. VmAirs::new is not free: it computes the preprocessed commitments, DECODE from the ELF and one per ELF data page among them, which is most of what the Challenge pass costs. Keep the AIRs in the Challenge and hand them to the later passes, as the ordinary prover builds them once. --- prover/src/challenge_phase.rs | 15 ++++++++---- prover/src/logup_phase.rs | 43 ++++------------------------------- prover/src/streaming.rs | 1 + 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs index 03705d9ea..d7aef1729 100644 --- a/prover/src/challenge_phase.rs +++ b/prover/src/challenge_phase.rs @@ -40,6 +40,9 @@ pub struct Challenge { pub challenges: Vec>, /// Every root absorbed, in AIR order. pub roots: Vec, + /// The AIRs of this proof, built once here: their preprocessed commitments + /// (DECODE from the ELF, one per ELF data page, ...) are the bulk of this pass. + pub(crate) airs: crate::VmAirs, /// The transcript right after the sampling, which every later pass forks /// per table. Kept rather than rebuilt: re-absorbing 227 roots to get back /// to this state is both slower and a second place for the order to be @@ -109,15 +112,17 @@ pub fn run( .map(|_| transcript.sample_field_element()) .collect(); + let order = crate::streaming::AirOrder::new( + table_counts, + airs.include_halt, + remaining.page_configs.len(), + ); Ok(Challenge { challenges, roots, + airs, transcript, - order: crate::streaming::AirOrder::new( - table_counts, - airs.include_halt, - remaining.page_configs.len(), - ), + order, }) } diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 8fe3a2ba7..829e7fb2c 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -200,7 +200,7 @@ pub fn run( })?; let chunks = done.into_inner().expect("logup results"); - let tables = assemble(chunks, &mut resident, elf, proof_options, challenge)?; + let tables = assemble(chunks, &mut resident, challenge)?; Ok(LogUp { tables, resident }) } @@ -212,23 +212,10 @@ pub fn run( fn assemble( chunks: Vec, resident: &mut Resident, - elf: &Elf, - proof_options: &ProofOptions, challenge: &Challenge, ) -> Result>, Error> { let order = &challenge.order; - let airs = crate::VmAirs::new( - elf, - proof_options, - false, - &resident.page_configs, - order.counts(), - None, - true, - None, - None, - None, - ); + let airs = &challenge.airs; let mut slots: Vec>> = (0..order.len()).map(|_| None).collect(); @@ -503,18 +490,7 @@ pub fn run_batched( // The tables the walk could not retire, folded after it in AIR order. let order = &challenge.order; - let airs = crate::VmAirs::new( - elf, - proof_options, - false, - &resident.page_configs, - order.counts(), - None, - true, - None, - None, - None, - ); + let airs = &challenge.airs; let n = order.len(); { let mut state = done.lock().expect("fold state"); @@ -745,18 +721,7 @@ pub fn run_open( let mut opens = done.into_inner().expect("openings"); let order = &challenge.order; - let airs = crate::VmAirs::new( - elf, - proof_options, - false, - &resident.page_configs, - order.counts(), - None, - true, - None, - None, - None, - ); + let airs = &challenge.airs; let n = order.len(); let build = |idx: usize, air: &crate::VmAir, diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index e40c62c98..ff343ce63 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -101,6 +101,7 @@ impl AirOrder { None } + #[cfg(test)] pub(crate) fn counts(&self) -> &crate::TableCounts { &self.counts } From cf2d51e176a580f87047c2fd05e5cd52bb414d29 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 17 Sep 2026 22:53:25 -0300 Subject: [PATCH 54/63] Take the resident commits off the critical path The Challenge pass did two things after the walk that did not have to cost what they did. It computed the preprocessed commitments that depend on the ELF alone, DECODE and one per ELF data page; those now run on a thread started with the walk, which leaves most cores idle, and reach VmAirs::new through the parameters continuations already use for the same purpose. And it committed the resident tables one at a time, BITWISE, DECODE, the accumulators, REGISTER, HALT, then 43 PAGE tables, each too small to fill the machine on its own; they are committed in parallel now, in the same order. Ethrex on 96 cores: pass 2 7.3 -> 4.6 s, per-table streaming 116.4 -> 113.9 s, batched 179.8 -> 176.7 s. What is left of pass 2 is KECCAK_RND's commit (3.75 s), which needs the whole walk. --- prover/src/challenge_phase.rs | 26 ++++++++++++++++------- prover/src/commit_phase.rs | 40 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs index d7aef1729..d95cafa8a 100644 --- a/prover/src/challenge_phase.rs +++ b/prover/src/challenge_phase.rs @@ -17,6 +17,8 @@ use std::collections::HashMap; +use rayon::prelude::*; + use crypto::fiat_shamir::default_transcript::DefaultTranscript; use crypto::fiat_shamir::is_transcript::IsTranscript; use stark::proof::options::ProofOptions; @@ -77,10 +79,10 @@ pub fn run( false, &remaining.page_configs, &table_counts, - None, + Some(committed.precomputed.decode), true, None, - None, + Some(&committed.precomputed.pages), None, ); @@ -156,9 +158,13 @@ fn assemble_roots( (&airs.hint, &accumulated.hint, "HINT"), (&airs.register, &remaining.register, "REGISTER"), ]; - for (air, trace, name) in fixed { - roots.push(commit_resident(air, trace, name)?); - } + // Small tables, many of them: one commit at a time leaves most cores idle. + roots.extend( + fixed + .par_iter() + .map(|(air, trace, name)| commit_resident(air, trace, name)) + .collect::, _>>()?, + ); if airs.include_halt { roots.push(commit_resident(&airs.halt, &remaining.halt, "HALT")?); } @@ -177,9 +183,13 @@ fn assemble_roots( let Some(kind) = group else { // PAGE is built from the ELF image rather than from an op list, so // it is never retired and is committed here with the rest. - for (air, trace) in page_airs.by_ref() { - roots.push(commit_resident(air, trace, "PAGE")?); - } + let pages: Vec<_> = page_airs.by_ref().collect(); + roots.extend( + pages + .par_iter() + .map(|(air, trace)| commit_resident(air, trace, "PAGE")) + .collect::, _>>()?, + ); continue; }; for chunk in 0..count_for(table_counts, kind) { diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index 9bb154f45..d73e539b1 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -8,6 +8,7 @@ //! table die. What it produces is a root per chunk and, at the end, the tables //! that cannot be retired — which the Challenge phase commits and samples from. +use stark::config::Commitment; use stark::proof::options::ProofOptions; use stark::prover::{IsStarkProver, MainRoots}; @@ -28,6 +29,34 @@ pub struct Committed { pub chunks: Vec, /// The tables the walk could not commit, still as traces. pub remaining: Resident, + /// The preprocessed commitments the ELF alone determines, computed beside the walk. + pub precomputed: Precomputed, +} + +/// The preprocessed commitments that depend on the ELF and nothing else — DECODE and +/// one per ELF data page. They are most of what building the AIRs costs, and they +/// need nothing from the execution, so they run on a thread beside the walk. +pub struct Precomputed { + pub decode: Commitment, + pub pages: Vec<(u64, Commitment)>, +} + +impl Precomputed { + pub fn new(elf: &Elf, proof_options: &ProofOptions) -> Result { + let decode = crate::tables::decode::commitment_from_elf(elf, proof_options) + .map_err(|e| Error::Prover(format!("decode commitment: {e}")))?; + let pages = Traces::page_configs_from_elf(elf) + .iter() + .filter(|config| config.init_values.is_some()) + .map(|config| { + ( + config.page_base, + crate::tables::page::compute_precomputed_commitment(config, proof_options), + ) + }) + .collect(); + Ok(Self { decode, pages }) + } } /// What the walk alone produced. @@ -140,13 +169,16 @@ pub fn run_to_end( ) -> Result { let airs = ChunkAirs::new(proof_options); let roots = std::sync::Mutex::new(Vec::new()); - let remaining = std::thread::scope(|s| { + let (remaining, precomputed) = std::thread::scope(|s| { + let precomputed = s.spawn(|| Precomputed::new(elf, proof_options)); let mut visitor = CommitMain::new(s, &airs, &roots); - pass::run(elf, private_input, max_rows, &mut visitor) - })?; + let remaining = pass::run(elf, private_input, max_rows, &mut visitor); + (remaining, precomputed.join().expect("precompute thread")) + }); Ok(Committed { chunks: roots.into_inner().expect("roots"), - remaining, + remaining: remaining?, + precomputed: precomputed?, }) } From fbba10a8eb244fc99bb355ed3fd3a93c3c38df42 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 07:36:57 -0300 Subject: [PATCH 55/63] Give LT and BITWISE what retired chunks owe them The streaming proof of the ethrex block did not verify: the LogUp bus did not balance, while every table passed its own rounds. Tables built at the end were missing rows the walk owed them from work it had already retired, and derivations the ordinary build does after its CPU pass. LT takes a row for every MEMW timestamp check, and finalize derived those from the tail alone; the MEMW and MEMW_A chunks retired during the walk never handed theirs over, so ethrex got 3 LT chunks where the ordinary build has 21. The walk now derives them as the chunk closes and finalize appends them in the ordinary build's order, general MEMW then aligned, so the tables come out byte for byte the same. BITWISE never counted the lookups CPU32 sends: the ordinary build sums collect_cpu32_bitwise over every word instruction outside the CPU collector, and the walk's fold had no arm for the kind. fib.asm has no word instructions, which is why the existing test could not see it; every Rust program has them in its runtime. MUL and DVRM were built empty: derive_from_cpu returns their ops with the branch, eq, bytewise and store ops, and the walk kept only those four. keccak showed it; programs without multiplies could not. And the ordinary build derives more after the CPU pass: CPU32 rows dispatch to SHIFT, MUL and DVRM (cpu32_chip_op), and every DVRM op owes LT a |r| < |d| row and MUL its d * q rows. The walk did none of it. Those run now for the retired CPU32 chunks as they close and for the tail in finalize, appended in the ordinary build's order so the tables match it byte for byte. HINT appends three LT range checks per call, selector and both address low limbs, after every other LT row; the walk stopped at the MEMW ones. On the ethrex block that was the last LT chunk, 261 rows short. Two tests cover the pattern from now on: a cell-by-cell diff of the walk's BITWISE table against the ordinary build's, and a full prove-and-verify with small chunks of every kind on a Rust program, which is what the block does at scale. trace-build gains --verify, which assembles the per-table proof and runs the ordinary verifier on it, and --output to keep the proof; cmp_proofs diffs two proofs table by table. --- bin/cli/examples/cmp_proofs.rs | 102 +++++++++++++ bin/cli/src/main.rs | 105 +++++++++++--- prover/src/logup_phase.rs | 21 +++ prover/src/streaming.rs | 3 +- prover/src/tables/trace_builder.rs | 98 +++++++++++++ prover/src/tests/challenge_phase_tests.rs | 166 ++++++++++++++++++++-- 6 files changed, 456 insertions(+), 39 deletions(-) create mode 100644 bin/cli/examples/cmp_proofs.rs diff --git a/bin/cli/examples/cmp_proofs.rs b/bin/cli/examples/cmp_proofs.rs new file mode 100644 index 000000000..41cb5f4f1 --- /dev/null +++ b/bin/cli/examples/cmp_proofs.rs @@ -0,0 +1,102 @@ +//! Compare two `VmProof` files table by table: `cmp_proofs A.proof B.proof`. +//! +//! A diagnostic for the streaming prover: which table, and which part of it, +//! first departs from the monolithic prover's proof of the same execution. + +use std::os::unix::fs::FileExt; + +use prover::VmProof; + +fn read(path: &str) -> VmProof { + let file = std::fs::File::open(path).expect("open"); + let len = file.metadata().expect("metadata").len() as usize; + let mut buf = rkyv::util::AlignedVec::<16>::with_capacity(len); + buf.resize(len, 0); + file.read_exact_at(&mut buf, 0).expect("read"); + rkyv::from_bytes::(&buf).expect("deserialize") +} + +fn main() { + let args: Vec = std::env::args().collect(); + let (a, b) = (read(&args[1]), read(&args[2])); + println!( + "tables: {} vs {}", + a.proof.proofs.len(), + b.proof.proofs.len() + ); + println!( + "table_counts equal: {}", + format!("{:?}", a.table_counts) == format!("{:?}", b.table_counts) + ); + println!("counts A: {:?}", a.table_counts); + println!("counts B: {:?}", b.table_counts); + println!( + "runtime_page_ranges equal: {}", + format!("{:?}", a.runtime_page_ranges) == format!("{:?}", b.runtime_page_ranges) + ); + println!( + "num_private_input_pages: {} vs {}", + a.num_private_input_pages, b.num_private_input_pages + ); + println!( + "public_output equal: {}", + a.public_output == b.public_output + ); + let mut shown = 0; + for (i, (x, y)) in a.proof.proofs.iter().zip(b.proof.proofs.iter()).enumerate() { + let mut diffs = Vec::new(); + if x.trace_length != y.trace_length { + diffs.push(format!( + "trace_length {} vs {}", + x.trace_length, y.trace_length + )); + } + if x.lde_trace_main_merkle_root != y.lde_trace_main_merkle_root { + diffs.push("main root".into()); + } + if x.lde_trace_precomputed_merkle_root != y.lde_trace_precomputed_merkle_root { + diffs.push("precomputed root".into()); + } + if x.lde_trace_aux_merkle_root != y.lde_trace_aux_merkle_root { + diffs.push("aux root".into()); + } + if format!("{:?}", x.trace_ood_evaluations) != format!("{:?}", y.trace_ood_evaluations) { + diffs.push("ood".into()); + } + if x.composition_poly_root != y.composition_poly_root { + diffs.push("composition root".into()); + } + if x.fri_layers_merkle_roots != y.fri_layers_merkle_roots { + diffs.push("fri roots".into()); + } + if x.fri_final_poly_coeffs != y.fri_final_poly_coeffs { + diffs.push("fri final".into()); + } + if x.query_list.len() != y.query_list.len() { + diffs.push(format!( + "queries {} vs {}", + x.query_list.len(), + y.query_list.len() + )); + } + if !diffs.is_empty() && shown < 40 { + println!("table {i}: {}", diffs.join(", ")); + shown += 1; + } + } + println!("(showing at most 40 differing tables)"); + let main_diff: Vec = a + .proof + .proofs + .iter() + .zip(b.proof.proofs.iter()) + .enumerate() + .filter(|(_, (x, y))| x.lde_trace_main_merkle_root != y.lde_trace_main_merkle_root) + .map(|(i, _)| i) + .collect(); + println!( + "tables whose MAIN root differs ({}): {:?}", + main_diff.len(), + main_diff + ); +} diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 5bc5827b5..24442e8de 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -319,6 +319,16 @@ enum Commands { /// --streaming; each stage includes the ones before it. #[arg(long, value_enum, default_value = "logup", requires = "streaming")] through: Stage, + + /// Assemble the per-table proof the LogUp stage leaves and run the + /// ordinary verifier on it, after the timings are reported. + #[arg(long, requires = "streaming")] + verify: bool, + + /// Write the assembled per-table proof here (implies the assembly, not + /// the verification). + #[arg(short, long, requires = "streaming", value_hint = ValueHint::FilePath)] + output: Option, }, } @@ -408,7 +418,9 @@ fn main() -> ExitCode { private_input, streaming, through, - } => cmd_trace_build(elf, private_input, streaming, through), + verify, + output, + } => cmd_trace_build(elf, private_input, streaming, through, verify, output), } } @@ -1103,7 +1115,8 @@ fn run_approach_1( max_rows: &prover::tables::MaxRowsConfig, options: &stark::proof::options::ProofOptions, through: Stage, -) -> Result { + verify: bool, +) -> Result<(usize, Option), String> { #[cfg(feature = "instruments")] stark::instruments::reset_timeline(); let t0 = std::time::Instant::now(); @@ -1111,14 +1124,14 @@ fn run_approach_1( let resident = prover::logup_phase::walk_only(elf, private_inputs, max_rows) .map_err(|e| format!("{e:?}"))?; println!(" walk only {:>8.2}s", t0.elapsed().as_secs_f64()); - return Ok(resident.pages.len()); + return Ok((resident.pages.len(), None)); } let committed = prover::commit_phase::run_to_end(elf, private_inputs, max_rows, options) .map_err(|e| format!("{e:?}"))?; let t_commit = t0.elapsed(); if through == Stage::Commit { println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); - return Ok(committed.chunks.len()); + return Ok((committed.chunks.len(), None)); } let t1 = std::time::Instant::now(); let challenge = prover::challenge_phase::run(&committed, elf, elf_bytes, options) @@ -1129,7 +1142,7 @@ fn run_approach_1( if through == Stage::Challenge { println!(" pass 1 (commit) {:>8.2}s", t_commit.as_secs_f64()); println!(" pass 2 (challenge) {:>8.2}s", t_challenge.as_secs_f64()); - return Ok(challenge.roots.len()); + return Ok((challenge.roots.len(), None)); } // The batched path replaces the per-table prove; running both would measure // neither. @@ -1160,7 +1173,7 @@ fn run_approach_1( println!(" pass 5 (open) {:>8.2}s", t_open.as_secs_f64()); report_span_totals(); report_batched_size(&proof, tables, groups); - return Ok(tables); + return Ok((tables, None)); } let t2 = std::time::Instant::now(); @@ -1172,7 +1185,9 @@ fn run_approach_1( println!(" pass 3 (prove) {:>8.2}s", t_prove.as_secs_f64()); report_span_totals(); report_fri_shape(&logup.tables); - Ok(logup.tables.len()) + let tables = logup.tables.len(); + let proof = verify.then(|| prover::logup_phase::assemble_vm_proof(logup, &challenge)); + Ok((tables, proof)) } /// What the batched proof weighs, against what the per-table one weighs. @@ -1296,6 +1311,8 @@ fn cmd_trace_build( private_input_path: Option, streaming: bool, through: Stage, + verify: bool, + output: Option, ) -> ExitCode { let elf_data = match std::fs::read(&elf_path) { Ok(data) => data, @@ -1324,14 +1341,14 @@ fn cmd_trace_build( let started = std::time::Instant::now(); let max_rows = prover::tables::MaxRowsConfig::default(); + let options = match stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) { + Ok(o) => o, + Err(e) => { + eprintln!("bad proof options: {e:?}"); + return ExitCode::FAILURE; + } + }; let outcome = if streaming { - let options = match stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) { - Ok(o) => o, - Err(e) => { - eprintln!("bad proof options: {e:?}"); - return ExitCode::FAILURE; - } - }; run_approach_1( &elf, &elf_data, @@ -1339,25 +1356,29 @@ fn cmd_trace_build( &max_rows, &options, through, + verify || output.is_some(), ) } else { prover::commit_phase::build_resident(&elf, &private_inputs, &max_rows) - .map(|t| t.cpus.len()) + .map(|t| (t.cpus.len(), None)) .map_err(|e| format!("{e:?}")) }; let elapsed = started.elapsed(); - match outcome { - Ok(n) => println!( - "Trace build ({}): {n} tables, {:.3}s", - if streaming { "streaming" } else { "resident" }, - elapsed.as_secs_f64() - ), + let proof = match outcome { + Ok((n, proof)) => { + println!( + "Trace build ({}): {n} tables, {:.3}s", + if streaming { "streaming" } else { "resident" }, + elapsed.as_secs_f64() + ); + proof + } Err(e) => { eprintln!("trace build failed: {e}"); return ExitCode::FAILURE; } - } + }; #[cfg(feature = "jemalloc-stats")] { @@ -1368,6 +1389,46 @@ fn cmd_trace_build( peak_at_ms as f64 / 1000.0 ); } + + let Some(proof) = proof else { + return ExitCode::SUCCESS; + }; + if let Some(path) = output { + let bytes = match rkyv::to_bytes::(&proof) { + Ok(b) => b, + Err(e) => { + eprintln!("Failed to serialize the A1 proof: {e}"); + return ExitCode::FAILURE; + } + }; + if let Err(e) = std::fs::write(&path, &bytes) { + eprintln!("Failed to write {}: {e}", path.display()); + return ExitCode::FAILURE; + } + println!( + "A1 proof written: {} ({} bytes)", + path.display(), + bytes.len() + ); + } + if verify { + let started = std::time::Instant::now(); + match prover::verify_with_options(&proof, &elf_data, &options, None, None) { + Ok(true) => println!( + "A1 proof verifies: {} tables, {:.3}s", + proof.proof.proofs.len(), + started.elapsed().as_secs_f64() + ), + Ok(false) => { + eprintln!("A1 proof REJECTED by the verifier"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("A1 proof verification error: {e}"); + return ExitCode::FAILURE; + } + } + } ExitCode::SUCCESS } diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 829e7fb2c..96b562c9e 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -37,6 +37,27 @@ pub struct LogUp { pub resident: Resident, } +/// The pass's output as the proof the ordinary verifier takes: the same +/// `MultiProof` the monolithic prover emits, with the layout the walk resolved. +pub fn assemble_vm_proof(logup: LogUp, challenge: &Challenge) -> crate::VmProof { + let resident = &logup.resident; + crate::VmProof { + proof: stark::proof::stark::MultiProof { + proofs: logup.tables, + }, + runtime_page_ranges: crate::tables::trace_builder::runtime_page_ranges( + &resident.page_configs, + ), + table_counts: challenge.order.counts().clone(), + public_output: resident.public_output.clone(), + num_private_input_pages: resident + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(), + } +} + type Item = ( TableKind, usize, diff --git a/prover/src/streaming.rs b/prover/src/streaming.rs index ff343ce63..fe788ff07 100644 --- a/prover/src/streaming.rs +++ b/prover/src/streaming.rs @@ -101,8 +101,7 @@ impl AirOrder { None } - #[cfg(test)] - pub(crate) fn counts(&self) -> &crate::TableCounts { + pub fn counts(&self) -> &crate::TableCounts { &self.counts } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 8bcc3032a..96edfe646 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1319,12 +1319,59 @@ impl WalkLeftover { self.tail.memw_aligned_ops.extend(buckets.aligned); self.tail.memw_ops.extend(buckets.general); + // What the ordinary build derives after the CPU pass, in its order. CPU32 + // rows dispatch to SHIFT, MUL and DVRM: the retired chunks' first, then the + // tail's, after everything the CPU itself sent. + let tail_cpu32 = self.tail.cpu32_ops.len(); + for c in &self.tail.cpu32_ops[..tail_cpu32] { + cpu32_chip_op( + c, + &mut self.tail.retired_cpu32_shift, + &mut self.tail.retired_cpu32_mul, + &mut self.tail.retired_cpu32_dvrm, + ); + } + let shift = std::mem::take(&mut self.tail.retired_cpu32_shift); + self.tail.shift_ops.extend(shift); + let mul = std::mem::take(&mut self.tail.retired_cpu32_mul); + self.tail.mul_ops.extend(mul); + let dvrm = std::mem::take(&mut self.tail.retired_cpu32_dvrm); + self.tail.dvrm_ops.extend(dvrm); + // Every DVRM op owes LT |r| < |d| and MUL d * q, lo and hi. + for (op, _wants_remainder) in &self.tail.dvrm_ops { + self.tail + .lt_ops + .push(LtOperation::new(op.abs_r(), op.abs_d(), false)); + } + for (op, _wants_remainder) in &self.tail.dvrm_ops { + let mul_op = MulOperation::new(op.d, op.signed, op.compute_quotient(), op.sign_q()); + self.tail.mul_ops.push((mul_op.clone(), false)); + self.tail.mul_ops.push((mul_op, true)); + } + + // MEMW's timestamp checks are LT rows. The chunks retired during the + // walk left theirs behind; the tail's are derived now. Same order as the + // ordinary build: every general MEMW op, then every aligned one. + let retired = std::mem::take(&mut self.tail.retired_memw_lt); + self.tail.lt_ops.extend(retired); self.tail .lt_ops .extend(collect_lt_from_memw(&self.tail.memw_ops)); + let retired = std::mem::take(&mut self.tail.retired_memw_aligned_lt); + self.tail.lt_ops.extend(retired); self.tail .lt_ops .extend(collect_lt_from_memw_aligned(&self.tail.memw_aligned_ops)); + // HINT's range checks, last of all: selector and both address low limbs. + self.tail + .lt_ops + .extend(self.tail.hint_ops.iter().flat_map(|op| { + [ + LtOperation::new(op.hint_id, hint::HINT_SELECTOR_BOUND, false), + LtOperation::new(op.in_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), + LtOperation::new(op.out_addr & 0xFFFF_FFFF, hint::HINT_ADDR_LIMB_BOUND, false), + ] + })); // Fold the tail's own BITWISE lookups in now, while the tail is whole. // The retired chunks contributed theirs as they closed; from here the @@ -1338,6 +1385,7 @@ impl WalkLeftover { TableKind::Bytewise, TableKind::Eq, TableKind::Store, + TableKind::Cpu32, ] { let n = self.tail.buffered(kind); self.tail.fold_bitwise_from_front(kind, n, &mut hist); @@ -3541,6 +3589,11 @@ impl CollectedOps { hist.add_ops(&op.collect_bitwise_ops()); } } + TableKind::Cpu32 => { + for c in &self.cpu32_ops[..n] { + hist.add_ops(&collect_cpu32_bitwise(c)); + } + } _ => {} } } @@ -3644,6 +3697,14 @@ impl CollectedOps { #[derive(Default)] pub(crate) struct CollectedOps { pub(crate) cpu_ops: Vec, + /// LT rows owed by the MEMW chunks already retired, kept apart so LT gets + /// them in the ordinary build's order once the tail's are known. + pub(crate) retired_memw_lt: Vec, + pub(crate) retired_memw_aligned_lt: Vec, + /// SHIFT, MUL and DVRM rows the retired CPU32 chunks dispatched, likewise. + pub(crate) retired_cpu32_shift: Vec, + pub(crate) retired_cpu32_mul: Vec<(MulOperation, bool)>, + pub(crate) retired_cpu32_dvrm: Vec<(DvrmOperation, bool)>, pub(crate) memw_ops: Vec, pub(crate) memw_aligned_ops: Vec, /// Direct-fill MEMW_R rows (register fast path). @@ -3942,6 +4003,11 @@ fn collect_all_ops( } CollectedOps { + retired_memw_lt: Vec::new(), + retired_memw_aligned_lt: Vec::new(), + retired_cpu32_shift: Vec::new(), + retired_cpu32_mul: Vec::new(), + retired_cpu32_dvrm: Vec::new(), cpu_ops, memw_ops, memw_aligned_ops, @@ -3989,6 +4055,11 @@ fn build_traces( retire_chunked: bool, ) -> Result<(Traces, CollectedOps), Error> { let CollectedOps { + retired_memw_lt: _, + retired_memw_aligned_lt: _, + retired_cpu32_shift: _, + retired_cpu32_mul: _, + retired_cpu32_dvrm: _, cpu_ops, memw_ops, memw_aligned_ops, @@ -5500,6 +5571,8 @@ impl Traces { buf.eq_ops.extend(derived.eq_ops); buf.bytewise_ops.extend(derived.bytewise_ops); buf.store_ops.extend(derived.store_ops); + buf.mul_ops.extend(derived.mul_ops); + buf.dvrm_ops.extend(derived.dvrm_ops); buf.cpu_ops.extend(cpu); buf.memw_register_rows.extend(memw.register_rows); buf.memw_aligned_ops.extend(memw.aligned); @@ -5525,6 +5598,31 @@ impl Traces { // Before the ops go: BITWISE counts them across the whole // run, and this chunk is about to stop existing. buf.fold_bitwise_from_front(*kind, limit, &mut bitwise_hist); + // So does LT, which owes a row to every MEMW timestamp check + // and is never closed mid-walk itself. + match kind { + TableKind::Memw => { + let derived = collect_lt_from_memw(&buf.memw_ops[..limit]); + buf.retired_memw_lt.extend(derived); + } + TableKind::MemwAligned => { + let derived = + collect_lt_from_memw_aligned(&buf.memw_aligned_ops[..limit]); + buf.retired_memw_aligned_lt.extend(derived); + } + // CPU32 rows dispatch to SHIFT, MUL and DVRM. + TableKind::Cpu32 => { + for c in &buf.cpu32_ops[..limit] { + cpu32_chip_op( + c, + &mut buf.retired_cpu32_shift, + &mut buf.retired_cpu32_mul, + &mut buf.retired_cpu32_dvrm, + ); + } + } + _ => {} + } if *kind == TableKind::Cpu { // Each CPU chunk pads to a power of two, and every // padding row looks DECODE up at the padding pc. diff --git a/prover/src/tests/challenge_phase_tests.rs b/prover/src/tests/challenge_phase_tests.rs index 53b4b82fb..957167c79 100644 --- a/prover/src/tests/challenge_phase_tests.rs +++ b/prover/src/tests/challenge_phase_tests.rs @@ -129,9 +129,13 @@ fn challenge_matches_the_ordinary_prover() { fn logup_matches_the_ordinary_prover() { let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); let elf = Elf::load(&elf_bytes).expect("ELF load"); + // Small enough that MEMW and MEMW_A chunks close mid-walk: their timestamp + // checks are LT rows, and a walk that retires a chunk must still hand LT + // the rows the chunk owes it. let max_rows = MaxRowsConfig { cpu: 1 << 15, memw: 1 << 10, + memw_aligned: 1 << 10, load: 1 << 10, branch: 1 << 12, ..Default::default() @@ -150,6 +154,11 @@ fn logup_matches_the_ordinary_prover() { let logup = crate::logup_phase::run(&elf, &[], &max_rows, &proof_options, &challenge) .expect("logup phase"); + assert_eq!( + format!("{:?}", challenge.order.counts()), + format!("{:?}", vm_proof.table_counts), + "the pass laid the tables out differently from the ordinary prover" + ); assert_eq!( logup.tables.len(), vm_proof.proof.proofs.len(), @@ -182,24 +191,151 @@ fn logup_matches_the_ordinary_prover() { // The decisive one: the pass's own proof, verified. Everything above says // it matches the ordinary prover piece by piece; this says the assembled // whole is a proof. - let rebuilt = crate::VmProof { - proof: stark::proof::stark::MultiProof { - proofs: logup.tables, - }, - runtime_page_ranges: crate::tables::trace_builder::runtime_page_ranges( - &logup.resident.page_configs, - ), - table_counts: vm_proof.table_counts.clone(), - public_output: logup.resident.public_output.clone(), - num_private_input_pages: logup - .resident - .page_configs - .iter() - .filter(|c| c.is_private_input) - .count(), + let rebuilt = crate::logup_phase::assemble_vm_proof(logup, &challenge); + assert!( + crate::verify(&rebuilt, &elf_bytes).expect("verify"), + "the proof the pass assembled does not verify" + ); +} + +/// Many chunks of every kind, on a program that works memory. The +/// small program above has one chunk per kind, so it cannot tell per-chunk +/// accounting from whole-table accounting; ethrex could, and did not verify. +#[test] +fn a1_verifies_with_many_chunks() { + let elf_bytes = { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let path = root.parent().expect("workspace root").join(format!( + "executor/program_artifacts/rust/{}.elf", + std::env::var("A1_DIAG_ELF").unwrap_or_else(|_| "vector".into()) + )); + std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read ELF: {}", path.display())) + }; + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 11, + memw: 1 << 9, + memw_aligned: 1 << 9, + dvrm: 1 << 9, + mul: 1 << 9, + lt: 1 << 9, + shift: 1 << 9, + load: 1 << 9, + branch: 1 << 9, + memw_register: 1 << 9, + eq: 1 << 9, + bytewise: 1 << 9, + store: 1 << 9, + cpu32: 1 << 9, }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + + let vm_proof = crate::prove_with_options_and_inputs(&elf_bytes, &[], &proof_options, &max_rows) + .expect("ordinary prove"); + + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let logup = crate::logup_phase::run(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("logup phase"); + let rebuilt = crate::logup_phase::assemble_vm_proof(logup, &challenge); + + eprintln!( + "tables: pass {} vs ordinary {}; counts pass {:?} vs ordinary {:?}", + rebuilt.proof.proofs.len(), + vm_proof.proof.proofs.len(), + rebuilt.table_counts, + vm_proof.table_counts + ); + let differing: Vec = rebuilt + .proof + .proofs + .iter() + .zip(vm_proof.proof.proofs.iter()) + .enumerate() + .filter(|(_, (a, b))| a.lde_trace_main_merkle_root != b.lde_trace_main_merkle_root) + .map(|(i, _)| i) + .collect(); + eprintln!("tables whose main root differs from the ordinary prover's: {differing:?}"); assert!( crate::verify(&rebuilt, &elf_bytes).expect("verify"), "the proof the pass assembled does not verify" ); } + +/// Diagnostic: where A1's BITWISE multiplicities depart from the ordinary build's. +#[test] +#[allow(clippy::needless_range_loop)] +fn bitwise_multiplicities_match_the_ordinary_build() { + let elf_bytes = { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let name = std::env::var("A1_DIAG_ELF").unwrap_or_else(|_| "vector".into()); + std::fs::read( + root.parent() + .unwrap() + .join(format!("executor/program_artifacts/rust/{name}.elf")), + ) + .expect("ELF") + }; + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = if std::env::var("A1_DIAG_DEFAULT_ROWS").is_ok() { + MaxRowsConfig::default() + } else { + MaxRowsConfig { + cpu: 1 << 11, + memw: 1 << 9, + memw_aligned: 1 << 9, + dvrm: 1 << 9, + mul: 1 << 9, + lt: 1 << 9, + shift: 1 << 9, + load: 1 << 9, + branch: 1 << 9, + memw_register: 1 << 9, + eq: 1 << 9, + bytewise: 1 << 9, + store: 1 << 9, + cpu32: 1 << 9, + } + }; + let input = std::env::var("A1_DIAG_INPUT") + .ok() + .map(|path| std::fs::read(path).expect("private input")) + .unwrap_or_default(); + let ordinary = crate::commit_phase::build_resident(&elf, &input, &max_rows).expect("ordinary"); + let walked = crate::logup_phase::walk_only(&elf, &input, &max_rows).expect("walk"); + eprintln!( + "cpu rows: {}", + ordinary.cpus.iter().map(|t| t.num_rows()).sum::() + ); + let (a, b) = (&walked.bitwise, &ordinary.bitwise); + assert_eq!(a.num_rows(), b.num_rows()); + assert_eq!(a.num_cols(), b.num_cols()); + let mut shown = 0; + let mut per_col = vec![0usize; a.num_cols()]; + for row in 0..a.num_rows() { + for col in 0..a.num_cols() { + if a.get_main(row, col) != b.get_main(row, col) { + per_col[col] += 1; + if shown < 25 { + eprintln!( + "row {row} (x={:?} y={:?}) col {col}: walk {:?} vs ordinary {:?}", + a.get_main(row, 0).value(), + a.get_main(row, 1).value(), + a.get_main(row, col).value(), + b.get_main(row, col).value() + ); + shown += 1; + } + } + } + } + eprintln!("differing cells per column: {per_col:?}"); + assert!( + per_col.iter().all(|&n| n == 0), + "BITWISE multiplicities differ" + ); +} From 1f6bae7ae7bb2cdc7eadff21c9f756f6473d59ed Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 07:37:36 -0300 Subject: [PATCH 56/63] Keep the composition parts for the Open pass The Open pass rebuilt every table in full to serve its openings: round 1 for the trace commitments, round 2 for the composition commitment and round 3 for the out-of-domain values. The openings need the composition parts over the LDE domain and their tree, not the constraint evaluation that produces them, and round 3 not at all. With A1_KEEP_COMPOSITION set the fold pass keeps each table's composition parts, which it had already computed and was dropping, and the Open pass rebuilds round 1, commits the kept parts again and opens. Ethrex on 96 cores: pass 5 80.9 -> 57.0 s, the batched proof 194.9 -> 171.0 s, for 8.6 GB more of peak heap (30.4 -> 39.1 GB). Off by default: it is the memory-for-time trade the approach otherwise avoids, priced here at 2.8 s per GB. --- crypto/stark/src/prover.rs | 56 +++++++++++++++++++++++++++ prover/src/logup_phase.rs | 52 +++++++++++++++++++++++-- prover/src/tests/batched_fri_tests.rs | 2 + 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 8ab1d683b..13ec7a51a 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -728,6 +728,10 @@ pub struct TableDeep { pub trace_ood: Table, pub trace_ood_next: Table, pub parts_ood: Vec>, + /// The composition parts over the LDE domain, kept for the Open pass when + /// the caller trades memory for not recomputing them. Round 2 is the + /// constraint evaluation, the costliest thing a rebuild does. + pub composition_lde: Option>>>, } /// Source of truth for a table whose *trace* has been retired. @@ -2109,6 +2113,7 @@ pub trait IsStarkProver< challenges: &[FieldElement], transcript: &mut (impl IsStarkTranscript + Clone), known_main: Option, + keep_composition: bool, ) -> Result, ProvingError> where FieldElement: AsBytes + math::traits::ByteConversion, @@ -2190,6 +2195,8 @@ pub trait IsStarkProver< trace_ood, trace_ood_next, parts_ood: round_3_result.composition_poly_parts_ood_evaluation.clone(), + composition_lde: keep_composition + .then(|| std::mem::take(&mut round_2_result.lde_composition_poly_evaluations)), }) } @@ -2320,6 +2327,55 @@ pub trait IsStarkProver< Ok(out) } + /// [`open_for_table`](Self::open_for_table) with the composition parts the + /// fold pass kept: round 1 is rebuilt for the trace commitments, the + /// composition commitment is rebuilt from the evaluations, and rounds 2 and + /// 3 — the constraint evaluation and the out-of-domain values — are skipped. + fn open_for_table_kept( + air: &dyn AIR, + trace: &mut TraceTable, + challenges: &[FieldElement], + transcript: &mut (impl IsStarkTranscript + Clone), + iotas: &[usize], + composition_lde: Vec>>, + ) -> Result, ProvingError> + where + FieldElement: AsBytes + math::traits::ByteConversion, + FieldElement: AsBytes + math::traits::ByteConversion, + PI: Send + Sync + Clone, + { + let (domain, _twiddles) = domain_and_twiddles(air, trace.num_rows()); + #[cfg(feature = "instruments")] + let __o1 = crate::instruments::span("a1_open_r1"); + let round_1_result = Self::round_1_from_trace(air, trace, challenges, transcript, None)?; + #[cfg(feature = "instruments")] + drop(__o1); + #[cfg(feature = "instruments")] + let __o2 = crate::instruments::span("a1_open_kept_commit"); + let (composition_poly_merkle_tree, composition_poly_root) = + crate::commitment::commit_bit_reversed( + &composition_lde, + crate::commitment::ROWS_PER_LEAF, + ) + .ok_or(ProvingError::EmptyCommitment)?; + let round_2_result = Round2 { + lde_composition_poly_evaluations: composition_lde, + composition_poly_merkle_tree, + composition_poly_root, + #[cfg(feature = "cuda")] + gpu_composition_tree: None, + }; + #[cfg(feature = "instruments")] + drop(__o2); + #[cfg(feature = "instruments")] + let __od = crate::instruments::span("a1_open_deep"); + let out = + Self::open_deep_composition_poly(&domain, &round_1_result, &round_2_result, iotas); + #[cfg(feature = "instruments")] + drop(__od); + Ok(out) + } + /// One FRI over a group's finished accumulator. /// /// The members were folded in as they were produced, so by here the group diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 96b562c9e..4d957d498 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -335,10 +335,20 @@ pub struct Batched { /// table depends on every table folded before it, so the verifier has to /// replay this order and the proof carries it. pub fold_order: Vec, + /// Per table, by AIR index: its composition parts over the LDE domain when + /// the fold pass kept them (`A1_KEEP_COMPOSITION`), taken by the Open pass. + pub composition_ldes: Vec>>, pub resident: Resident, } type Deep = stark::prover::TableDeep; +type CompositionLde = Vec>>; + +/// Whether the fold pass keeps each table's composition parts for the Open +/// pass: memory for the constraint evaluation the rebuild would repeat. +fn keep_composition() -> bool { + std::env::var("A1_KEEP_COMPOSITION").is_ok_and(|v| v != "0") +} /// What the fold walk carries between batches. /// /// The seed and the accumulators are advanced sequentially — the coefficient of @@ -353,6 +363,7 @@ struct FoldState { /// so a verifier can replay the same sequence of coefficients. order: Vec, tables: Vec<(usize, TablePublic)>, + kept: Vec<(usize, CompositionLde)>, } type Deeps = std::sync::Mutex; @@ -415,8 +426,11 @@ fn deep_batch( } /// Absorb a table, draw its coefficient, add it to its group, drop it. -fn fold_one(state: &mut FoldState, idx: usize, deep: Deep) { +fn fold_one(state: &mut FoldState, idx: usize, mut deep: Deep) { type P = stark::prover::Prover; + if let Some(lde) = deep.composition_lde.take() { + state.kept.push((idx, lde)); + } let coefficient =

>::fold_coefficient(&mut state.seed, &deep); let entry = state .acc @@ -461,6 +475,7 @@ fn deep_of( challenges, transcript, known_main, + keep_composition(), ) .map_err(|e| format!("{e:?}")) } @@ -503,6 +518,7 @@ pub fn run_batched( acc: std::collections::BTreeMap::new(), order: Vec::new(), tables: Vec::new(), + kept: Vec::new(), }); let mut resident = std::thread::scope(|s| { let mut visitor = BuildDeep::new(s, &chunk_airs, challenge, &done); @@ -566,6 +582,7 @@ pub fn run_batched( acc, order: fold_order, mut tables, + kept, } = done.into_inner().expect("fold state"); if tables.len() != n { return Err(Error::Prover(format!( @@ -601,12 +618,19 @@ pub fn run_batched( } tables.sort_by_key(|(idx, _)| *idx); + let composition_ldes: Vec>> = (0..tables.len()) + .map(|_| std::sync::Mutex::new(None)) + .collect(); + for (idx, lde) in kept { + *composition_ldes[idx].lock().expect("kept composition") = Some(lde); + } Ok(Batched { tables: tables.into_iter().map(|(_, t)| t).collect(), fold_order, groups, members, group_of, + composition_ldes, resident, }) } @@ -679,6 +703,7 @@ fn open_batch( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, + take_kept(batched, idx), ) .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; Ok((idx, opening)) @@ -698,10 +723,30 @@ fn open_of( challenges: &[FieldElement], transcript: &mut DefaultTranscript, iotas: &[usize], + kept: Option, ) -> Result { type P = stark::prover::Prover; -

>::open_for_table(air, &(), trace, challenges, transcript, iotas) - .map_err(|e| format!("{e:?}")) + match kept { + Some(lde) =>

>::open_for_table_kept( + air, trace, challenges, transcript, iotas, lde, + ), + None =>

>::open_for_table( + air, + &(), + trace, + challenges, + transcript, + iotas, + ), + } + .map_err(|e| format!("{e:?}")) +} + +fn take_kept(batched: &Batched, idx: usize) -> Option { + batched + .composition_ldes + .get(idx) + .and_then(|slot| slot.lock().expect("kept composition").take()) } impl Visitor for OpenTables<'_> { @@ -755,6 +800,7 @@ pub fn run_open( &challenge.challenges, &mut transcript, iotas_of(batched, idx)?, + take_kept(batched, idx), ) .map_err(|e| Error::Prover(format!("open pass: table {idx}: {e}")))?; Ok((idx, opening)) diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 570062647..25dda5c81 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -72,6 +72,7 @@ fn a_batch_of_one_matches_the_unbatched_fri() { &challenge.challenges, &mut fork, None, + false, ) .expect("deep"); @@ -157,6 +158,7 @@ fn alpha_moves_when_any_table_moves() { trace_ood: Table::new(vec![FieldElement::::from(seed + 1)], 1), trace_ood_next: Table::new(vec![FieldElement::::from(seed + 2)], 1), parts_ood: vec![FieldElement::::from(seed + 3)], + composition_lde: None, }; let pre_fork = DefaultTranscript::::new(&[7, 7, 7]); From 43700b1a14711c80778cceedff8508de2538ecb5 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 08:03:45 -0300 Subject: [PATCH 57/63] Batch the resident tables after the walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the walk is over, the tables it could not retire — BITWISE, DECODE, the accumulators, REGISTER, HALT and one PAGE per page — were proved, folded and opened one at a time, each with only its own parallelism, most of them far too small to fill the machine. The three passes now build them as one parallel batch; the fold keeps its sequential order, which the proof records, and only the codewords are computed together. --- prover/src/logup_phase.rs | 84 +++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 4d957d498..c6880ec36 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -235,6 +235,7 @@ fn assemble( resident: &mut Resident, challenge: &Challenge, ) -> Result>, Error> { + use rayon::prelude::*; let order = &challenge.order; let airs = &challenge.airs; @@ -278,17 +279,32 @@ fn assemble( (&airs.hint, &mut resident.accumulated.hint), (&airs.register, &mut resident.register), ]; - for (idx, (air, trace)) in fixed.into_iter().enumerate() { - slots[idx] = Some(build(idx, air, trace)?); - } + // Independent tables, most of them small: one at a time would leave the + // machine idle after the walk. + let mut jobs: Vec<( + usize, + &crate::VmAir, + &mut TraceTable, + )> = fixed + .into_iter() + .enumerate() + .map(|(idx, (air, trace))| (idx, air, trace)) + .collect(); if airs.include_halt { - slots[NUM_FIXED_AIRS] = Some(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); + jobs.push((NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)); } for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { let idx = order .page_index(i) .ok_or_else(|| Error::Prover(format!("logup phase: page {i} is not in the layout")))?; - slots[idx] = Some(build(idx, air, trace)?); + jobs.push((idx, air, trace)); + } + let built: Vec<(usize, StarkProof)> = jobs + .into_par_iter() + .map(|(idx, air, trace)| build(idx, air, trace).map(|proof| (idx, proof))) + .collect::>()?; + for (idx, proof) in built { + slots[idx] = Some(proof); } slots @@ -530,12 +546,12 @@ pub fn run_batched( let airs = &challenge.airs; let n = order.len(); { + use rayon::prelude::*; let mut state = done.lock().expect("fold state"); - let build = |state: &mut FoldState, - idx: usize, + let build = |idx: usize, air: &crate::VmAir, trace: &mut TraceTable| - -> Result<(), Error> { + -> Result<(usize, Deep), Error> { let mut transcript = fork(&challenge.transcript, idx, n); let deep = deep_of( air.as_ref(), @@ -545,8 +561,7 @@ pub fn run_batched( challenge.roots.get(idx).cloned(), ) .map_err(|e| Error::Prover(format!("batched phase: table {idx}: {e}")))?; - fold_one(state, idx, deep); - Ok(()) + Ok((idx, deep)) }; let fixed: [( &crate::VmAir, @@ -563,17 +578,32 @@ pub fn run_batched( (&airs.hint, &mut resident.accumulated.hint), (&airs.register, &mut resident.register), ]; - for (idx, (air, trace)) in fixed.into_iter().enumerate() { - build(&mut state, idx, air, trace)?; - } + // The codewords are independent and computed in parallel; the fold + // itself is sequential and keeps this order, which the proof records. + let mut jobs: Vec<( + usize, + &crate::VmAir, + &mut TraceTable, + )> = fixed + .into_iter() + .enumerate() + .map(|(idx, (air, trace))| (idx, air, trace)) + .collect(); if airs.include_halt { - build(&mut state, NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?; + jobs.push((NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)); } for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { let idx = order.page_index(i).ok_or_else(|| { Error::Prover(format!("batched phase: page {i} is not in the layout")) })?; - build(&mut state, idx, air, trace)?; + jobs.push((idx, air, trace)); + } + let deeps: Vec<(usize, Deep)> = jobs + .into_par_iter() + .map(|(idx, air, trace)| build(idx, air, trace)) + .collect::>()?; + for (idx, deep) in deeps { + fold_one(&mut state, idx, deep); } } @@ -820,17 +850,31 @@ pub fn run_open( (&airs.hint, &mut resident.accumulated.hint), (&airs.register, &mut resident.register), ]; - for (idx, (air, trace)) in fixed.into_iter().enumerate() { - opens.push(build(idx, air, trace)?); - } + let mut jobs: Vec<( + usize, + &crate::VmAir, + &mut TraceTable, + )> = fixed + .into_iter() + .enumerate() + .map(|(idx, (air, trace))| (idx, air, trace)) + .collect(); if airs.include_halt { - opens.push(build(NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)?); + jobs.push((NUM_FIXED_AIRS, &airs.halt, &mut resident.halt)); } for (i, (air, trace)) in airs.pages.iter().zip(resident.pages.iter_mut()).enumerate() { let idx = order .page_index(i) .ok_or_else(|| Error::Prover(format!("open pass: page {i} is not in the layout")))?; - opens.push(build(idx, air, trace)?); + jobs.push((idx, air, trace)); + } + { + use rayon::prelude::*; + opens.extend( + jobs.into_par_iter() + .map(|(idx, air, trace)| build(idx, air, trace)) + .collect::, _>>()?, + ); } opens.sort_by_key(|(idx, _)| *idx); From ed06112852a35cf6a8433d624e8ad6d4ba1962a4 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 08:46:30 -0300 Subject: [PATCH 58/63] Verify the batched proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched proof had a transcript replay and nothing behind it. This is the rest: every table's rounds after round 1, its openings at its group's query indices and its DEEP evaluations there, the fold of those evaluations with the replayed coefficients, and one FRI per height group verified from that first layer down to the final polynomial; on the VM side, the statement, the AIRs rebuilt from the layout the proof now carries, the preprocessed roots against the AIRs' constants and the LogUp bus balance against the public output. The per-table steps are the ordinary verifier's, run on a view of each table's data with the FRI left empty, so the replay of rounds 2 and 3 is split out of replay_rounds_after_round_1 and shared. The group FRI runs the ordinary query checks on a view carrying the group's layers, fed the accumulated DEEP values instead of a single table's. Two tests: the proof verifies, and each part the verifier reads — an out-of-domain value, an opening, the fold order, a final polynomial coefficient, a query index, the public output, the layout — is caught when changed on its own while the untouched proof still passes. trace-build --through batched --verify runs it on real workloads. --- bin/cli/src/main.rs | 11 + crypto/stark/src/batched_verifier.rs | 360 ++++++++++++++++++++++++++ crypto/stark/src/lib.rs | 1 + crypto/stark/src/verifier.rs | 59 ++++- prover/src/batched_verifier.rs | 186 ++++++++++++- prover/src/logup_phase.rs | 6 + prover/src/tests/batched_fri_tests.rs | 108 ++++++++ 7 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 crypto/stark/src/batched_verifier.rs diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 24442e8de..00f17d493 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1173,6 +1173,17 @@ fn run_approach_1( println!(" pass 5 (open) {:>8.2}s", t_open.as_secs_f64()); report_span_totals(); report_batched_size(&proof, tables, groups); + if verify { + let started = std::time::Instant::now(); + match prover::batched_verifier::verify(&proof, elf_bytes, options) { + Ok(true) => println!( + "Batched proof verifies: {tables} tables in {groups} groups, {:.3}s", + started.elapsed().as_secs_f64() + ), + Ok(false) => return Err("batched proof REJECTED by the verifier".into()), + Err(e) => return Err(format!("batched proof verification error: {e}")), + } + } return Ok((tables, None)); } diff --git a/crypto/stark/src/batched_verifier.rs b/crypto/stark/src/batched_verifier.rs new file mode 100644 index 000000000..8d47f103d --- /dev/null +++ b/crypto/stark/src/batched_verifier.rs @@ -0,0 +1,360 @@ +//! Verification of a batched proof: every table's rounds after round 1, and one +//! FRI per height group over the fold of their DEEP codewords. +//! +//! The per-table steps are the ordinary verifier's, run on a view of each +//! table's data with the FRI left empty; the fold coefficients and the group +//! FRI challenges are replayed from the shared seed the prover used, and the +//! group's first FRI layer is the coefficient-weighted sum of the tables' DEEP +//! evaluations at the group's query indices. + +use crate::config::Commitment; +use crate::domain::{VerifierDomain, new_verifier_domain}; +use crate::proof::stark::StarkProof; +use crate::proof::view::StarkProofView; +use crate::prover::GroupFri; +use crate::table::Table; +use crate::traits::AIR; +use crate::verifier::{Challenges, IsStarkVerifier, RoundsChallenges, Verifier}; +use crypto::fiat_shamir::is_transcript::IsStarkTranscript; +use log::error; +use math::field::element::FieldElement; +use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; +use math::traits::AsBytes; + +/// One height group as the verifier sees it. +pub struct BatchedGroup<'a, E: IsField> { + /// Rows of every member's trace: they share the domain or they could not + /// have been folded together. + pub trace_rows: usize, + pub fri: &'a GroupFri, +} + +struct GroupReplay { + domain: VerifierDomain, + layout: crate::fri::terminal::FriFoldLayout, + zetas: Vec>, + iotas: Vec, + acc: Vec>, + acc_sym: Vec>, +} + +/// Verify the rounds after round 1 of every table and each group's FRI. +/// +/// `transcript` is the shared transcript right after the LogUp challenges: the +/// state every table's fork and the fold seed start from. `tables[i]` carries +/// table `i`'s round-1 roots, round-3 data and openings; its FRI fields are +/// ignored. `fold_order` is the order the prover drew the coefficients in and +/// `groups` the order it ran the FRIs in. +#[allow(clippy::too_many_arguments)] +pub fn verify_batched( + airs: &[&dyn AIR], + public_inputs: &[PI], + tables: &[StarkProof], + group_of: &[usize], + groups: &[BatchedGroup<'_, FieldExtension>], + fold_order: &[usize], + transcript: &(impl IsStarkTranscript + Clone), + rap_challenges: &[FieldElement], +) -> bool +where + Field: IsSubFieldOf + IsFFTField + Send + Sync, + FieldExtension: IsField + Send + Sync, + Field::BaseType: math::field::element::NativeArchived, + FieldExtension::BaseType: math::field::element::NativeArchived, + PI: rkyv::Archive + Clone, + ::Archived: rkyv::Deserialize, + FieldElement: AsBytes + Sync + Send, + FieldElement: AsBytes + Sync + Send, +{ + type V = Verifier; + let n = airs.len(); + if tables.len() != n || public_inputs.len() != n || group_of.len() != n || fold_order.len() != n + { + error!("batched: {} AIRs against {} tables", n, tables.len()); + return false; + } + if groups.is_empty() || group_of.iter().any(|&g| g >= groups.len()) { + error!("batched: a table names a group the proof does not have"); + return false; + } + let mut seen = vec![false; n]; + for &idx in fold_order { + if idx >= n || std::mem::replace(&mut seen[idx], true) { + error!("batched: fold order is not a permutation of the tables"); + return false; + } + } + let Some(first_air) = airs.first() else { + return false; + }; + let num_queries = first_air.options().fri_number_of_queries; + let grinding_factor = first_air.context().proof_options.grinding_factor; + + // Every table's domain is its group's. + for (idx, (table, &g)) in tables.iter().zip(group_of).enumerate() { + if table.trace_length == 0 || table.trace_length != groups[g].trace_rows { + error!("batched: table {idx} does not live on its group's domain"); + return false; + } + } + + // The fold coefficients, from the seed, in the prover's order. + let mut seed = transcript.clone(); + let mut coefficients = vec![FieldElement::::zero(); n]; + for &idx in fold_order { + let t = &tables[idx]; + if let Some(ref bpi) = t.bus_public_inputs { + seed.append_field_element(&bpi.table_contribution); + } + seed.append_bytes(&t.composition_poly_root); + for ood in [&t.trace_ood_evaluations, &t.trace_ood_next_evaluations] { + for col_idx in 0..ood.width { + for row_idx in 0..ood.height { + seed.append_field_element(&ood.get_row(row_idx)[col_idx]); + } + } + } + for elem in t.composition_poly_parts_ood_evaluation.iter() { + seed.append_field_element(elem); + } + coefficients[idx] = seed.sample_field_element(); + } + + // Each group's FRI challenges, from the same seed, in the prover's order. + let mut replays: Vec> = Vec::with_capacity(groups.len()); + for (g, group) in groups.iter().enumerate() { + let Some(member) = group_of.iter().position(|&h| h == g) else { + error!("batched: group {g} has no member"); + return false; + }; + let domain = new_verifier_domain(airs[member], group.trace_rows); + let layout = V::::fri_termination_params(airs[member], &domain); + let fri = group.fri; + if fri.layer_roots.len() != layout.num_committed + || fri.final_poly_coeffs.len() != (1usize << layout.effective_k) + || fri.query_list.len() != num_queries + || fri.iotas.len() != num_queries + || fri.query_list.iter().any(|q| { + q.layers_auth_paths.len() != layout.num_committed + || q.layers_evaluations_sym.len() != layout.num_committed + }) + { + error!("batched: group {g}'s FRI has the wrong shape"); + return false; + } + let mut zetas: Vec> = fri + .layer_roots + .iter() + .map(|root| { + let zeta = seed.sample_field_element(); + seed.append_bytes(root); + zeta + }) + .collect(); + if layout.total_folds > 0 { + zetas.push(seed.sample_field_element()); + } + for c in fri.final_poly_coeffs.iter() { + seed.append_field_element(c); + } + if grinding_factor > 0 { + let grinding_seed = seed.state(); + let Some(nonce) = fri.nonce else { + error!("batched: group {g} has no grinding nonce"); + return false; + }; + if !crate::grinding::is_valid_nonce(&grinding_seed, nonce, grinding_factor) { + error!("batched: group {g}'s grinding nonce is not valid"); + return false; + } + seed.append_bytes(&nonce.to_be_bytes()); + } + let iotas = + V::::sample_query_indexes(num_queries, &domain, &mut seed); + if iotas != fri.iotas { + error!("batched: group {g}'s query indices are not the transcript's"); + return false; + } + replays.push(GroupReplay { + domain, + layout, + zetas, + iotas, + acc: vec![FieldElement::zero(); num_queries], + acc_sym: vec![FieldElement::zero(); num_queries], + }); + } + + // Every table: rounds 2 and 3 against its fork, its openings at the group's + // indices, and its DEEP evaluations there, folded into the group's. + for (idx, ((air, table), &g)) in airs.iter().zip(tables).zip(group_of).enumerate() { + let view = StarkProofView::Owned(table); + let mut fork = transcript.clone(); + if n > 1 { + fork.append_bytes(&(idx as u64).to_le_bytes()); + } + if let Some(ref root) = table.lde_trace_aux_merkle_root { + fork.append_bytes(root); + } + if let Some(ref bpi) = table.bus_public_inputs { + fork.append_field_element(&bpi.table_contribution); + } + let domain = new_verifier_domain(*air, table.trace_length); + if table.composition_poly_parts_ood_evaluation.len() + != air.composition_poly_degree_bound(table.trace_length) / table.trace_length + || !V::::ood_blocks_well_formed(*air, view) + || !V::::trace_opening_widths_well_formed( + *air, + view, + num_queries, + ) + { + error!("batched: table {idx}'s blocks or openings are malformed"); + return false; + } + let layout = V::::ood_layout(*air); + let RoundsChallenges { + z, + boundary_coeffs, + transition_coeffs, + trace_term_coeffs, + gammas, + } = V::::replay_rounds_2_and_3( + *air, + view, + &public_inputs[idx], + &domain, + &mut fork, + rap_challenges, + &layout, + ); + let replay = &replays[g]; + let challenges = Challenges { + z, + boundary_coeffs, + transition_coeffs, + trace_term_coeffs, + gammas, + zetas: replay.zetas.clone(), + iotas: replay.iotas.clone(), + rap_challenges: rap_challenges.to_vec(), + grinding_seed: [0u8; 32], + }; + let ood_current = view.trace_ood_evaluations(); + let ood_next = view.trace_ood_next_evaluations(); + let ood_full = layout.reconstruct_full( + ood_current.row_major_data(), + ood_current.width(), + ood_next.row_major_data(), + ); + if !V::::step_2_verify_claimed_composition_polynomial( + *air, + view, + &public_inputs[idx], + &domain, + &challenges, + &ood_full, + layout.step_size(), + ) { + error!("batched: table {idx} fails the out-of-domain consistency check"); + return false; + } + if !V::::step_4_verify_trace_and_composition_openings( + view, + &challenges, + ) { + error!("batched: table {idx}'s openings do not authenticate"); + return false; + } + let Some((evals, evals_sym)) = + V::::reconstruct_deep_composition_poly_evaluations_for_all_queries( + &challenges, + &domain, + view, + &ood_full, + layout.next_row_cols(), + layout.step_size(), + ) + else { + error!("batched: table {idx}'s DEEP evaluations cannot be reconstructed"); + return false; + }; + let coefficient = &coefficients[idx]; + let replay = &mut replays[g]; + for (acc, eval) in replay.acc.iter_mut().zip(evals.iter()) { + *acc = &*acc + coefficient * eval; + } + for (acc, eval) in replay.acc_sym.iter_mut().zip(evals_sym.iter()) { + *acc = &*acc + coefficient * eval; + } + } + + // Each group's FRI, from the folded first layer down to the final polynomial. + for (g, (group, replay)) in groups.iter().zip(replays.iter()).enumerate() { + let member = group_of + .iter() + .position(|&h| h == g) + .expect("checked above"); + let fri = group.fri; + let synthetic = StarkProof:: { + trace_length: group.trace_rows, + lde_trace_main_merkle_root: Commitment::default(), + lde_trace_aux_merkle_root: None, + lde_trace_precomputed_merkle_root: None, + trace_ood_evaluations: Table::new(Vec::new(), 0), + trace_ood_next_evaluations: Table::new(Vec::new(), 0), + composition_poly_root: Commitment::default(), + composition_poly_parts_ood_evaluation: Vec::new(), + fri_layers_merkle_roots: fri.layer_roots.clone(), + fri_final_poly_coeffs: fri.final_poly_coeffs.clone(), + query_list: fri.query_list.clone(), + deep_poly_openings: Vec::new(), + nonce: fri.nonce, + bus_public_inputs: None, + public_inputs: public_inputs[member].clone(), + }; + let view = StarkProofView::Owned(&synthetic); + let terminal_offset = replay + .domain + .coset_offset + .pow(1u64 << replay.layout.total_folds); + let terminal_codeword = + crate::fri::terminal::terminal_codeword_from_coeffs::( + &fri.final_poly_coeffs, + &terminal_offset, + replay.layout.terminal_len, + ); + let mut inverses: Vec> = replay + .iotas + .iter() + .map(|&iota| { + V::::query_challenge_to_evaluation_point( + iota, + false, + &replay.domain, + ) + }) + .collect(); + if FieldElement::inplace_batch_inverse(&mut inverses).is_err() { + error!("batched: group {g} has a query at a zero point"); + return false; + } + let ok = (0..num_queries).zip(inverses).all(|(i, inv)| { + V::::verify_query_and_sym_openings( + view, + &replay.zetas, + replay.iotas[i], + view.query(i), + inv, + &replay.acc[i], + &replay.acc_sym[i], + &terminal_codeword, + ) + }); + if !ok { + error!("batched: group {g}'s FRI does not verify"); + return false; + } + } + true +} diff --git a/crypto/stark/src/lib.rs b/crypto/stark/src/lib.rs index 6f8e7c82e..b7b6b2605 100644 --- a/crypto/stark/src/lib.rs +++ b/crypto/stark/src/lib.rs @@ -3,6 +3,7 @@ #[cfg(all(target_arch = "wasm32", feature = "disk-spill"))] compile_error!("the `disk-spill` feature requires memmap2, which does not compile on wasm32"); +pub mod batched_verifier; #[cfg(feature = "debug-checks")] pub mod bus_debug; pub mod commitment; diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 44add9c21..7600060c5 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -84,6 +84,16 @@ where pub grinding_seed: [u8; 32], } +/// The challenges of rounds 2 and 3 alone: what a table's fork yields before +/// any FRI, which is where a batched proof parts ways with a per-table one. +pub struct RoundsChallenges { + pub z: FieldElement, + pub boundary_coeffs: Vec>, + pub transition_coeffs: Vec>, + pub trace_term_coeffs: Vec>>, + pub gammas: Vec>, +} + pub type DeepPolynomialEvaluations = (Vec>, Vec>); /// Deep-composition sums that are identical across all FRI queries of a @@ -1446,15 +1456,19 @@ pub trait IsStarkVerifier< /// Replays rounds 2, 3 and 4 of the protocol for a given proof, assuming round 1 has /// already been replayed and the RAP challenges are known. - fn replay_rounds_after_round_1( + /// Rounds 2 and 3 of a table's transcript: the constraint coefficients, the + /// out-of-domain point and the DEEP coefficients. The per-table proof goes + /// on to its own FRI from here; the batched proof samples its fold + /// coefficient instead. + fn replay_rounds_2_and_3( air: &dyn AIR, proof: StarkProofView<'_, Field, FieldExtension, PI>, public_inputs: &PI, domain: &VerifierDomain, transcript: &mut impl IsStarkTranscript, - rap_challenges: Vec>, + rap_challenges: &[FieldElement], layout: &crate::ood::OodLayout, - ) -> Challenges + ) -> RoundsChallenges where FieldElement: AsBytes, FieldElement: AsBytes, @@ -1475,7 +1489,7 @@ pub trait IsStarkVerifier< let num_boundary_constraints = air .boundary_constraints( public_inputs, - &rap_challenges, + rap_challenges, bus_public_inputs.as_ref(), trace_length, ) @@ -1549,6 +1563,43 @@ pub trait IsStarkVerifier< let gammas = deep_composition_coefficients; // FRI commit phase + RoundsChallenges { + z, + boundary_coeffs, + transition_coeffs, + trace_term_coeffs, + gammas, + } + } + + fn replay_rounds_after_round_1( + air: &dyn AIR, + proof: StarkProofView<'_, Field, FieldExtension, PI>, + public_inputs: &PI, + domain: &VerifierDomain, + transcript: &mut impl IsStarkTranscript, + rap_challenges: Vec>, + layout: &crate::ood::OodLayout, + ) -> Challenges + where + FieldElement: AsBytes, + FieldElement: AsBytes, + { + let RoundsChallenges { + z, + boundary_coeffs, + transition_coeffs, + trace_term_coeffs, + gammas, + } = Self::replay_rounds_2_and_3( + air, + proof, + public_inputs, + domain, + transcript, + &rap_challenges, + layout, + ); let merkle_roots = proof.fri_layers_merkle_roots(); let mut zetas = merkle_roots .iter() diff --git a/prover/src/batched_verifier.rs b/prover/src/batched_verifier.rs index 0bef9a21d..7c9155e4e 100644 --- a/prover/src/batched_verifier.rs +++ b/prover/src/batched_verifier.rs @@ -13,11 +13,16 @@ use crypto::fiat_shamir::default_transcript::DefaultTranscript; use crypto::fiat_shamir::is_transcript::IsTranscript; +use log::error; use math::field::element::FieldElement; +use stark::batched_verifier::BatchedGroup; +use stark::proof::options::ProofOptions; +use stark::proof::stark::StarkProof; use crate::Error; use crate::logup_phase::BatchedProof; -use crate::tables::types::GoldilocksExtension; +use crate::tables::trace_builder::Traces; +use crate::tables::types::{GoldilocksExtension, GoldilocksField}; /// Every challenge a batched proof's verification needs, derived from the proof. pub struct Replay { @@ -106,3 +111,182 @@ pub fn replay( iotas: Vec::new(), }) } + +/// Verify a batched proof of `elf_bytes`. +/// +/// The VM half — the statement, the AIRs rebuilt from the layout the proof +/// declares, the preprocessed roots, the LogUp bus balance — is here; every +/// table's rounds after round 1 and each group's FRI are +/// [`stark::batched_verifier::verify_batched`]. +pub fn verify( + proof: &BatchedProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, +) -> Result { + let table_counts = &proof.table_counts; + table_counts.validate()?; + let n = proof.tables.len(); + if proof.openings.len() != n || proof.group_of.len() != n || proof.fold_order.len() != n { + return Err(Error::InvalidTableCounts(format!( + "batched proof: {n} tables, {} openings, {} group slots, {} fold entries", + proof.openings.len(), + proof.group_of.len(), + proof.fold_order.len() + ))); + } + let elf = executor::elf::Elf::load(elf_bytes) + .map_err(|e| Error::Prover(format!("batched verify: ELF: {e}")))?; + let num_private_input_pages = proof + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + let max_pages = crate::tables::page::max_private_input_pages(); + if num_private_input_pages > max_pages { + return Err(Error::InvalidTableCounts(format!( + "num_private_input_pages ({num_private_input_pages}) exceeds max ({max_pages})", + ))); + } + let runtime_page_ranges = + crate::tables::trace_builder::runtime_page_ranges(&proof.page_configs); + let page_configs = Traces::page_configs_from_elf_and_runtime( + &elf, + &runtime_page_ranges, + num_private_input_pages, + n, + )?; + let expected = table_counts.total() + crate::FIXED_TABLE_COUNT + page_configs.len(); + if expected != n { + return Err(Error::InvalidTableCounts(format!( + "table_counts total ({}) + {} fixed + {} pages = {expected}, but the proof has {n} tables", + table_counts.total(), + crate::FIXED_TABLE_COUNT, + page_configs.len(), + ))); + } + let vm_airs = crate::VmAirs::new( + &elf, + proof_options, + false, + &page_configs, + table_counts, + None, + true, + None, + None, + None, + ); + let airs = vm_airs.air_refs(); + if airs.len() != n { + error!("batched verify: {} AIRs for {n} tables", airs.len()); + return Ok(false); + } + + let mut transcript = DefaultTranscript::::new(&[]); + crate::statement::absorb_statement( + &mut transcript, + crate::statement::StatementKind::Monolithic, + elf_bytes, + &proof.public_output, + table_counts, + num_private_input_pages, + &runtime_page_ranges, + proof_options.fri_final_poly_log_degree, + ); + // Round 1, in AIR order. A preprocessed table's precomputed root is the + // AIR's constant, not the prover's word. + for (idx, (air, t)) in airs.iter().zip(proof.tables.iter()).enumerate() { + if air.is_preprocessed() { + let expected = air.precomputed_commitment(); + match t.precomputed_root { + Some(actual) if actual == expected => {} + _ => { + error!("batched verify: table {idx}'s precomputed root is not the AIR's"); + return Ok(false); + } + } + transcript.append_bytes(&expected); + } else if t.precomputed_root.is_some() { + error!("batched verify: table {idx} carries a precomputed root it should not"); + return Ok(false); + } + transcript.append_bytes(&t.main_root); + } + let logup: Vec> = (0..stark::lookup::LOGUP_NUM_CHALLENGES) + .map(|_| transcript.sample_field_element()) + .collect(); + + // Every interacting table contributes to the bus, no other does, and the + // contributions balance against the public output. + for (idx, (air, t)) in airs.iter().zip(proof.tables.iter()).enumerate() { + if air.has_trace_interaction() != t.bus_public_inputs.is_some() { + error!("batched verify: table {idx}'s bus inputs do not match its AIR"); + return Ok(false); + } + } + let Some(expected_balance) = crate::compute_commit_bus_offset( + &proof.public_output, + 0, + &logup[0], + &logup[stark::lookup::LOGUP_CHALLENGE_ALPHA], + ) else { + error!("batched verify: the public output has no bus balance"); + return Ok(false); + }; + let mut total = FieldElement::::zero(); + for (air, t) in airs.iter().zip(proof.tables.iter()) { + if air.has_trace_interaction() + && let Some(ref bpi) = t.bus_public_inputs + { + total += bpi.table_contribution; + } + } + if total != expected_balance { + error!("batched verify: LogUp bus does not balance"); + return Ok(false); + } + + // Each table as the ordinary verifier reads one, with the FRI left empty. + let tables: Vec> = proof + .tables + .iter() + .zip(proof.openings.iter()) + .map(|(t, opening)| StarkProof { + trace_length: t.trace_rows, + lde_trace_main_merkle_root: t.main_root, + lde_trace_aux_merkle_root: t.aux_root, + lde_trace_precomputed_merkle_root: t.precomputed_root, + trace_ood_evaluations: t.trace_ood.clone(), + trace_ood_next_evaluations: t.trace_ood_next.clone(), + composition_poly_root: t.composition_poly_root, + composition_poly_parts_ood_evaluation: t.parts_ood.clone(), + fri_layers_merkle_roots: Vec::new(), + fri_final_poly_coeffs: Vec::new(), + query_list: Vec::new(), + deep_poly_openings: opening.clone(), + nonce: None, + bus_public_inputs: t.bus_public_inputs.clone(), + public_inputs: (), + }) + .collect(); + let blowup = proof_options.blowup_factor as usize; + let groups: Vec> = proof + .groups + .iter() + .map(|(lde_size, fri)| BatchedGroup { + trace_rows: lde_size / blowup, + fri, + }) + .collect(); + let public_inputs = vec![(); n]; + Ok(stark::batched_verifier::verify_batched( + &airs, + &public_inputs, + &tables, + &proof.group_of, + &groups, + &proof.fold_order, + &transcript, + &logup, + )) +} diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index c6880ec36..ac78659b5 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -351,6 +351,8 @@ pub struct Batched { /// table depends on every table folded before it, so the verifier has to /// replay this order and the proof carries it. pub fold_order: Vec, + /// The chunk layout, which the verifier needs to rebuild the AIRs. + pub table_counts: crate::TableCounts, /// Per table, by AIR index: its composition parts over the LDE domain when /// the fold pass kept them (`A1_KEEP_COMPOSITION`), taken by the Open pass. pub composition_ldes: Vec>>, @@ -660,6 +662,7 @@ pub fn run_batched( groups, members, group_of, + table_counts: challenge.order.counts().clone(), composition_ldes, resident, }) @@ -903,6 +906,8 @@ pub fn run_open( pub struct BatchedProof { /// Per table, in AIR order. pub tables: Vec, + /// The chunk layout the tables follow; the verifier rebuilds the AIRs from it. + pub table_counts: crate::TableCounts, /// Per table, in AIR order: its rows at its group's indices. pub openings: Vec, /// Which group each table belongs to. @@ -931,6 +936,7 @@ pub fn assemble_batched_proof(batched: Batched, opened: Opened) -> Result ( + Vec, + stark::proof::options::ProofOptions, + crate::logup_phase::BatchedProof, +) { + let elf_bytes = crate::test_utils::asm_elf_bytes("fib_iterative_160k"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let max_rows = MaxRowsConfig { + cpu: 1 << 15, + memw: 1 << 10, + load: 1 << 10, + branch: 1 << 12, + ..Default::default() + }; + let proof_options = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup 2 is valid"); + let committed = crate::commit_phase::run_to_end(&elf, &[], &max_rows, &proof_options) + .expect("commit phase"); + let challenge = crate::challenge_phase::run(&committed, &elf, &elf_bytes, &proof_options) + .expect("challenge phase"); + drop(committed); + let batched = crate::logup_phase::run_batched(&elf, &[], &max_rows, &proof_options, &challenge) + .expect("batched phase"); + let opened = + crate::logup_phase::run_open(&elf, &[], &max_rows, &proof_options, &challenge, &batched) + .expect("open pass"); + let proof = crate::logup_phase::assemble_batched_proof(batched, opened).expect("assemble"); + (elf_bytes, proof_options, proof) +} + +/// The whole thing, from nothing but the proof, the program and the options. +#[test] +fn the_batched_proof_verifies() { + let (elf_bytes, proof_options, proof) = batched_proof_of_fib(); + assert!( + crate::batched_verifier::verify(&proof, &elf_bytes, &proof_options).expect("verify"), + "the batched proof does not verify" + ); +} + +/// Each part the verifier reads, changed on its own, is caught — and the +/// untouched proof still passes afterwards, so it is the change that was caught. +#[test] +fn a_tampered_batched_proof_is_rejected() { + let (elf_bytes, proof_options, mut proof) = batched_proof_of_fib(); + let one = math::field::element::FieldElement::::one(); + let rejected = |proof: &crate::logup_phase::BatchedProof, what: &str| { + assert!( + !matches!( + crate::batched_verifier::verify(proof, &elf_bytes, &proof_options), + Ok(true) + ), + "{what} was not caught" + ); + }; + let accepted = |proof: &crate::logup_phase::BatchedProof| { + assert!( + crate::batched_verifier::verify(proof, &elf_bytes, &proof_options).expect("verify"), + "the untouched proof no longer verifies" + ); + }; + + // A composition part at z. + let orig = proof.tables[0].parts_ood[0]; + proof.tables[0].parts_ood[0] = &orig + &one; + rejected(&proof, "a composition part at z"); + proof.tables[0].parts_ood[0] = orig; + accepted(&proof); + + // An opened value. + let orig = proof.openings[0][0].composition_poly.evaluations[0]; + proof.openings[0][0].composition_poly.evaluations[0] = &orig + &one; + rejected(&proof, "an opened composition value"); + proof.openings[0][0].composition_poly.evaluations[0] = orig; + accepted(&proof); + + // The fold order. + proof.fold_order.swap(0, 1); + rejected(&proof, "a swapped fold order"); + proof.fold_order.swap(0, 1); + accepted(&proof); + + // A group's final polynomial. + let orig = proof.groups[0].1.final_poly_coeffs[0]; + proof.groups[0].1.final_poly_coeffs[0] = &orig + &one; + rejected(&proof, "a final polynomial coefficient"); + proof.groups[0].1.final_poly_coeffs[0] = orig; + accepted(&proof); + + // A group's query index. + proof.groups[0].1.iotas[0] ^= 1; + rejected(&proof, "a query index"); + proof.groups[0].1.iotas[0] ^= 1; + accepted(&proof); + + // The public output, which the statement binds. + proof.public_output.push(0); + rejected(&proof, "a longer public output"); + proof.public_output.pop(); + accepted(&proof); + + // The layout. + proof.table_counts.cpu += 1; + rejected(&proof, "a layout with one more CPU chunk"); + proof.table_counts.cpu -= 1; + accepted(&proof); +} From b6ce5d304215611bfa4e0ded3989d6129d4f33bd Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 12:01:47 -0300 Subject: [PATCH 59/63] Document the prove-and-retire prover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design document for Approach 1, beside the continuations one: what each pass does and keeps, the per-table and batched variants and when each is the right one, every knob, how to run and verify both, the numbers on the ethrex block against the monolithic prover and continuations, where it departs from the spec, the code map, and the rule every change to the walk has to respect — what a retired chunk owes the shared tables is derived when it closes. --- docs/SUMMARY.md | 1 + docs/prove_and_retire_design.md | 227 ++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 docs/prove_and_retire_design.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8ba066462..94d7501b7 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -18,6 +18,7 @@ - [Lookup argument](./cryptography/lookup.md) - [Virtual machine](./virtual_machine/introduction.md) - [Continuations design](./continuations_design.md) +- [Prove-and-retire design](./prove_and_retire_design.md) ## Getting started diff --git a/docs/prove_and_retire_design.md b/docs/prove_and_retire_design.md new file mode 100644 index 000000000..162d87add --- /dev/null +++ b/docs/prove_and_retire_design.md @@ -0,0 +1,227 @@ +# Prove-and-retire prover (Approach 1) — how it works and how to run it + +This is the design and usage document for the prove-and-retire prover — +Approach 1 of the streaming spec (`spec/streaming.typ` at `624998db`), the +sibling of [Continuations design](./continuations_design.md), which is the +spec's Approach 2 ("prove-epoch"). Both are "streaming" in the spec's sense of +bounding the prover's memory; this one does it by re-walking the execution and +retiring tables, continuations by splitting it into epochs. It +covers what the prover does in each of its passes, the two proof formats it can +produce and when each is the right one, every knob, what verifies with what, +the numbers measured on a mainnet ethrex block, where the code lives, and the +correctness rule that every future change to the walk has to respect. + +It is written to be read by a human picking this up cold. + +## 1. The problem and the idea + +A proof of an execution is a LogUp over many tables — the CPU, the memory +tables, the ALU chips, the preprocessed tables (BITWISE, DECODE), the +accelerators, one table per page. The monolithic prover builds every trace, +commits every one (LDE + Merkle), samples **one** LogUp challenge `(z, α)` that +ties all tables together through the bus, and only then proves each table. Until +every round-1 root exists nothing can be dropped, so the peak is the sum of all +tables: **108 GB** for a mainnet ethrex block of 30.5 M cycles. + +Approach 1 accepts redoing work in exchange for not retaining it. The execution +is **walked several times**; each pass proves what it can from a table and +drops the table. What crosses from one pass to the next is small: roots, +challenges, an accumulated codeword. + +## 2. The passes + +"Walk" means re-executing the program and rebuilding the tables chunk by chunk +(`prover/src/pass.rs`, `trace_builder::walk_and_emit_chunks`). Chunked tables +(CPU, MEMW, LOAD, LT, …) are handed to the pass's visitor as soon as a chunk +fills; the tables that cannot be chunked — the preprocessed ones, the +accumulators (KECCAK, ECSM, …), REGISTER, HALT, one PAGE per page — are the +*residents*, handed over when the walk ends. + +| pass | what it does | keeps | drops | +|---|---|---|---| +| **1 Commit** (walk) | per chunk: trace → LDE main → Merkle → root, in pipelined batches of *k* tables; the walk never waits for a batch | 245 roots, the resident traces | traces, LDEs, trees | +| **2 Challenge** | commits the residents (in parallel), absorbs the statement and every root in AIR order, samples **one `(z, α)`**, builds the AIRs once | `(z, α)`, transcript, AIRs | — | +| **3 LogUp** (walk) | per table: aux (LogUp), rounds 2–3 (β, z_ood, γ), composition. **Per-table variant**: continues with its own FRI and openings → one `StarkProof` per table. **Batched variant**: stops at the DEEP codeword | per-table proofs, or DEEP codewords folded per height | everything else | +| **4 Fold** (batched, inside pass 3) | each DEEP codeword is multiplied by a coefficient drawn from the shared transcript and added to the accumulator of its height; then one FRI + grinding + query indices per height group | 13 group FRIs (ethrex), the fold order | codewords | +| **5 Open** (walk, batched) | the query indices of a group are only known after its FRI, and its tables are gone: each table is **rebuilt** (round 1, and round 2 unless `A1_KEEP_COMPOSITION`) and opened at its group's indices | openings → `BatchedProof` | — | + +The residents are proved / folded / opened as one parallel batch after each +walk; one at a time they left the machine idle. + +## 3. The two variants + +### Per-table (`--through logup`) + +Ends at pass 3 and emits the same `MultiProof` the monolithic prover emits — +on ethrex, 245 tables with roots **byte for byte identical** to the monolithic +proof — so the existing verifier (`prover::verify`) checks it unchanged. This is +the drop-in: same proof, same verifier, one fifth of the memory, 1.27× the time. + +### Batched (`--through batched`) + +Continues with passes 4 and 5. Folding every DEEP codeword of one height into a +single codeword leaves **13 FRIs instead of 245** on ethrex. It is a new proof +format (`logup_phase::BatchedProof`) with its own verifier +(`prover::batched_verifier::verify`, §5). It costs 1.96× the monolithic time +(1.75× with the knob below) and buys a proof that is 57% smaller in the same +encoding and verifies in half the time with 40% fewer hashes — which is what +recursion pays for. Choose it when the proof will be verified inside a guest. + +### `A1_KEEP_COMPOSITION=1` (batched only) + +Pass 5 rebuilds each table to open it; the constraint evaluation (round 2) is +the costliest part of that rebuild and its output — the composition parts over +the LDE domain — was already computed in pass 3-4 and dropped. With the knob +the fold pass keeps them and pass 5 only rebuilds round 1 and re-commits the +kept parts: **−24 s for +8.6 GB** on ethrex. Off by default because it is the +memory-for-time trade the approach otherwise avoids. + +## 4. Running it + +```sh +# The Rust ELFs in the repo may predate a syscall the branch decodes: rebuild them. +SYSROOT_DIR=$HOME/.lambda-vm-sysroot make compile-programs-rust +cargo build --release -p cli --features jemalloc-stats # jemalloc-stats prints the peak heap + +E=executor/program_artifacts/rust/ethrex.elf +I=executor/tests/ethrex_mainnet_25368371.bin + +# Per-table: the monolithic proof format, verified with the existing verifier. +./target/release/cli trace-build $E --private-input $I --prove-and-retire --through logup --verify + +# Batched: one FRI per height, verified with the batched verifier. +./target/release/cli trace-build $E --private-input $I --prove-and-retire --through batched --verify +A1_KEEP_COMPOSITION=1 ./target/release/cli trace-build $E --private-input $I --prove-and-retire --through batched --verify + +# Stages, for measuring one pass at a time: walk | commit | challenge | logup | batched +# --output writes the per-table proof (the batched one is not serialized yet). +``` + +Each run prints the pass timings, `Trace build (prove-and-retire): N tables, T s`, +`Peak heap: M MB` and, with `--verify`, `A1 proof verifies: 245 tables` or +`Batched proof verifies: 245 tables in 13 groups`. + +| knob | default | what it does | +|---|---|---| +| `A1_TABLE_PARALLELISM` | cores / 6, at most 16 | tables in flight per batch. Measured flat between 12 and 24; 24 costs 6 GB more | +| `A1_PIPELINE=0` | pipelined | run each batch inline instead of on the consumer thread (diagnostic) | +| `A1_KEEP_COMPOSITION=1` | off | keep the composition parts for the Open pass (§3) | +| `_RJEM_MALLOC_CONF` | — | jemalloc options for experiments; the CLI's own setting is §6 | + +Verification on its own: `cli verify ` for a per-table proof +written with `--output`. With `--features hash-metrics` (#987) every verify +path prints the keccak hashes it did. + +## 5. What verifies, and with what + +| variant | verifier | evidence | +|---|---|---| +| per-table | `prover::verify` (unchanged) | ethrex: "A1 proof verifies: 245 tables"; tables byte-identical to the monolithic proof (`examples/cmp_proofs.rs`) | +| batched | `prover::batched_verifier::verify` + `stark::batched_verifier::verify_batched` | ethrex: "Batched proof verifies: 245 tables in 13 groups"; `a_tampered_batched_proof_is_rejected` | + +The batched verifier replays the transcript from the proof — statement, roots +in AIR order, `(z, α)`, the fold coefficients in the order the proof records, +per group the FRI challenges, grinding and query indices — then, per table, +runs the ordinary verifier's steps on a view of the table's data with the FRI +left empty (out-of-domain consistency, authentication of the openings at the +group's indices, reconstruction of its DEEP value there), and per group sums +those values with the coefficients and verifies the group's FRI from that first +layer down to the final polynomial. On the VM side it rebuilds the AIRs from the +layout the proof declares, checks the preprocessed roots against the AIRs' +constants and the LogUp bus balance against the public output. Its tamper test +changes one thing at a time — an out-of-domain value, an opening, the fold +order, a final-polynomial coefficient, a query index, the public output, the +layout — and requires each to be rejected while the untouched proof passes. + +Still to be reviewed by someone who did not write it: the soundness of the +fold coefficient (sampled per table from the shared seed after absorbing that +table's round-3 data) and of the absorption order. + +## 6. Numbers (ethrex block 25368371, 30.5 M cycles, 96 cores) + +| | peak heap | prove | vs monolithic | proof | verify | verify hashes | +|---|---|---|---|---|---|---| +| monolithic | 107.7 GB | 88.1 s | 1× | 437 MB (838 CBOR) | 6.0 s | 19.8 M | +| **A1 per-table** | **22.9 GB** | **112 s** | **1.27×** | identical | 6.0 s | 19.8 M | +| A1 batched | 31.3 GB | 173 s | 1.96× | 360 MB CBOR (−57%) | 3.1 s | **11.9 M** | +| A1 batched + `KEEP_COMPOSITION` | 39.0 GB | 154 s | 1.75× | same | 3.0 s | 11.9 M | +| continuations 2^22 (CI bench) | 46.6 GB | 110.6 s | 1.26× | 727 MB | 11.5 s | 33.1 M | +| continuations 2^21 | 29.0 GB | 120.7 s | 1.37× | 1 045 MB | 17.8 s | 51.9 M | +| continuations 2^20 | 18.7 GB | 142.2 s | 1.61× | 1 671 MB | 30.4 s | 90.5 M | + +Verify hashes are the keccak-256 finalizes the verifier does (grinding +excluded), the proxy for the recursion guest's cost. Every epoch of a +continuation carries its own fixed tables and its own FRIs, hence 1.7×–4.6× the +hashes of a single proof; the batched proof needs 0.36× of the CI bench's. + +Where the time goes: prove-and-retire per-table = monolithic − trace build + round 1 twice + +pass 2 + pipeline edges, almost to the second; the profile and the core +utilization are the monolithic prover's. What is left without a protocol +change: chunking KECCAK_RND (the 3.8 s of pass 2). + +Two things that were not the approach but decided its speed: jemalloc purges +every extent of 8 MiB or more the moment it is freed (`extent.c`, +`extent_may_force_decay`), which made every chunk re-fault its pages — the CLI +disables that arena's decay and purges it every 10 s +(`keep_large_buffers_warm`, Linux only, −13%); and the resident tables were +processed one at a time after each walk (−10% once batched). + +## 7. Against the spec + +- *Commit*, *Challenge*, *LogUp re-execution*, *FRI*, *Open*: as written. +- The spec has the Commit phase already accumulating "FRI polynomials"; nothing + FRI-able exists before the LogUp challenge (aux and composition need `(z, α)`), + so batching starts in the re-execution pass. +- One batch polynomial **per height**, not one in total: the fold squares the + coset offset each layer, so codewords of different lengths do not line up + without a mixed-height commitment (#951's direction). +- The spec's Open optimization (keep the Merkle internal nodes, drop the leaves) + measured +9 GB for −4 s and was not kept; keeping the composition parts + (`A1_KEEP_COMPOSITION`) is the trade that pays. +- The per-table variant is not in the spec; it is what makes the approach a + drop-in. Distribution across workers is not attempted; the pipelined batch + worker is the seam for it. + +## 8. Code map + +``` +prover/src/pass.rs Visitor (one table per call), Batched (channel → consumer thread, + batches of k), Resident, the walk driver (walk / finish) +prover/src/commit_phase.rs pass 1; Precomputed (the ELF-only preprocessed commitments, beside the walk) +prover/src/challenge_phase.rs pass 2: residents in parallel, roots in AIR order, (z, α), the AIRs +prover/src/logup_phase.rs run (per-table) · run_batched (3-4) · run_open (5) · assemble_vm_proof · + assemble_batched_proof · BatchedProof +prover/src/batched_verifier.rs the VM half of the batched verifier +crypto/stark/src/batched_verifier.rs the STARK half: rounds 2-3 per table, openings, accumulated DEEP, + one FRI per group +crypto/stark/src/prover.rs round_1_from_trace, rounds_2_and_3, deep_for_table, fold_coefficient, + batch_fri, open_for_table / open_for_table_kept +prover/src/streaming.rs AirOrder (the AIR order every pass and the verifier agree on) +prover/src/tables/trace_builder.rs walk_and_emit_chunks, WalkLeftover::finalize (what the shared tables owe) +bin/cli/src/main.rs trace-build --prove-and-retire, keep_large_buffers_warm; examples/cmp_proofs.rs +``` + +## 9. The rule every change to the walk must respect + +Several tables are fed by others: LT gets a row for every MEMW timestamp check, +for every DVRM `|r| < |d|` and for every HINT range check; BITWISE gets the +lookups of every chip including CPU32; MUL and DVRM get the CPU's derived ops +and the CPU32 dispatch; SHIFT gets the CPU32 dispatch. The monolithic build +derives all of that from complete op lists. The walk retires chunks before +those lists are complete, so **whatever a retired chunk owes another table has +to be derived when the chunk closes**, and the tail's share in +`WalkLeftover::finalize`, in the monolithic build's order. Five such gaps kept +the ethrex proof from verifying while every small-program test passed; two +tests now pin the pattern — `bitwise_multiplicities_match_the_ordinary_build` +(cell-by-cell diff of the walk's BITWISE against the ordinary build) and +`a1_verifies_with_many_chunks` (small chunks of every kind on a Rust program +with memory and ALU, proved and verified) — and `--verify` on a real block is +the last word. When adding a table or a derived lookup, `grep` every producer +of it in the monolithic build and check the walk has each. + +## 10. Not done + +- Chunking KECCAK_RND (protocol change; the remaining ~4 s of pass 2). +- Serializing `BatchedProof` to disk (`--output` covers the per-table proof). +- Distributing retirement batches across workers. +- An independent soundness review of the batched fold (§5). From f800e4b0add8d7fb11d7f25002525d77e4d4507a Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 12:01:48 -0300 Subject: [PATCH 60/63] Name the trace-build flag after what it does --streaming selected prove-and-retire, but "streaming" is the spec's name for both memory-bounding approaches, continuations included, and the comparison against continuations is the one this path is measured by. The flag is --prove-and-retire now, and the summary line says so. --- bin/cli/src/main.rs | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 00f17d493..69d51cd93 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -310,24 +310,30 @@ enum Commands { #[arg(long, value_hint = ValueHint::FilePath)] private_input: Option, - /// Walk the execution, committing and retiring each table as it fills - /// (Approach 1's Commit phase), instead of building every trace first. + /// Prove-and-retire (the spec's Approach 1): walk the execution, + /// committing and retiring each table as it fills, instead of building + /// every trace first. #[arg(long)] - streaming: bool, + prove_and_retire: bool, /// How far down Approach 1's pipeline to run. Only meaningful with - /// --streaming; each stage includes the ones before it. - #[arg(long, value_enum, default_value = "logup", requires = "streaming")] + /// --prove-and-retire; each stage includes the ones before it. + #[arg( + long, + value_enum, + default_value = "logup", + requires = "prove_and_retire" + )] through: Stage, /// Assemble the per-table proof the LogUp stage leaves and run the /// ordinary verifier on it, after the timings are reported. - #[arg(long, requires = "streaming")] + #[arg(long, requires = "prove_and_retire")] verify: bool, /// Write the assembled per-table proof here (implies the assembly, not /// the verification). - #[arg(short, long, requires = "streaming", value_hint = ValueHint::FilePath)] + #[arg(short, long, requires = "prove_and_retire", value_hint = ValueHint::FilePath)] output: Option, }, } @@ -416,11 +422,18 @@ fn main() -> ExitCode { Commands::TraceBuild { elf, private_input, - streaming, + prove_and_retire, through, verify, output, - } => cmd_trace_build(elf, private_input, streaming, through, verify, output), + } => cmd_trace_build( + elf, + private_input, + prove_and_retire, + through, + verify, + output, + ), } } @@ -1320,7 +1333,7 @@ fn report_fri_shape( fn cmd_trace_build( elf_path: PathBuf, private_input_path: Option, - streaming: bool, + prove_and_retire: bool, through: Stage, verify: bool, output: Option, @@ -1359,7 +1372,7 @@ fn cmd_trace_build( return ExitCode::FAILURE; } }; - let outcome = if streaming { + let outcome = if prove_and_retire { run_approach_1( &elf, &elf_data, @@ -1380,7 +1393,11 @@ fn cmd_trace_build( Ok((n, proof)) => { println!( "Trace build ({}): {n} tables, {:.3}s", - if streaming { "streaming" } else { "resident" }, + if prove_and_retire { + "prove-and-retire" + } else { + "resident" + }, elapsed.as_secs_f64() ); proof From 0d5abe2eaef1c712756e1e607474aea2fed3baae Mon Sep 17 00:00:00 2001 From: Mauro Toscano <12560266+MauroToscano@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:13:37 -0300 Subject: [PATCH 61/63] Review fixes for prove-and-retire (#994) (#995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reject a malformed out-of-domain block instead of panicking `verify_batched`'s fold seed indexes both out-of-domain blocks at the `width`/`height` the proof advertises, but `ood_blocks_well_formed` — the guard that pins those dimensions to the AIR and calls `dimensions_consistent()` — did not run until 92 lines later. A proof whose advertised dimensions disagree with its data length therefore panicked in `Table::get_row`'s unchecked slice rather than being rejected. That is the exact gap the guard's own doc comment says it exists to close, and the ordinary verifier keeps the ordering by running it inside the round-1 loop. Hoist the three shape checks into the pre-pass that already validates each table's domain, before any of them is read. The division by `trace_length` is safe there: the same loop rejects zero first. No transcript byte moves — the checks touch no transcript. `prover::batched_verifier::replay` had the same pre-guard read through `Table::columns`; it is test-only, but a patch that fixed only the STARK half would leave a reader thinking the family was covered. Direction is robustness, not soundness: nothing wrong is accepted, the verifier aborts instead of returning false. It is unreachable from bytes today because `BatchedProof` has no derives — which is also why it is the cheapest moment to pay for it, since serializing that format is the point. Also drop `Replay::iotas`, documented as the per-group query indices and always empty, and say plainly that `replay` is a test oracle: it takes the prover's word on the precomputed root and never checks `fold_order` is a permutation, both of which `verify` does. `a_tampered_batched_proof_is_rejected` grows five arms: a group's FRI layer root (the one commitment batching relocated from per-table to per-group), a main root, a composition root, an out-of-domain value, and a block whose advertised width lies. The last one panics at `table.rs:362` without this change and is rejected with it. * Reattach eight doc comments to the items they describe Each of these inserted a new item between an existing doc comment and the item it documented, with no blank line, so rustdoc merged the two blocks and the original item lost its docs: prover.rs table_parallelism -> MainRoots prover.rs plain -> known_roots verifier.rs replay_rounds_* -> replay_rounds_2_and_3 decode.rs update_multiplicities -> add_multiplicities trace_builder.rs cpu32_chip_op -> WalkLeftover trace_builder.rs build_initial_image-> runtime_page_ranges trace_builder.rs touched_memory_cells -> op_count trace_builder.rs collect_epoch -> walk_and_emit_chunks Three of the adopted sentences were actively wrong about their new owner: `known_roots` was labelled "for a plain (non-preprocessed) table" when it takes `precomputed: Option` and serves both; `add_multiplicities` was described in terms of a `lookups` parameter it does not have; and `WalkLeftover`'s public rustdoc opened by describing an ALU dispatch helper. `replay_rounds_2_and_3` is renamed `replay_rounds_2_to_4`: its body still carries an explicit `Round 4` section sampling gamma and the DEEP coefficients, so the orphaned sentence ("rounds 2, 3 and 4") was the accurate one and the new name was not. `bitwise_histogram` carried two stacked doc blocks, the first saying LT, MUL, DVRM, SHIFT, the accelerators and PAGE "are not here yet" and the second, directly below, saying the histogram is complete. The first is left over from an earlier state; `finalize` folds all of them in. * Say what the walk actually holds, and drop a stale table count Two claims about residency were wrong in the same way. `pass.rs`'s header said what the walk holds is one table plus the residents "no matter how long the run is", and the design doc listed LT among the tables handed over "as soon as a chunk fills". LT is not: it, MUL, DVRM and SHIFT are deliberately absent from `CHUNKED_KINDS` because later derivations keep appending to them, so their chunk boundaries are not knowable until the run ends — `walk_and_emit_chunks`'s own doc comment says so two paragraphs below the sentence that contradicted it. Their op lists, the `retired_*` rows a closing chunk converts its ops into, and the walk's BITWISE lookups are all held whole, so that term is O(cycles). It is a small term — compact routed intermediates against trace rows, a low single-digit percentage of the measured peak — and closing it would move LT's chunk boundaries and cost the byte-identical-roots property that makes the per-table variant a drop-in. So this changes no code: it makes the documents say what the code does, and lists the gap in §10 with the observation that BITWISE's half is the cheap one, since a histogram is commutative and could be folded per segment without moving any root. Also in the design doc: - the `A1_TABLE_PARALLELISM` row quoted a sweep ("flat between 12 and 24; 24 costs 6 GB") that does not match the one recorded on `pass::table_parallelism` (no k=12 or k=24 rows; the step is 16 -> 32 for 5.0 GB); - `A1_INFLIGHT` and `LAMBDA_STREAM_LDE` were missing from a table that claims to list every knob, and the second changes this approach's own memory profile; - `--features hash-metrics` is from #987, which is not on this branch, so the verify-hash column cannot be reproduced here — say so rather than give a build command that fails; - "the spec's Open optimization ... was not kept" described code that ships: what was dropped is holding whole Merkle trees between passes, while leaf-dropping is `drop_leaves`/`retire_leaves` behind `LAMBDA_STREAM_LDE`; - §6's header omitted the blowup and the knob settings the numbers were taken at. And five comments said the ethrex block has 227 tables where the doc says 245. Rather than guess which run is stale, they now say "once per table" and the like: none of them needed the number. * Stop the CLI changing the allocator for every command Three things, all outside the prove-and-retire path. `keep_large_buffers_warm()` ran as the first statement of `main()`, so every subcommand — `prove`, `verify`, `execute`, `--help` — allocated 16 MiB, disabled dirty decay on the oversize arena for the life of the process, and left a 10-second purge thread behind. Disabling decay retains RSS that `auto_storage::available_ram_bytes()` does not model, and it is the sort of change that quietly moves every memory number taken with this binary. Call it from the prove-and-retire path, which is the one that allocates and drops trace-sized buffers in a loop. Both of its mallctl failure paths returned silently, and `env_logger::init()` ran on the next line, so nothing could have been logged even if it had tried. A run where the knob did not land was indistinguishable from one where it did. They now warn. The doc comment also records why `opt.narenas` is the right index — jemalloc 5 reserves the slot after the automatic arenas for the oversize arena (`arena_init_huge`), whose threshold defaults to the same 8 MiB the comment names — since a count used as an index invites a second look. `tikv-jemalloc-ctl` had become a hard dependency carrying `features = ["stats"]`, and `jemalloc-stats` an empty feature. That propagates to `tikv-jemalloc-sys/stats` and so to `--enable-stats`, which puts counters on the malloc fast path of every CLI build, including ones measuring baselines. `keep_large_buffers_warm` needs only `raw`/`mallctl`, so the dependency stays and `stats` goes back behind `jemalloc-stats`, which is what the heap tracker is gated on anyway. Finally, `--output` with any stage but `logup` walked the whole execution, returned no proof, wrote no file and exited 0 — and with `--through batched` it also forced a verification the user had not asked for, because `--output` is OR'd into the `verify` argument. It now fails before the walk with a message naming the stage. * Make four test assertions able to fail `retire_lde_proof_is_byte_identical` compared a proof against itself under `cuda`: there `retire_leaves` returns `None` unconditionally and `retire_main_lde` is compiled out, so both arms take the resident path. That configuration is not hypothetical — `make test-prover-cuda` runs this suite on the merge queue. It is now `#[cfg(not(feature = "cuda"))]`, and each arm asserts `streaming_retire_lde()` actually returned what it set, so the test fails rather than passes if the flag ever stops taking effect. Its `ENV_LOCK` was a function-local `static` that nothing else could name, and libtest calls each `#[test]` once, so it could never be contended — it guarded nothing, and the SAFETY comment above the `set_var` ("single-threaded section guarded by ENV_LOCK") was false on both clauses. Replaced with what is actually true: this is the only writer in the binary, every reader goes through `std::env`, which serialises readers against writers on its own lock, so the exposure is other tests observing the flag under a plain `cargo test` — their coverage, not memory safety. `cargo nextest`, which CI runs, forks per test. The note names the real fix (its own integration binary, as `prover/tests/gpu_force_downgrade.rs` already does) without doing it here. `checkpoint_tests`' `assert!(full.len() > 100_000)` followed an `assert_eq!(full.len(), N_ADDI + 1)` with `N_ADDI = 100_005` — a tautology. The property it was reaching for is already checked by the `logs.len() < full.len()` assertion further down. `chunk_shape_matches_the_built_chunk` gave ops to LT only, so for the other thirteen kinds both sides collapsed to the 4-row padding floor and only the column width was pinned. The row half was covered, but by one kind — so a divergence in a single generator's padding would be missed. It now also populates MUL (dedup, like LT) and SHIFT (plain, 20 ops over a limit of 8, so its chunks are 8/8/4 and sit above the floor), asserts each fixture exercises what it is there for, and counts populated chunks so the loop cannot silently go back to comparing constants. `prover/src/tests/mod.rs` declared `batched_fri_tests` and `challenge_phase_tests` without the `#[cfg(test)]` every other entry carries; the parent `mod tests` is ungated, so those two were the only ones compiled into a non-test build of the library. * Satisfy the lint gate `cargo fmt --all`, plus a `clone()` on a `Copy` field that the new out-of-domain tamper arm introduced. * Declare the `log` dependency the CLI actually uses `keep_large_buffers_warm`'s warnings are inside `#[cfg(target_os = "linux")]`, so a macOS build never compiles them and my local lint runs said nothing. CI, on Linux, did: `use of unresolved module or unlinked crate log`. `log` was reaching `bin/cli` only as a transitive dependency of `env_logger`, which is not a dependency you may name. Declared, with a note on the file that its only user is Linux-gated. * Build the prover without `parallel` again `make compile-recursion-elfs` compiles `lambda-vm-prover` for the RISC-V guest, where `parallel` is off and there is no rayon. The three new phase modules `use rayon::prelude::*` unconditionally and call `into_par_iter`/`par_iter`, so the recursion guest stopped building: 27 errors, 8 unresolved-`rayon` and 9 missing-method, plus three `E0505`s in `trace_builder`. This is on #994's branch as it stands, not introduced by this PR — the same `cargo check -p lambda-vm-prover --no-default-features` fails identically at `f800e4b0`. It went unnoticed because no CI run has ever touched that branch; this PR is the first, which is how it surfaced. The four `make lint` arms do not catch it either: the workspace-level `--no-default-features` arm still resolves `parallel` through another member's feature unification. Gated with the idiom already used in `trace_builder.rs` — a `#[cfg]` pair around the iterator source, serial arm `into_iter`/`iter`. Where the closure was long enough that duplicating it would be worse than the problem, it is hoisted to a named binding first and both arms map over that, so the body appears once. No behaviour change on any path that runs today: the serial arms exist to compile for the guest, which links the crate for its verifier and never executes these phases. The `E0505`s were the serial arm of the BITWISE collector loop iterating `&collectors` where the parallel arm moves it into `units`, so the closures' borrows of the op lists outlived the point where `CollectedOps` moves those lists. Consumed by value, matching the parallel arm. Verified: `make compile-recursion-elfs` succeeds, all four `make lint` arms and `cargo fmt --check` pass, and the prove-and-retire tests are unchanged at 13/13. --- Cargo.lock | 1 + bin/cli/Cargo.toml | 11 +- bin/cli/src/main.rs | 54 +++++- crypto/crypto/src/merkle_tree/merkle.rs | 9 +- crypto/stark/src/batched_verifier.rs | 51 ++++-- crypto/stark/src/prover.rs | 57 +++--- .../src/tests/prove_verify_roundtrip_tests.rs | 30 +++- crypto/stark/src/verifier.rs | 11 +- docs/prove_and_retire_design.md | 55 ++++-- executor/src/tests/checkpoint_tests.rs | 4 - prover/src/batched_verifier.rs | 38 ++-- prover/src/challenge_phase.rs | 37 ++-- prover/src/commit_phase.rs | 22 +-- prover/src/logup_phase.rs | 169 ++++++++++-------- prover/src/pass.rs | 22 ++- prover/src/tables/decode.rs | 6 +- prover/src/tables/trace_builder.rs | 46 ++--- prover/src/tests/batched_fri_tests.rs | 48 ++++- prover/src/tests/mod.rs | 6 +- prover/src/tests/trace_builder_tests.rs | 47 +++++ 20 files changed, 505 insertions(+), 219 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cbef8dbe..3c289ff08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,7 @@ dependencies = [ "env_logger", "executor", "lambda-vm-prover", + "log", "rkyv", "serde_cbor", "stark", diff --git a/bin/cli/Cargo.toml b/bin/cli/Cargo.toml index 816b34e9c..5047b2edf 100644 --- a/bin/cli/Cargo.toml +++ b/bin/cli/Cargo.toml @@ -14,12 +14,19 @@ clap = { version = "4.3.10", features = ["derive"] } rkyv = { version = "0.8.10", default-features = false, features = ["alloc", "bytecheck", "aligned", "pointer_width_64"] } tempfile = "3" tikv-jemallocator = "0.6" -tikv-jemalloc-ctl = { version = "0.6", features = ["stats"] } +# No `stats` by default: that feature propagates to tikv-jemalloc-sys and builds +# jemalloc with `--enable-stats`, i.e. counters on the malloc fast path, for every +# binary. `keep_large_buffers_warm` needs only `raw`/`mallctl`; the heap tracker is +# what needs the counters, and it is behind `jemalloc-stats`. +tikv-jemalloc-ctl = { version = "0.6" } tikv-jemalloc-sys = "0.6" env_logger = "0.11" +# Used by `keep_large_buffers_warm`, whose body is Linux-only — so a macOS build +# will not catch its absence. +log = "0.4" [features] -jemalloc-stats = [] +jemalloc-stats = ["tikv-jemalloc-ctl/stats"] disk-spill = ["prover/disk-spill"] instruments = ["prover/instruments", "stark/instruments"] # GPU profiling build (Nsight): CUDA prover + instruments spans + NVTX ranges. diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 69d51cd93..d24acccda 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -18,8 +18,18 @@ static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; /// Disable the arena's decay and purge it on our own clock instead: hot buffers are /// reused across threads, cold ones still go back to the OS. /// +/// The index is `opt.narenas`: jemalloc 5 reserves the slot immediately after the +/// automatic arenas for the oversize arena (`arena_init_huge`, `huge_arena_ind = +/// narenas_total_get()`), and `opt.oversize_threshold` defaults to the same 8 MiB. +/// /// Linux only: elsewhere jemalloc is built without background threads and the decay /// mallctl traps. +/// +/// Called only from the paths that allocate trace-sized buffers, not from `main`: +/// it disables an arena's decay for the life of the process and leaves a purge +/// thread behind, which is not something `cli verify` or `--help` should pay, and +/// it would otherwise change the allocator under every baseline measured with +/// this binary. fn keep_large_buffers_warm() { #[cfg(target_os = "linux")] { @@ -35,13 +45,28 @@ fn keep_large_buffers_warm() { // SAFETY: `opt.narenas` is `unsigned`, the decay knob is `ssize_t`, and `purge` // takes no value. unsafe { - let Ok(huge_arena) = raw::read::(b"opt.narenas\0") else { - return; + let huge_arena = match raw::read::(b"opt.narenas\0") { + Ok(n) => n, + Err(e) => { + // Not fatal, but the run is then indistinguishable from one + // where the knob worked — which is exactly what makes a + // memory measurement unreadable. Say so. + log::warn!( + "keep_large_buffers_warm: cannot read opt.narenas ({e}); \ + the oversize arena keeps jemalloc's default decay" + ); + return; + } }; let decay = format!("arena.{huge_arena}.dirty_decay_ms\0"); - if raw::write(decay.as_bytes(), -1i64).is_err() { + if let Err(e) = raw::write(decay.as_bytes(), -1i64) { + log::warn!( + "keep_large_buffers_warm: cannot disable decay on arena \ + {huge_arena} ({e}); the oversize arena keeps jemalloc's default" + ); return; } + log::debug!("keep_large_buffers_warm: decay disabled on arena {huge_arena}"); let purge = CString::new(format!("arena.{huge_arena}.purge")).unwrap(); std::thread::spawn(move || { loop { @@ -356,7 +381,6 @@ enum Stage { } fn main() -> ExitCode { - keep_large_buffers_warm(); env_logger::init(); let cli = Cli::parse(); @@ -1258,7 +1282,7 @@ fn report_batched_size(proof: &prover::logup_phase::BatchedProof, tables: usize, /// Where the time went, summed per span label. /// -/// The prover's own spans are per table and there are 227 of them, so the raw +/// The prover's own spans are per table and there are hundreds of them, so the raw /// timeline is unreadable; what answers "where is the time" is the total per /// label. Sums exceed wall time, because tables run concurrently — the ratios /// between labels are the point, not the absolute figures. @@ -1338,6 +1362,23 @@ fn cmd_trace_build( verify: bool, output: Option, ) -> ExitCode { + // Only the per-table proof is serializable today, so `--output` with any + // other stage would walk the whole execution and then write nothing. Say so + // before spending the walk rather than exiting 0 in silence. + if output.is_some() && prove_and_retire && through != Stage::Logup { + eprintln!( + "--output writes the per-table proof, which only `--through logup` assembles; \ + `--through {}` has no serializable proof yet (see docs/prove_and_retire_design.md \u{00A7}10).", + match through { + Stage::Walk => "walk", + Stage::Commit => "commit", + Stage::Challenge => "challenge", + Stage::Batched => "batched", + Stage::Logup => unreachable!(), + } + ); + return ExitCode::FAILURE; + } let elf_data = match std::fs::read(&elf_path) { Ok(data) => data, Err(e) => { @@ -1373,6 +1414,9 @@ fn cmd_trace_build( } }; let outcome = if prove_and_retire { + // The walk allocates and drops one trace-sized buffer after another, + // which is the pattern this works around. + keep_large_buffers_warm(); run_approach_1( &elf, &elf_data, diff --git a/crypto/crypto/src/merkle_tree/merkle.rs b/crypto/crypto/src/merkle_tree/merkle.rs index 54ef9a3d5..01e489b0a 100644 --- a/crypto/crypto/src/merkle_tree/merkle.rs +++ b/crypto/crypto/src/merkle_tree/merkle.rs @@ -330,7 +330,7 @@ where leaves_len: usize, sibling_leaf: B::Node, ) -> Option> { - if leaves_len <= 1 || pos >= leaves_len { + if leaves_len <= 1 || pos >= leaves_len || self.is_root_only() { return None; } let mut merkle_path = Vec::with_capacity(leaves_len.trailing_zeros() as usize); @@ -338,7 +338,12 @@ where let mut node = parent_index(pos + leaves_len - 1); while node != ROOT { - merkle_path.push(self.nodes.get(sibling_index(node))?.clone()); + // `node_get`, not `self.nodes` directly: every other read in this + // file goes through it for the disk-spill mmap indirection. The two + // are mutually exclusive today — `drop_leaves` refuses an mmap-backed + // tree — but a direct read would silently yield `None` here if that + // ever stopped holding, and the prover's opening path unwraps this. + merkle_path.push(self.node_get(sibling_index(node))?.clone()); node = parent_index(node); } self.create_proof(merkle_path) diff --git a/crypto/stark/src/batched_verifier.rs b/crypto/stark/src/batched_verifier.rs index 8d47f103d..363c8fceb 100644 --- a/crypto/stark/src/batched_verifier.rs +++ b/crypto/stark/src/batched_verifier.rs @@ -36,6 +36,10 @@ struct GroupReplay { iotas: Vec, acc: Vec>, acc_sym: Vec>, + /// The first table of the group, whose AIR supplied the domain. Kept from + /// the scan that already found it rather than looked up again: the second + /// scan is what forced an `expect` into verifier code. + member: usize, } /// Verify the rounds after round 1 of every table and each group's FRI. @@ -90,12 +94,33 @@ where let num_queries = first_air.options().fri_number_of_queries; let grinding_factor = first_air.context().proof_options.grinding_factor; - // Every table's domain is its group's. - for (idx, (table, &g)) in tables.iter().zip(group_of).enumerate() { + // Every table's domain is its group's, and every block and opening has the + // shape its AIR declares. + // + // Both run before the fold seed below reads a single out-of-domain value. + // That seed indexes the blocks at the dimensions the *proof* advertises, so + // a proof whose advertised dimensions disagree with its data length has to + // be rejected here rather than panic there — the rule `ood_blocks_well_formed` + // documents, and the one the ordinary verifier keeps by running it in round 1. + for (idx, ((air, table), &g)) in airs.iter().zip(tables).zip(group_of).enumerate() { if table.trace_length == 0 || table.trace_length != groups[g].trace_rows { error!("batched: table {idx} does not live on its group's domain"); return false; } + let view = StarkProofView::Owned(table); + // `trace_length` is non-zero by the check above, so the division is safe. + if table.composition_poly_parts_ood_evaluation.len() + != air.composition_poly_degree_bound(table.trace_length) / table.trace_length + || !V::::ood_blocks_well_formed(*air, view) + || !V::::trace_opening_widths_well_formed( + *air, + view, + num_queries, + ) + { + error!("batched: table {idx}'s blocks or openings are malformed"); + return false; + } } // The fold coefficients, from the seed, in the prover's order. @@ -182,6 +207,7 @@ where iotas, acc: vec![FieldElement::zero(); num_queries], acc_sym: vec![FieldElement::zero(); num_queries], + member, }); } @@ -199,19 +225,9 @@ where if let Some(ref bpi) = table.bus_public_inputs { fork.append_field_element(&bpi.table_contribution); } + // Shapes were pinned to the AIR in the pre-pass above, before the fold + // seed read any of this table's blocks. let domain = new_verifier_domain(*air, table.trace_length); - if table.composition_poly_parts_ood_evaluation.len() - != air.composition_poly_degree_bound(table.trace_length) / table.trace_length - || !V::::ood_blocks_well_formed(*air, view) - || !V::::trace_opening_widths_well_formed( - *air, - view, - num_queries, - ) - { - error!("batched: table {idx}'s blocks or openings are malformed"); - return false; - } let layout = V::::ood_layout(*air); let RoundsChallenges { z, @@ -219,7 +235,7 @@ where transition_coeffs, trace_term_coeffs, gammas, - } = V::::replay_rounds_2_and_3( + } = V::::replay_rounds_2_to_4( *air, view, &public_inputs[idx], @@ -291,10 +307,7 @@ where // Each group's FRI, from the folded first layer down to the final polynomial. for (g, (group, replay)) in groups.iter().zip(replays.iter()).enumerate() { - let member = group_of - .iter() - .position(|&h| h == g) - .expect("checked above"); + let member = replay.member; let fri = group.fri; let synthetic = StarkProof:: { trace_length: group.trace_rows, diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 13ec7a51a..00c1e5a44 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -131,10 +131,12 @@ impl TableCommit where FieldElement: AsBytes, { - /// Build a `TableCommit` for a plain (non-preprocessed) table. /// Roots without a tree, for a pass that will not open against it. The /// tree is the expensive half; a caller that already knows the roots and /// only needs them to travel should not pay for one. + /// + /// Serves preprocessed and plain tables alike — `precomputed` is `Some` + /// exactly when the AIR is preprocessed. fn known_roots(root: Commitment, precomputed: Option) -> Self { Self { tree: Arc::new(BatchedMerkleTree::from_root(root)), @@ -146,6 +148,7 @@ where } } + /// Build a `TableCommit` for a plain (non-preprocessed) table. fn plain(#[allow(unused_mut)] mut tree: BatchedMerkleTree, root: Commitment) -> Self { let leaves_dropped = Self::retire_leaves(&mut tree); Self { @@ -646,26 +649,6 @@ fn host_cores() -> usize { .unwrap_or(4) } -/// Number of tables `multi_prove` proves concurrently, out of `num_airs` of -/// them. -/// -/// Defaults: **every table** under `cuda`, `num_cores / 3` on CPU builds -/// (benchmarked optimal on both M3 Pro and EPYC 9454P — every table there is -/// pure host work, so `k` genuinely competes for cores). Both arms are -/// overridden by the `TABLE_PARALLELISM` env var, and the result is clamped to -/// `1..=num_airs`. Without the `parallel` feature this is 1 and the env var is -/// ignored. -/// -/// # Why the `cuda` arm has no core term -/// -/// Measured over 881 runs on two RTX 5090 boxes (sweep record linked from -/// PR #911): the work `k` divides is device- and workload-bound — invariant to -/// host core count over an 8× range — so `available_parallelism()` is the -/// wrong quantity to scale `k` by. `k` is not a thread count; it counts -/// concurrent drivers whose per-table work all runs on the one global rayon -/// pool. Worst case against the best measured `k`: `num_airs` +1.6 % (inside -/// noise), the old `cores*2/3` +13.0 %. Bounding concurrency is memory -/// admission's job (`VramGate`), not this count's. /// A table's Round 1 roots, in the order Fiat-Shamir absorbs them. /// /// A plain table contributes one root. A preprocessed one contributes two: its @@ -708,10 +691,6 @@ pub struct TableDeep { pub trace_rows: usize, /// The DEEP composition codeword, `lde_size` long. pub deep: Vec>, - /// Where the table sits in the AIR order, carried so the batch can say - /// which group each table ended up in once the codewords are sorted by - /// domain rather than by position. - pub air_index: usize, /// What the batch's coefficient is drawn from: this table's public round-3 /// data, in the order a transcript absorbs it. /// @@ -826,6 +805,29 @@ const RETIRE_OVERRIDE_OFF: u8 = 1; const RETIRE_OVERRIDE_ON: u8 = 2; static RETIRE_LDE_OVERRIDE: AtomicU8 = AtomicU8::new(RETIRE_OVERRIDE_UNSET); +/// Number of tables `multi_prove` proves concurrently, out of `num_airs` of +/// them. +/// +/// Defaults: **every table** under `cuda`, `num_cores / 3` on CPU builds +/// (benchmarked optimal on both M3 Pro and EPYC 9454P — every table there is +/// pure host work, so `k` genuinely competes for cores). Both arms are +/// overridden by the `TABLE_PARALLELISM` env var, and the result is clamped to +/// `1..=num_airs`. Without the `parallel` feature this is 1 and the env var is +/// ignored. +/// +/// Not to be confused with `prover::pass::table_parallelism`, which sizes the +/// prove-and-retire walk's batches and reads `A1_TABLE_PARALLELISM`. +/// +/// # Why the `cuda` arm has no core term +/// +/// Measured over 881 runs on two RTX 5090 boxes (sweep record linked from +/// PR #911): the work `k` divides is device- and workload-bound — invariant to +/// host core count over an 8× range — so `available_parallelism()` is the +/// wrong quantity to scale `k` by. `k` is not a thread count; it counts +/// concurrent drivers whose per-table work all runs on the one global rayon +/// pool. Worst case against the best measured `k`: `num_airs` +1.6 % (inside +/// noise), the old `cores*2/3` +13.0 %. Bounding concurrency is memory +/// admission's job (`VramGate`), not this count's. pub fn table_parallelism(num_airs: usize) -> usize { #[cfg(feature = "parallel")] { @@ -2102,7 +2104,7 @@ pub trait IsStarkProver< /// /// The codeword is kept rather than the LDEs it came from. That is the /// whole reason this split is affordable: on the ethrex block the trace and - /// composition LDEs of all 227 tables are tens of gigabytes, while their + /// composition LDEs of every table are tens of gigabytes, while their /// DEEP codewords together are about 6.5 GB — one extension element per row /// instead of every column. Holding them is what saves walking the /// execution again just to recompute them once the coefficient is known. @@ -2180,7 +2182,6 @@ pub trait IsStarkProver< lde_size: domain.interpolation_domain_size * domain.blowup_factor, trace_rows: domain.interpolation_domain_size, deep, - air_index: usize::MAX, bus_contribution: round_1_result .bus_public_inputs .as_ref() @@ -2262,7 +2263,7 @@ pub trait IsStarkProver< /// The accumulator is the batch polynomial the spec describes. A member is /// added and dropped, so what is held is one codeword per distinct domain /// rather than one per table — which on the ethrex block is 13 instead of - /// 227, and about a gigabyte instead of eight and a half. + /// one per table, and about a gigabyte instead of eight and a half. fn accumulate( acc: &mut Vec>, coefficient: &FieldElement, diff --git a/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs b/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs index 4ef3f96be..42ab4f74c 100644 --- a/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs +++ b/crypto/stark/src/tests/prove_verify_roundtrip_tests.rs @@ -253,13 +253,20 @@ fn create_mul_air( /// row-major coset LDE inside the table's fused chain — never its contents. /// A mismatch means the rebuild diverged from what was committed. /// -/// The env var is process-global, so the two proving runs are serialized under -/// a mutex and the prior value is restored. +/// Skipped under `cuda`: there `retire_leaves` returns `None` unconditionally +/// and `retire_main_lde` is compiled out, so both arms would take the resident +/// path and the assertion would compare a proof against itself. +/// +/// The env var is process-global. `cargo nextest`, which is what CI runs, gives +/// each test its own process; a plain `cargo test` does not, so under `make +/// test` the other proving tests in this binary may observe the flag while this +/// one holds it. That costs those tests the mode they meant to exercise, not +/// memory safety: `std::env`'s own `RwLock` serialises every Rust-side reader +/// against the write. The proper fix is this test's own integration binary, the +/// way `prover/tests/gpu_force_downgrade.rs` does it. #[test] +#[cfg(not(feature = "cuda"))] fn retire_lde_proof_is_byte_identical() { - use std::sync::Mutex; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - fn prove_once() -> Vec { let add_column = vec![ FE::one(), @@ -333,14 +340,23 @@ fn retire_lde_proof_is_byte_identical() { serde_cbor::to_vec(&proofs).expect("serialize proofs") } - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); let prev = std::env::var("LAMBDA_STREAM_LDE").ok(); - // SAFETY: single-threaded section guarded by ENV_LOCK; restored below. + // SAFETY: the only writer in this binary, and every reader of it goes + // through `std::env`, which serialises reads against writes on its own + // lock. Restored below. unsafe { std::env::set_var("LAMBDA_STREAM_LDE", "0") }; + assert!( + !crate::prover::streaming_retire_lde(), + "the flag did not take: this test would compare a proof against itself" + ); let resident = prove_once(); unsafe { std::env::set_var("LAMBDA_STREAM_LDE", "1") }; + assert!( + crate::prover::streaming_retire_lde(), + "the flag did not take: this test would compare a proof against itself" + ); let retired = prove_once(); match prev { diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 7600060c5..94694119c 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -1455,12 +1455,13 @@ pub trait IsStarkVerifier< } /// Replays rounds 2, 3 and 4 of the protocol for a given proof, assuming round 1 has - /// already been replayed and the RAP challenges are known. - /// Rounds 2 and 3 of a table's transcript: the constraint coefficients, the - /// out-of-domain point and the DEEP coefficients. The per-table proof goes + /// already been replayed and the RAP challenges are known: the constraint + /// coefficients, the out-of-domain point, and round 4's DEEP coefficients. + /// + /// Stops where the two proof formats part company. The per-table proof goes /// on to its own FRI from here; the batched proof samples its fold /// coefficient instead. - fn replay_rounds_2_and_3( + fn replay_rounds_2_to_4( air: &dyn AIR, proof: StarkProofView<'_, Field, FieldExtension, PI>, public_inputs: &PI, @@ -1591,7 +1592,7 @@ pub trait IsStarkVerifier< transition_coeffs, trace_term_coeffs, gammas, - } = Self::replay_rounds_2_and_3( + } = Self::replay_rounds_2_to_4( air, proof, public_inputs, diff --git a/docs/prove_and_retire_design.md b/docs/prove_and_retire_design.md index 162d87add..0b2b111a3 100644 --- a/docs/prove_and_retire_design.md +++ b/docs/prove_and_retire_design.md @@ -31,11 +31,27 @@ challenges, an accumulated codeword. ## 2. The passes "Walk" means re-executing the program and rebuilding the tables chunk by chunk -(`prover/src/pass.rs`, `trace_builder::walk_and_emit_chunks`). Chunked tables -(CPU, MEMW, LOAD, LT, …) are handed to the pass's visitor as soon as a chunk -fills; the tables that cannot be chunked — the preprocessed ones, the -accumulators (KECCAK, ECSM, …), REGISTER, HALT, one PAGE per page — are the -*residents*, handed over when the walk ends. +(`prover/src/pass.rs`, `trace_builder::walk_and_emit_chunks`). Tables fall into three +groups, not two. Most chunked tables (CPU, MEMW, MEMW_A, MEMW_R, LOAD, CPU32, +BRANCH, EQ, BYTEWISE, STORE — the list is `CHUNKED_KINDS`) are handed to the +pass's visitor as soon as a chunk fills. Four more — **LT, MUL, DVRM and +SHIFT** — are chunked tables in the finished proof but are *not* handed over +during the walk: later derivations keep appending to them (MEMW and HINT feed +LT, DVRM feeds LT and MUL, CPU32 feeds SHIFT, MUL and DVRM), and the finished +run concatenates each source whole, so closing them early would cut their +chunks somewhere the monolithic build does not — which would move their roots +and cost the byte-identical property of §3. Their op lists are held to the end +and chunked in `pass::finish`. The tables that cannot be chunked at all — the +preprocessed ones, the accumulators (KECCAK, ECSM, …), REGISTER, HALT, one PAGE +per page — are the *residents*, handed over when the walk ends. + +So what the walk holds is one chunk per table of the first group, plus the +residents, plus the op lists of the second group and of BITWISE. The last term +grows with the execution rather than with `k`: on the ethrex block it is a low +single-digit percentage of the peak, but a workload that sends many wide memory +accesses down the general MEMW path (up to eight LT ops each, against one for +the aligned path) grows it considerably faster. §10 lists it as the known +asymptotic gap. | pass | what it does | keeps | drops | |---|---|---|---| @@ -103,14 +119,18 @@ Each run prints the pass timings, `Trace build (prove-and-retire): N tables, T s | knob | default | what it does | |---|---|---| -| `A1_TABLE_PARALLELISM` | cores / 6, at most 16 | tables in flight per batch. Measured flat between 12 and 24; 24 costs 6 GB more | +| `A1_TABLE_PARALLELISM` | cores / 6, at most 16 | tables in flight per batch. The sweep recorded on `pass::table_parallelism` is flat from 1 to 16 (21550 MB) and steps at 32 (26640 MB, +5.0 GB) for 1% of time | | `A1_PIPELINE=0` | pipelined | run each batch inline instead of on the consumer thread (diagnostic) | | `A1_KEEP_COMPOSITION=1` | off | keep the composition parts for the Open pass (§3) | +| `A1_INFLIGHT` | 0 | full batches allowed to wait in the channel beyond the one being processed | +| `LAMBDA_STREAM_LDE` | off | `1`/`true` retires each table's main LDE after the Round 1 commit and rebuilds it on demand, and frees the leaf half of every committed Merkle tree; `auto` decides from the peak-RAM estimate (needs `disk-spill`). Off by default, and it changes this approach's memory profile too — §6's numbers were taken with it off | | `_RJEM_MALLOC_CONF` | — | jemalloc options for experiments; the CLI's own setting is §6 | Verification on its own: `cli verify ` for a per-table proof -written with `--output`. With `--features hash-metrics` (#987) every verify -path prints the keccak hashes it did. +written with `--output`. The `hash-metrics` feature that prints a verify's +keccak count comes from #987, which is **not on this branch** — the verify-hash +column in §6 was measured on a tree that has it, and cannot be reproduced here +until this branch is rebased onto it. ## 5. What verifies, and with what @@ -137,7 +157,11 @@ Still to be reviewed by someone who did not write it: the soundness of the fold coefficient (sampled per table from the shared seed after absorbing that table's round-3 data) and of the absorption order. -## 6. Numbers (ethrex block 25368371, 30.5 M cycles, 96 cores) +## 6. Numbers (ethrex block 25368371, 30.5 M cycles, 96 cores, blowup 2) + +Taken with `LAMBDA_STREAM_LDE` off and `A1_KEEP_COMPOSITION` off except where +the row names it. `trace-build` hard-codes blowup 2 and has no `--blowup` +flag, so the batched path cannot yet be measured at the blowup-4 posture. | | peak heap | prove | vs monolithic | proof | verify | verify hashes | |---|---|---|---|---|---|---| @@ -175,9 +199,12 @@ processed one at a time after each walk (−10% once batched). - One batch polynomial **per height**, not one in total: the fold squares the coset offset each layer, so codewords of different lengths do not line up without a mixed-height commitment (#951's direction). -- The spec's Open optimization (keep the Merkle internal nodes, drop the leaves) +- Holding each table's whole Merkle tree from the fold pass to the Open pass measured +9 GB for −4 s and was not kept; keeping the composition parts - (`A1_KEEP_COMPOSITION`) is the trade that pays. + (`A1_KEEP_COMPOSITION`) is the trade that pays. The spec's Open optimization + proper — keep the internal nodes, drop the leaves — *is* implemented + (`MerkleTree::drop_leaves`, `get_proof_by_pos_with_leaf_sibling`, + `TableCommit::retire_leaves`) and ships behind `LAMBDA_STREAM_LDE`. - The per-table variant is not in the spec; it is what makes the approach a drop-in. Distribution across workers is not attempted; the pipelined batch worker is the seam for it. @@ -225,3 +252,9 @@ of it in the monolithic build and check the walk has each. - Serializing `BatchedProof` to disk (`--output` covers the per-table proof). - Distributing retirement batches across workers. - An independent soundness review of the batched fold (§5). +- Bounding the op lists the walk holds whole (§2): LT, MUL, DVRM and SHIFT + are excluded from `CHUNKED_KINDS` because their chunk boundaries are not + knowable until the run ends, so residency is O(cycles) rather than O(chunk) + for that term. BITWISE's lookup list is the cheaper half of the same problem + and has no ordering constraint — a histogram is commutative, so it could be + folded per segment without moving any root. diff --git a/executor/src/tests/checkpoint_tests.rs b/executor/src/tests/checkpoint_tests.rs index e0a6f519e..3cac51b85 100644 --- a/executor/src/tests/checkpoint_tests.rs +++ b/executor/src/tests/checkpoint_tests.rs @@ -37,10 +37,6 @@ fn snapshot_resume_produces_identical_logs() { let full = Executor::new(&elf, vec![]).unwrap().run().unwrap().logs; assert_eq!(full.len(), N_ADDI + 1, "every instruction should log once"); - assert!( - full.len() > 100_000, - "must span more than one resume() chunk" - ); // One chunk, then snapshot: the cut lands mid-execution. let mut exec = Executor::new(&elf, vec![]).unwrap(); diff --git a/prover/src/batched_verifier.rs b/prover/src/batched_verifier.rs index 7c9155e4e..2c44bdc1d 100644 --- a/prover/src/batched_verifier.rs +++ b/prover/src/batched_verifier.rs @@ -30,14 +30,21 @@ pub struct Replay { pub logup: Vec>, /// Per table, in AIR order: its fold coefficient. pub coefficients: Vec>, - /// Per group, in the order the prover ran them: the query indices. - pub iotas: Vec>, } /// Replay the transcript the prover walked, from the proof. /// /// `elf_bytes` and the statement come from outside the proof on purpose: a /// proof that could choose its own statement would prove nothing. +/// +/// # This is a test oracle, not a verifier +/// +/// It derives the challenges and stops. [`verify`] does not call it — it walks +/// the same prefix itself and is the stricter of the two: it checks each +/// preprocessed root against the AIR's own constant where this takes the +/// prover's word, and it rejects a `fold_order` that is not a permutation. +/// Promoting this function to a verification path without closing both gaps +/// would be a soundness hole; it exists so a test can compare challenges. pub fn replay( proof: &BatchedProof, elf_bytes: &[u8], @@ -73,6 +80,25 @@ pub fn replay( // The fold coefficients, in the order the prover folded — which the proof // carries because a table's coefficient depends on every table before it. + if proof.fold_order.len() != proof.tables.len() { + return Err(Error::Prover(format!( + "batched verify: {} tables folded of {}", + proof.fold_order.len(), + proof.tables.len() + ))); + } + // Shapes before the seed reads a value, for the same reason + // `verify_batched` pins them before its own fold loop: `Table::columns` + // indexes at the advertised dimensions. + for (idx, t) in proof.tables.iter().enumerate() { + for block in [&t.trace_ood, &t.trace_ood_next] { + if !block.dimensions_consistent() { + return Err(Error::Prover(format!( + "batched verify: table {idx}'s out-of-domain block is malformed" + ))); + } + } + } let mut seed = transcript.clone(); let mut coefficients = vec![FieldElement::::zero(); proof.tables.len()]; for &idx in proof.fold_order.iter() { @@ -97,18 +123,10 @@ pub fn replay( } coefficients[idx] = seed.sample_field_element(); } - if proof.fold_order.len() != proof.tables.len() { - return Err(Error::Prover(format!( - "batched verify: {} tables folded of {}", - proof.fold_order.len(), - proof.tables.len() - ))); - } Ok(Replay { logup, coefficients, - iotas: Vec::new(), }) } diff --git a/prover/src/challenge_phase.rs b/prover/src/challenge_phase.rs index d95cafa8a..a99ec8568 100644 --- a/prover/src/challenge_phase.rs +++ b/prover/src/challenge_phase.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; +#[cfg(feature = "parallel")] use rayon::prelude::*; use crypto::fiat_shamir::default_transcript::DefaultTranscript; @@ -46,7 +47,7 @@ pub struct Challenge { /// (DECODE from the ELF, one per ELF data page, ...) are the bulk of this pass. pub(crate) airs: crate::VmAirs, /// The transcript right after the sampling, which every later pass forks - /// per table. Kept rather than rebuilt: re-absorbing 227 roots to get back + /// per table. Kept rather than rebuilt: re-absorbing every root to get back /// to this state is both slower and a second place for the order to be /// wrong. pub transcript: DefaultTranscript, @@ -159,12 +160,17 @@ fn assemble_roots( (&airs.register, &remaining.register, "REGISTER"), ]; // Small tables, many of them: one commit at a time leaves most cores idle. - roots.extend( - fixed - .par_iter() - .map(|(air, trace, name)| commit_resident(air, trace, name)) - .collect::, _>>()?, - ); + #[cfg(feature = "parallel")] + let fixed_roots = fixed + .par_iter() + .map(|(air, trace, name)| commit_resident(air, trace, name)) + .collect::, _>>()?; + #[cfg(not(feature = "parallel"))] + let fixed_roots = fixed + .iter() + .map(|(air, trace, name)| commit_resident(air, trace, name)) + .collect::, _>>()?; + roots.extend(fixed_roots); if airs.include_halt { roots.push(commit_resident(&airs.halt, &remaining.halt, "HALT")?); } @@ -184,12 +190,17 @@ fn assemble_roots( // PAGE is built from the ELF image rather than from an op list, so // it is never retired and is committed here with the rest. let pages: Vec<_> = page_airs.by_ref().collect(); - roots.extend( - pages - .par_iter() - .map(|(air, trace)| commit_resident(air, trace, "PAGE")) - .collect::, _>>()?, - ); + #[cfg(feature = "parallel")] + let page_roots = pages + .par_iter() + .map(|(air, trace)| commit_resident(air, trace, "PAGE")) + .collect::, _>>()?; + #[cfg(not(feature = "parallel"))] + let page_roots = pages + .iter() + .map(|(air, trace)| commit_resident(air, trace, "PAGE")) + .collect::, _>>()?; + roots.extend(page_roots); continue; }; for chunk in 0..count_for(table_counts, kind) { diff --git a/prover/src/commit_phase.rs b/prover/src/commit_phase.rs index d73e539b1..c07062190 100644 --- a/prover/src/commit_phase.rs +++ b/prover/src/commit_phase.rs @@ -100,18 +100,20 @@ fn commit_batch( roots: &std::sync::Mutex>, items: Vec, ) -> Result<(), Error> { + #[cfg(feature = "parallel")] use rayon::prelude::*; type P = stark::prover::Prover; - let done: Result, Error> = items - .into_par_iter() - .map(|(kind, chunk, trace)| { -

>::commit_table_root(airs.get(kind).as_ref(), &trace) - .map(|root| (kind, chunk, root)) - .ok_or_else(|| { - Error::Prover(format!("commit phase: no commitment for a {kind:?} chunk")) - }) - }) - .collect(); + let commit = |(kind, chunk, trace): Item| -> Result { +

>::commit_table_root(airs.get(kind).as_ref(), &trace) + .map(|root| (kind, chunk, root)) + .ok_or_else(|| { + Error::Prover(format!("commit phase: no commitment for a {kind:?} chunk")) + }) + }; + #[cfg(feature = "parallel")] + let done: Result, Error> = items.into_par_iter().map(commit).collect(); + #[cfg(not(feature = "parallel"))] + let done: Result, Error> = items.into_iter().map(commit).collect(); roots.lock().expect("roots").extend(done?); Ok(()) } diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index ac78659b5..4ffaf8739 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -97,28 +97,30 @@ fn rounds_batch( done: &Proofs, items: Vec, ) -> Result<(), Error> { + #[cfg(feature = "parallel")] use rayon::prelude::*; let order = &challenge.order; let n = order.len(); - let built: Result, Error> = items - .into_par_iter() - .map(|(kind, chunk, mut trace)| { - let idx = order.index_of(kind, chunk).ok_or_else(|| { - Error::Prover(format!( - "logup phase: {kind:?} chunk {chunk} is not in the layout the Commit phase produced" - )) - })?; - let mut transcript = fork(&challenge.transcript, idx, n); - let rounds = prove_table( - airs.get(kind).as_ref(), - &mut trace, - &challenge.challenges, - &mut transcript, - ) - .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; - Ok((idx, rounds)) - }) - .collect(); + let run_one = |(kind, chunk, mut trace): Item| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "logup phase: {kind:?} chunk {chunk} is not in the layout the Commit phase produced" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let rounds = prove_table( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + ) + .map_err(|e| Error::Prover(format!("logup phase: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, rounds)) + }; + #[cfg(feature = "parallel")] + let built: Result, Error> = items.into_par_iter().map(run_one).collect(); + #[cfg(not(feature = "parallel"))] + let built: Result, Error> = items.into_iter().map(run_one).collect(); done.lock().expect("logup results").extend(built?); Ok(()) } @@ -235,6 +237,7 @@ fn assemble( resident: &mut Resident, challenge: &Challenge, ) -> Result>, Error> { + #[cfg(feature = "parallel")] use rayon::prelude::*; let order = &challenge.order; let airs = &challenge.airs; @@ -299,10 +302,16 @@ fn assemble( .ok_or_else(|| Error::Prover(format!("logup phase: page {i} is not in the layout")))?; jobs.push((idx, air, trace)); } + #[cfg(feature = "parallel")] let built: Vec<(usize, StarkProof)> = jobs .into_par_iter() .map(|(idx, air, trace)| build(idx, air, trace).map(|proof| (idx, proof))) .collect::>()?; + #[cfg(not(feature = "parallel"))] + let built: Vec<(usize, StarkProof)> = jobs + .into_iter() + .map(|(idx, air, trace)| build(idx, air, trace).map(|proof| (idx, proof))) + .collect::>()?; for (idx, proof) in built { slots[idx] = Some(proof); } @@ -409,29 +418,31 @@ fn deep_batch( done: &Deeps, items: Vec, ) -> Result<(), Error> { + #[cfg(feature = "parallel")] use rayon::prelude::*; let order = &challenge.order; let n = order.len(); - let built: Result, Error> = items - .into_par_iter() - .map(|(kind, chunk, mut trace)| { - let idx = order.index_of(kind, chunk).ok_or_else(|| { - Error::Prover(format!( - "batched phase: {kind:?} chunk {chunk} is not in the layout" - )) - })?; - let mut transcript = fork(&challenge.transcript, idx, n); - let deep = deep_of( - airs.get(kind).as_ref(), - &mut trace, - &challenge.challenges, - &mut transcript, - challenge.roots.get(idx).cloned(), - ) - .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; - Ok((idx, deep)) - }) - .collect(); + let run_one = |(kind, chunk, mut trace): Item| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "batched phase: {kind:?} chunk {chunk} is not in the layout" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let deep = deep_of( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + challenge.roots.get(idx).cloned(), + ) + .map_err(|e| Error::Prover(format!("batched phase: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, deep)) + }; + #[cfg(feature = "parallel")] + let built: Result, Error> = items.into_par_iter().map(run_one).collect(); + #[cfg(not(feature = "parallel"))] + let built: Result, Error> = items.into_iter().map(run_one).collect(); // Folded in a fixed order within the batch, so the sequence is a function // of the walk and not of which thread finished first. let mut built = built?; @@ -548,6 +559,7 @@ pub fn run_batched( let airs = &challenge.airs; let n = order.len(); { + #[cfg(feature = "parallel")] use rayon::prelude::*; let mut state = done.lock().expect("fold state"); let build = |idx: usize, @@ -600,10 +612,16 @@ pub fn run_batched( })?; jobs.push((idx, air, trace)); } + #[cfg(feature = "parallel")] let deeps: Vec<(usize, Deep)> = jobs .into_par_iter() .map(|(idx, air, trace)| build(idx, air, trace)) .collect::>()?; + #[cfg(not(feature = "parallel"))] + let deeps: Vec<(usize, Deep)> = jobs + .into_iter() + .map(|(idx, air, trace)| build(idx, air, trace)) + .collect::>()?; for (idx, deep) in deeps { fold_one(&mut state, idx, deep); } @@ -626,13 +644,9 @@ pub fn run_batched( // One FRI per accumulator, and the mapping from table to group. let mut group_of = vec![usize::MAX; n]; let sizes: Vec = acc.keys().copied().collect(); - for (&idx, _) in tables.iter().map(|(i, t)| (i, t)) { - let rows = tables - .iter() - .find(|(i, _)| *i == idx) - .map(|(_, t)| t.trace_rows) - .expect("table just listed"); - let lde = rows * proof_options.blowup_factor as usize; + for (idx, table) in tables.iter() { + let lde = table.trace_rows * proof_options.blowup_factor as usize; + let idx = *idx; group_of[idx] = sizes.iter().position(|s| *s == lde).ok_or_else(|| { Error::Prover(format!( "batched phase: table {idx} has no group of size {lde}" @@ -718,30 +732,32 @@ fn open_batch( done: &Opens, items: Vec, ) -> Result<(), Error> { + #[cfg(feature = "parallel")] use rayon::prelude::*; let order = &challenge.order; let n = order.len(); - let built: Result, Error> = items - .into_par_iter() - .map(|(kind, chunk, mut trace)| { - let idx = order.index_of(kind, chunk).ok_or_else(|| { - Error::Prover(format!( - "open pass: {kind:?} chunk {chunk} is not in the layout" - )) - })?; - let mut transcript = fork(&challenge.transcript, idx, n); - let opening = open_of( - airs.get(kind).as_ref(), - &mut trace, - &challenge.challenges, - &mut transcript, - iotas_of(batched, idx)?, - take_kept(batched, idx), - ) - .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; - Ok((idx, opening)) - }) - .collect(); + let run_one = |(kind, chunk, mut trace): Item| { + let idx = order.index_of(kind, chunk).ok_or_else(|| { + Error::Prover(format!( + "open pass: {kind:?} chunk {chunk} is not in the layout" + )) + })?; + let mut transcript = fork(&challenge.transcript, idx, n); + let opening = open_of( + airs.get(kind).as_ref(), + &mut trace, + &challenge.challenges, + &mut transcript, + iotas_of(batched, idx)?, + take_kept(batched, idx), + ) + .map_err(|e| Error::Prover(format!("open pass: {kind:?} chunk {chunk}: {e}")))?; + Ok((idx, opening)) + }; + #[cfg(feature = "parallel")] + let built: Result, Error> = items.into_par_iter().map(run_one).collect(); + #[cfg(not(feature = "parallel"))] + let built: Result, Error> = items.into_iter().map(run_one).collect(); done.lock().expect("openings").extend(built?); Ok(()) } @@ -872,12 +888,19 @@ pub fn run_open( jobs.push((idx, air, trace)); } { + #[cfg(feature = "parallel")] use rayon::prelude::*; - opens.extend( - jobs.into_par_iter() - .map(|(idx, air, trace)| build(idx, air, trace)) - .collect::, _>>()?, - ); + #[cfg(feature = "parallel")] + let opened = jobs + .into_par_iter() + .map(|(idx, air, trace)| build(idx, air, trace)) + .collect::, _>>()?; + #[cfg(not(feature = "parallel"))] + let opened = jobs + .into_iter() + .map(|(idx, air, trace)| build(idx, air, trace)) + .collect::, _>>()?; + opens.extend(opened); } opens.sort_by_key(|(idx, _)| *idx); @@ -902,7 +925,7 @@ pub fn run_open( /// The split is the whole point. A table keeps what only it can answer for — /// its roots, its out-of-domain values, its openings — and a group carries the /// FRI those tables share. That is the 57.9% of a per-table proof that stops -/// being paid 227 times. +/// being paid once per table. pub struct BatchedProof { /// Per table, in AIR order. pub tables: Vec, diff --git a/prover/src/pass.rs b/prover/src/pass.rs index e1b14c924..fb379fee4 100644 --- a/prover/src/pass.rs +++ b/prover/src/pass.rs @@ -9,7 +9,21 @@ //! //! Trading re-execution for memory is the whole bargain: a pass never keeps a //! chunk it has dealt with, so what it holds is one table plus the tables that -//! cannot be retired, no matter how long the run is. +//! cannot be retired. +//! +//! That bound is not O(1) in the length of the run, and the difference is worth +//! being precise about. Four chunked tables — LT, MUL, DVRM and SHIFT — are +//! deliberately absent from [`crate::tables::trace_builder::CHUNKED_KINDS`] +//! because later derivations keep appending to them (MEMW and HINT feed LT, +//! DVRM feeds LT and MUL, CPU32 feeds all three), so their chunk boundaries are +//! not knowable until the run ends. Their op lists, and the `retired_*` buffers +//! a closing MEMW/MEMW_A/CPU32 chunk converts its rows into, are held whole and +//! chunked only in [`finish`]. So is the walk's BITWISE lookup list. Those are +//! compact routed intermediates — tens of bytes per op against the hundreds a +//! trace row costs — and on the measured mainnet block they are a low single +//! digit percentage of the peak, but they grow with the execution, and a +//! workload that sends many wide memory accesses down the general MEMW path +//! (which emits up to eight LT ops each) grows them faster than this one does. use stark::proof::options::ProofOptions; @@ -61,8 +75,10 @@ pub trait Visitor { /// /// Up to 16 the peak does not move at all, because it is set at the end of the /// run by the tables that cannot be retired — BITWISE, DECODE, the pages — and -/// a batch of chunks is small beside them. At 32 the batch itself becomes the -/// peak (it moves to halfway through the run) and buys 1% of time for 5 GB. +/// by the op lists the walk holds whole (LT, MUL, DVRM, SHIFT and the walk's +/// BITWISE lookups; see this module's header), against which a batch of chunks +/// is small. At 32 the batch itself becomes the peak (it moves to halfway +/// through the run) and buys 1% of time for 5 GB. /// /// So the knee is 16 on that machine, and the bound is memory rather than /// cores: a smaller run has a smaller resident set for a batch to hide behind. diff --git a/prover/src/tables/decode.rs b/prover/src/tables/decode.rs index c92537f07..df197de27 100644 --- a/prover/src/tables/decode.rs +++ b/prover/src/tables/decode.rs @@ -172,9 +172,6 @@ pub fn generate_decode_trace( (trace, pc_to_row) } -/// Updates multiplicities in the DECODE trace table. -/// -/// For each PC in `lookups`, increments the MU column in the corresponding row. /// Add `count` lookups of `pc` at once. /// /// The per-lookup form needs one entry per executed cycle, which a prover that @@ -196,6 +193,9 @@ pub fn add_multiplicities( } } +/// Updates multiplicities in the DECODE trace table. +/// +/// For each PC in `lookups`, increments the MU column in the corresponding row. pub fn update_multiplicities( trace: &mut TraceTable, pc_to_row: &PcToRow, diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 96edfe646..5ffe32949 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1249,8 +1249,6 @@ fn collect_cpu32_bitwise(c: &cpu32::Cpu32Operation) -> Vec { ops } -/// The ALU-chip op a word ALU instruction dispatches (SHIFT/MUL/DVRM). ADDW/SUBW -/// are the CPU32 ADD/SUB fast-path (no external chip), returning `None`. /// What a Commit-phase walk still holds when the execution ends. /// /// The chunks it closed are gone — committed and dropped as they filled. This @@ -1439,9 +1437,6 @@ impl WalkLeftover { /// whose rows are the lookup space — only its multiplicity columns depend /// on the run — so it is built once, at the end, and never chunked. /// - /// Only the three sources a walk can retire are folded so far. The rest — - /// LT, MUL, DVRM, SHIFT, the accelerators, PAGE — feed BITWISE too and are - /// not here yet. /// Every BITWISE lookup the run owes, from the retired chunks and from the /// tail — `finalize` folded both in, so this is complete whatever has been /// drained since. PAGE's own lookups are added by `build_pages`. @@ -1636,6 +1631,8 @@ pub struct AccumulatedTables { pub const CPU32_APPENDS_TO: [TableKind; 3] = [TableKind::Shift, TableKind::Mul, TableKind::Dvrm]; #[allow(clippy::type_complexity)] +/// The ALU-chip op a word ALU instruction dispatches (SHIFT/MUL/DVRM). ADDW/SUBW +/// are the CPU32 ADD/SUB fast-path (no external chip), returning `None`. fn cpu32_chip_op( c: &cpu32::Cpu32Operation, shift_ops: &mut Vec, @@ -2499,9 +2496,6 @@ fn private_input_bytes(private_input: &[u8]) -> Vec { .collect() } -/// Build the initial-memory image (byte address -> value) from the ELF segments -/// and the private-input region. Single source of "what memory starts as", read -/// by both `MemoryState` seeding and PAGE/bitwise init. /// Run-length encode the runtime (non-ELF) page bases into `(base, count)`. /// /// Zero-init pages are the runtime ones, so `init_values == None` identifies @@ -2541,6 +2535,9 @@ pub(crate) fn runtime_page_ranges( ranges } +/// Build the initial-memory image (byte address -> value) from the ELF segments +/// and the private-input region. Single source of "what memory starts as", read +/// by both `MemoryState` seeding and PAGE/bitwise init. pub(crate) fn build_initial_image(elf: &Elf, private_input: &[u8]) -> HashMap { let mut image: HashMap = HashMap::new(); for segment in &elf.data { @@ -3221,16 +3218,16 @@ pub struct CollectedEpoch { } impl CollectedEpoch { - /// The epoch's touched memory cells (sorted by address): the exact values - /// `build_traces` later stores in `Traces::touched_memory_cells` (both are - /// [`touched_cells_from_memory_state`] over the same immutable - /// `memory_state`), available before any table is built. /// Ops collected for `kind`. Mirrors [`CollectedOps::buffered`] so a walk's /// output and a finished run's can be compared on the same footing. pub fn op_count(&self, kind: TableKind) -> usize { self.ops.buffered(kind) } + /// The epoch's touched memory cells (sorted by address): the exact values + /// `build_traces` later stores in `Traces::touched_memory_cells` (both are + /// [`touched_cells_from_memory_state`] over the same immutable + /// `memory_state`), available before any table is built. pub fn touched_memory_cells(&self) -> local_to_global::EpochTouches { touched_cells_from_memory_state(&self.memory_state) } @@ -4229,7 +4226,10 @@ fn build_traces( { base.add_ops(&bitwise_ops); memw_register::collect_bitwise_from_memw_register(&memw_register_rows, &mut base); - for f in &collectors { + // By value, like the parallel arm's `units.extend(collectors)`: these + // closures borrow the op lists, and the lists are moved into + // `CollectedOps` below, so the collectors have to be dropped here. + for f in collectors { f(&mut base); } } @@ -5490,20 +5490,18 @@ impl Traces { ) } - /// The sequential-critical half of an epoch's trace build: log collection - /// and op routing (Phases 1-2), which read the pre-epoch memory image and - /// produce the epoch's memory/register end state. This must run in epoch - /// order (the image advances between epochs); the table generation that - /// consumes the result ([`Self::build_from_collected`]) is epoch-local and - /// can run on another thread. /// Walk the execution and hand each chunked table's chunk to `on_chunk` as /// soon as it is full, dropping its ops right after. /// /// This is Approach 1's Commit phase seen from the producer side: the spec /// has the prover commit tables "once the memory pressure becomes too /// large" and drop them, which it can only do if the tables arrive while - /// the execution is still being walked. Buffers here never exceed one - /// chunk per table, so what the walk holds does not grow with the run. + /// the execution is still being walked. The buffers of the kinds listed in + /// [`CHUNKED_KINDS`] never exceed one chunk each; the rest — the four the + /// paragraph below names, the `retired_*` rows a closing chunk converts its + /// ops into, the accumulators and the walk's own BITWISE lookups — are held + /// whole and grow with the run. See `crate::pass`'s header for the size of + /// that term. /// /// The chunks come out exactly as `ops.chunks(max_rows)` would cut them and /// each is built by the same generator, so a consumer sees byte-identical @@ -5739,6 +5737,12 @@ impl Traces { }) } + /// The sequential-critical half of an epoch's trace build: log collection + /// and op routing (Phases 1-2), which read the pre-epoch memory image and + /// produce the epoch's memory/register end state. This must run in epoch + /// order (the image advances between epochs); the table generation that + /// consumes the result ([`Self::build_from_collected`]) is epoch-local and + /// can run on another thread. pub fn collect_epoch( artifacts: &DecodeArtifacts, initial_image: &I, diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index c7501c341..8d083f88b 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -146,7 +146,6 @@ fn alpha_moves_when_any_table_moves() { lde_size: 8, trace_rows: 4, deep: Vec::new(), - air_index: seed as usize, bus_contribution: Some(FieldElement::::from(seed)), main_roots: stark::prover::MainRoots { precomputed: None, @@ -641,4 +640,51 @@ fn a_tampered_batched_proof_is_rejected() { rejected(&proof, "a layout with one more CPU chunk"); proof.table_counts.cpu -= 1; accepted(&proof); + + // A group's FRI layer commitment. This is the one thing batching actually + // relocated — per table before, per group now — so it is the commitment most + // worth pinning. It is bound only indirectly: the seed absorbs each layer + // root, so changing one moves the group's query indices. + // + // Groups at or below the terminal size commit no layers at all, so pick one + // that has some. Asserting that such a group exists keeps this from becoming + // a tamper that silently does nothing if the fixture's shape changes. + let with_layers = proof + .groups + .iter() + .position(|(_, fri)| !fri.layer_roots.is_empty()) + .expect("the fixture must produce at least one group that commits FRI layers"); + proof.groups[with_layers].1.layer_roots[0][0] ^= 1; + rejected(&proof, "a group's FRI layer root"); + proof.groups[with_layers].1.layer_roots[0][0] ^= 1; + accepted(&proof); + + // A table's round-1 main commitment. + proof.tables[0].main_root[0] ^= 1; + rejected(&proof, "a main trace root"); + proof.tables[0].main_root[0] ^= 1; + accepted(&proof); + + // A table's composition commitment, which the fold seed absorbs directly. + proof.tables[0].composition_poly_root[0] ^= 1; + rejected(&proof, "a composition root"); + proof.tables[0].composition_poly_root[0] ^= 1; + accepted(&proof); + + // An out-of-domain value, the other thing the fold seed binds. + let orig = *proof.tables[0].trace_ood.get(0, 0); + proof.tables[0].trace_ood.set(0, 0, &orig + &one); + rejected(&proof, "an out-of-domain trace value"); + proof.tables[0].trace_ood.set(0, 0, orig); + accepted(&proof); + + // A block whose advertised dimensions disagree with its data length. The + // verifier must REJECT this, not panic: the fold seed indexes both + // out-of-domain blocks at the dimensions the proof declares, so the shape + // has to be pinned to the AIR before that read rather than after it. + let orig_width = proof.tables[0].trace_ood.width; + proof.tables[0].trace_ood.width += 1; + rejected(&proof, "an out-of-domain block with a lying width"); + proof.tables[0].trace_ood.width = orig_width; + accepted(&proof); } diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index dcd180464..045d31a7d 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -1,6 +1,7 @@ #[cfg(all(test, feature = "disk-spill"))] pub mod auto_storage_tests; -mod batched_fri_tests; +#[cfg(test)] +pub mod batched_fri_tests; #[cfg(test)] pub mod bitwise_bus_tests; #[cfg(test)] @@ -11,7 +12,8 @@ pub mod branch_bus_tests; pub mod branch_constraints_tests; #[cfg(test)] pub mod bytewise_tests; -mod challenge_phase_tests; +#[cfg(test)] +pub mod challenge_phase_tests; #[cfg(test)] pub mod commit_tests; #[cfg(test)] diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index c9742f203..da68f7676 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -1277,13 +1277,32 @@ fn chunk_shape_matches_the_built_chunk() { let lt_ops: Vec<_> = (0..8u64) .map(|i| crate::tables::lt::LtOperation::new(i % 3, i % 3 + 1, false)) .collect(); + // SHIFT is a plain kind (no dedup): 20 ops over a limit of 8 give chunks of + // 8/8/4, i.e. row counts of 8/8/4. Sizes above 4 matter — an empty op list + // pads to 4, so a fixture whose chunks all land on 4 compares the padding + // floor against itself and would pass even if the row rule were wrong. + let shift_ops: Vec<_> = (0..20u64) + .map(|i| crate::tables::shift::ShiftOperation::new(i, i % 5, false, false, false)) + .collect(); + // MUL deduplicates like LT, and its rows are keyed on the op with the lo/hi + // flag folded into the multiplicity: 12 entries, 3 distinct. + let mul_ops: Vec<_> = (0..6u64) + .flat_map(|i| { + let op = crate::tables::mul::MulOperation::new(i % 3, false, i % 3 + 1, false); + [(op.clone(), false), (op, true)] + }) + .collect(); let routed = CollectedOps { lt_ops, + shift_ops, + mul_ops, ..Default::default() }; let max_rows = crate::tables::MaxRowsConfig { lt: 16, + shift: 8, + mul: 16, ..Default::default() }; @@ -1292,7 +1311,24 @@ fn chunk_shape_matches_the_built_chunk() { 4, "the LT fixture must exercise deduplication (8 ops, 3 distinct)" ); + assert_eq!( + routed.chunk_shape(TableKind::Mul, 0, &max_rows).0, + 4, + "the MUL fixture must exercise deduplication (12 entries, 3 distinct)" + ); + assert_eq!( + routed.num_chunks(TableKind::Shift, &max_rows), + 3, + "the SHIFT fixture must split into several chunks, or the plain path is \ + only ever checked on one" + ); + assert_eq!( + routed.chunk_shape(TableKind::Shift, 0, &max_rows).0, + 8, + "the SHIFT fixture must produce chunks wider than the 4-row padding floor" + ); + let mut populated = 0usize; for kind in [ TableKind::Cpu, TableKind::Memw, @@ -1316,8 +1352,19 @@ fn chunk_shape_matches_the_built_chunk() { (built.num_rows(), built.num_main_columns), "{kind:?} chunk {chunk}: declared shape differs from the built one" ); + if routed.buffered(kind) > 0 { + populated += 1; + } } } + // A kind with no ops pads to the same 4 rows on both sides, so it pins the + // column width and nothing else. Without at least a few populated kinds this + // whole loop is a constant compared against itself. + assert!( + populated >= 3, + "the fixture must give several kinds real ops, or the row half of the \ + comparison is vacuous (populated chunks: {populated})" + ); } /// Collecting an execution chunk by chunk must produce exactly what collecting From f96026271221bbe5515d3a9e0f817369961374fd Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 18 Sep 2026 23:50:34 -0300 Subject: [PATCH 62/63] Verify a batched proof inside the VM The batched proof had no recursion guest, so what it costs an outer prover was guesswork. Give `BatchedProof` and the two types it still lacked an rkyv layout, move it to its own module so it compiles without `parallel`, let the batched verifier take the ELF-only preprocessed roots instead of recomputing them in-VM, and add the `batched` guest variant with its dump path. An `owned` variant comes with it: the same monolithic blob read through one rkyv deserialize instead of in place, so that trade can be measured rather than assumed. It costs 7 110 292 406 guest cycles against 6 681 722 024 and twice the memory, which settles it the other way round from the guess. On the mainnet block the batched proof costs 5 308 306 439 guest cycles against the monolithic proof's 6 681 722 024, with a 188 MB blob instead of 442 MB and a third of the keccak calls. --- Makefile | 25 ++++- bench_vs/lambda/recursion/Cargo.toml | 24 +++++ bench_vs/lambda/recursion/src/main.rs | 18 +++- crypto/stark/src/prover.rs | 10 ++ prover/src/batched_proof.rs | 60 +++++++++++ prover/src/batched_verifier.rs | 19 +++- prover/src/lib.rs | 1 + prover/src/logup_phase.rs | 48 +-------- prover/src/recursion.rs | 129 +++++++++++++++++++++++ prover/src/tables/page.rs | 2 +- prover/src/tests/recursion_smoke_test.rs | 61 +++++++++++ 11 files changed, 344 insertions(+), 53 deletions(-) create mode 100644 prover/src/batched_proof.rs diff --git a/Makefile b/Makefile index cf794e081..529e1b7da 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,15 @@ RECURSION_VERIFIER_PRESETS := min blowup2 blowup4 blowup8 # `continuation` feature: verify a multi-epoch ContinuationProof bundle instead # of a monolithic VmProof. Only the presets the benchmarks actually measure. RECURSION_CONT_PRESETS := min blowup2 blowup4 +# `batched` feature: verify a BatchedProof (one FRI per domain height) instead +# of a monolithic VmProof. +RECURSION_BATCHED_PRESETS := min blowup2 +# `owned` feature: the monolithic proof, deserialized instead of read in place. +RECURSION_OWNED_PRESETS := blowup2 RECURSION_VERIFIER_ARTIFACTS := $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-, $(addsuffix .elf, $(RECURSION_VERIFIER_PRESETS))) \ - $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-cont-, $(addsuffix .elf, $(RECURSION_CONT_PRESETS))) + $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-cont-, $(addsuffix .elf, $(RECURSION_CONT_PRESETS))) \ + $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-batched-, $(addsuffix .elf, $(RECURSION_BATCHED_PRESETS))) \ + $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-owned-, $(addsuffix .elf, $(RECURSION_OWNED_PRESETS))) # Override with: make ... SYSROOT_DIR=$HOME/.lambda-vm-sysroot # to install the sysroot in a user-writable location and avoid sudo. @@ -248,6 +255,22 @@ $(RECURSION_ARTIFACTS_DIR)/recursion-cont-$(1).elf: FORCE | prepare-sysroot $(RE endef $(foreach preset,$(RECURSION_CONT_PRESETS),$(eval $(call recursion_cont_verifier_rule,$(preset)))) +# Batched variants: same crate, `batched` feature on top of the preset feature +# -> recursion-batched--bench -> recursion-batched-.elf. +define recursion_batched_verifier_rule +$(RECURSION_ARTIFACTS_DIR)/recursion-batched-$(1).elf: FORCE | prepare-sysroot $(RECURSION_ARTIFACTS_DIR) + $$(call build_guest_elf,$$(RECURSION_GUESTS_DIR)/recursion,recursion-batched-$(1)-bench,--features "batched $(1)") +endef +$(foreach preset,$(RECURSION_BATCHED_PRESETS),$(eval $(call recursion_batched_verifier_rule,$(preset)))) + +# Owned variants: same crate and the same blob as the default guest, read via +# one rkyv deserialize instead of in place. +define recursion_owned_verifier_rule +$(RECURSION_ARTIFACTS_DIR)/recursion-owned-$(1).elf: FORCE | prepare-sysroot $(RECURSION_ARTIFACTS_DIR) + $$(call build_guest_elf,$$(RECURSION_GUESTS_DIR)/recursion,recursion-owned-$(1)-bench,--features "owned $(1)") +endef +$(foreach preset,$(RECURSION_OWNED_PRESETS),$(eval $(call recursion_owned_verifier_rule,$(preset)))) + clean-asm: -rm -rf $(ASM_ARTIFACTS_DIR) diff --git a/bench_vs/lambda/recursion/Cargo.toml b/bench_vs/lambda/recursion/Cargo.toml index cc4d00a70..cf14deaef 100644 --- a/bench_vs/lambda/recursion/Cargo.toml +++ b/bench_vs/lambda/recursion/Cargo.toml @@ -21,6 +21,15 @@ blowup8 = [] # memory-bounded inner prove) instead of a monolithic VmProof. Selects the # `recursion-cont--bench` bins below. continuation = [] +# Orthogonal to the presets: verify a BatchedProof (one FRI per domain height +# instead of one per table) instead of a monolithic VmProof. Selects the +# `recursion-batched--bench` bins below. Mutually exclusive with +# `continuation` — main.rs guards it. +batched = [] +# Orthogonal to the presets: verify the same monolithic VmProof as the default +# guest, but deserialized into owned values instead of read in place. Exists to +# price zero-copy against an owned copy in guest cycles. +owned = [] # One distinctly named binary per preset (selected by its feature) so a parallel # `make -j` builds them to different filenames — structurally race-free, no cp @@ -60,6 +69,21 @@ name = "recursion-cont-blowup4-bench" path = "src/main.rs" required-features = ["continuation", "blowup4"] +[[bin]] +name = "recursion-batched-min-bench" +path = "src/main.rs" +required-features = ["batched", "min"] + +[[bin]] +name = "recursion-batched-blowup2-bench" +path = "src/main.rs" +required-features = ["batched", "blowup2"] + +[[bin]] +name = "recursion-owned-blowup2-bench" +path = "src/main.rs" +required-features = ["owned", "blowup2"] + [dependencies] lambda-vm-prover = { path = "../../../prover", default-features = false, features = [ "profile-markers", diff --git a/bench_vs/lambda/recursion/src/main.rs b/bench_vs/lambda/recursion/src/main.rs index 1a846109b..5cae557c1 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -53,6 +53,12 @@ compile_error!("select exactly one of the `min`/`blowup2`/`blowup4`/`blowup8` fe all(feature = "blowup4", feature = "blowup8"), ))] compile_error!("select exactly one of the `min`/`blowup2`/`blowup4`/`blowup8` features"); +#[cfg(any( + all(feature = "continuation", feature = "batched"), + all(feature = "continuation", feature = "owned"), + all(feature = "batched", feature = "owned"), +))] +compile_error!("`continuation`, `batched` and `owned` are mutually exclusive proof layouts"); /// The build preset fixing the inner `ProofOptions` (see the module docs). #[cfg(feature = "min")] @@ -88,7 +94,7 @@ pub fn main() -> ! { // not self-enforcing here. let options = PRESET.options(); - #[cfg(not(feature = "continuation"))] + #[cfg(not(any(feature = "continuation", feature = "batched", feature = "owned")))] let attestation = lambda_vm_prover::recursion::verify_and_attest_blob(blob, &options) .expect("verify errored") .expect("inner proof failed verification"); @@ -98,6 +104,16 @@ pub fn main() -> ! { .expect("verify errored") .expect("inner continuation proof failed verification"); + #[cfg(feature = "batched")] + let attestation = lambda_vm_prover::recursion::verify_batched_and_attest(blob, &options) + .expect("verify errored") + .expect("inner batched proof failed verification"); + + #[cfg(feature = "owned")] + let attestation = lambda_vm_prover::recursion::verify_owned_and_attest(blob, &options) + .expect("verify errored") + .expect("inner proof failed verification"); + lambda_vm_syscalls::syscalls::commit(&attestation); lambda_vm_syscalls::syscalls::sys_halt(); } diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 00c1e5a44..bc2672685 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -666,6 +666,16 @@ pub struct MainRoots { } /// One height group's FRI: the instance every member of the group folds into. +#[derive( + Debug, + Clone, + serde::Serialize, + serde::Deserialize, + rkyv::Archive, + rkyv::Serialize, + rkyv::Deserialize, +)] +#[serde(bound = "")] pub struct GroupFri { pub layer_roots: Vec, pub final_poly_coeffs: Vec>, diff --git a/prover/src/batched_proof.rs b/prover/src/batched_proof.rs new file mode 100644 index 000000000..b83f0a3c5 --- /dev/null +++ b/prover/src/batched_proof.rs @@ -0,0 +1,60 @@ +//! The batched proof, as a value. +//! +//! Kept apart from the passes that build it so the recursion guest, which +//! compiles the prover without `parallel`, can carry and verify one. + +use math::field::element::FieldElement; + +use crate::tables::types::{GoldilocksExtension, GoldilocksField}; + +/// One table's DEEP openings at its group's query indices. +pub type Open = stark::proof::stark::DeepPolynomialOpenings; + +/// A table's half of a batched proof: everything it contributes that is not a +/// FRI, which is now its group's business. +/// +/// This is what the per-table `StarkProof` keeps once the layers, the final +/// polynomial, the queries and the nonce move to the group — the 57.9% of the +/// proof that stops being paid once per table. +#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct TablePublic { + pub trace_rows: usize, + pub main_root: stark::config::Commitment, + pub precomputed_root: Option, + pub aux_root: Option, + pub composition_poly_root: stark::config::Commitment, + pub trace_ood: stark::table::Table, + pub trace_ood_next: stark::table::Table, + pub parts_ood: Vec>, + pub bus_public_inputs: Option>, +} + +/// A batched proof: what the five passes produce, assembled. +/// +/// Additive, not a replacement. `StarkProof` and `multi_verify` are untouched +/// and still produce byte-identical proofs; this is a second format alongside +/// them, for the path that folds one FRI per domain instead of one per table. +/// +/// The split is the whole point. A table keeps what only it can answer for — +/// its roots, its out-of-domain values, its openings — and a group carries the +/// FRI those tables share. That is the 57.9% of a per-table proof that stops +/// being paid 227 times. +#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct BatchedProof { + /// Per table, in AIR order. + pub tables: Vec, + /// The chunk layout the tables follow; the verifier rebuilds the AIRs from it. + pub table_counts: crate::TableCounts, + /// Per table, in AIR order: its rows at its group's indices. + pub openings: Vec, + /// Which group each table belongs to. + pub group_of: Vec, + /// The AIR indices in the order they were folded, which the verifier + /// replays because a table's coefficient depends on every table before it. + pub fold_order: Vec, + /// Per group, in ascending domain: the FRI they share. + pub groups: Vec<(usize, stark::prover::GroupFri)>, + /// The statement, which the verifier binds before absorbing any root. + pub public_output: Vec, + pub page_configs: Vec, +} diff --git a/prover/src/batched_verifier.rs b/prover/src/batched_verifier.rs index 183796d16..a07987c96 100644 --- a/prover/src/batched_verifier.rs +++ b/prover/src/batched_verifier.rs @@ -20,7 +20,7 @@ use stark::proof::options::ProofOptions; use stark::proof::stark::StarkProof; use crate::Error; -use crate::logup_phase::BatchedProof; +use crate::batched_proof::BatchedProof; use crate::tables::trace_builder::Traces; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; @@ -140,6 +140,19 @@ pub fn verify( proof: &BatchedProof, elf_bytes: &[u8], proof_options: &ProofOptions, +) -> Result { + verify_with_precomputed(proof, elf_bytes, proof_options, None, None) +} + +/// [`verify`] with the ELF-only preprocessed roots supplied instead of +/// recomputed. The recursion guest holds them already; recomputing DECODE and +/// every data page in-VM is the single most expensive thing a verifier can do. +pub fn verify_with_precomputed( + proof: &BatchedProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + decode_commitment: Option, + page_commitments: Option<&[(u64, stark::config::Commitment)]>, ) -> Result { let table_counts = &proof.table_counts; table_counts.validate()?; @@ -192,10 +205,10 @@ pub fn verify( false, &page_configs, table_counts, - None, + decode_commitment, true, None, - None, + page_commitments, None, ); let airs = vm_airs.air_refs(); diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 097d43850..797cea5b7 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -12,6 +12,7 @@ #[cfg(feature = "disk-spill")] pub mod auto_storage; +pub mod batched_proof; pub mod batched_verifier; pub mod challenge_phase; pub mod commit_phase; diff --git a/prover/src/logup_phase.rs b/prover/src/logup_phase.rs index 40675661a..0aee6b87c 100644 --- a/prover/src/logup_phase.rs +++ b/prover/src/logup_phase.rs @@ -337,23 +337,7 @@ fn assemble( .collect() } -/// A table's half of a batched proof: everything it contributes that is not a -/// FRI, which is now its group's business. -/// -/// This is what the per-table `StarkProof` keeps once the layers, the final -/// polynomial, the queries and the nonce move to the group — the 57.9% of the -/// proof that stops being paid once per table. -pub struct TablePublic { - pub trace_rows: usize, - pub main_root: stark::config::Commitment, - pub precomputed_root: Option, - pub aux_root: Option, - pub composition_poly_root: stark::config::Commitment, - pub trace_ood: stark::table::Table, - pub trace_ood_next: stark::table::Table, - pub parts_ood: Vec>, - pub bus_public_inputs: Option>, -} +pub use crate::batched_proof::{BatchedProof, Open, TablePublic}; /// One FRI per height group, instead of one per table. pub struct Batched { @@ -714,7 +698,6 @@ pub struct Opened { pub resident: Resident, } -type Open = stark::proof::stark::DeepPolynomialOpenings; type Opens = std::sync::Mutex>; struct OpenTables<'a> { @@ -952,35 +935,6 @@ pub fn run_open( }) } -/// A batched proof: what the five passes produce, assembled. -/// -/// Additive, not a replacement. `StarkProof` and `multi_verify` are untouched -/// and still produce byte-identical proofs; this is a second format alongside -/// them, for the path that folds one FRI per domain instead of one per table. -/// -/// The split is the whole point. A table keeps what only it can answer for — -/// its roots, its out-of-domain values, its openings — and a group carries the -/// FRI those tables share. That is the 57.9% of a per-table proof that stops -/// being paid once per table. -pub struct BatchedProof { - /// Per table, in AIR order. - pub tables: Vec, - /// The chunk layout the tables follow; the verifier rebuilds the AIRs from it. - pub table_counts: crate::TableCounts, - /// Per table, in AIR order: its rows at its group's indices. - pub openings: Vec, - /// Which group each table belongs to. - pub group_of: Vec, - /// The AIR indices in the order they were folded, which the verifier - /// replays because a table's coefficient depends on every table before it. - pub fold_order: Vec, - /// Per group, in ascending domain: the FRI they share. - pub groups: Vec<(usize, stark::prover::GroupFri)>, - /// The statement, which the verifier binds before absorbing any root. - pub public_output: Vec, - pub page_configs: Vec, -} - /// Assemble what the batched and Open passes produced. /// /// Takes them rather than running them, so the two walks stay independently diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index efca722c9..39c04edbf 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -193,6 +193,135 @@ pub fn encode_continuation_guest_input( Ok(blob) } +/// [`verify_and_attest_blob`], but the monolithic blob is deserialized into +/// owned values first instead of being read in place. +/// +/// Same proof, same verifier, same attestation — only the read path differs. +/// Zero-copy saves the guest a full owned copy of the proof; what it costs is +/// an archived-pointer dereference on every field access, and the verifier +/// touches most fields many times. This exists to price that trade in guest +/// cycles, which is what an outer prover pays for. +pub fn verify_owned_and_attest( + blob: &[u8], + proof_options: &ProofOptions, +) -> Result>, Error> { + use rkyv::rancor::Error as RkyvError; + + let archive_bytes = crate::recursion_archive_bytes(blob) + .ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?; + let mut aligned_fallback = rkyv::util::AlignedVec::<{ crate::RECURSION_INPUT_ALIGN }>::new(); + let archive: &[u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(crate::RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + &aligned_fallback + }; + let input = rkyv::from_bytes::(archive) + .map_err(|e| Error::Execution(format!("blob validation failed: {e}")))?; + + let ok = crate::verify_with_options( + &input.vm_proof, + &input.inner_elf, + proof_options, + Some(input.decode_commitment), + Some(&input.page_commitments), + )?; + if !ok { + return Ok(None); + } + + let id = program_id_from_elf( + &input.inner_elf, + &input.decode_commitment, + &input.page_commitments, + )?; + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&input.vm_proof.public_output); + Ok(Some(attestation)) +} + +/// The batched guest's private-input layout (the `batched` guest feature). +/// Mirrors [`crate::GuestInput`] with the monolithic proof replaced by a +/// [`BatchedProof`]: one FRI per domain height instead of one per table. +/// Rkyv-archived on the same magic-prefixed wire format as the other two +/// blobs; the guest is feature-pinned to one layout, and a blob of another +/// kind fails the bytecheck validation. +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct BatchedGuestInput { + pub proof: crate::batched_proof::BatchedProof, + pub inner_elf: Vec, + pub decode_commitment: Commitment, + pub page_commitments: Vec<(u64, Commitment)>, +} + +/// Build the batched guest's private-input blob for `proof` of `inner_elf`. +pub fn encode_batched_guest_input( + proof: crate::batched_proof::BatchedProof, + inner_elf: &[u8], + opts: &ProofOptions, +) -> Result, Error> { + let (decode_commitment, page_commitments) = precomputed_commitments(inner_elf, opts)?; + let input = BatchedGuestInput { + proof, + inner_elf: inner_elf.to_vec(), + decode_commitment, + page_commitments, + }; + let archive = rkyv::to_bytes::(&input) + .map_err(|e| Error::Execution(format!("rkyv encode failed: {e}")))?; + let mut blob = Vec::with_capacity(crate::RECURSION_INPUT_PREFIX_LEN + archive.len()); + blob.extend_from_slice(&crate::RECURSION_INPUT_MAGIC); + blob.extend_from_slice(&crate::RECURSION_INPUT_VERSION.to_le_bytes()); + blob.extend_from_slice(&[0u8; 4]); // reserved + blob.extend_from_slice(&archive); + Ok(blob) +} + +/// [`verify_and_attest_blob`]'s logic for a batched proof: verify it against +/// the supplied roots and attest `program_id(elf, roots) || public_output`. +/// The batched verifier works on owned values, so the archive is deserialized +/// once rather than read in place. +pub fn verify_batched_and_attest( + blob: &[u8], + proof_options: &ProofOptions, +) -> Result>, Error> { + use rkyv::rancor::Error as RkyvError; + + let archive_bytes = crate::recursion_archive_bytes(blob).ok_or_else(|| { + Error::Execution(String::from("batched recursion blob: bad magic or version")) + })?; + let mut aligned_fallback = rkyv::util::AlignedVec::<{ crate::RECURSION_INPUT_ALIGN }>::new(); + let archive: &[u8] = + if (archive_bytes.as_ptr() as usize).is_multiple_of(crate::RECURSION_INPUT_ALIGN) { + archive_bytes + } else { + aligned_fallback.extend_from_slice(archive_bytes); + &aligned_fallback + }; + let input = rkyv::from_bytes::(archive) + .map_err(|e| Error::Execution(format!("batched blob validation failed: {e}")))?; + + if !crate::batched_verifier::verify_with_precomputed( + &input.proof, + &input.inner_elf, + proof_options, + Some(input.decode_commitment), + Some(&input.page_commitments), + )? { + return Ok(None); + } + + let id = program_id_from_elf( + &input.inner_elf, + &input.decode_commitment, + &input.page_commitments, + )?; + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&input.proof.public_output); + Ok(Some(attestation)) +} + /// Domain tag for [`program_id`]. const PROGRAM_ID_TAG: &[u8] = b"LAMBDAVM_PROGRAM_ID_V1"; diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index 6788bee08..6c2425839 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -111,7 +111,7 @@ pub struct FinalByteState { pub type FinalStateMap = HashMap; /// Configuration for a single PAGE table instance. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub struct PageConfig { /// Base address of this page (must be page-aligned). pub page_base: u64, diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index d1fd2009e..335c491bf 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -1036,6 +1036,67 @@ fn test_dump_recursion_input() { // public output), computed here while the `ContinuationProof` bundle // still exists (`encode_continuation_guest_input` consumes it) — lets a // consumer check the pre-proved fixture without re-deriving it. + // `RECURSION_DUMP_BATCHED=1` proves the inner program with the + // prove-and-retire pipeline's batched path and dumps a `BatchedProof` + // blob for the `batched` guest, so the two proof layouts can be compared + // on guest cycles. + if std::env::var("RECURSION_DUMP_BATCHED").is_ok() { + let opts = preset.options(); + let elf = executor::elf::Elf::load(&inner_elf_bytes).expect("load inner ELF"); + let max_rows = crate::tables::MaxRowsConfig::default(); + eprintln!( + "[dump-input] proving inner batched (blowup={}, fri_queries={}) ...", + opts.blowup_factor, opts.fri_number_of_queries + ); + let committed = crate::commit_phase::run_to_end(&elf, &inner_input, &max_rows, &opts) + .expect("commit pass"); + let challenge = crate::challenge_phase::run(&committed, &elf, &inner_elf_bytes, &opts) + .expect("challenge pass"); + drop(committed); + let batched = + crate::logup_phase::run_batched(&elf, &inner_input, &max_rows, &opts, &challenge) + .expect("batched pass"); + let opened = crate::logup_phase::run_open( + &elf, + &inner_input, + &max_rows, + &opts, + &challenge, + &batched, + ) + .expect("open pass"); + let proof = crate::logup_phase::assemble_batched_proof(batched, opened) + .expect("assemble batched proof"); + assert!( + crate::batched_verifier::verify(&proof, &inner_elf_bytes, &opts) + .expect("batched verify errored"), + "batched proof must verify on host before dumping" + ); + let public_output = proof.public_output.clone(); + let (decode, pages) = + crate::recursion::precomputed_commitments(&inner_elf_bytes, &opts).expect("roots"); + let id = crate::recursion::program_id_from_elf(&inner_elf_bytes, &decode, &pages) + .expect("program id"); + let blob = crate::recursion::encode_batched_guest_input(proof, &inner_elf_bytes, &opts) + .expect("encode batched guest input"); + eprintln!("[dump-input] batched blob bytes: {}", blob.len()); + assert!( + blob.len() <= executor::vm::memory::MAX_PRIVATE_INPUT_SIZE as usize, + "batched recursion input exceeds MAX_PRIVATE_INPUT_SIZE" + ); + let path = "/tmp/recursion_input.bin"; + std::fs::write(path, &blob).expect("write blob"); + let mut sidecar = id.to_vec(); + sidecar.extend_from_slice(&public_output); + std::fs::write(format!("{path}.expected"), &sidecar).expect("write sidecar"); + eprintln!( + "[dump-input] preset={} inner={inner_label} wrote {} bytes to {path}", + preset.name(), + blob.len() + ); + return; + } + let (blob, expected_sidecar) = match std::env::var("RECURSION_DUMP_EPOCH_LOG2") { Ok(s) => { // No recursion-cont-blowup8.elf is built (RECURSION_CONT_PRESETS From 95bd62bcf4f5a63ee3f27b8c0a1856231c9c6030 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Mon, 21 Sep 2026 18:18:34 -0300 Subject: [PATCH 63/63] Reject a lying width when the proof is bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_tampered_batched_proof_is_rejected` mounts every one of its attacks on a `BatchedProof` in memory, which was the only way to mount them: the type had no derives, and #995's own note says that is why the malformed out-of-domain block it hoists the shape checks for was unreachable from bytes. It has derives now, and serializing the batched format is the point of the path that uses it — the guest reads a blob. So the last of those arms, an out-of-domain block whose advertised width disagrees with its data, is worth mounting the way it would actually arrive. The untouched proof attests after the same round trip first, so what the second half catches is the tamper rather than the encoding. Which layer rejects the tampered blob is deliberately not asserted: rkyv's validation and the verifier's shape check both stand between the bytes and an attestation, and pinning one of them would make the test fail the day the other catches it first. What is asserted is the property that matters — a blob with a lying width yields no attestation, and does not panic. --- prover/src/tests/batched_fri_tests.rs | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/prover/src/tests/batched_fri_tests.rs b/prover/src/tests/batched_fri_tests.rs index 8d083f88b..6df85c4f9 100644 --- a/prover/src/tests/batched_fri_tests.rs +++ b/prover/src/tests/batched_fri_tests.rs @@ -688,3 +688,43 @@ fn a_tampered_batched_proof_is_rejected() { proof.tables[0].trace_ood.width = orig_width; accepted(&proof); } + +/// The lying width again, delivered the way a guest gets it: as bytes. +/// +/// `a_tampered_batched_proof_is_rejected` mounts this on a proof in memory, +/// which was the only way to mount it while `BatchedProof` had no derives — +/// the reason the guard that rejects it was documented as unreachable from +/// bytes. Serializing the format is the point of the batched path, so the +/// shape has to be pinned on the way out of the archive too. +/// +/// Rejection may come from rkyv's validation or from the verifier's own shape +/// check; which one catches it is not the property under test. What is: a +/// blob whose advertised width disagrees with its data yields no attestation, +/// and does not panic. +#[test] +fn a_lying_width_is_rejected_when_the_proof_arrives_as_bytes() { + let (elf_bytes, proof_options, mut proof) = batched_proof_of_fib(); + + // The honest proof attests after the round trip, so what the second half + // catches is the tamper and not the encoding. + let blob = + crate::recursion::encode_batched_guest_input(proof.clone(), &elf_bytes, &proof_options) + .expect("encode the untouched proof"); + assert!( + crate::recursion::verify_batched_and_attest(&blob, &proof_options) + .expect("verify the untouched blob") + .is_some(), + "the untouched proof does not attest after a round trip through bytes" + ); + + proof.tables[0].trace_ood.width += 1; + let blob = crate::recursion::encode_batched_guest_input(proof, &elf_bytes, &proof_options) + .expect("encode the tampered proof"); + assert!( + !matches!( + crate::recursion::verify_batched_and_attest(&blob, &proof_options), + Ok(Some(_)) + ), + "a block with a lying width attested after a round trip through bytes" + ); +}