diff --git a/.github/workflows/bench-verify.yml b/.github/workflows/bench-verify.yml index baf550bac..e79022082 100644 --- a/.github/workflows/bench-verify.yml +++ b/.github/workflows/bench-verify.yml @@ -92,9 +92,12 @@ jobs: scripts/bench_verify.sh "$HEAD_SHA" origin/main "$PAIRS" 2>&1 | tee /tmp/verify_out.txt sed -n '/=== Verify ABBA result/,$p' /tmp/verify_out.txt > /tmp/verify_result.txt - # Additive: deterministic recursion-guest cycle+accelerator diff (PR vs main). - # The measurement is one exact `execute --cycles` reading per ref (~1 min total, no - # ABBA). Cold wall time is dominated by BUILDS: MEASURE_CLI (release cli) once, plus + # Additive: deterministic recursion-guest cycle+accelerator diff (PR vs main), + # in three regimes: `min` (blowup=2, 1 query — cheap diagnostic floor) plus the + # realistic full-query base-layer points `blowup2` (219 queries) and `blowup4` + # (110 queries). Each measurement is one exact `execute --cycles` reading per ref + # (no ABBA; the full-query executes are ~1-2 min each). + # Cold wall time is dominated by BUILDS: MEASURE_CLI (release cli) once, plus # per ref a guest build (~10-20 min) and a prover-test build for the blob dump; the # GUEST_TARGET_DIR / HOST_TARGET_DIR below share build-std and the host cargo target # across the two ref worktrees so the second ref is much cheaper (and per-worktree @@ -122,6 +125,19 @@ jobs: set -o pipefail scripts/bench_recursion_cycles.sh "$HEAD_SHA" origin/main min 2>&1 | tee /tmp/recursion_out.txt sed -n '/=== Recursion-guest cycle/,$p' /tmp/recursion_out.txt > /tmp/recursion_result.txt + # Full-query regimes: the realistic base-layer proofs (the single-query `min` + # regime above is a diagnostic floor, not the real verifier cost). Guest builds + # and worktrees are already cached from the min run, so each adds only a blob + # dump (a full-query prove of the empty inner program, seconds) and one + # ~1-2 min execute per ref. Tolerated failure (else-note): refs predating the + # preset-aware dump test can only measure `min`; the min table above still posts. + for preset in blowup2 blowup4; do + if scripts/bench_recursion_cycles.sh "$HEAD_SHA" origin/main "$preset" 2>&1 | tee "/tmp/recursion_out_${preset}.txt"; then + { echo; sed -n '/=== Recursion-guest cycle/,$p' "/tmp/recursion_out_${preset}.txt"; } >> /tmp/recursion_result.txt + else + { echo; echo "_(${preset} full-query regime unavailable for these refs — see the workflow log.)_"; } >> /tmp/recursion_result.txt + fi + done - name: Post result if: always() diff --git a/Makefile b/Makefile index 12ac291f5..f473198f9 100644 --- a/Makefile +++ b/Makefile @@ -56,13 +56,18 @@ RECURSION_GUESTS := empty fibonacci RECURSION_ARTIFACTS := $(addprefix $(RECURSION_ARTIFACTS_DIR)/, $(addsuffix .elf, $(RECURSION_GUESTS))) # The recursion verifier itself (bench_vs/lambda/recursion) requires picking -# exactly one of its `min`/`blowup8` Cargo features at build time (fixes the -# inner ProofOptions — see main.rs). Each preset builds its own distinctly -# named [[bin]] (recursion--bench) to its own artifact, via the +# exactly one of its preset Cargo features at build time (fixes the inner +# ProofOptions — see main.rs). Each preset builds its own distinctly named +# [[bin]] (recursion--bench) to its own artifact, via the # define/foreach/eval below rather than the generic %.elf pattern rule. The -# distinct bin names also make the two `cp`s race-free under `make -j`. -RECURSION_VERIFIER_PRESETS := min blowup8 -RECURSION_VERIFIER_ARTIFACTS := $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-, $(addsuffix .elf, $(RECURSION_VERIFIER_PRESETS))) +# distinct bin names also make the per-preset `cp`s race-free under `make -j`. +RECURSION_VERIFIER_PRESETS := min blowup2 blowup4 blowup8 +# Continuation-verifying variants (the guest's `continuation` feature): verify a +# multi-epoch ContinuationProof bundle instead of a monolithic VmProof. Kept to +# the presets the recursion benchmarks actually measure. +RECURSION_CONT_PRESETS := min blowup2 blowup4 +RECURSION_VERIFIER_ARTIFACTS := $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-, $(addsuffix .elf, $(RECURSION_VERIFIER_PRESETS))) \ + $(addprefix $(RECURSION_ARTIFACTS_DIR)/recursion-cont-, $(addsuffix .elf, $(RECURSION_CONT_PRESETS))) # Override with: make ... SYSROOT_DIR=$HOME/.lambda-vm-sysroot # to install the sysroot in a user-writable location and avoid sudo. @@ -215,7 +220,7 @@ $(BENCH_ARTIFACTS_DIR)/%.elf: FORCE | prepare-sysroot $(BENCH_ARTIFACTS_DIR) $(RECURSION_ARTIFACTS_DIR)/%.elf: FORCE | prepare-sysroot $(RECURSION_ARTIFACTS_DIR) $(call build_guest_elf,$(RECURSION_GUESTS_DIR)/$*,$*-bench) -# The recursion verifier's `min`/`blowup8` presets: same crate dir, one +# The recursion verifier's presets (RECURSION_VERIFIER_PRESETS): same crate dir, one # differently named [[bin]] per preset (recursion--bench, gated on that # preset's Cargo feature) -> a differently named artifact. Generated per preset # from RECURSION_VERIFIER_PRESETS via define/foreach/eval rather than a pattern @@ -236,6 +241,14 @@ $(RECURSION_ARTIFACTS_DIR)/recursion-$(1).elf: FORCE | prepare-sysroot $(RECURSI endef $(foreach preset,$(RECURSION_VERIFIER_PRESETS),$(eval $(call recursion_verifier_rule,$(preset)))) +# Continuation variants: same crate, `continuation` feature on top of the preset +# feature -> recursion-cont--bench -> recursion-cont-.elf. +define recursion_cont_verifier_rule +$(RECURSION_ARTIFACTS_DIR)/recursion-cont-$(1).elf: FORCE | prepare-sysroot $(RECURSION_ARTIFACTS_DIR) + $$(call build_guest_elf,$$(RECURSION_GUESTS_DIR)/recursion,recursion-cont-$(1)-bench,--features "continuation $(1)") +endef +$(foreach preset,$(RECURSION_CONT_PRESETS),$(eval $(call recursion_cont_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 473e3107c..f612949a8 100644 --- a/bench_vs/lambda/recursion/Cargo.toml +++ b/bench_vs/lambda/recursion/Cargo.toml @@ -9,26 +9,57 @@ edition = "2024" # Exactly one selects the fixed ProofOptions (see main.rs) — hardcoded, not # private input, so a malicious input can't downgrade the security level. # Cargo features are additive by design, so the compile_error! mutual-exclusion -# guard in main.rs is the loud failure that stops a mislabeled artifact if both +# guard in main.rs is the loud failure that stops a mislabeled artifact if two # ever get enabled at once (e.g. under `--all-features`). The crate must stay a # standalone `[workspace]` (not a root-workspace member) so root-level feature -# unification can never turn both on. +# unification can never turn two on. min = [] +blowup2 = [] +blowup4 = [] blowup8 = [] +# Orthogonal to the presets: verify a ContinuationProof bundle (multi-epoch, +# memory-bounded inner prove) instead of a monolithic VmProof. Selects the +# `recursion-cont--bench` bins below. +continuation = [] # 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 -# clobbering. Both use src/main.rs; required-features gates each to its preset. +# clobbering. All use src/main.rs; required-features gates each to its preset. [[bin]] name = "recursion-min-bench" path = "src/main.rs" required-features = ["min"] +[[bin]] +name = "recursion-blowup2-bench" +path = "src/main.rs" +required-features = ["blowup2"] + +[[bin]] +name = "recursion-blowup4-bench" +path = "src/main.rs" +required-features = ["blowup4"] + [[bin]] name = "recursion-blowup8-bench" path = "src/main.rs" required-features = ["blowup8"] +[[bin]] +name = "recursion-cont-min-bench" +path = "src/main.rs" +required-features = ["continuation", "min"] + +[[bin]] +name = "recursion-cont-blowup2-bench" +path = "src/main.rs" +required-features = ["continuation", "blowup2"] + +[[bin]] +name = "recursion-cont-blowup4-bench" +path = "src/main.rs" +required-features = ["continuation", "blowup4"] + [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 33a061364..9e190ad14 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -12,15 +12,23 @@ //! `recursion::verify_and_attest_blob` — no deserialization pass, no owned //! `VmProof`. //! -//! `ProofOptions` is fixed by the `min`/`blowup8` Cargo feature (a `Preset`), -//! not private input — an attacker could otherwise pick trivially weak options -//! and have the guest accept as if a real proof had been checked. +//! The `continuation` feature swaps the monolithic proof for a multi-epoch +//! `ContinuationProof` bundle on the same wire format +//! (`recursion::ContinuationGuestInput`, built by +//! `recursion::encode_continuation_guest_input`), verified via +//! `recursion::verify_continuation_and_attest_blob` — same trust model; the +//! bundle is materialized with one rkyv deserialize pass (zero-copy epoch +//! verify is follow-up work). //! -//! On success commits `program_id || inner_public_output` via -//! `recursion::verify_and_attest_blob` (a single ELF parse and a single -//! full-ELF Keccak, shared between the statement absorb and the `program_id` -//! fold). The id fold is what the consumer rebinds to a trusted ELF -//! (`check_attestation`); it is not self-enforcing here — the binding is +//! `ProofOptions` is fixed by exactly one preset Cargo feature +//! (`min`/`blowup2`/`blowup4`/`blowup8` — a `Preset`), not private input — an +//! attacker could otherwise pick trivially weak options and have the guest +//! accept as if a real proof had been checked. +//! +//! On success commits `program_id || inner_public_output` (a single ELF parse +//! and a single full-ELF Keccak, shared between the statement absorb and the +//! `program_id` fold). The id fold is what the consumer rebinds to a trusted +//! ELF (`check_attestation`); it is not self-enforcing here — the binding is //! established by the consumer via `recursion::check_attestation` (a //! host-side recompute+compare), never in-guest. //! @@ -31,14 +39,30 @@ use lambda_vm_prover::recursion::Preset; -#[cfg(not(any(feature = "min", feature = "blowup8")))] -compile_error!("select exactly one of the `min`/`blowup8` features"); -#[cfg(all(feature = "min", feature = "blowup8"))] -compile_error!("select exactly one of the `min`/`blowup8` features"); +#[cfg(not(any( + feature = "min", + feature = "blowup2", + feature = "blowup4", + feature = "blowup8" +)))] +compile_error!("select exactly one of the `min`/`blowup2`/`blowup4`/`blowup8` features"); +#[cfg(any( + all(feature = "min", feature = "blowup2"), + all(feature = "min", feature = "blowup4"), + all(feature = "min", feature = "blowup8"), + all(feature = "blowup2", feature = "blowup4"), + all(feature = "blowup2", feature = "blowup8"), + all(feature = "blowup4", feature = "blowup8"), +))] +compile_error!("select exactly one of the `min`/`blowup2`/`blowup4`/`blowup8` features"); /// The build preset fixing the inner `ProofOptions` (see the module docs). #[cfg(feature = "min")] const PRESET: Preset = Preset::Min; +#[cfg(feature = "blowup2")] +const PRESET: Preset = Preset::Blowup2; +#[cfg(feature = "blowup4")] +const PRESET: Preset = Preset::Blowup4; #[cfg(feature = "blowup8")] const PRESET: Preset = Preset::Blowup8; @@ -65,9 +89,18 @@ pub fn main() -> ! { // is what the consumer rebinds to a trusted ELF (`check_attestation`); it is // not self-enforcing here. let options = PRESET.options(); + + #[cfg(not(feature = "continuation"))] let attestation = lambda_vm_prover::recursion::verify_and_attest_blob(blob, &options) .expect("verify errored") .expect("inner proof failed verification"); + + #[cfg(feature = "continuation")] + let attestation = + lambda_vm_prover::recursion::verify_continuation_and_attest_blob(blob, &options) + .expect("verify errored") + .expect("inner continuation proof failed verification"); + lambda_vm_syscalls::syscalls::commit(&attestation); lambda_vm_syscalls::syscalls::sys_halt(); } diff --git a/executor/.gitignore b/executor/.gitignore index fa48867ab..3ed575591 100644 --- a/executor/.gitignore +++ b/executor/.gitignore @@ -2,3 +2,9 @@ /program_artifacts/rust /tests/ethrex_hoodi.bin /tests/ethrex_bench_*.bin +# The small scaling-ladder fixtures ARE committed (scripts/bench_recursion_scaling.sh +# reads them; ~13-30 KB each). Bigger generated fixtures (e.g. _20) stay ignored. +!/tests/ethrex_bench_1.bin +!/tests/ethrex_bench_4.bin +!/tests/ethrex_bench_8.bin +!/tests/ethrex_bench_16.bin diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index ea3b06c20..fbdc914a9 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -42,10 +42,16 @@ pub type U64HashMap = HashMap; /// The COMMIT AIR concatenates calls via the running `x254` index, so this /// is enforced as a running-total budget rather than a per-call limit. pub const MAX_PUBLIC_OUTPUT_TOTAL_SIZE: u64 = 1024 * 1024; -/// Maximum size of the private input memory region (in bytes). 64 MiB so that a -/// whole `VmProof` can be passed as private input to a verifier guest (naive -/// recursion). -pub const MAX_PRIVATE_INPUT_SIZE: u64 = 64 * 1024 * 1024; +/// Maximum size of the private input memory region (in bytes). Sized so that a +/// whole `VmProof` — or a multi-epoch `ContinuationProof` bundle — can be +/// passed as private input to a verifier guest (naive recursion): at +/// production options (blowup=2, 219 FRI queries) real proofs are large — +/// ethrex with 20 transfers proves to ~231 MiB monolithic, and a continuation +/// bundle carries one such proof per epoch — so 512 MiB with the guest ELF and +/// encoding overhead riding along. Nothing else is mapped above +/// `PRIVATE_INPUT_START_INDEX` until the stack at the top of the address +/// space, so growing this region is layout-safe. +pub const MAX_PRIVATE_INPUT_SIZE: u64 = 512 * 1024 * 1024; /// Fixed high address where private input is mapped. Guest programs can read /// directly from this address (ZisK-style memory-mapped input). /// Layout: 4-byte LE length prefix at `PRIVATE_INPUT_START_INDEX`, then data at +4. diff --git a/executor/tests/ethrex_bench_1.bin b/executor/tests/ethrex_bench_1.bin new file mode 100644 index 000000000..6a9c94380 Binary files /dev/null and b/executor/tests/ethrex_bench_1.bin differ diff --git a/executor/tests/ethrex_bench_16.bin b/executor/tests/ethrex_bench_16.bin new file mode 100644 index 000000000..d466f9aba Binary files /dev/null and b/executor/tests/ethrex_bench_16.bin differ diff --git a/executor/tests/ethrex_bench_4.bin b/executor/tests/ethrex_bench_4.bin new file mode 100644 index 000000000..45fe93038 Binary files /dev/null and b/executor/tests/ethrex_bench_4.bin differ diff --git a/executor/tests/ethrex_bench_8.bin b/executor/tests/ethrex_bench_8.bin new file mode 100644 index 000000000..b5790e949 Binary files /dev/null and b/executor/tests/ethrex_bench_8.bin differ diff --git a/executor/tests/flamegraph.rs b/executor/tests/flamegraph.rs index b0c5b7a24..f5735c226 100644 --- a/executor/tests/flamegraph.rs +++ b/executor/tests/flamegraph.rs @@ -892,7 +892,7 @@ fn test_run_with_flamegraph_returns_generator_on_executor_new_failure() { // even on failure. let elf_bytes = std::fs::read("./program_artifacts/rust/add.elf").unwrap(); let program = executor::elf::Elf::load(&elf_bytes).unwrap(); - let oversized_input = vec![0u8; 64 * 1024 * 1024 + 1]; + let oversized_input = vec![0u8; executor::vm::memory::MAX_PRIVATE_INPUT_SIZE as usize + 1]; let (generator, result) = executor::flamegraph::run_with_flamegraph( &elf_bytes, diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 2e5c56a8b..d1eb047aa 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -212,6 +212,7 @@ fn l2g_memory_air( fn global_memory_air( opts: &ProofOptions, config: &PageConfig, + preprocessed: Option, ) -> AirWithBuses { let air = AirWithBuses::new( global_memory::cols::NUM_COLUMNS, @@ -225,11 +226,17 @@ fn global_memory_air( if config.is_private_input { return air; } - let commitment = if config.init_values.is_some() { - page::compute_precomputed_commitment(config, opts) - } else { - page::zero_init_preprocessed_commitment(opts) - }; + // `preprocessed` lets a caller skip the genesis recompute (an FFT + Merkle + // pass per page) by supplying the root — the recursion guest's in-VM path, + // where the binding comes from the attestation fold instead (see + // `recursion::verify_continuation_and_attest`). `None` = recompute here. + let commitment = preprocessed.unwrap_or_else(|| { + if config.init_values.is_some() { + page::compute_precomputed_commitment(config, opts) + } else { + page::zero_init_preprocessed_commitment(opts) + } + }); air.with_preprocessed(commitment, global_memory::NUM_PREPROCESSED_COLS) } @@ -404,6 +411,7 @@ impl ContinuationProof { /// INIT = `register_init` and FINI = `reg_fini`. Continuation epochs /// use the L2G bookend, so PAGE is skipped and `page_configs` is empty. The /// epoch-local L2G air is built separately by the caller (it needs the `label`). +#[allow(clippy::too_many_arguments)] fn build_epoch_airs( elf: &Elf, opts: &ProofOptions, @@ -412,6 +420,7 @@ fn build_epoch_airs( register_init: &[u32], reg_fini: &[u32], is_final: bool, + decode_commitment: Option, ) -> 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 @@ -427,7 +436,7 @@ fn build_epoch_airs( false, page_configs, table_counts, - None, + decode_commitment, is_final, None, None, @@ -480,6 +489,7 @@ fn prove_epoch( start.register_init, ®_fini, is_final, + None, ); let label = start.label; @@ -536,6 +546,7 @@ fn prove_epoch( /// continuation epochs, so the AIRs are built with no page configs (the bundle does /// not get to supply any). Returns `true` iff the proof verifies and its committed /// L2G root matches the claimed one. +#[allow(clippy::too_many_arguments)] fn verify_epoch( elf: &Elf, elf_bytes: &[u8], @@ -544,6 +555,7 @@ fn verify_epoch( is_final: bool, label: u64, opts: &ProofOptions, + decode_commitment: Option, ) -> bool { // Reject degenerate table counts (mirrors the monolithic verifier). if epoch.table_counts.validate().is_err() { @@ -571,6 +583,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(); @@ -670,7 +683,7 @@ fn prove_global( .collect(); let gm_airs: Vec<_> = gm_configs .iter() - .map(|config| global_memory_air(opts, config)) + .map(|config| global_memory_air(opts, config, None)) .collect(); let mut pairs: Vec<(AirRef, &mut TraceTable, &())> = l2g_airs @@ -697,6 +710,7 @@ fn prove_global( .map_err(|e| Error::Prover(format!("{e:?}"))) } +#[allow(clippy::too_many_arguments)] fn verify_global( num_epochs: usize, page_bases: &[u64], @@ -705,6 +719,7 @@ fn verify_global( elf_bytes: &[u8], num_private_input_pages: usize, opts: &ProofOptions, + page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> bool { // One L2G air per epoch, each with its own 1-based `fini_epoch` constant — // must match the order/labels the global proof committed in `prove_global`. @@ -719,10 +734,29 @@ fn verify_global( // recomputes their genesis from the ELF; the GlobalMemory bus enforces them. A // wrong `num_private_input_pages` flips a touched page's preprocessed mode, so the // rebuilt AIR no longer matches the committed trace and `multi_verify` rejects. + // + // `page_genesis_commitments` (the recursion guest's supplied roots) skips the + // per-data-page recompute; a supplied root shifts the genesis binding to the + // attestation fold + consumer recompute, exactly like the monolithic guest's + // `page_commitments`. Zero-init pages always share one commitment, computed + // once here rather than per touched page. let gm_configs = global_memory_configs(page_bases, elf, num_private_input_pages); + let supplied: HashMap = page_genesis_commitments + .map(|s| s.iter().copied().collect()) + .unwrap_or_default(); + let zero_init_root = page::zero_init_preprocessed_commitment(opts); let gm_airs: Vec<_> = gm_configs .iter() - .map(|config| global_memory_air(opts, config)) + .map(|config| { + let preprocessed = if config.is_private_input { + None + } else if config.init_values.is_some() { + supplied.get(&config.page_base).copied() + } else { + Some(zero_init_root) + }; + global_memory_air(opts, config, preprocessed) + }) .collect(); let mut refs: Vec = l2g_airs.iter().map(|a| a as AirRef).collect(); @@ -924,6 +958,25 @@ pub fn verify_continuation( elf_bytes: &[u8], bundle: &ContinuationProof, opts: &ProofOptions, +) -> Result>, Error> { + verify_continuation_with_roots(elf_bytes, bundle, opts, None, None) +} + +/// [`verify_continuation`] with caller-supplied ELF-derived roots: the DECODE +/// preprocessed root (shared by every epoch) and the global-memory genesis +/// roots for touched data pages. Supplied roots are used VERBATIM — they are +/// NOT bound to `elf_bytes` here, exactly like `verify_with_options`' supplied +/// roots on the monolithic path. The recursion guest supplies them via private +/// input to skip the in-VM FFT + Merkle recomputes; on success it folds them +/// into the attestation's `program_id`, and the consumer's recompute+compare +/// ([`crate::recursion::check_attestation`]) is what restores the binding. +/// `None` = recompute from the ELF (the trustless host path). +pub fn verify_continuation_with_roots( + elf_bytes: &[u8], + bundle: &ContinuationProof, + opts: &ProofOptions, + decode_commitment: Option, + page_genesis_commitments: Option<&[(u64, Commitment)]>, ) -> Result>, Error> { // Bound the claimed private-input page count before using it to size/allocate AIRs // (mirrors `verify_with_options`). The count is also bound into the global proof's @@ -974,6 +1027,7 @@ pub fn verify_continuation( is_final, label, opts, + decode_commitment, ) { return Ok(None); } @@ -1019,6 +1073,7 @@ pub fn verify_continuation( elf_bytes, bundle.num_private_input_pages, opts, + page_genesis_commitments, ) { return Ok(None); } @@ -1031,6 +1086,28 @@ pub fn verify_continuation( Ok(Some(public_output)) } +/// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts: +/// the DECODE preprocessed root and one genesis root per touched non-private +/// data page (the same set `verify_global` would rebuild from the ELF). These +/// are what the host packs into the continuation recursion guest's private +/// input, and what a consumer recomputes to re-bind the guest's attestation. +pub fn continuation_precomputed_commitments( + elf_bytes: &[u8], + bundle: &ContinuationProof, + opts: &ProofOptions, +) -> Result<(Commitment, Vec<(u64, Commitment)>), Error> { + let elf = Elf::load(elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; + let decode_commitment = crate::tables::decode::commitment_from_elf(&elf, opts) + .map_err(|e| Error::Recursion(format!("DECODE commitment from ELF: {e}")))?; + let page_bases = canonical_page_bases(&bundle.touched_page_bases); + let page_commitments = global_memory_configs(&page_bases, &elf, bundle.num_private_input_pages) + .iter() + .filter(|c| !c.is_private_input && c.init_values.is_some()) + .map(|c| (c.page_base, page::compute_precomputed_commitment(c, opts))) + .collect(); + Ok((decode_commitment, page_commitments)) +} + /// Convenience wrapper: prove then verify in one call (the original integrated API). /// Returns `Ok(Some(public_output))` iff the continuation proves and verifies. pub fn prove_and_verify_continuation( @@ -1504,9 +1581,9 @@ mod tests { let page_size = page::DEFAULT_PAGE_SIZE; let max = page::max_private_input_pages(); - // (64 MiB + 4-byte prefix) / 256 KiB page = 257 pages (256 full data pages plus + // (512 MiB + 4-byte prefix) / 256 KiB page = 2049 pages (2048 full data pages plus // the one page the length prefix spills into). Pinned so a size/page change is caught. - assert_eq!(max, 257); + assert_eq!(max, 2049); // No slack: an honest MAX-size input needs the whole last page (the bound is not // padded), and never overflows into an extra one. diff --git a/prover/src/recursion.rs b/prover/src/recursion.rs index 6bb3b1247..54cdbe505 100644 --- a/prover/src/recursion.rs +++ b/prover/src/recursion.rs @@ -20,9 +20,9 @@ //! //! [`program_id`] deliberately does not fold the `ProofOptions`: the security //! level is pinned by which verifier guest the outer proof is checked against -//! (`recursion-min.elf` vs `recursion-blowup8.elf`, fixed at build time — see -//! [`Preset`]). A consumer must pin that outer ELF too, or a 1-query `min` -//! attestation is indistinguishable from a 128-bit `blowup8` one. +//! (`recursion-min.elf` vs `recursion-blowup2.elf`/`recursion-blowup8.elf`, +//! fixed at build time — see [`Preset`]). A consumer must pin that outer ELF +//! too, or a 1-query `min` attestation is indistinguishable from a 128-bit one. use crypto::hash::platform_keccak::PlatformKeccak256 as Keccak256; use digest::Digest; @@ -52,15 +52,37 @@ pub const MIN_PROOF_OPTIONS: ProofOptions = ProofOptions { pub enum Preset { /// Blowup=2, 1 query ([`MIN_PROOF_OPTIONS`]) — insecure, diagnostics only. Min, - /// Blowup=8, multi-query — 128-bit security. + /// Blowup=2, full 128-bit query count (219 queries at 20 grinding bits) — + /// the realistic base-layer shape: production pipelines prove the base + /// proof at low blowup (2/4) and reserve high blowup for the final wrap. + Blowup2, + /// Blowup=4, 110 queries — the other realistic base-layer point (e.g. + /// Zisk's compressor layer): 2× the prover LDE of blowup=2 for half the + /// queries to verify. + Blowup4, + /// Blowup=8, multi-query (73 queries) — 128-bit security at final-wrap-style + /// parameters: more prover work per row, far fewer queries to verify. Blowup8, } impl Preset { + /// Every preset, for name→preset lookups (e.g. the blob-dump test's + /// `RECURSION_DUMP_PRESET`). Keep in sync with the enum. + pub const ALL: [Preset; 4] = [ + Preset::Min, + Preset::Blowup2, + Preset::Blowup4, + Preset::Blowup8, + ]; + /// The fixed `ProofOptions` this preset's guest verifies with. pub fn options(&self) -> ProofOptions { match self { Preset::Min => MIN_PROOF_OPTIONS, + Preset::Blowup2 => crate::GoldilocksCubicProofOptions::with_blowup(2) + .expect("blowup=2 is always valid"), + Preset::Blowup4 => crate::GoldilocksCubicProofOptions::with_blowup(4) + .expect("blowup=4 is always valid"), Preset::Blowup8 => crate::GoldilocksCubicProofOptions::with_blowup(8) .expect("blowup=8 is always valid"), } @@ -71,6 +93,8 @@ impl Preset { pub fn artifact_stem(&self) -> &'static str { match self { Preset::Min => "recursion-min", + Preset::Blowup2 => "recursion-blowup2", + Preset::Blowup4 => "recursion-blowup4", Preset::Blowup8 => "recursion-blowup8", } } @@ -79,6 +103,8 @@ impl Preset { pub fn name(&self) -> &'static str { match self { Preset::Min => "min", + Preset::Blowup2 => "blowup2", + Preset::Blowup4 => "blowup4", Preset::Blowup8 => "blowup8", } } @@ -127,6 +153,49 @@ pub fn encode_guest_input( }) } +/// The continuation guest's private-input layout (the `continuation` guest +/// feature). Mirrors [`crate::GuestInput`] with the monolithic proof replaced +/// by the bundle and the PAGE roots replaced by the global-memory genesis +/// roots (see [`crate::continuation::continuation_precomputed_commitments`]). +/// Rkyv-archived on the same magic-prefixed wire format as the monolithic +/// blob ([`crate::encode_recursion_input`]); the guest is feature-pinned to +/// one layout, and a blob of the other kind fails the bytecheck validation. +#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct ContinuationGuestInput { + pub bundle: crate::continuation::ContinuationProof, + pub inner_elf: Vec, + pub decode_commitment: Commitment, + pub page_commitments: Vec<(u64, Commitment)>, +} + +/// Build the continuation guest's private-input blob for `bundle` of +/// `inner_elf`: precomputes the roots and rkyv-encodes a +/// [`ContinuationGuestInput`] behind the standard aligning prefix. Takes the +/// bundle by value (it is large; the encoder is its last consumer). +pub fn encode_continuation_guest_input( + bundle: crate::continuation::ContinuationProof, + inner_elf: &[u8], + opts: &ProofOptions, +) -> Result, Error> { + let (decode_commitment, page_commitments) = + crate::continuation::continuation_precomputed_commitments(inner_elf, &bundle, opts)?; + let input = ContinuationGuestInput { + bundle, + 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 + debug_assert_eq!(blob.len(), crate::RECURSION_INPUT_PREFIX_LEN); + blob.extend_from_slice(&archive); + Ok(blob) +} + /// Domain tag for [`program_id`]. const PROGRAM_ID_TAG: &[u8] = b"LAMBDAVM_PROGRAM_ID_V1"; @@ -223,6 +292,81 @@ pub fn verify_and_attest_blob( Ok(Some(attestation)) } +/// [`verify_and_attest_blob`]'s core for a continuation bundle (the +/// `continuation` guest feature): verify every epoch + the global memory +/// proof against the supplied roots, then attest +/// `program_id(elf, roots) || public_output`. The fold uses +/// the same [`program_id`] as the monolithic path but over the continuation's +/// root set (DECODE + touched data-page genesis roots), so a consumer re-binds +/// with [`crate::continuation::continuation_precomputed_commitments`] over the +/// bundle it holds — the touched-page set is bundle-dependent, unlike the +/// monolithic path's ELF-only page set. +pub fn verify_continuation_and_attest( + bundle: &crate::continuation::ContinuationProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + decode_commitment: Commitment, + page_commitments: &[(u64, Commitment)], +) -> Result>, Error> { + let Some(public_output) = crate::continuation::verify_continuation_with_roots( + elf_bytes, + bundle, + proof_options, + Some(decode_commitment), + Some(page_commitments), + )? + else { + return Ok(None); + }; + let id = program_id_from_elf(elf_bytes, &decode_commitment, page_commitments)?; + let mut attestation = id.to_vec(); + attestation.extend_from_slice(&public_output); + Ok(Some(attestation)) +} + +/// [`verify_continuation_and_attest`] over the wire-format blob +/// ([`encode_continuation_guest_input`]) — the `continuation` guest's whole +/// job in one call. The archive is bytecheck-validated, then the input is +/// materialized with one `rkyv::deserialize` pass (a structured copy, not a +/// parse) because the epoch/global verifiers currently consume an owned +/// [`crate::continuation::ContinuationProof`]; verifying epochs zero-copy over +/// the archived bundle (as [`crate::verify_recursion_blob`] does for the +/// monolithic proof) is follow-up work. +pub fn verify_continuation_and_attest_blob( + 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( + "continuation recursion blob: bad magic or version", + )) + })?; + // Host callers' Vec carries no alignment guarantee; the guest slice is + // aligned by construction (same prefix arithmetic as the monolithic blob). + 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 archived = rkyv::access::(archive) + .map_err(|e| Error::Execution(format!("continuation blob validation failed: {e}")))?; + let input: ContinuationGuestInput = rkyv::deserialize::<_, RkyvError>(archived) + .map_err(|e| Error::Execution(format!("continuation blob deserialize failed: {e}")))?; + + verify_continuation_and_attest( + &input.bundle, + &input.inner_elf, + proof_options, + input.decode_commitment, + &input.page_commitments, + ) +} + /// Split committed attestation bytes into `(program_id, inner_public_output)`. /// `None` if too short to contain an id. pub fn split_attestation(committed: &[u8]) -> Option<([u8; 32], &[u8])> { diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 15817df3f..de675e4c1 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -531,6 +531,69 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { assert!(v.ok, "misaligned-buffer verify must also succeed"); } +/// Continuation flavor of the roundtrip guard: prove the empty program via +/// continuations (tiny epochs so the bundle is genuinely multi-epoch), encode +/// the [`recursion::ContinuationGuestInput`] blob, decode it exactly as the +/// `continuation`-feature guest does, and mirror its +/// `verify_continuation_and_attest` call — a cheap host-side check of the +/// encode/decode/verify/attest contract without running the VM. +#[test] +fn test_recursion_continuation_blob_decodes_and_verifies_on_host() { + let root = workspace_root(); + let fib_elf_bytes = read_guest_elf(&root, "fibonacci"); + let inner_input = 10u64.to_le_bytes(); + + let bundle = crate::continuation::prove_continuation( + &fib_elf_bytes, + &inner_input, + 4, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation prove should succeed"); + assert!( + bundle.num_epochs() > 1, + "epoch=2^4 must split fibonacci(10) into multiple epochs for this test to bite" + ); + // Ground truth: the trustless recompute path must accept the bundle. + let expected_output = + crate::continuation::verify_continuation(&fib_elf_bytes, &bundle, &MIN_PROOF_OPTIONS) + .expect("verify_continuation errored") + .expect("bundle must verify with recomputed roots"); + // Consumer re-bind values, computed before the encode consumes the bundle: + // recompute the roots from the bundle + trusted ELF and compare ids (the + // continuation analog of check_attestation). + let (expected_decode, expected_pages) = + crate::continuation::continuation_precomputed_commitments( + &fib_elf_bytes, + &bundle, + &MIN_PROOF_OPTIONS, + ) + .expect("continuation_precomputed_commitments errored"); + let expected_id = + recursion::program_id_from_elf(&fib_elf_bytes, &expected_decode, &expected_pages) + .expect("program_id_from_elf errored"); + + let blob = + recursion::encode_continuation_guest_input(bundle, &fib_elf_bytes, &MIN_PROOF_OPTIONS) + .expect("encode_continuation_guest_input failed"); + + // Verify exactly as the guest does (built with `continuation` + `min`): + // prefix validation + rkyv access + deserialize + verify + attest. + let attestation = recursion::verify_continuation_and_attest_blob(&blob, &MIN_PROOF_OPTIONS) + .expect("verify_continuation_and_attest_blob errored") + .expect("continuation proof did not survive the rkyv round-trip"); + let (id, output) = recursion::split_attestation(&attestation).expect("attestation too short"); + assert_eq!( + id, expected_id, + "attested id must match the honest recompute" + ); + assert_eq!( + output, + &expected_output[..], + "supplied-roots output must match the recompute path's output" + ); +} + /// Corrupting a private-input commitment on an *honest* proof makes /// verification fail (`Ok(false)`). Necessary but not sufficient alone — a /// custom prover can supply consistent mismatched roots (see @@ -708,19 +771,93 @@ fn test_recursion_prove_1query() { } /// Dump the guest's private-input blob to `/tmp/recursion_input.bin` for the -/// CLI's `execute --flamegraph`. +/// CLI's `execute --flamegraph` and `scripts/bench_recursion_cycles.sh`. +/// +/// Env knobs: +/// * `RECURSION_DUMP_PRESET` (`min`|`blowup2`|`blowup4`|`blowup8`, default +/// `min`) — the [`Preset`] whose options the inner proof is generated under; +/// must match the `recursion-.elf` the blob will be fed to, or the +/// guest rejects the proof. +/// * `RECURSION_DUMP_INNER_ELF` (path, default the `empty` guest) — the inner +/// program to prove, for realistic trace heights (e.g. the ethrex guest). +/// * `RECURSION_DUMP_INNER_INPUT` (path, default none) — the inner program's +/// private input (e.g. an ethrex-fixtures block). +/// * `RECURSION_DUMP_EPOCH_LOG2` (int, default unset = monolithic) — prove the +/// inner via continuations with `2^n`-cycle epochs (memory-bounded prover) +/// and encode a [`recursion::ContinuationGuestInput`] blob instead; feed it +/// to `recursion-cont-.elf`, not the monolithic guest. #[test] #[ignore = "diagnostic: writes recursion private input to /tmp/recursion_input.bin"] fn test_dump_recursion_input() { let root = workspace_root(); - let empty_elf_bytes = read_guest_elf(&root, "empty"); - let (_inner_proof, blob) = - prove_inner_and_encode_blob("dump-input", &empty_elf_bytes, &[], &MIN_PROOF_OPTIONS); + let preset_name = std::env::var("RECURSION_DUMP_PRESET").unwrap_or_else(|_| "min".to_string()); + let preset = Preset::ALL + .into_iter() + .find(|p| p.name() == preset_name) + .unwrap_or_else(|| { + panic!( + "unknown RECURSION_DUMP_PRESET '{preset_name}' (expected min|blowup2|blowup4|blowup8)" + ) + }); + + let (inner_elf_bytes, inner_label) = match std::env::var("RECURSION_DUMP_INNER_ELF") { + Ok(p) => ( + std::fs::read(&p).unwrap_or_else(|e| panic!("read RECURSION_DUMP_INNER_ELF {p}: {e}")), + p, + ), + Err(_) => (read_guest_elf(&root, "empty"), "empty".to_string()), + }; + let inner_input = match std::env::var("RECURSION_DUMP_INNER_INPUT") { + Ok(p) => { + std::fs::read(&p).unwrap_or_else(|e| panic!("read RECURSION_DUMP_INNER_INPUT {p}: {e}")) + } + Err(_) => Vec::new(), + }; + + let blob = match std::env::var("RECURSION_DUMP_EPOCH_LOG2") { + Ok(s) => { + let epoch_log2: u32 = s + .parse() + .unwrap_or_else(|e| panic!("bad RECURSION_DUMP_EPOCH_LOG2 '{s}': {e}")); + let opts = preset.options(); + eprintln!( + "[dump-input] proving inner continuation (blowup={}, fri_queries={}, epoch=2^{epoch_log2}) ...", + opts.blowup_factor, opts.fri_number_of_queries + ); + let bundle = crate::continuation::prove_continuation( + &inner_elf_bytes, + &inner_input, + epoch_log2, + &opts, + ) + .expect("inner continuation prove should succeed"); + eprintln!("[dump-input] continuation epochs: {}", bundle.num_epochs()); + recursion::encode_continuation_guest_input(bundle, &inner_elf_bytes, &opts) + .expect("recursion::encode_continuation_guest_input failed") + } + Err(_) => { + let (_inner_proof, blob) = prove_inner_and_encode_blob( + "dump-input", + &inner_elf_bytes, + &inner_input, + &preset.options(), + ); + blob + } + }; + assert!( + blob.len() <= executor::vm::memory::MAX_PRIVATE_INPUT_SIZE as usize, + "recursion input exceeds MAX_PRIVATE_INPUT_SIZE" + ); let path = "/tmp/recursion_input.bin"; std::fs::write(path, &blob).expect("write blob"); - eprintln!("[dump-input] wrote {} bytes to {path}", blob.len()); + eprintln!( + "[dump-input] preset={} inner={inner_label} wrote {} bytes to {path}", + preset.name(), + blob.len() + ); } /// Cycle count only of the recursion guest verifying a 1-query inner proof. @@ -737,6 +874,22 @@ fn test_recursion_cycles_multiquery() { run_profile(Preset::Blowup8, 500, false); } +/// Cycle count only at 128-bit security with the realistic base-layer shape: +/// blowup=2 yields ~0.49 bits/query, so the full 219-query FRI dominates. +#[test] +#[ignore = "diagnostic: recursion guest cycle count (blowup=2, 219 queries)"] +fn test_recursion_cycles_blowup2() { + run_profile(Preset::Blowup2, 500, false); +} + +/// Cycle count only at 128-bit security, blowup=4 (110 queries) — the other +/// realistic base-layer point. +#[test] +#[ignore = "diagnostic: recursion guest cycle count (blowup=4, 110 queries)"] +fn test_recursion_cycles_blowup4() { + run_profile(Preset::Blowup4, 500, false); +} + /// Full profile (top-25 + per-step) of the 1-query run. #[test] #[ignore = "diagnostic: ~8 min; recursion guest histogram + steps (1 query)"] diff --git a/scripts/bench_recursion_cycles.sh b/scripts/bench_recursion_cycles.sh index 5db264a45..c6ae76058 100755 --- a/scripts/bench_recursion_cycles.sh +++ b/scripts/bench_recursion_cycles.sh @@ -18,8 +18,8 @@ # single measuring CLI (MEASURE_CLI) built once from the checkout this script runs in: # * Guest cycles — retired instructions. # * Keccak calls — keccak-permutation accelerator ecalls (one cycle each, but each -# runs a whole permutation invisibly, so it's the companion signal; -# currently 0 until the verifier is wired to the keccak syscall). +# runs a whole permutation invisibly, so it's the companion signal: +# the verifier's Merkle/transcript hashing rides on this syscall). # The CLI also prints an Ecsm (EC scalar-mul) count, but the STARK verifier does no # scalar-mul, so it is structurally 0 for a recursion proof — dropped as noise, not read. # MEASURE_CLI's executor counts ANY ref's guest ELF correctly (it just feeds the blob @@ -35,14 +35,22 @@ # Usage: scripts/bench_recursion_cycles.sh REF_A [REF_B=origin/main] [PRESET=min] # REF_A ref/SHA to evaluate (the PR side). # REF_B baseline ref/SHA (default origin/main). -# PRESET recursion-verifier preset (default min). Per ref the tool prefers -# recursion-.elf and falls back to recursion.elf (older refs / main -# build a single unnamed recursion guest). It is EXPECTED and correct for the -# two sides to use DIFFERENT artifacts — e.g. main→recursion.elf while a -# preset PR→recursion-min.elf — because both are verified under the SAME min -# proof options (the dump test pins MIN_PROOF_OPTIONS). The printed per-ref -# `guest=` labels show which each side used; a differing name is the -# expected comparison, not a mismatch. +# PRESET recursion-verifier preset (default min): min = blowup=2, 1 query +# (cheap diagnostic regime); blowup2 = blowup=2, 219 queries (the +# realistic base-layer proof shape at 128-bit — production pipelines +# prove the base at low blowup and wrap at high blowup); blowup4 = +# blowup=4, 110 queries (the other realistic base-layer point); +# blowup8 = blowup=8, 73 queries. The preset picks BOTH the guest ELF +# (recursion-.elf, falling back to recursion.elf on older refs +# that build a single unnamed min guest) AND the inner-proof options of +# the dumped blob (exported as RECURSION_DUMP_PRESET to the dump test). +# Refs predating the preset-aware dump test always dump min options, so +# only PRESET=min is measurable for them — the script fails loudly +# up front rather than let the guest reject the blob in-VM. It is +# EXPECTED and correct for the two sides to use DIFFERENT artifact +# names (e.g. main→recursion.elf vs PR→recursion-min.elf) — both are +# verified under the SAME preset options. The printed per-ref +# `guest=` labels show which each side used. # Env: # REBUILD=1 force rebuild of MEASURE_CLI and re-run of every ref # (guest build + blob dump + measurement); ignore caches. @@ -104,8 +112,8 @@ prune_worktree_cache() { echo "==> Pruning old ref worktree $wt (keeping newest $PRUNE_KEEP)" >&2 git worktree remove --force "$wt" >/dev/null 2>&1 || rm -rf "$wt" rm -f "$WORK"/result_"${s8}"_*.txt "$WORK"/blob_"${s8}"_*.bin \ - "$WORK"/build_guest_"${s8}".log "$WORK"/dump_"${s8}".log \ - "$WORK"/measure_"${s8}".err + "$WORK"/build_guest_"${s8}".log "$WORK"/dump_"${s8}"*.log \ + "$WORK"/measure_"${s8}"*.err done <<< "$stale" git worktree prune >/dev/null 2>&1 || true } @@ -229,22 +237,29 @@ measure_ref() { fi echo "==> [$role] guest ELF: $(basename "$guest_elf")" >&2 - # 2c. Generate this ref's own input blob via its ignored dump test. + # 2c. Generate this ref's own input blob via its ignored dump test, under this + # preset's options (RECURSION_DUMP_PRESET). Refs predating the preset-aware dump + # test always prove the min options; feeding that blob to a non-min guest would + # only fail in-VM verification much later, so refuse loudly up front instead. if ! grep -rq "fn test_dump_recursion_input" "$wt/prover/src/tests/" 2>/dev/null; then echo "ERROR: [$role] ref $ref ($sha8) has no 'test_dump_recursion_input' — cannot generate its input blob." >&2 exit 1 fi - echo "==> [$role] dumping recursion input blob (cargo test test_dump_recursion_input) ..." >&2 + if [ "$PRESET" != "min" ] && ! grep -rq "RECURSION_DUMP_PRESET" "$wt/prover/src/tests/" 2>/dev/null; then + echo "ERROR: [$role] ref $ref ($sha8) predates the preset-aware dump test (no RECURSION_DUMP_PRESET) — only PRESET=min is measurable for it." >&2 + exit 1 + fi + echo "==> [$role] dumping recursion input blob (cargo test test_dump_recursion_input, preset=$PRESET) ..." >&2 rm -f /tmp/recursion_input.bin - local dlog="$WORK/dump_${sha8}.log" + local dlog="$WORK/dump_${sha8}_${PRESET}.log" if [ -n "${HOST_TARGET_DIR:-}" ]; then - if ! ( cd "$wt" && CARGO_TARGET_DIR="$HOST_TARGET_DIR" cargo test -p lambda-vm-prover --lib test_dump_recursion_input -- --ignored --nocapture ) >"$dlog" 2>&1; then + if ! ( cd "$wt" && RECURSION_DUMP_PRESET="$PRESET" CARGO_TARGET_DIR="$HOST_TARGET_DIR" cargo test --release -p lambda-vm-prover --lib test_dump_recursion_input -- --ignored --nocapture ) >"$dlog" 2>&1; then echo "ERROR: [$role] blob-dump test failed for $ref ($sha8). Tail of $dlog:" >&2 tail -40 "$dlog" >&2 exit 1 fi else - if ! ( cd "$wt" && cargo test -p lambda-vm-prover --lib test_dump_recursion_input -- --ignored --nocapture ) >"$dlog" 2>&1; then + if ! ( cd "$wt" && RECURSION_DUMP_PRESET="$PRESET" cargo test --release -p lambda-vm-prover --lib test_dump_recursion_input -- --ignored --nocapture ) >"$dlog" 2>&1; then echo "ERROR: [$role] blob-dump test failed for $ref ($sha8). Tail of $dlog:" >&2 tail -40 "$dlog" >&2 exit 1 @@ -262,9 +277,9 @@ measure_ref() { echo "==> [$role] measuring: $MEASURE_CLI execute $(basename "$guest_elf") --private-input --cycles" >&2 local t0 t1 dt out t0=$(date +%s) - if ! out="$("$MEASURE_CLI" execute "$guest_elf" --private-input "$blob" --cycles 2>"$WORK/measure_${sha8}.err")"; then + if ! out="$("$MEASURE_CLI" execute "$guest_elf" --private-input "$blob" --cycles 2>"$WORK/measure_${sha8}_${PRESET}.err")"; then echo "ERROR: [$role] MEASURE_CLI execute failed for $ref ($sha8). Tail of stderr:" >&2 - tail -20 "$WORK/measure_${sha8}.err" >&2 + tail -20 "$WORK/measure_${sha8}_${PRESET}.err" >&2 exit 1 fi t1=$(date +%s); dt=$((t1 - t0)) @@ -334,10 +349,13 @@ mcycd() { } # Human label for the proof regime this preset measures, so a reader can't mistake the -# single-query `min` number for the full 128-bit verifier cost. CI always passes `min`. +# single-query `min` number for the full 128-bit verifier cost. CI passes `min` plus +# the full-query regimes `blowup2`/`blowup4` (see .github/workflows/bench-verify.yml). case "$PRESET" in min) REGIME="single query (blowup=2, 1 query)" ;; - blowup8) REGIME="128-bit (blowup=8, multi-query)" ;; + blowup2) REGIME="128-bit (blowup=2, 219 queries — realistic base-layer)" ;; + blowup4) REGIME="128-bit (blowup=4, 110 queries — realistic base-layer)" ;; + blowup8) REGIME="128-bit (blowup=8, 73 queries)" ;; *) REGIME="$PRESET" ;; esac diff --git a/scripts/bench_recursion_scaling.sh b/scripts/bench_recursion_scaling.sh new file mode 100755 index 000000000..3242c0b06 --- /dev/null +++ b/scripts/bench_recursion_scaling.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# bench_recursion_scaling.sh — in-VM recursion-verifier scaling ladder. +# +# Sweeps ethrex block sizes × verifier presets: proves each block's inner +# execution via CONTINUATIONS (memory-bounded 2^EPOCH_LOG2-cycle epochs, so any +# block size proves on a bounded-RAM box), then executes the continuation +# recursion guest (recursion-cont-.elf) on the bundle and records the +# EXACT deterministic guest cycle count — the in-VM cost of verifying that +# block's proof. +# +# Sweep order is PRESET-MAJOR on purpose: the full block-size curve for the +# first preset completes before the next preset starts, so the headline regime +# (blowup2 = the realistic base-layer options, 219 FRI queries) yields a usable +# scaling curve as early as possible instead of only when everything ends. +# +# Usage: scripts/bench_recursion_scaling.sh [RESULTS_FILE=/tmp/recursion_scaling.txt] +# Env: +# TXS="1 4 8 16" block sizes (transfers); fixtures are read from +# executor/tests/ethrex_bench_.bin (committed for +# 1/4/8/16) and generated via tooling/ethrex-fixtures +# when missing. +# PRESETS="blowup2 blowup4 min" verifier presets, most important first. +# EPOCH_LOG2=21 inner continuation epoch size (log2 cycles). +# DUMP_FEATURES="" extra cargo features for the proving dump, +# e.g. DUMP_FEATURES=cuda on a GPU box. +# +# Prereqs (the script fails fast on each): +# cargo build --release -p cli +# make compile-recursion-elfs (recursion-cont-*.elf) +# make executor/program_artifacts/rust/ethrex.elf (the inner guest) +# +# Output: one key=value line per cell in RESULTS_FILE, e.g. +# txs=4 preset=blowup2 epochs=2 blob=145080513 cycles=18984803380 keccak=3152604 exec_wall_s=164 +# Cycle counts are deterministic (machine-independent); wall times are not. +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +TXS="${TXS:-1 4 8 16}" +PRESETS="${PRESETS:-blowup2 blowup4 min}" +EPOCH_LOG2="${EPOCH_LOG2:-21}" +RESULTS="${1:-/tmp/recursion_scaling.txt}" +WORK="$(mktemp -d /tmp/recursion_scaling.XXXXXX)" + +CLI=target/release/cli +ART=executor/program_artifacts/recursion +ETHREX=executor/program_artifacts/rust/ethrex.elf + +[ -x "$CLI" ] || { echo "ERROR: $CLI missing — run: cargo build --release -p cli" >&2; exit 1; } +[ -f "$ETHREX" ] || { echo "ERROR: $ETHREX missing — run: make $ETHREX" >&2; exit 1; } +for P in $PRESETS; do + [ -f "$ART/recursion-cont-${P}.elf" ] || { + echo "ERROR: $ART/recursion-cont-${P}.elf missing — run: make compile-recursion-elfs" >&2 + exit 1 + } +done + +echo "==> results -> $RESULTS (work dir: $WORK)" +: > "$RESULTS" + +for P in $PRESETS; do + for N in $TXS; do + FIX=executor/tests/ethrex_bench_${N}.bin + if [ ! -f "$FIX" ]; then + echo "==> [${P}/${N}tx] generating missing fixture $FIX" >&2 + ( cd tooling/ethrex-fixtures && cargo build --release ) >"$WORK/fixtures_build.log" 2>&1 + tooling/ethrex-fixtures/target/release/ethrex-fixtures "$N" "$FIX" distinct >&2 + fi + + # Inner block cost, once per block size (cheap; repeated per preset is fine — + # the count is deterministic and the run takes well under a second). + ic="$("$CLI" execute "$ETHREX" --private-input "$FIX" --cycles | awk -F': ' '/^Cycles:/{print $2; exit}')" + + echo "==> [${P}/${N}tx] proving inner continuation (epoch=2^${EPOCH_LOG2}) ..." >&2 + rm -f /tmp/recursion_input.bin + DLOG="$WORK/dump_${N}tx_${P}.log" + if ! ( RECURSION_DUMP_PRESET="$P" RECURSION_DUMP_EPOCH_LOG2="$EPOCH_LOG2" \ + RECURSION_DUMP_INNER_ELF="$PWD/$ETHREX" RECURSION_DUMP_INNER_INPUT="$PWD/$FIX" \ + cargo test --release -p lambda-vm-prover ${DUMP_FEATURES:+--features "$DUMP_FEATURES"} --lib test_dump_recursion_input -- --ignored --nocapture ) \ + >"$DLOG" 2>&1 || [ ! -f /tmp/recursion_input.bin ]; then + echo "txs=$N preset=$P inner_cycles=$ic DUMP_FAILED" >> "$RESULTS" + echo "ERROR: [${P}/${N}tx] dump failed; tail of $DLOG:" >&2 + tail -20 "$DLOG" >&2 + continue + fi + epochs="$(grep -o 'continuation epochs: [0-9]*' "$DLOG" | awk '{print $3}')" + BLOB="$WORK/blob_${N}tx_${P}.bin" + mv /tmp/recursion_input.bin "$BLOB" + sz="$(wc -c < "$BLOB" | tr -d ' ')" + + echo "==> [${P}/${N}tx] executing recursion-cont-${P}.elf (${epochs} epochs, ${sz} bytes) ..." >&2 + t0=$(date +%s) + if out="$("$CLI" execute "$ART/recursion-cont-${P}.elf" --private-input "$BLOB" --cycles 2>"$WORK/exec_${N}tx_${P}.err")"; then + t1=$(date +%s) + cyc="$(printf '%s\n' "$out" | awk -F': ' '/^Cycles:/{print $2; exit}')" + kec="$(printf '%s\n' "$out" | awk -F': ' '/^Keccak calls:/{print $2; exit}')" + line="txs=$N preset=$P inner_cycles=$ic epochs=$epochs blob=$sz cycles=$cyc keccak=$kec exec_wall_s=$((t1 - t0))" + echo "$line" >> "$RESULTS" + echo " $line" >&2 + else + echo "txs=$N preset=$P inner_cycles=$ic epochs=$epochs blob=$sz EXEC_FAILED" >> "$RESULTS" + echo "ERROR: [${P}/${N}tx] guest execute failed; tail of stderr:" >&2 + tail -10 "$WORK/exec_${N}tx_${P}.err" >&2 + fi + rm -f "$BLOB" + done +done + +echo "==> done. Results:" +cat "$RESULTS" diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index ad9947855..7165dff81 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -8,14 +8,14 @@ use core::arch::asm; #[cfg(target_arch = "riscv64")] pub const PRIVATE_INPUT_START: usize = 0xFF000000; -/// Maximum private-input length the guest will read, in bytes (64 MiB). +/// Maximum private-input length the guest will read, in bytes (512 MiB). /// The host caps stored input at this size in `Memory::store_private_inputs`, /// so an honest length prefix is always `<=` this bound; a larger value can only /// come from a malformed or forged prefix. The reader clamps to this cap so a /// bogus length can never make the guest fabricate an arbitrarily long slice. /// Must match `executor::vm::memory::MAX_PRIVATE_INPUT_SIZE`. #[cfg(target_arch = "riscv64")] -const MAX_PRIVATE_INPUT_SIZE: usize = 64 * 1024 * 1024; +const MAX_PRIVATE_INPUT_SIZE: usize = 512 * 1024 * 1024; #[cfg(target_arch = "riscv64")] pub enum SyscallNumbers {