From 77ca0b1a70fed01c915ea3d4f2910819936df440 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 11:09:17 -0300 Subject: [PATCH 1/7] perf(verifier): compute each FRI query evaluation point once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step_3 computed the primary point (pow per query) for the batch inverse, and the DEEP reconstruction recomputed both the primary and symmetric points — 3 domain exponentiations per query per table. Compute the primary points once up front and share them; the symmetric point is the primary's negation (bit-reversed indices 2i/2i+1 differ by lde_length/2, and ω^(lde_length/2) = −1), so it costs one field negation instead of a pow. The batch inverse now consumes the shared vector. --- crypto/stark/src/verifier.rs | 49 ++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index ae26afbe9..b2b6cb644 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -401,6 +401,16 @@ pub trait IsStarkVerifier< FieldElement: AsBytes + Sync + Send, { crate::profile_markers::step_marker::<{ crate::profile_markers::STEP_VERIFY_FRI }>(); + // Each query's primary evaluation point is used twice — by the DEEP + // reconstruction (which also needs its symmetric counterpart, the + // negation) and by the FRI fold check below (which needs its + // inverse). Compute the points once here and share them, instead of + // recomputing a `pow` per use (3 domain exponentiations per query). + let evaluation_points = challenges + .iotas + .iter() + .map(|iota| Self::query_challenge_to_evaluation_point(*iota, domain)) + .collect::>>(); let (deep_poly_evaluations, deep_poly_evaluations_sym) = match Self::reconstruct_deep_composition_poly_evaluations_for_all_queries( challenges, @@ -409,6 +419,7 @@ pub trait IsStarkVerifier< ood_full, next_row_cols, step_size, + &evaluation_points, ) { Some(pair) => pair, None => return false, @@ -455,13 +466,9 @@ pub trait IsStarkVerifier< layout.terminal_len, ); - // verify FRI - let mut evaluation_point_inverse = challenges - .iotas - .iter() - .map(|iota| Self::query_challenge_to_evaluation_point(*iota, false, domain)) - .collect::>>(); - // Any zero evaluation point means a malformed query index, reject. + // verify FRI — reuse the points computed above; any zero evaluation + // point means a malformed query index, reject. + let mut evaluation_point_inverse = evaluation_points; if FieldElement::inplace_batch_inverse(&mut evaluation_point_inverse).is_err() { return false; } @@ -482,17 +489,16 @@ pub trait IsStarkVerifier< }) } - /// Returns the field element element of the domain `domain` corresponding to the given FRI query index challenge `iota`. - /// Returns the LDE-coset element for FRI query challenge `iota`. The - /// `sym` flag picks the symmetric counterpart (`iota*2+1`) instead of the - /// primary index (`iota*2`). + /// Returns the primary LDE-coset element for FRI query challenge `iota` + /// (index `2·iota` in the bit-reversed domain). The symmetric counterpart + /// (index `2·iota+1`) is its negation — bit-reversed indices `2i` and + /// `2i+1` differ only in the top bit, i.e. by `lde_length/2`, and + /// `ω^(lde_length/2) = −1` — so callers negate instead of recomputing. fn query_challenge_to_evaluation_point( iota: usize, - sym: bool, domain: &VerifierDomain, ) -> FieldElement { - let raw = iota * 2 + if sym { 1 } else { 0 }; - domain.lde_coset_element(reverse_index(raw, domain.lde_length as u64)) + domain.lde_coset_element(reverse_index(iota * 2, domain.lde_length as u64)) } /// Verify a row-paired `PolynomialOpenings` against `root`. The row pair @@ -825,6 +831,11 @@ pub trait IsStarkVerifier< ood_full: &Table, next_row_cols: &[usize], step_size: usize, + // Primary evaluation point per query, computed once by the caller + // (one entry per `challenges.iotas`) and shared with the FRI fold + // check. Each query's symmetric point is its negation (see + // `query_challenge_to_evaluation_point`). + evaluation_points: &[FieldElement], ) -> Option> { let num_queries = challenges.iotas.len(); @@ -856,7 +867,7 @@ pub trait IsStarkVerifier< step_size, )?; - for (i, iota) in challenges.iotas.iter().enumerate() { + for i in 0..challenges.iotas.len() { let opening = proof.deep_poly_opening(i); // Base-field portion as two borrowed slices in commit order — @@ -885,12 +896,12 @@ pub trait IsStarkVerifier< .map(|a| a.evaluations_sym()) .unwrap_or(&[]); - let evaluation_point = Self::query_challenge_to_evaluation_point(*iota, false, domain); - let evaluation_point_sym = - Self::query_challenge_to_evaluation_point(*iota, true, domain); + let evaluation_point = evaluation_points.get(i)?; + // The symmetric point is the primary's negation — no second `pow`. + let evaluation_point_sym = -evaluation_point; let (evaluation, evaluation_sym) = Self::reconstruct_deep_composition_poly_evaluation_pair( - &evaluation_point, + evaluation_point, &evaluation_point_sym, primitive_root, challenges, From f274fa2a41c439899584fbb19386708403233ca2 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 11:38:24 -0300 Subject: [PATCH 2/7] perf(continuations): hash the ELF once per bundle, not twice per epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A continuation verify hashed the entire inner ELF 2E+1 times: each verify_epoch built its seeded transcript twice (bus-balance replay + multi_verify) and each build ran a full-ELF Keccak inside absorb_statement. Compute the digest once in verify_continuation and thread it through verify_epoch/verify_global via the existing absorb_*_with_digest variants (adding the global-statement one), and clone the seeded transcript for the replay instead of reseeding. The absorbed bytes are identical, so this is transcript-neutral. Prove-side epoch/global transcripts take a precomputed digest too. Guest-cycle impact: one full-ELF Keccak per bundle instead of 2E+1 (e.g. a 1 MiB ELF ≈ 7.5k permutations ≈ 10M cycles per pass at the current ~1.3k cycles/permutation sponge-glue cost). --- prover/src/continuation.rs | 78 ++++++++++++++++++++------------------ prover/src/statement.rs | 27 ++++++++++++- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 2e5c56a8b..75f2ad991 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -60,7 +60,9 @@ use stark::trace::TraceTable; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; -use crate::statement::{StatementKind, absorb_continuation_global_statement, absorb_statement}; +use crate::statement::{ + StatementKind, absorb_continuation_global_statement_with_digest, absorb_statement_with_digest, +}; use crate::tables::local_to_global::{self, CellBoundary}; use crate::tables::page::{self, PageConfig}; use crate::tables::register; @@ -81,7 +83,7 @@ type AirRef<'a> = &'a dyn AIR; /// bus-balance replay all seed via this so their challenges match; the seeding /// pins each epoch proof to its program and position (replay protection). fn epoch_transcript( - elf_bytes: &[u8], + elf_digest: &[u8; 32], public_output: &[u8], table_counts: &TableCounts, runtime_page_ranges: &[RuntimePageRange], @@ -89,10 +91,10 @@ fn epoch_transcript( fri_final_poly_log_degree: u8, ) -> DefaultTranscript { let mut transcript = DefaultTranscript::::new(&[]); - absorb_statement( + absorb_statement_with_digest( &mut transcript, StatementKind::ContinuationEpoch { epoch_label }, - elf_bytes, + elf_digest, public_output, table_counts, // Continuation epochs skip PAGE (the L2G bookend replaces it), so they never @@ -107,16 +109,16 @@ fn epoch_transcript( /// Fresh transcript seeded with the global proof's statement (ELF + epoch count). /// `prove_global` and `verify_global` both seed via this so their challenges match. fn global_transcript( - elf_bytes: &[u8], + elf_digest: &[u8; 32], num_epochs: usize, num_private_input_pages: usize, fri_final_poly_log_degree: u8, touched_page_bases: &[u64], ) -> DefaultTranscript { let mut transcript = DefaultTranscript::::new(&[]); - absorb_continuation_global_statement( + absorb_continuation_global_statement_with_digest( &mut transcript, - elf_bytes, + elf_digest, num_epochs, num_private_input_pages, fri_final_poly_log_degree, @@ -483,16 +485,14 @@ fn prove_epoch( ); let label = start.label; - let seed = || { - epoch_transcript( - elf_bytes, - &public_output, - &table_counts, - &runtime_page_ranges, - label, - opts.fri_final_poly_log_degree, - ) - }; + let mut seed = epoch_transcript( + &crate::statement::elf_digest(elf_bytes), + &public_output, + &table_counts, + &runtime_page_ranges, + label, + opts.fri_final_poly_log_degree, + ); let l2g_air = l2g_memory_air(opts, label); // Build this epoch's L2G table from the cross-epoch boundary so it is identical @@ -504,7 +504,7 @@ fn prove_epoch( pairs.push((&l2g_air, &mut l2g_trace, &())); let proof = Prover::multi_prove( pairs, - &mut seed(), + &mut seed, #[cfg(feature = "disk-spill")] stark::storage_mode::StorageMode::Ram, ) @@ -538,7 +538,7 @@ fn prove_epoch( /// L2G root matches the claimed one. fn verify_epoch( elf: &Elf, - elf_bytes: &[u8], + elf_digest: &[u8; 32], epoch: &EpochProof, register_init: &[u32], is_final: bool, @@ -576,16 +576,17 @@ fn verify_epoch( let mut refs = airs.air_refs(); refs.push(&l2g_air); - let seed = || { - epoch_transcript( - elf_bytes, - &epoch.public_output, - &epoch.table_counts, - &epoch.runtime_page_ranges, - label, - opts.fri_final_poly_log_degree, - ) - }; + // One seeded transcript per epoch, cloned for the bus-balance replay — + // building it absorbs the statement, and the ELF digest arrives precomputed + // so no full-ELF Keccak runs per seed (previously two per epoch). + let mut seed = epoch_transcript( + elf_digest, + &epoch.public_output, + &epoch.table_counts, + &epoch.runtime_page_ranges, + label, + opts.fri_final_poly_log_degree, + ); // Start the commit index from the carried x254 (the derived INIT), not a free // input — this is what binds the per-epoch commit slice to its global position. @@ -599,13 +600,13 @@ fn verify_epoch( &epoch.proof, &epoch.public_output, commit_start_index, - &mut seed(), + &mut seed.clone(), ) { Some(expected) => expected, None => return false, }; - if !Verifier::multi_verify(&refs, &epoch.proof, &mut seed(), &expected) { + if !Verifier::multi_verify(&refs, &epoch.proof, &mut seed, &expected) { return false; } @@ -685,7 +686,7 @@ fn prove_global( Prover::multi_prove( pairs, &mut global_transcript( - elf_bytes, + &crate::statement::elf_digest(elf_bytes), boundaries.len(), num_private_input_pages, opts.fri_final_poly_log_degree, @@ -702,7 +703,7 @@ fn verify_global( page_bases: &[u64], proof: &MultiProof, elf: &Elf, - elf_bytes: &[u8], + elf_digest: &[u8; 32], num_private_input_pages: usize, opts: &ProofOptions, ) -> bool { @@ -734,7 +735,7 @@ fn verify_global( &refs, proof, &mut global_transcript( - elf_bytes, + elf_digest, num_epochs, num_private_input_pages, opts.fri_final_poly_log_degree, @@ -940,6 +941,11 @@ pub fn verify_continuation( let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; + // One full-ELF Keccak for the whole bundle: every epoch transcript and the + // global transcript absorb the same digest (previously each `verify_epoch` + // hashed the entire ELF twice — once per seeded transcript). + let elf_digest = crate::statement::elf_digest(elf_bytes); + let n = bundle.epochs.len(); if n == 0 { return Ok(None); @@ -968,7 +974,7 @@ pub fn verify_continuation( if !verify_epoch( &elf, - elf_bytes, + &elf_digest, epoch, ®ister_init, is_final, @@ -1016,7 +1022,7 @@ pub fn verify_continuation( &page_bases, &bundle.global, &elf, - elf_bytes, + &elf_digest, bundle.num_private_input_pages, opts, ) { diff --git a/prover/src/statement.rs b/prover/src/statement.rs index 81c18baa5..17eca2279 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -167,6 +167,9 @@ const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V2"; /// shape, exactly as the monolithic and epoch statements bind it), and the touched /// page-base set (which GLOBAL_MEMORY tables exist). /// Prove and verify must call this with identical arguments. +/// Test-only: production callers share one ELF digest per bundle via +/// [`absorb_continuation_global_statement_with_digest`]. +#[cfg(test)] pub(crate) fn absorb_continuation_global_statement( t: &mut impl IsTranscript, elf_bytes: &[u8], @@ -174,9 +177,31 @@ pub(crate) fn absorb_continuation_global_statement( num_private_input_pages: usize, fri_final_poly_log_degree: u8, touched_page_bases: &[u64], +) { + absorb_continuation_global_statement_with_digest( + t, + &elf_digest(elf_bytes), + num_epochs, + num_private_input_pages, + fri_final_poly_log_degree, + touched_page_bases, + ); +} + +/// [`absorb_continuation_global_statement`] with the ELF digest precomputed. +/// A continuation verify checks many transcripts against one ELF; sharing one +/// full-ELF Keccak pass across all of them mirrors +/// [`absorb_statement_with_digest`] (a full-ELF hash is expensive in-guest). +pub(crate) fn absorb_continuation_global_statement_with_digest( + t: &mut impl IsTranscript, + elf_digest: &[u8; 32], + num_epochs: usize, + num_private_input_pages: usize, + fri_final_poly_log_degree: u8, + touched_page_bases: &[u64], ) { t.append_bytes(CONTINUATION_GLOBAL_TAG); - t.append_bytes(&elf_digest(elf_bytes)); + t.append_bytes(elf_digest); t.append_bytes(&(num_epochs as u64).to_le_bytes()); t.append_bytes(&(num_private_input_pages as u64).to_le_bytes()); From 724678c7acd05e41fa36e0c22ab2711251bf1525 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 12:34:35 -0300 Subject: [PATCH 3/7] bench(scripts): ABBA bench for the continuation elf_digest cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves one ethrex continuation bundle (real program, many epochs) and times verify --continuations on main vs the fix branch, reporting the implied per-full-ELF-hash cost from the epoch count and ELF size. bench_verify.sh cannot see this fix — it only exercises the monolithic path, which already shared one digest. --- scripts/bench_elf_digest.sh | 161 ++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100755 scripts/bench_elf_digest.sh diff --git a/scripts/bench_elf_digest.sh b/scripts/bench_elf_digest.sh new file mode 100755 index 000000000..99c7c1b52 --- /dev/null +++ b/scripts/bench_elf_digest.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# bench_elf_digest.sh — measure the full-ELF Keccak cost on the CONTINUATION +# verify path (native wall-clock A/B), plus an estimate of the guest-cycle +# equivalent. This is the bench for the 2E+1 -> 1 elf_digest fix +# (perf/verifier-eval-points-elf-digest); bench_verify.sh cannot see it +# because it only exercises the monolithic path. +# +# What it does: +# 1. Builds the ethrex guest ELF + a big transfer fixture (real program, +# many epochs) — needs the sysroot toolchain (server). +# 2. Builds cli at REF_A (branch) and REF_B (baseline) in an isolated worktree. +# 3. Proves ONE continuation bundle with the baseline binary (the change is +# proof-format neutral: both binaries verify the same bundle). +# 4. Interleaved AB/BA timing of `verify --continuations`. +# 5. Reports mean times, delta, and — using the epoch count and ELF size — +# the implied per-full-ELF-hash cost, to compare against the theory: +# delta = 2 * epochs * ceil(elf_bytes/136) * per-permutation-cost. +# +# Usage: scripts/bench_elf_digest.sh [REF_A] [REF_B] [N_PAIRS] [TRANSFERS] [EPOCH_LOG2] +# REF_A default perf/verifier-eval-points-elf-digest, REF_B origin/main, +# N_PAIRS 10 (even), TRANSFERS 100, EPOCH_LOG2 20. +# Env: REBUILD=1 forces rebuild + re-prove. + +set -euo pipefail + +REF_A="${1:-perf/verifier-eval-points-elf-digest}" +REF_B="${2:-origin/main}" +N_PAIRS="${3:-10}" +TRANSFERS="${4:-100}" +EPOCH_LOG2="${5:-20}" + +ELF_REL="executor/program_artifacts/rust/ethrex.elf" +INPUT_REL="executor/tests/ethrex_bench_${TRANSFERS}.bin" +WORK="/tmp/elf_digest_bench" +WT="/tmp/elf_digest_wt" + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +if [ $((N_PAIRS % 2)) -ne 0 ]; then + echo "WARNING: N_PAIRS=$N_PAIRS is odd; use an even count so AB/BA orders balance." +fi + +mkdir -p "$WORK" +git fetch origin --quiet || echo "WARNING: fetch failed; resolving against possibly-stale refs." +SHA_A="$(git rev-parse "$REF_A")" +SHA_B="$(git rev-parse "$REF_B")" +echo "==> A (PR) $REF_A -> ${SHA_A:0:10}" +echo "==> B (base) $REF_B -> ${SHA_B:0:10}" + +# --- 1. Guest ELF + fixture (built once, shared) --- +if [ ! -f "$ELF_REL" ]; then + echo "==> Building ethrex guest ELF (needs sysroot)" + export SYSROOT_DIR="${SYSROOT_DIR:-$HOME/.lambda-vm-sysroot}" + make "$ELF_REL" +fi +if [ ! -f "$INPUT_REL" ]; then + echo "==> Generating ethrex ${TRANSFERS}-transfer fixture" + ( cd tooling/ethrex-fixtures && cargo build --release ) + tooling/ethrex-fixtures/target/release/ethrex-fixtures "$TRANSFERS" "$INPUT_REL" distinct +fi +ELF="$(cd "$(dirname "$ELF_REL")" && pwd)/$(basename "$ELF_REL")" +INPUT="$(cd "$(dirname "$INPUT_REL")" && pwd)/$(basename "$INPUT_REL")" +ELF_BYTES="$(wc -c < "$ELF" | tr -d '[:space:]')" +PERMS=$(( (ELF_BYTES + 135) / 136 )) +echo "==> ELF size: $ELF_BYTES bytes -> $PERMS Keccak permutations per full-ELF hash" + +# --- 2. Build both cli binaries (isolated worktree, shared target dir) --- +need_build=0 +if [ "${REBUILD:-0}" = "1" ] || [ ! -x "$WORK/cli_A" ] || [ ! -x "$WORK/cli_B" ]; then + need_build=1 +elif [ "$(cat "$WORK/cli_A.sha" 2>/dev/null)" != "$SHA_A" ] || \ + [ "$(cat "$WORK/cli_B.sha" 2>/dev/null)" != "$SHA_B" ]; then + need_build=1 +fi +if [ "$need_build" = "1" ]; then + cleanup() { git worktree remove --force "$WT" 2>/dev/null || true; } + trap cleanup EXIT + git worktree remove --force "$WT" 2>/dev/null || true + echo "==> Building both cli binaries in isolated worktree $WT" + git worktree add --detach "$WT" "$SHA_B" >/dev/null + build_cli() { + echo "==> Building cli @ ${1:0:10} -> $2" + git -C "$WT" checkout --quiet -f "$1" + ( cd "$WT" && cargo build --release -p cli >"$WORK/build_$2.log" 2>&1 ) \ + || { echo "ERROR: build failed for $2; tail of log:"; tail -30 "$WORK/build_$2.log"; exit 1; } + cp "$WT/target/release/cli" "$WORK/$2" + echo "$1" >"$WORK/$2.sha" + } + build_cli "$SHA_B" cli_B + build_cli "$SHA_A" cli_A + cleanup + trap - EXIT +else + echo "==> Reusing cached cli binaries (REBUILD=1 to force)" +fi + +# --- 3. Prove ONE continuation bundle with the baseline binary (format-neutral change) --- +BUNDLE="$WORK/bundle.bin" +if [ "${REBUILD:-0}" = "1" ] || [ ! -f "$BUNDLE" ]; then + echo "==> Proving continuation bundle (epochs of 2^$EPOCH_LOG2 cycles)" + "$WORK/cli_B" prove "$ELF" --private-input "$INPUT" -o "$BUNDLE" \ + --continuations --epoch-size-log2 "$EPOCH_LOG2" >"$WORK/prove.log" 2>&1 \ + || { echo "ERROR: prove failed; tail:"; tail -20 "$WORK/prove.log"; exit 1; } +else + echo "==> Reusing cached bundle" +fi + +# Epoch count: run the program once for its cycle count, derive E = ceil(cycles/2^log). +CYCLES="$("$WORK/cli_B" run "$ELF" --private-input "$INPUT" --cycles 2>/dev/null | grep 'Cycles:' | grep -oE '[0-9]+' | tail -1 || true)" +if [ -n "${CYCLES:-}" ] && [ "$CYCLES" -gt 0 ]; then + EPOCHS=$(( (CYCLES + (1 << EPOCH_LOG2) - 1) / (1 << EPOCH_LOG2) )) + echo "==> Program cycles: $CYCLES -> ~$EPOCHS epochs of 2^$EPOCH_LOG2" +else + EPOCHS=0 + echo "==> WARNING: could not count cycles; per-pass estimate will be skipped." +fi + +# --- 4. Interleaved AB/BA timing of verify --continuations --- +verify_time() { # $1=binary -> seconds or empty + local out + out="$("$1" verify "$BUNDLE" "$ELF" --continuations --time 2>&1)" || true + printf '%s\n' "$out" | grep -o 'Verification time: [0-9.]*' | awk '{print $3}' || true +} + +echo "==> Running $N_PAIRS interleaved pairs (negative delta = A faster)" +printf 'pair,a_time,b_time\n' > "$WORK/pairs.csv" +for i in $(seq 1 "$N_PAIRS"); do + a="$(verify_time "$WORK/cli_A")"; b="$(verify_time "$WORK/cli_B")" + [ -z "$a" ] || [ -z "$b" ] && { echo "ERROR: unparseable verify output"; "$WORK/cli_A" verify "$BUNDLE" "$ELF" --continuations --time; exit 1; } + printf '%d,%s,%s\n' "$i" "$a" "$b" >> "$WORK/pairs.csv" + printf ' pair %2d/%d A=%ss B=%ss %+0.2f%%\n' \ + "$i" "$N_PAIRS" "$a" "$b" "$(awk "BEGIN{print ($a-$b)/$b*100}")" +done + +# --- 5. Report --- +python3 - "$WORK/pairs.csv" "$ELF_BYTES" "$PERMS" "$EPOCHS" <<'PY' +import csv, sys +rows = list(csv.DictReader(open(sys.argv[1]))) +A = [float(r['a_time']) for r in rows] +B = [float(r['b_time']) for r in rows] +mA, mB = sum(A)/len(A), sum(B)/len(B) +elf_bytes, perms, epochs = int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]) +d = mA - mB +print(f"\n=== elf-digest continuation bench ===") +print(f" mean A (PR): {mA:.3f}s mean B (base): {mB:.3f}s delta: {d:+.3f}s ({(mA-mB)/mB*100:+.2f}%)") +if epochs > 0: + saved_passes = 2 * epochs # 2 seeded-transcript builds per epoch removed + secs_per_pass = -d / saved_passes if saved_passes else 0.0 + # Native software Keccak ~= 9000 cycles/perm; report both observed and theory. + theory = saved_passes * perms * 9000 / 3e9 # seconds at ~3GHz + print(f" removed passes: 2 x {epochs} epochs = {saved_passes} full-ELF hashes/bundle") + print(f" observed: {secs_per_pass*1000:.1f} ms per pass ({perms} perms)") + print(f" theory @9k cycles/perm, 3GHz: ~{theory:.3f}s total saved") + print(f" guest equivalent (pre-#847 sponge ~1.3k cyc/perm): {saved_passes*perms*1322/1e6:.1f}M cycles saved") +else: + print(f" (epoch count unknown; delta = 2*epochs*perms*per-perm-cost)") +PY + +echo "Artifacts + pairs.csv kept in $WORK" From c5638f5de2425dc0e6c001da6b12a1df3425c26d Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 12:39:31 -0300 Subject: [PATCH 4/7] make: bench-elf-digest target for the continuation digest A/B One command on the server: make bench-elf-digest (env overrides: N_PAIRS, TRANSFERS, EPOCH_LOG2, REF_A, REF_B). --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 110c2d31f..56717c768 100644 --- a/Makefile +++ b/Makefile @@ -405,3 +405,10 @@ lint: flamegraph-prover: cd crypto/stark && samply record cargo bench --bench profile_prover --features parallel + +# Continuation elf_digest A/B bench (branch vs main): proves one ethrex +# continuation bundle and times `verify --continuations` on both refs. +# Needs the guest sysroot (server). Overrides: N_PAIRS=20 TRANSFERS=100 make bench-elf-digest +.PHONY: bench-elf-digest +bench-elf-digest: + scripts/bench_elf_digest.sh "$${REF_A:-perf/verifier-eval-points-elf-digest}" "$${REF_B:-origin/main}" "$${N_PAIRS:-10}" "$${TRANSFERS:-20}" "$${EPOCH_LOG2:-20}" From 00ae02a52a77487098767a1d9fa7beb2afb3789d Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 14:00:42 -0300 Subject: [PATCH 5/7] perf(continuations): derive the DECODE commitment once per bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every epoch rebuilt its AIRs with decode_commitment=None, so VmAirs::new fell back to decode::commitment_from_elf — re-parsing every instruction, regenerating the DECODE trace, FFT-interpolating and LDE-evaluating each precomputed column, and Merkle-committing them (~1M Keccak hashes + the FFT batch for a 1 MiB program), byte-identical work on every epoch of both prove_continuation and verify_continuation. Compute the commitment once in the bundle drivers and thread it through prove_epoch/verify_epoch into build_epoch_airs. The recursion guest is unaffected (it already receives the root as supplied input); this is the native prove/verify path. --- prover/src/continuation.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 75f2ad991..2a1338281 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -65,7 +65,7 @@ use crate::statement::{ }; use crate::tables::local_to_global::{self, CellBoundary}; use crate::tables::page::{self, PageConfig}; -use crate::tables::register; +use crate::tables::{decode, register}; use crate::tables::trace_builder::{Traces, build_init_page_data, build_initial_image_paged}; use crate::tables::types::{GoldilocksExtension, GoldilocksField}; use crate::tables::{MaxRowsConfig, global_memory}; @@ -414,6 +414,7 @@ fn build_epoch_airs( register_init: &[u32], reg_fini: &[u32], is_final: bool, + decode_commitment: Commitment, ) -> VmAirs { // Continuation epochs preprocess FINI = R_{i+1} too (not just INIT = R_i), so the // final register file is a verifier-known public value bound by the REG-C2 @@ -429,7 +430,9 @@ fn build_epoch_airs( false, page_configs, table_counts, - None, + // Supplied by the caller, who computes it once per bundle: deriving it + // here would re-decode the ELF and re-commit the DECODE table per epoch. + Some(decode_commitment), is_final, None, None, @@ -450,6 +453,7 @@ fn prove_epoch( is_final: bool, boundary: &[CellBoundary], opts: &ProofOptions, + decode_commitment: Commitment, ) -> Result { // Count this L2G table's range-check lookups into the BITWISE table so its // AreBytes/IsHalfword multiplicities balance the range-check senders. @@ -482,6 +486,7 @@ fn prove_epoch( start.register_init, ®_fini, is_final, + decode_commitment, ); let label = start.label; @@ -544,6 +549,7 @@ fn verify_epoch( is_final: bool, label: u64, opts: &ProofOptions, + decode_commitment: Commitment, ) -> bool { // Reject degenerate table counts (mirrors the monolithic verifier). if epoch.table_counts.validate().is_err() { @@ -571,6 +577,7 @@ fn verify_epoch( register_init, &epoch.reg_fini, is_final, + decode_commitment, ); let l2g_air = l2g_memory_air(opts, label); let mut refs = airs.air_refs(); @@ -779,6 +786,11 @@ pub fn prove_continuation( let mut executor = Executor::new(&elf, private_inputs.to_vec()) .map_err(|e| Error::Execution(format!("{e}")))?; + // The DECODE preprocessed commitment depends only on the ELF — compute it + // once here rather than re-deriving it per epoch inside `build_epoch_airs`. + let decode_commitment = decode::commitment_from_elf(&elf, opts) + .map_err(|e| Error::Prover(format!("decode commitment: {e:?}")))?; + // The cross-epoch memory image, carried forward: epoch i+1's init is epoch i's // fini, updated in place with each epoch's touched-cell final values. let mut image = build_initial_image_paged(&elf, private_inputs); @@ -864,7 +876,16 @@ pub fn prove_continuation( register_init: ®ister_init, label, }; - let epoch = prove_epoch(&elf, elf_bytes, &start, traces, is_final, &boundary, opts)?; + let epoch = prove_epoch( + &elf, + elf_bytes, + &start, + traces, + is_final, + &boundary, + opts, + decode_commitment, + )?; prev_fini = Some(epoch.reg_fini.clone()); // Carry the image forward: this epoch's fini is the next epoch's init. @@ -946,6 +967,14 @@ pub fn verify_continuation( // hashed the entire ELF twice — once per seeded transcript). let elf_digest = crate::statement::elf_digest(elf_bytes); + // Same hoist for the DECODE preprocessed commitment: it depends only on the + // ELF, so derive it once instead of re-decoding and re-committing the + // DECODE table (a full column FFT batch + Merkle) on every epoch. + let decode_commitment = match decode::commitment_from_elf(&elf, opts) { + Ok(c) => c, + Err(_) => return Ok(None), + }; + let n = bundle.epochs.len(); if n == 0 { return Ok(None); @@ -980,6 +1009,7 @@ pub fn verify_continuation( is_final, label, opts, + decode_commitment, ) { return Ok(None); } From 41dbe8438b6776ef95b46b6fdde78f403a59ab78 Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 14:17:35 -0300 Subject: [PATCH 6/7] bench(scripts): allow ELF_REL/INPUT_REL overrides in bench_elf_digest --- scripts/bench_elf_digest.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/bench_elf_digest.sh b/scripts/bench_elf_digest.sh index 99c7c1b52..03db6ef17 100755 --- a/scripts/bench_elf_digest.sh +++ b/scripts/bench_elf_digest.sh @@ -30,8 +30,8 @@ N_PAIRS="${3:-10}" TRANSFERS="${4:-100}" EPOCH_LOG2="${5:-20}" -ELF_REL="executor/program_artifacts/rust/ethrex.elf" -INPUT_REL="executor/tests/ethrex_bench_${TRANSFERS}.bin" +ELF_REL="${ELF_REL:-executor/program_artifacts/rust/ethrex.elf}" +INPUT_REL="${INPUT_REL:-executor/tests/ethrex_bench_${TRANSFERS}.bin}" WORK="/tmp/elf_digest_bench" WT="/tmp/elf_digest_wt" @@ -51,14 +51,19 @@ echo "==> B (base) $REF_B -> ${SHA_B:0:10}" # --- 1. Guest ELF + fixture (built once, shared) --- if [ ! -f "$ELF_REL" ]; then - echo "==> Building ethrex guest ELF (needs sysroot)" + echo "==> Building guest ELF (needs sysroot): $ELF_REL" export SYSROOT_DIR="${SYSROOT_DIR:-$HOME/.lambda-vm-sysroot}" make "$ELF_REL" fi if [ ! -f "$INPUT_REL" ]; then - echo "==> Generating ethrex ${TRANSFERS}-transfer fixture" - ( cd tooling/ethrex-fixtures && cargo build --release ) - tooling/ethrex-fixtures/target/release/ethrex-fixtures "$TRANSFERS" "$INPUT_REL" distinct + if [ "$INPUT_REL" = "executor/tests/ethrex_bench_${TRANSFERS}.bin" ]; then + echo "==> Generating ethrex ${TRANSFERS}-transfer fixture" + ( cd tooling/ethrex-fixtures && cargo build --release ) + tooling/ethrex-fixtures/target/release/ethrex-fixtures "$TRANSFERS" "$INPUT_REL" distinct + else + echo "ERROR: input fixture not found: $INPUT_REL" >&2 + exit 1 + fi fi ELF="$(cd "$(dirname "$ELF_REL")" && pwd)/$(basename "$ELF_REL")" INPUT="$(cd "$(dirname "$INPUT_REL")" && pwd)/$(basename "$INPUT_REL")" From e3c8de28fa1f76bb1f3734fac6cb7c941758399f Mon Sep 17 00:00:00 2001 From: diegokingston Date: Fri, 17 Jul 2026 14:35:48 -0300 Subject: [PATCH 7/7] bench(scripts): fix subcommand name (run -> execute) for cycle counting --- scripts/bench_elf_digest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bench_elf_digest.sh b/scripts/bench_elf_digest.sh index 03db6ef17..39057ee99 100755 --- a/scripts/bench_elf_digest.sh +++ b/scripts/bench_elf_digest.sh @@ -113,7 +113,7 @@ else fi # Epoch count: run the program once for its cycle count, derive E = ceil(cycles/2^log). -CYCLES="$("$WORK/cli_B" run "$ELF" --private-input "$INPUT" --cycles 2>/dev/null | grep 'Cycles:' | grep -oE '[0-9]+' | tail -1 || true)" +CYCLES="$("$WORK/cli_B" execute "$ELF" --private-input "$INPUT" --cycles 2>/dev/null | grep 'Cycles:' | grep -oE '[0-9]+' | tail -1 || true)" if [ -n "${CYCLES:-}" ] && [ "$CYCLES" -gt 0 ]; then EPOCHS=$(( (CYCLES + (1 << EPOCH_LOG2) - 1) / (1 << EPOCH_LOG2) )) echo "==> Program cycles: $CYCLES -> ~$EPOCHS epochs of 2^$EPOCH_LOG2"