diff --git a/.github/scripts/aggregate_recursion_histogram.py b/.github/scripts/aggregate_recursion_histogram.py
index 0be0a3010..2092c05b0 100755
--- a/.github/scripts/aggregate_recursion_histogram.py
+++ b/.github/scripts/aggregate_recursion_histogram.py
@@ -93,10 +93,6 @@ def parse(text):
return total_cycles, unique_pcs, exec_time, tables
-def short(name, width=90):
- return name if len(name) <= width else name[: width - 1] + "…"
-
-
def render_table(rows, denom_label):
if not rows:
return "> _no rows_\n"
@@ -105,7 +101,7 @@ def render_table(rows, denom_label):
for i, r in enumerate(rows, 1):
body += (
f"| {i} | {r['cycles']:,} | {r['pct']}% | {r['cum']}% | "
- f"{r['pcs']} | `{short(r['fn'])}` |\n"
+ f"{r['pcs']} | `{r['fn']}` |\n"
)
last_cum = rows[-1]["cum"]
body += (
diff --git a/.github/scripts/run_recursion_bench.sh b/.github/scripts/run_recursion_bench.sh
new file mode 100755
index 000000000..05e7f5b1d
--- /dev/null
+++ b/.github/scripts/run_recursion_bench.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+#
+# Runs scripts/bench_recursion_cycles.sh across every preset regime (min,
+# blowup2/blowup4, blowup4-block) and appends each result — or an
+# "unavailable" note when the ref/preset combo isn't supported — to
+# /tmp/recursion_result.txt for the bench-verify.yml PR comment.
+#
+# Usage: .github/scripts/run_recursion_bench.sh HEAD_SHA
+set -euo pipefail
+
+HEAD_SHA="$1"
+RESULT=/tmp/recursion_result.txt
+: > "$RESULT"
+
+run_preset() {
+ local preset="$1"
+ local log="/tmp/recursion_out_${preset}.txt"
+ if scripts/bench_recursion_cycles.sh "$HEAD_SHA" origin/main "$preset" 2>&1 | tee "$log"; then
+ { echo; sed -n '/=== Recursion-guest cycle/,$p' "$log"; } >> "$RESULT"
+ else
+ { echo; echo "_(${preset} regime unavailable for these refs — see the workflow log.)_"; } >> "$RESULT"
+ fi
+}
+
+run_preset min
+# Post-result's raw-log fallback reads /tmp/recursion_out.txt (unsuffixed).
+cp -f /tmp/recursion_out_min.txt /tmp/recursion_out.txt
+
+# blowup2/blowup4: full-query base-layer regimes over the `empty` diagnostic
+# program. Need origin/main's RECURSION_DUMP_PRESET support to dump a
+# non-min blob; checked once up front instead of failing each preset in turn.
+if git grep -q RECURSION_DUMP_PRESET origin/main -- prover/src/tests/ 2>/dev/null; then
+ run_preset blowup2
+ run_preset blowup4
+else
+ { echo; echo "_(blowup2/blowup4 full-query regimes need \`origin/main\` to have the preset-aware dump test (RECURSION_DUMP_PRESET) — not merged yet, so only \`min\` is compared for this PR.)_"; } >> "$RESULT"
+fi
+
+# blowup4-block: same blowup=4 verifier over a REAL ethrex block (via the
+# `continuation` guest). Needs origin/main's RECURSION_DUMP_EPOCH_LOG2 support.
+if git grep -q RECURSION_DUMP_EPOCH_LOG2 origin/main -- prover/src/tests/ 2>/dev/null; then
+ run_preset blowup4-block
+else
+ { echo; echo "_(blowup4-block real-ethrex-block regime needs \`origin/main\` to support RECURSION_DUMP_EPOCH_LOG2 — not merged yet.)_"; } >> "$RESULT"
+fi
diff --git a/.github/workflows/bench-verify.yml b/.github/workflows/bench-verify.yml
index baf550bac..0641195cc 100644
--- a/.github/workflows/bench-verify.yml
+++ b/.github/workflows/bench-verify.yml
@@ -1,7 +1,15 @@
name: Bench verifier
-# Manual-only (/bench-verify); separate from /bench so they never share the bench server.
+# Manual-only (/bench-verify, or workflow_dispatch to test workflow changes on a
+# branch — issue_comment always runs main's copy of this file, see profile-recursion.yml);
+# separate from /bench so they never share the bench server.
on:
+ workflow_dispatch:
+ inputs:
+ pairs:
+ description: "ABBA pair count (2-40)"
+ required: false
+ default: "20"
issue_comment:
types: [created]
@@ -22,20 +30,19 @@ permissions:
jobs:
verify:
if: >-
- github.event.issue.pull_request &&
- startsWith(github.event.comment.body, '/bench-verify') &&
- contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'issue_comment' &&
+ github.event.issue.pull_request &&
+ startsWith(github.event.comment.body, '/bench-verify') &&
+ contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: [self-hosted, bench]
- # Job cap. The verifier bench is ~5-6 min and the recursion measurement itself ~1 min,
- # but on a cold runner the recursion BUILDS dominate: MEASURE_CLI (release cli) once,
- # plus PER REF a guest build (~10-20 min) and a prover-test build for the blob dump.
- # All cached in /tmp for later runs, and build-std / the host cargo target are shared
- # across ref worktrees (see the recursion step's env) to keep the cold run under this
- # cap. The recursion step also has its own tighter timeout so a runaway build there
- # can't burn the whole job and lose the already-captured verifier result.
+ # Job cap. On a cold runner the recursion BUILDS dominate: MEASURE_CLI once, plus
+ # per ref a guest build and a prover-test build. Cached in /tmp; build-std / host
+ # cargo target shared across ref worktrees (see the recursion step's env).
timeout-minutes: 90
steps:
- name: Acknowledge (react + occupancy notice)
+ if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
@@ -46,7 +53,7 @@ jobs:
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number,
- body: '⏳ **Benchmark started** on the bench server. The verifier bench takes ~5 min; the recursion-guest cycle comparison then adds guest builds — a few minutes when cached, up to ~1h on a cold run. The bench server is occupied until it finishes.'
+ body: '⏳ **Benchmark started** on the bench server. The recursion-guest cycle comparison adds guest builds on top of the verifier bench, longer on a cold runner. The bench server is occupied until it finishes.'
});
- name: Resolve PR head + pair count
@@ -55,14 +62,25 @@ jobs:
GH_TOKEN: ${{ github.token }}
PR_NUM: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
+ DISPATCH_PAIRS: ${{ github.event.inputs.pairs }}
run: |
- # Head SHA (not branch name) so fork PRs resolve and a mid-run force-push can't race.
- HEAD_SHA=$(gh pr view "$PR_NUM" --repo "$GITHUB_REPOSITORY" --json headRefOid -q .headRefOid)
+ if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
+ # Testing this workflow's own changes: bench the dispatched branch vs main.
+ HEAD_SHA="$GITHUB_SHA"
+ N="${DISPATCH_PAIRS:-20}"
+ else
+ # Head SHA (not branch name) so fork PRs resolve and a mid-run force-push can't race.
+ HEAD_SHA=$(gh pr view "$PR_NUM" --repo "$GITHUB_REPOSITORY" --json headRefOid -q .headRefOid)
+ # Optional pair count "/bench-verify 32"; default 20.
+ N=$(echo "$COMMENT_BODY" | sed -n 's|^/bench-verify[[:space:]]*\([0-9]\+\).*|\1|p')
+ N=${N:-20}
+ fi
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
- # Optional pair count "/bench-verify 32"; default 20, clamp [2,40].
- N=$(echo "$COMMENT_BODY" | sed -n 's|^/bench-verify[[:space:]]*\([0-9]\+\).*|\1|p')
- N=${N:-20}
- if [ "$N" -lt 2 ] 2>/dev/null || [ "$N" -gt 40 ] 2>/dev/null; then
+ if ! [[ "$N" =~ ^[0-9]+$ ]]; then
+ echo "::warning::pair count '$N' is not a number; using 20"
+ N=20
+ fi
+ if [ "$N" -lt 2 ] || [ "$N" -gt 40 ]; then
echo "::warning::pair count $N out of range [2,40]; using 20"
N=20
fi
@@ -74,6 +92,7 @@ jobs:
fetch-depth: 0
- name: Fetch PR head commit (works for fork PRs)
+ if: github.event_name == 'issue_comment'
env:
PR_NUM: ${{ github.event.issue.number }}
run: git fetch origin "pull/$PR_NUM/head" --quiet
@@ -92,24 +111,20 @@ 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
- # 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
- # disk shrinks). continue-on-error + `!cancelled()` keep this fully isolated from the
- # verifier bench above: a failure OR timeout here never fails the job nor clobbers the
- # verifier verdict, and it still runs if the verifier step failed.
+ # Additive: deterministic recursion-guest cycle+accelerator diff (PR vs main), in
+ # four regimes: `min`, `blowup2`, `blowup4` (empty diagnostic inner program) plus
+ # `blowup4-block` (same verifier over a REAL ethrex block, via the `continuation`
+ # guest). One exact `execute --cycles` reading per ref (no ABBA); blowup4-block's
+ # dumped blob is cached by ref SHA (bench_recursion_cycles.sh), so a repeat run
+ # skips re-proving. GUEST_TARGET_DIR / HOST_TARGET_DIR share build-std and the
+ # host cargo target across ref worktrees. continue-on-error + `!cancelled()`
+ # isolate this from the verifier bench above.
- name: Run recursion guest cycle benchmark
id: recursion
if: ${{ !cancelled() }}
continue-on-error: true
- # Fail-fast well under the 90-min job cap so a runaway recursion build can't burn
- # the whole job and lose the already-captured verifier result (continue-on-error
- # absorbs the timeout; the job still posts the verifier verdict). Sized with
- # headroom over a cold shared-build run (MEASURE_CLI + 2 guest builds + 2 blob-dump
- # builds).
+ # Fail-fast under the job cap so a runaway build can't burn the whole job
+ # (continue-on-error absorbs the timeout; the verifier verdict still posts).
timeout-minutes: 70
env:
HEAD_SHA: ${{ steps.cfg.outputs.head_sha }}
@@ -119,9 +134,7 @@ jobs:
HOST_TARGET_DIR: /tmp/recursion_cycles_run/shared_host_target
run: |
export SYSROOT_DIR="$HOME/.lambda-vm-sysroot"
- 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
+ .github/scripts/run_recursion_bench.sh "$HEAD_SHA"
- name: Post result
if: always()
@@ -163,6 +176,11 @@ jobs:
body += '⚠️ Recursion cycle bench did not complete (does not affect the verifier verdict above).';
body += rtail ? ' Last log lines:\n\n' + '```\n' + rtail + '\n```\n' : '\n';
}
+ // workflow_dispatch has no PR to comment on; write to the job summary instead.
+ if (context.eventName !== 'issue_comment') {
+ await core.summary.addRaw(body).write();
+ return;
+ }
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number,
@@ -180,3 +198,10 @@ jobs:
issue_number: context.issue.number, body
});
}
+
+ # continue-on-error above protects the posted verifier result, not the failure itself.
+ - name: Fail if recursion cycle bench didn't complete
+ if: always() && steps.recursion.outcome != 'success'
+ run: |
+ echo "::error::Recursion cycle bench step did not complete (outcome=${{ steps.recursion.outcome }}) — see its log and the posted result above."
+ exit 1
diff --git a/.github/workflows/profile-recursion.yml b/.github/workflows/profile-recursion.yml
index 5fc9817ba..6829dfff3 100644
--- a/.github/workflows/profile-recursion.yml
+++ b/.github/workflows/profile-recursion.yml
@@ -42,6 +42,9 @@ jobs:
- name: multi-query
test: multi
title: "Multi query (blowup=8, 128-bit)"
+ - name: block
+ test: block
+ title: "Real ethrex block, 4 transfers (blowup=4, 110 queries)"
steps:
- name: React to comment
if: github.event_name == 'issue_comment' && matrix.name == 'single-query'
@@ -136,9 +139,10 @@ jobs:
{
echo "## Recursion guest profile"
echo
- # Single-query first, then multi-query, then any others.
+ # Single-query first, then multi-query, then the real-block profile.
for frag in fragments/fragment-single-query.md \
- fragments/fragment-multi-query.md; do
+ fragments/fragment-multi-query.md \
+ fragments/fragment-block.md; do
[ -f "$frag" ] && { cat "$frag"; echo; }
done
echo "Commit: ${COMMIT_SHA:0:8} · Runner: self-hosted bench"
diff --git a/Makefile b/Makefile
index 12ac291f5..4abed4a90 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,7 @@
compile-programs compile-recursion-elfs clean-asm clean-rust clean-bench clean-shared \
clean-recursion-elfs clean test test-asm \
test-rust test-ethrex test-executor test-syscalls test-flamegraph flamegraph-prover test-profile-recursion test-profile-recursion-single test-profile-recursion-multi \
+test-profile-recursion-block recursion-profile-block-input \
test-fast test-prover test-prover-all test-prover-debug test-disk-spill test-math-cuda test-cuda-integration test-cuda-fallback \
test-prover-cuda test-prover-comprehensive-cuda \
bench-math-cuda bench-prover bench-prover-cuda build check clippy fmt lint regen-ethrex-fixtures \
@@ -56,13 +57,21 @@ 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
-# 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)))
+# 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.
+# `required-features` is a subset match, so e.g. `--features "continuation min"`
+# also satisfies plain `recursion-min-bench`'s `required-features = ["min"]`,
+# racing a concurrent `make -j` build of `recursion-min.elf` for the same
+# shared-target-dir path. `--bin $(2)` in build_guest_elf pins each invocation
+# to its one target bin.
+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
+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.
@@ -191,6 +200,7 @@ cd $(1) && \
-Z build-std=core,alloc,std,compiler_builtins,panic_abort \
-Z build-std-features=compiler-builtins-mem \
-Z json-target-spec \
+ --bin $(2) \
$(3)
cp $(SHARED_TARGET_DIR)/riscv64im-lambda-vm-elf/release/$(2) $@
endef
@@ -215,27 +225,26 @@ $(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
-# 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
-# rule (the stem "recursion-min" wouldn't match the crate dir "recursion") and
-# rather than copy-paste (the presets list is the single source of truth).
+# One differently named [[bin]] per preset (recursion--bench, gated on
+# that preset's Cargo feature) -> a differently named artifact. define/foreach/
+# eval rather than a pattern rule (stem "recursion-min" wouldn't match crate
+# dir "recursion") or copy-paste (presets list is the single source of truth).
# $(1) is the preset; the recipe uses $$ so `$$(call build_guest_elf,...)`
-# survives the $(call ...) expansion and is expanded at recipe-run time (where
-# $@ is defined). Because the two bins have distinct filenames the post-build
-# `cp`s read different files, so the `make -j` cp race is gone structurally and
-# no `.NOTPARALLEL` is needed: cargo's target-dir lock already serializes the
-# compiles, and `.NOTPARALLEL` with prerequisites was wrong on every make
-# version anyway (it serializes the whole build on GNU make <= 4.3 — macOS ships
-# 3.81, ubuntu-latest 4.3 — and on >= 4.4 serializes only the listed targets'
-# own prerequisites, never the two ELF targets against each other).
+# expands at recipe-run time (where $@ is defined).
define recursion_verifier_rule
$(RECURSION_ARTIFACTS_DIR)/recursion-$(1).elf: FORCE | prepare-sysroot $(RECURSION_ARTIFACTS_DIR)
$$(call build_guest_elf,$$(RECURSION_GUESTS_DIR)/recursion,recursion-$(1)-bench,--features $(1))
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)
@@ -278,6 +287,27 @@ test-profile-recursion-single: compile-recursion-elfs
test-profile-recursion-multi: compile-recursion-elfs
cargo test --package lambda-vm-prover --lib test_recursion_profile_multiquery -- --ignored --nocapture
+# Pre-proved continuation input for test_recursion_profile_blowup4_block: proving
+# a real ethrex block is real prover work, not the verifier-guest cost the test
+# profiles, so it's built ONCE here rather than re-proven on every test run.
+# Epoch=2^21 matches scripts/bench_recursion_scaling.sh's default.
+RECURSION_PROFILE_BLOCK_INPUT := $(RECURSION_ARTIFACTS_DIR)/recursion-cont-blowup4-block4.bin
+
+recursion-profile-block-input: $(RECURSION_PROFILE_BLOCK_INPUT)
+
+$(RECURSION_PROFILE_BLOCK_INPUT): $(RUST_ARTIFACTS_DIR)/ethrex.elf executor/tests/ethrex_bench_4.bin | $(RECURSION_ARTIFACTS_DIR)
+ rm -f /tmp/recursion_input.bin /tmp/recursion_input.bin.expected
+ RECURSION_DUMP_PRESET=blowup4 RECURSION_DUMP_EPOCH_LOG2=21 \
+ RECURSION_DUMP_INNER_ELF=$(CURDIR)/$(RUST_ARTIFACTS_DIR)/ethrex.elf \
+ RECURSION_DUMP_INNER_INPUT=$(CURDIR)/executor/tests/ethrex_bench_4.bin \
+ cargo test --release -p lambda-vm-prover --lib test_dump_recursion_input -- --ignored --nocapture
+ mv /tmp/recursion_input.bin $@
+ mv /tmp/recursion_input.bin.expected $@.expected
+
+# Real-block profile (ethrex, blowup=4/4 transfers), via the `continuation` guest.
+test-profile-recursion-block: compile-recursion-elfs $(RECURSION_PROFILE_BLOCK_INPUT)
+ cargo test --package lambda-vm-prover --lib --release test_recursion_profile_blowup4_block -- --ignored --nocapture
+
# Regenerate the committed ethrex block fixtures (see tooling/ethrex-fixtures).
# Run after bumping the ethrex rev; README checksums are refreshed automatically.
regen-ethrex-fixtures:
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..1a846109b 100644
--- a/bench_vs/lambda/recursion/src/main.rs
+++ b/bench_vs/lambda/recursion/src/main.rs
@@ -12,15 +12,21 @@
//! `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 (`recursion::ContinuationGuestInput`, built by
+//! `recursion::encode_continuation_guest_input`), verified via
+//! `recursion::verify_continuation_and_attest` — same trust model, 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 +37,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 +87,17 @@ 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, &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..17f7497d7 100644
--- a/executor/.gitignore
+++ b/executor/.gitignore
@@ -2,3 +2,7 @@
/program_artifacts/rust
/tests/ethrex_hoodi.bin
/tests/ethrex_bench_*.bin
+# _4 is committed (~17 KB): used by `make recursion-profile-block-input` and
+# scripts/bench_recursion_scaling.sh. Other sizes stay ignored — scaling.sh
+# generates any missing fixture on demand via tooling/ethrex-fixtures.
+!/tests/ethrex_bench_4.bin
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/prover/src/recursion.rs b/prover/src/recursion.rs
index 654b58fa4..efca722c9 100644
--- a/prover/src/recursion.rs
+++ b/prover/src/recursion.rs
@@ -20,9 +20,10 @@
//!
//! [`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-blowup4.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 +53,33 @@ 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, 219 queries — 128-bit, realistic base-layer shape (low
+ /// blowup, high query count; final wrap uses high blowup instead).
+ Blowup2,
+ /// Blowup=4, 110 queries — the other realistic base-layer point.
+ Blowup4,
+ /// Blowup=8, 73 queries — 128-bit, final-wrap-style parameters.
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 +90,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 +100,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 +150,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,49 +289,6 @@ pub fn verify_and_attest_blob(
Ok(Some(attestation))
}
-/// 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)
-}
-
/// [`verify_and_attest_blob`]'s logic for a continuation bundle: takes the
/// wire-format blob ([`encode_continuation_guest_input`]) and does the
/// intended `continuation` guest's whole job in one call — verify every
diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs
index bd1244c30..1f4800011 100644
--- a/prover/src/tests/recursion_smoke_test.rs
+++ b/prover/src/tests/recursion_smoke_test.rs
@@ -155,9 +155,17 @@ fn drive_executor(
(total_cycles, start.elapsed())
}
+/// The identity + output a correct in-VM run must commit — the profile
+/// tests' correctness oracle, computed host-side before the guest runs and
+/// checked against its committed attestation in [`run_profile_from`].
+struct ExpectedAttestation {
+ id: [u8; 32],
+ output: Vec,
+}
+
/// Shared preamble: build the blob (an `empty` inner proof under the preset's
/// options), load the `recursion-.elf` verifier, and stand up an
-/// executor. Returns `(elf_bytes, program, executor)`.
+/// executor. Returns `(elf_bytes, program, executor, expected_attestation)`.
fn setup_guest_run(
label: &str,
preset: Preset,
@@ -165,14 +173,21 @@ fn setup_guest_run(
Vec,
executor::elf::Elf,
executor::vm::execution::Executor,
+ ExpectedAttestation,
) {
let root = workspace_root();
let empty_elf_bytes = read_guest_elf(&root, "empty");
let guest_elf_bytes = read_guest_elf(&root, preset.artifact_stem());
- let (_inner_proof, blob) =
+ let (inner_proof, blob) =
prove_inner_and_encode_blob(label, &empty_elf_bytes, &[], &preset.options());
+ let expected = ExpectedAttestation {
+ id: recursion::expected_program_id(&empty_elf_bytes, &preset.options())
+ .expect("expected_program_id errored"),
+ output: inner_proof.public_output,
+ };
+
let program = executor::elf::Elf::load(&guest_elf_bytes).expect("ELF load failed");
assert_ne!(
program.entry_point,
@@ -182,7 +197,53 @@ fn setup_guest_run(
);
let executor =
executor::vm::execution::Executor::new(&program, blob).expect("Executor::new failed");
- (guest_elf_bytes, program, executor)
+ (guest_elf_bytes, program, executor, expected)
+}
+
+/// [`setup_guest_run`]'s fixture-based counterpart for a real ethrex block:
+/// reads a pre-proved continuation input (`make recursion-profile-block-input`)
+/// instead of proving one in-process, so this test only ever measures the
+/// verifier guest, never the inner prove.
+fn setup_block4_blowup4_guest_run() -> (
+ Vec,
+ executor::elf::Elf,
+ executor::vm::execution::Executor,
+ ExpectedAttestation,
+) {
+ let root = workspace_root();
+ let guest_elf_bytes = read_guest_elf(&root, "recursion-cont-blowup4");
+
+ let art = root.join("executor/program_artifacts/recursion");
+ let blob_path = art.join("recursion-cont-blowup4-block4.bin");
+ let blob = std::fs::read(&blob_path).unwrap_or_else(|e| {
+ panic!(
+ "failed to read {} — run `make recursion-profile-block-input`: {e}",
+ blob_path.display()
+ )
+ });
+ let expected_path = art.join("recursion-cont-blowup4-block4.bin.expected");
+ let expected_bytes = std::fs::read(&expected_path).unwrap_or_else(|e| {
+ panic!(
+ "failed to read {} — run `make recursion-profile-block-input`: {e}",
+ expected_path.display()
+ )
+ });
+ let (id_bytes, output) = expected_bytes.split_at(32);
+ let expected = ExpectedAttestation {
+ id: id_bytes
+ .try_into()
+ .expect("expected sidecar id is 32 bytes"),
+ output: output.to_vec(),
+ };
+
+ let program = executor::elf::Elf::load(&guest_elf_bytes).expect("ELF load failed");
+ assert_ne!(
+ program.entry_point, 0,
+ "recursion-cont-blowup4 ELF has entry_point=0 — build artifact is malformed",
+ );
+ let executor =
+ executor::vm::execution::Executor::new(&program, blob).expect("Executor::new failed");
+ (guest_elf_bytes, program, executor, expected)
}
/// Demangled enclosing-function name for a PC via the ELF symbol table;
@@ -324,16 +385,40 @@ fn print_step_breakdown(buckets: &[u64; 7], total_cycles: u64) {
}
}
-/// Single-pass execute-only profiler. Always prints total cycles, the
-/// per-step cycle breakdown (marker decode is cheap — one `InstructionCache`
-/// lookup per cycle), and a rough trace/LDE estimate; with `detailed`, also
-/// the top-25 functions table (needs a `pc_hist` HashMap, so gated).
+/// Single-pass execute-only profiler over the `empty` inner program (the
+/// verifier's intrinsic recursion overhead, not a real workload). Always
+/// prints total cycles, the per-step cycle breakdown (marker decode is cheap —
+/// one `InstructionCache` lookup per cycle), and a rough trace/LDE estimate;
+/// with `detailed`, also the top-25 functions table (needs a `pc_hist`
+/// HashMap, so gated).
fn run_profile(preset: Preset, progress_stride: usize, detailed: bool) {
+ let (guest_elf_bytes, program, executor, expected) = setup_guest_run("profile", preset);
+ run_profile_from(
+ preset,
+ &guest_elf_bytes,
+ &program,
+ executor,
+ progress_stride,
+ detailed,
+ &expected,
+ );
+}
+
+/// Shared profiling loop: runs an already-set-up guest executor and prints
+/// the same cycle/step/function breakdown regardless of the inner program.
+fn run_profile_from(
+ preset: Preset,
+ guest_elf_bytes: &[u8],
+ program: &executor::elf::Elf,
+ mut executor: executor::vm::execution::Executor,
+ progress_stride: usize,
+ detailed: bool,
+ expected: &ExpectedAttestation,
+) {
use std::collections::HashMap;
let opts = preset.options();
- let (guest_elf_bytes, program, mut executor) = setup_guest_run("profile", preset);
- let symbols = executor::elf::SymbolTable::parse(&guest_elf_bytes);
+ let symbols = executor::elf::SymbolTable::parse(guest_elf_bytes);
let instructions = executor::vm::execution::InstructionCache::new(&program.data)
.expect("instruction cache build failed");
@@ -386,6 +471,29 @@ fn run_profile(preset: Preset, progress_stride: usize, detailed: bool) {
},
);
+ // Correctness, not just crash-freedom: check the guest's committed
+ // attestation against the trusted host recompute (`expected`).
+ let committed = executor
+ .finish()
+ .expect("read committed output after execution")
+ .memory_values;
+ let (id, output) = recursion::split_attestation(&committed)
+ .expect("attestation too short (guest committed fewer than 32 bytes)");
+ assert_eq!(
+ id, expected.id,
+ "guest attestation program_id mismatch — in-VM verify accepted a different \
+ (ELF, roots) identity than the trusted host recompute"
+ );
+ assert_eq!(
+ output,
+ expected.output.as_slice(),
+ "attested inner public output mismatch — the in-VM verify's committed output \
+ diverges from the trusted host recompute"
+ );
+ eprintln!(
+ "[profile] guest attestation matched the trusted host recompute (program_id + inner public output) ✓"
+ );
+
eprintln!();
eprintln!("============================================================");
eprintln!(
@@ -674,7 +782,7 @@ fn test_recursion_execute_1query() {
#[test]
#[ignore = "slow: runs the in-VM STARK verifier (minutes on CI)"]
fn test_recursion_step_markers_observed_in_order() {
- let (_bytes, program, mut executor) = setup_guest_run("step-markers", Preset::Min);
+ let (_bytes, program, mut executor, _expected) = setup_guest_run("step-markers", Preset::Min);
let instructions = executor::vm::execution::InstructionCache::new(&program.data)
.expect("instruction cache build failed");
@@ -771,19 +879,129 @@ 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`) — must match the `recursion-.elf` the blob is fed to.
+/// * `RECURSION_DUMP_INNER_ELF` (path, default the `empty` guest).
+/// * `RECURSION_DUMP_INNER_INPUT` (path, default none).
+/// * `RECURSION_DUMP_EPOCH_LOG2` (int, default unset = monolithic) — prove via
+/// continuations with `2^n`-cycle epochs and encode a
+/// [`recursion::ContinuationGuestInput`] blob for `recursion-cont-.elf`.
#[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(),
+ };
+
+ // Continuation dumps also get an `.expected` sidecar (32-byte id || inner
+ // 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.
+ let (blob, expected_sidecar) = match std::env::var("RECURSION_DUMP_EPOCH_LOG2") {
+ Ok(s) => {
+ // No recursion-cont-blowup8.elf is built (RECURSION_CONT_PRESETS
+ // stops at blowup4).
+ assert_ne!(
+ preset,
+ Preset::Blowup8,
+ "RECURSION_DUMP_PRESET=blowup8 has no recursion-cont-blowup8.elf guest; \
+ continuation mode only supports min|blowup2|blowup4"
+ );
+ 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());
+
+ let expected_output =
+ crate::continuation::verify_continuation(&inner_elf_bytes, &bundle, &opts)
+ .expect("verify_continuation errored")
+ .expect("continuation bundle must verify on host before dumping");
+ let (expected_decode, expected_pages) =
+ crate::continuation::continuation_precomputed_commitments(
+ &inner_elf_bytes,
+ &bundle,
+ &opts,
+ )
+ .expect("continuation_precomputed_commitments errored");
+ let expected_id =
+ recursion::program_id_from_elf(&inner_elf_bytes, &expected_decode, &expected_pages)
+ .expect("program_id_from_elf errored");
+
+ let blob = recursion::encode_continuation_guest_input(bundle, &inner_elf_bytes, &opts)
+ .expect("recursion::encode_continuation_guest_input failed");
+ (blob, Some((expected_id, expected_output)))
+ }
+ Err(_) => {
+ let (_inner_proof, blob) = prove_inner_and_encode_blob(
+ "dump-input",
+ &inner_elf_bytes,
+ &inner_input,
+ &preset.options(),
+ );
+ (blob, None)
+ }
+ };
+ 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()
+ );
+
+ if let Some((id, output)) = expected_sidecar {
+ let mut sidecar_data = Vec::with_capacity(32 + output.len());
+ sidecar_data.extend_from_slice(&id);
+ sidecar_data.extend_from_slice(&output);
+ let sidecar_path = format!("{path}.expected");
+ std::fs::write(&sidecar_path, &sidecar_data).expect("write expected sidecar");
+ eprintln!(
+ "[dump-input] wrote {} bytes to {sidecar_path}",
+ sidecar_data.len()
+ );
+ }
}
/// Cycle count only of the recursion guest verifying a 1-query inner proof.
@@ -800,6 +1018,41 @@ 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 recursion `continuation` guest
+/// verifying a REAL ethrex block (4 transfers), blowup=4 — not the
+/// `empty`-program diagnostic floor `test_recursion_profile_1query`/
+/// `_multiquery` measure. Requires `make recursion-profile-block-input`.
+#[test]
+#[ignore = "diagnostic: heavy; recursion guest histogram + steps over a real ethrex block (blowup=4)"]
+fn test_recursion_profile_blowup4_block() {
+ let (guest_elf_bytes, program, executor, expected) = setup_block4_blowup4_guest_run();
+ run_profile_from(
+ Preset::Blowup4,
+ &guest_elf_bytes,
+ &program,
+ executor,
+ 500,
+ true,
+ &expected,
+ );
+}
+
/// 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..c4bc06461 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,21 @@
# 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); blowup2 = blowup=2, 219 queries (realistic
+# base-layer, 128-bit); blowup4 = blowup=4, 110 queries (the other
+# base-layer point); blowup8 = blowup=8, 73 queries. Picks BOTH the
+# guest ELF (recursion-.elf, falling back to recursion.elf
+# on older refs) AND the dumped blob's inner-proof options (via
+# RECURSION_DUMP_PRESET). Refs predating the preset-aware dump test
+# only support PRESET=min — the script fails loudly up front rather
+# than let the guest reject the blob in-VM. Different artifact
+# names across refs (e.g. recursion.elf vs recursion-min.elf) is
+# expected — both verify under the SAME preset options.
+# `blowup4-block` isn't a build preset: it's the `continuation` guest
+# (recursion-cont-blowup4.elf) verifying a real ethrex block instead
+# of the `empty` diagnostic program — real prover minutes per ref
+# (see the blob cache below), not seconds.
# Env:
# REBUILD=1 force rebuild of MEASURE_CLI and re-run of every ref
# (guest build + blob dump + measurement); ignore caches.
@@ -55,6 +62,9 @@
# PRUNE_KEEP= cap on cached ref worktrees kept under $WORK (default 10);
# older ones (+ their results/blobs/logs) are pruned at startup
# to bound disk on the long-lived bench runner.
+# BLOCK_TXS=4 PRESET=blowup4-block only: ethrex block size, reading
+# executor/tests/ethrex_bench_.bin (only _4 committed).
+# BLOCK_EPOCH_LOG2=21 PRESET=blowup4-block only: inner continuation epoch size.
#
# Caching: each ref's result is cached in $WORK keyed on its resolved SHA + preset + the
# MEASURE_CLI source SHA (so a baseline and PR side are never compared across two
@@ -62,7 +72,9 @@
# read: a truncated/partial cache is discarded and re-measured, never emitted as zeros.
# Ref worktrees are kept (named by SHA) so a re-measure is a cargo no-op; the newest
# PRUNE_KEEP are retained and older ones pruned. A worktree whose guest build fails
-# mid-run is removed immediately. REBUILD=1 forces everything.
+# mid-run is removed immediately. The dumped input blob is also cached (keyed on SHA +
+# preset), so re-proving blowup4-block's real ethrex block only happens once per ref.
+# REBUILD=1 forces everything.
#
set -euo pipefail
@@ -104,8 +116,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
}
@@ -168,11 +180,36 @@ valid_result() {
measure_ref() {
local ref="$1" sha="$2" role="$3"
local sha8="${sha:0:8}"
- # Key the cache on ref SHA + preset AND the MEASURE_CLI source SHA, so a baseline and
- # PR side measured by different counters (after a cli change) never share a result.
- local result="$WORK/result_${sha8}_${PRESET}_m${HEAD_SHA:0:8}.txt"
+ # `blowup4-block`: same cache/worktree/measure plumbing, but a real ethrex
+ # block through the continuation guest instead of the `min`/`blowup*`
+ # presets' `empty`-program blob. BLOCK_PRESET is the underlying build
+ # preset (blowup4); BLOCK_TXS/BLOCK_EPOCH_LOG2 pin the fixture and epoch
+ # size to what `make recursion-profile-block-input` proves.
+ local is_block=0 block_preset=""
+ if [ "$PRESET" = "blowup4-block" ]; then
+ is_block=1
+ block_preset="blowup4"
+ fi
+ local block_txs="${BLOCK_TXS:-4}"
+ local block_epoch_log2="${BLOCK_EPOCH_LOG2:-21}"
+
+ # Blob cache: keyed on sha + preset (+ block fixture/epoch), persists across runs.
+ local blob_key="$PRESET"
+ if [ "$is_block" = 1 ]; then
+ blob_key="${PRESET}_txs${block_txs}_epoch${block_epoch_log2}"
+ fi
+ # Key the result cache on ref SHA + blob_key (so a BLOCK_TXS/BLOCK_EPOCH_LOG2
+ # override never reuses a stale measurement) AND the MEASURE_CLI source SHA
+ # (so a baseline and PR side measured by different counters never share a result).
+ local result="$WORK/result_${sha8}_${blob_key}_m${HEAD_SHA:0:8}.txt"
local wt="$WORK/wt_${sha8}"
+ local blob="$WORK/blob_${sha8}_${blob_key}.bin"
+ local need_dump=1
+ if [ "${REBUILD:-0}" != "1" ] && [ -s "$blob" ]; then
+ need_dump=0
+ fi
+
if [ "${REBUILD:-0}" != "1" ] && [ -f "$result" ]; then
if valid_result < "$result"; then
echo "==> [$role] Reusing cached measurement: $ref ($sha8) preset=$PRESET" >&2
@@ -196,16 +233,21 @@ measure_ref() {
fi
touch "$wt" 2>/dev/null || true
- # 2a. Build the recursion guest ELF(s) (+ empty.elf inner program). GUEST_TARGET_DIR,
- # when set, shares the RV64 build dir across ref worktrees (reuses build-std).
- echo "==> [$role] make compile-recursion-elfs @ $sha8 (this can take 10-20 min the first time) ..." >&2
+ # 2a. Build the recursion guest ELF(s) (+ empty.elf inner program), and for
+ # block mode also the ethrex inner guest. GUEST_TARGET_DIR, when set, shares
+ # the RV64 build dir across ref worktrees (reuses build-std).
+ echo "==> [$role] make compile-recursion-elfs @ $sha8 (slow the first time) ..." >&2
local glog="$WORK/build_guest_${sha8}.log"
- local -a make_args=(compile-recursion-elfs)
+ local -a make_goals=(compile-recursion-elfs)
+ if [ "$is_block" = 1 ] && [ "$need_dump" = 1 ]; then
+ make_goals+=(executor/program_artifacts/rust/ethrex.elf)
+ fi
+ local -a make_args=("${make_goals[@]}")
if [ -n "${GUEST_TARGET_DIR:-}" ]; then
make_args+=("SHARED_TARGET_DIR=$GUEST_TARGET_DIR")
fi
if ! ( cd "$wt" && SYSROOT_DIR="$SYSROOT_DIR" make "${make_args[@]}" ) >"$glog" 2>&1; then
- echo "ERROR: [$role] 'make compile-recursion-elfs' failed for $ref ($sha8). Tail of $glog:" >&2
+ echo "ERROR: [$role] 'make ${make_goals[*]}' failed for $ref ($sha8). Tail of $glog:" >&2
tail -40 "$glog" >&2
# A failed build can leave a partial worktree; drop it so it never lingers or
# poisons a later reuse. (The startup prune also caps total worktrees.)
@@ -214,10 +256,17 @@ measure_ref() {
exit 1
fi
- # 2b. Detect the guest ELF: prefer recursion-.elf, else recursion.elf.
+ # 2b. Detect the guest ELF: block mode always wants recursion-cont-.elf;
+ # otherwise prefer recursion-.elf, else recursion.elf.
local artdir="$wt/executor/program_artifacts/recursion"
local guest_elf=""
- if [ -f "$artdir/recursion-${PRESET}.elf" ]; then
+ if [ "$is_block" = 1 ]; then
+ guest_elf="$artdir/recursion-cont-${block_preset}.elf"
+ if [ ! -f "$guest_elf" ]; then
+ echo "ERROR: [$role] no $guest_elf for $ref ($sha8) — ref predates the continuation guest." >&2
+ exit 1
+ fi
+ elif [ -f "$artdir/recursion-${PRESET}.elf" ]; then
guest_elf="$artdir/recursion-${PRESET}.elf"
elif [ -f "$artdir/recursion.elf" ]; then
guest_elf="$artdir/recursion.elf"
@@ -229,42 +278,68 @@ measure_ref() {
fi
echo "==> [$role] guest ELF: $(basename "$guest_elf")" >&2
- # 2c. Generate this ref's own input blob via its ignored dump test.
- 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
- rm -f /tmp/recursion_input.bin
- local dlog="$WORK/dump_${sha8}.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
- echo "ERROR: [$role] blob-dump test failed for $ref ($sha8). Tail of $dlog:" >&2
- tail -40 "$dlog" >&2
+ # 2c. Generate this ref's own input blob via its ignored dump test, unless a
+ # cached blob covers this sha/preset already (need_dump=0). Refuse up front if
+ # the ref predates a needed knob, instead of failing in-VM verification later.
+ if [ "$need_dump" = 0 ]; then
+ echo "==> [$role] Reusing cached recursion input blob ($blob) — skipping re-prove." >&2
+ else
+ 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
- else
- if ! ( cd "$wt" && cargo test -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
+ 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
+ local -a dump_env=("RECURSION_DUMP_PRESET=${block_preset:-$PRESET}")
+ if [ "$is_block" = 1 ]; then
+ if ! grep -rq "RECURSION_DUMP_EPOCH_LOG2" "$wt/prover/src/tests/" 2>/dev/null; then
+ echo "ERROR: [$role] ref $ref ($sha8) predates RECURSION_DUMP_EPOCH_LOG2 — blowup4-block is not measurable for it." >&2
+ exit 1
+ fi
+ local block_fixture="$wt/executor/tests/ethrex_bench_${block_txs}.bin"
+ if [ ! -f "$block_fixture" ]; then
+ echo "ERROR: [$role] ref $ref ($sha8) is missing $block_fixture (ethrex block fixture) — blowup4-block is not measurable for it." >&2
+ exit 1
+ fi
+ dump_env+=(
+ "RECURSION_DUMP_EPOCH_LOG2=$block_epoch_log2"
+ "RECURSION_DUMP_INNER_ELF=$wt/executor/program_artifacts/rust/ethrex.elf"
+ "RECURSION_DUMP_INNER_INPUT=$block_fixture"
+ )
+ 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}_${PRESET}.log"
+ if [ -n "${HOST_TARGET_DIR:-}" ]; then
+ if ! ( cd "$wt" && env "${dump_env[@]}" 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" && env "${dump_env[@]}" 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
+ fi
+ if [ ! -f /tmp/recursion_input.bin ]; then
+ echo "ERROR: [$role] test_dump_recursion_input did not write /tmp/recursion_input.bin for $ref ($sha8)." >&2
+ exit 1
+ fi
+ mv /tmp/recursion_input.bin "$blob"
fi
- if [ ! -f /tmp/recursion_input.bin ]; then
- echo "ERROR: [$role] test_dump_recursion_input did not write /tmp/recursion_input.bin for $ref ($sha8)." >&2
- exit 1
- fi
- local blob="$WORK/blob_${sha8}_${PRESET}.bin"
- cp /tmp/recursion_input.bin "$blob"
echo "==> [$role] blob: $(wc -c <"$blob" | tr -d '[:space:]') bytes -> $blob" >&2
# 2d. Measure: one deterministic execute --cycles run. Time it (CI feasibility).
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 +409,14 @@ 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)" ;;
+ blowup4-block) REGIME="128-bit (blowup=4, 110 queries) — real ethrex block, 4 transfers" ;;
*) REGIME="$PRESET" ;;
esac
diff --git a/scripts/bench_recursion_scaling.sh b/scripts/bench_recursion_scaling.sh
new file mode 100755
index 000000000..c868586db
--- /dev/null
+++ b/scripts/bench_recursion_scaling.sh
@@ -0,0 +1,119 @@
+#!/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.
+#
+# Sweep order is PRESET-MAJOR: the full block-size curve for the first preset
+# completes before the next starts, so the headline regime (blowup2) yields a
+# usable curve early 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 (only _4 is
+# committed) 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)"
+trap 'rm -rf "$WORK"' EXIT
+
+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; deterministic).
+ if ! ic="$("$CLI" execute "$ETHREX" --private-input "$FIX" --cycles | awk -F': ' '/^Cycles:/{print $2; exit}')" || [ -z "$ic" ]; then
+ echo "txs=$N preset=$P inner_cycles=FAILED" >> "$RESULTS"
+ echo "ERROR: [${P}/${N}tx] inner cycle measurement failed for $FIX" >&2
+ continue
+ fi
+
+ 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}')"
+ if [ -z "$epochs" ]; then
+ echo "txs=$N preset=$P inner_cycles=$ic EPOCHS_PARSE_FAILED" >> "$RESULTS"
+ echo "ERROR: [${P}/${N}tx] could not parse epoch count from $DLOG" >&2
+ continue
+ fi
+ 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"