Skip to content

perf(executor): back the private-input region with a paged Box map#852

Draft
Oppen wants to merge 8 commits into
mainfrom
perf/private-input-paged-boxed-backend
Draft

perf(executor): back the private-input region with a paged Box map#852
Oppen wants to merge 8 commits into
mainfrom
perf/private-input-paged-boxed-backend

Conversation

@Oppen

@Oppen Oppen commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

MAX_PRIVATE_INPUT_SIZE is 512 MiB (#843), but Memory::cells stores every guest word — program/stack/heap and private input — in one HashMap<u64, [u8;4]>. A max-size private input costs ~134M entries there.

Measured on this branch vs. the current per-word design, for a single store_private_inputs call at exactly MAX_PRIVATE_INPUT_SIZE:

  • Peak RSS: ~9.7 GB → ~1.08 GB
  • store_private_inputs time: ~2.2s → ~28ms

This PR routes the private-input address range ([PRIVATE_INPUT_START_INDEX, PRIVATE_INPUT_START_INDEX + MAX_PRIVATE_INPUT_SIZE)) to a separate HashMap<u64, Box<[u8; 256 KiB]>>, one boxed page allocated lazily per touched page — everything else (program/stack/heap) still goes through the original cells map, unchanged. Only an 8-byte pointer lives in the page map's table slot, so growing/rehashing that table only ever moves pointers, never 256 KiB page payloads (this was compared against a sibling design storing the page array inline, which pays a real resize-memcpy cost as the table grows — the boxed version avoids that).

How this was chosen

Five backing designs were prototyped and benchmarked (dense Vec, dense-eager, paged Vec, paged inline-array, paged Box) against both a synthetic max-size input and a real 4-tx ethrex block proven via the continuation system. This one had the best combination of RAM/time and no resize-cost risk. Two independent adversarial reviews (of this diff and the dense-Vec sibling) found no semantic divergence from the original per-word design for any load/store access pattern, at any width, including boundary/alignment edge cases — confirmed further by a raw-execution cycle-count test showing 0.0017% difference against the unmodified backend on the same real ethrex-block input.

A separate ~1.1% divergence observed in full continuation-proving cycle/proof-size (not raw execution) between the old and new backends is still not root-caused — it reproduces on every prototyped backend (including a plain dense Vec with none of this diff's paging), is unaffected by disabling the STARK grinding parallelism, and isn't explained by anything in the accounting path (which is built from the ELF + raw private-input slice + execution logs, never from Memory's internals). It's filed as a follow-up investigation, not a blocker for this change.

Test plan

  • cargo build --workspace
  • cargo test -p executor (all pass)
  • cargo test -p lambda-vm-prover --lib (515 passed, 0 failed, 20 ignored)
  • Adversarial review of the diff (no semantic/behavioral divergence found)
  • Raw ethrex-guest execution cycle-count parity check (0.0017% delta)

MAX_PRIVATE_INPUT_SIZE is 512 MiB (#843), but Memory::cells stores every
guest word (program/stack/heap AND private input) in one
HashMap<u64, [u8;4]>. A max-size private input costs ~134M entries there --
measured peak RSS ~9.7 GB for a single store_private_inputs call, and
store_private_inputs itself takes ~2.2s.

Route the private-input address range to a separate
HashMap<u64, Box<[u8; 256 KiB]>>, one boxed page allocated lazily per
touched page. Only an 8-byte pointer lives in the hashmap's table slot, so
growing/rehashing the table only ever moves pointers, never page payloads --
avoiding the resize-memcpy cost a same-size inline-array page value would
pay. Measured: peak RSS ~1.08 GB (vs ~9.7 GB), store_private_inputs ~28ms
(vs ~2.2s), for the same 512 MiB input. Guest-visible behavior is
unchanged; all load/store paths route by address only.
@Oppen

Oppen commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Highmemory.rs:401: A maximum-sized input occupies MAX_PRIVATE_INPUT_SIZE + 4 bytes. The final four bytes are written into an extra paged-store page, but reads classify addresses at START + MAX as ordinary memory and return zero. Include the prefix in the private-address range or reduce the accepted payload size.

  • Mediummemory.rs:156: Guest byte stores allocate 256 KiB with infallible Box::new. Touching 2,048 private pages can force 512 MiB allocation in very few instructions and abort on OOM, bypassing the new AllocationFailed handling. Use the fallible page allocator here too and propagate the error.

Comment thread executor/src/vm/memory.rs Outdated
let page_idx = private_page_index(address);
let offset = private_page_offset(address);
let page = self
.private_input_pages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store_byte is the only private-write path that uses infallible allocation (or_insert_with(|| Box::new([0u8; PRIVATE_INPUT_PAGE_SIZE]))), while store_word/store_doubleword/store_half/set_private_bytes_aligned all go through the fallible get_or_insert_private_pagetry_allocate_private_page. Two consequences:

  1. Bypasses the OOM-safety design. try_allocate_private_page's doc says a guest touching a page near MAX_PRIVATE_INPUT_SIZE should "fail cleanly with MemoryError::AllocationFailed instead of aborting the host process." A guest-triggered SB into the private region (and unaligned store_word/half/doubleword, which dispatch to store_byte) still aborts the host on allocation failure.
  2. Box::new([0u8; 256 KiB]) constructs the array on the stack before boxing in unoptimized builds — a 256 KiB stack allocation footgun that try_allocate_private_page's Vec-based path deliberately avoids.

store_byte can't return a Result, but it can still allocate through the same heap path (e.g. a helper that builds the page via Vec and unwraps / falls back), rather than a stack-array + infallible-abort. At minimum, avoid the stack-array construction here.

Comment thread executor/src/vm/memory.rs Outdated
/// themselves. Chosen to match the prover's `DEFAULT_PAGE_SIZE` concept
/// (`prover/src/tables/page.rs`); redeclared locally since the executor must
/// not depend on the prover crate.
const PRIVATE_INPUT_PAGE_SIZE: usize = 256 * 1024;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every aligned fast-path here relies on an unstated invariant: that PRIVATE_INPUT_START_INDEX and MAX_PRIVATE_INPUT_SIZE are both multiples of PRIVATE_INPUT_PAGE_SIZE (the comments in load_word/load_doubleword say "either fully inside one page or fully outside"). They currently are (0xFF000000 / 256 KiB = 16320, 512 MiB / 256 KiB = 2048), so there's no bug today — but nothing guards it. If someone later moves PRIVATE_INPUT_START_INDEX to a non-page-aligned address, a 4-aligned word could straddle a page boundary and the p[offset..offset + 4] slices would panic with an out-of-bounds range (or read wrong bytes), silently.

Consider pinning the invariant at compile time next to the constant:

const _: () = assert!(PRIVATE_INPUT_START_INDEX % PRIVATE_INPUT_PAGE_SIZE as u64 == 0);
const _: () = assert!(MAX_PRIVATE_INPUT_SIZE % PRIVATE_INPUT_PAGE_SIZE as u64 == 0);

Comment thread executor/src/vm/memory.rs Outdated
let mut buf: Vec<u8> = Vec::new();
buf.try_reserve_exact(PRIVATE_INPUT_PAGE_SIZE)
.map_err(|_| MemoryError::AllocationFailed)?;
buf.resize(PRIVATE_INPUT_PAGE_SIZE, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantic note on iter_bytes: for the private region this now yields every byte of each allocated page (262 144 addresses per touched page, including never-written words as zeros), whereas the old per-word cells design yielded only the 4-byte words that were actually written. So a single private write now surfaces a full 256 KiB run of (addr, 0) entries in the snapshot.

Reads still return 0 for those addresses either way, and iter_bytes appears to be used only in tests/benches (not the production proving path, which builds from touched_memory_cells), so this is low-impact — but it does change epoch-snapshot content/size and is worth calling out given the unexplained ~1.1% continuation divergence mentioned in the description. Worth double-checking the affected tests actually tolerate the extra zero entries rather than passing by coincidence.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Benchmark Results for unmodified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
base binary_search 60.7 ± 2.6 58.2 65.5 1.00
head binary_search 65.0 ± 5.9 58.4 73.6 1.07 ± 0.11
Command Mean [ms] Min [ms] Max [ms] Relative
base bitwise_ops 59.9 ± 4.2 57.7 71.2 1.01 ± 0.08
head bitwise_ops 59.0 ± 1.5 58.0 61.2 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base ecsm 3.3 ± 0.1 3.1 3.5 1.32 ± 0.07
head ecsm 2.5 ± 0.1 2.4 2.6 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base fibonacci_26 66.6 ± 3.4 62.7 71.9 1.11 ± 0.06
head fibonacci_26 60.3 ± 0.9 59.5 61.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base hashmap 138.1 ± 3.9 130.6 143.7 1.05 ± 0.04
head hashmap 131.5 ± 3.8 123.3 136.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base keccak 130.8 ± 2.5 127.1 135.1 1.05 ± 0.03
head keccak 124.9 ± 1.9 120.9 127.6 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base matrix_multiply 64.8 ± 1.3 62.6 67.3 1.07 ± 0.03
head matrix_multiply 60.7 ± 1.1 59.3 62.1 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base modular_exp 59.1 ± 0.7 58.2 60.6 1.00
head modular_exp 59.7 ± 0.6 58.2 60.4 1.01 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base quicksort 63.9 ± 1.8 62.1 68.0 1.04 ± 0.03
head quicksort 61.3 ± 0.2 61.1 61.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base sieve 65.0 ± 0.6 64.0 65.7 1.07 ± 0.02
head sieve 60.6 ± 0.8 59.9 62.1 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base sum_array 73.9 ± 0.6 73.1 74.7 1.21 ± 0.01
head sum_array 61.3 ± 0.2 61.0 61.5 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base syscall_commit 91.0 ± 0.7 90.1 91.9 1.02 ± 0.01
head syscall_commit 88.8 ± 0.8 87.7 89.8 1.00

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review summary

Reviewed the paged private-input backing store in executor/src/vm/memory.rs. The core design is sound: routing [PRIVATE_INPUT_START_INDEX, +MAX_PRIVATE_INPUT_SIZE) to a HashMap<page_idx, Box<[u8; 256 KiB]>> and dispatching per aligned word is correct — both region boundaries and the page size are word/8-byte aligned, so no aligned word/half/doubleword straddles a page or region boundary, and unaligned accesses fall through to per-byte load/store which check membership individually. Mixed ranges crossing the region boundary (load_bytes, set_bytes_aligned) are handled correctly word-by-word.

Findings (all Medium/Low — no blockers)

  1. [Medium] store_byte uses infallible allocation, bypassing the fallible try_allocate_private_page used by every other private-write path. It aborts the host on OOM (contradicting the stated safety goal) and constructs a 256 KiB array on the stack in unoptimized builds. See inline comment (line 157).

  2. [Medium] The page/region alignment invariant is unguarded. All aligned fast-paths depend on PRIVATE_INPUT_START_INDEX and MAX_PRIVATE_INPUT_SIZE being multiples of PRIVATE_INPUT_PAGE_SIZE. True today, but a future change to the start address would silently produce out-of-bounds slice panics. Suggested a const _: () = assert!(...) guard. See inline comment (line 67).

  3. [Low] iter_bytes semantic change — allocated pages now expand to all 262144 byte addresses (incl. never-written zeros) vs. only-written words before. Test/bench-only path, low impact. See inline comment (line 92).

Note: the working directory is on main (2baad17), not the PR branch, so this review is based on the provided diff plus the base file for context.

The unresolved ~1.1% continuation-proving divergence is acknowledged as a documented follow-up that reproduces on all prototyped backends, so it is not attributable to this diff specifically.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

AI Review

PR #852 · 13 changed files

Findings

Status Sev Location Finding Found by
confirmed medium crypto/stark/src/proof/view.rs:541 MultiProofView::get lacks bounds checking nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
confirmed low executor/src/vm/memory.rs:144 Memory::clone now deep-copies 256KB pages, slowing run_epochs-based test snapshots glm
openrouter/z-ai/glm-5.2
confirmed low executor/src/vm/memory.rs:199 iter_bytes now yields all allocated-page bytes (incl. unwritten zeros), breaking bench footprint/density metrics glm
openrouter/z-ai/glm-5.2

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-003: MultiProofView::get lacks bounds checking
  • Status: confirmed
  • Severity: medium
  • Location: crypto/stark/src/proof/view.rs:541
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

MultiProofView::get(i) panics if i >= len() with no safe alternative

Evidence

The method directly indexes p.proofs[i] / p.proofs.as_slice()[i] without bounds check. While current call sites (verify_l2g_commitment_binding_view, verify_epoch) guard with length checks, this is a footgun for future callers.

Suggested fix

Add a get_checked(&self, i: usize) -> Option<StarkProofView<'a, F, E, PI>> method that returns None on OOB, or make get() return Option and update call sites.

AI-007: Memory::clone now deep-copies 256KB pages, slowing run_epochs-based test snapshots
  • Status: confirmed
  • Severity: low
  • Location: executor/src/vm/memory.rs:144
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

Memory derives Clone (line 144: #[derive(Default, Debug, Clone)]), and its pages field is Djb2HashMap<Box<[u8; MEMORY_PAGE_SIZE]>> with MEMORY_PAGE_SIZE=256KB. A derived clone deep-copies every boxed page's 262144 bytes. run_epochs (executor/src/vm/execution.rs:176-189) calls self.memory.clone() at every epoch boundary to snapshot end_memory into EpochExecution. With the old per-word cells (U64HashMap<[u8;4]>) a clone copied small 4-byte entries; now each epoch boundary clones all allocated 256KB pages. For tests using run_epochs with many epochs and several touched pages (e.g. all_loadstore_32 split into 8-cycle epochs across ~34 cycles, or larger programs), this is O(epochs * pages * 256KB) in time and memory. The production continuation path (prove_continuation) is unaffected: it uses resume_with_limit + a separate PagedMem image, never cloning Memory.

Evidence

Line 154: pages: Djb2HashMap<Box<[u8; MEMORY_PAGE_SIZE]>>. Line 114: const MEMORY_PAGE_SIZE: usize = 256 * 1024. execution.rs:186: end_memory: self.memory.clone(). run_epochs is only called from tests (grep shows no production callers), so the regression is test-only but real for multi-epoch test fixtures.

Suggested fix

If run_epochs snapshotting remains needed for tests, consider sharing page ownership via Rc<[u8; MEMORY_PAGE_SIZE]> with copy-on-write (Rc::make_mut) in get_or_insert_page, so a clone only bumps refcounts and a subsequent write copies only the one mutated page. Alternatively, have run_epochs produce a cheaper snapshot (e.g. page-base set + borrowed reference) instead of a full deep clone.

AI-008: iter_bytes now yields all allocated-page bytes (incl. unwritten zeros), breaking bench footprint/density metrics
  • Status: confirmed
  • Severity: low
  • Location: executor/src/vm/memory.rs:199
  • Found by: glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

Memory::iter_bytes now yields ALL bytes of ALL allocated pages (including unwritten zeros within an allocated page), instead of only written bytes as before. The doc comment acknowledges this ('Each allocated page expands into its byte addresses'), but it changes the semantics: a single byte write to a fresh 256KB page now causes iter_bytes to yield 262144 (addr, 0) pairs instead of 1 written byte. This breaks bench_continuation.rs's 'footprint: {total} touched bytes' and 'density' metrics (lines 77-107), which now report allocated-page-bytes (256KB per touched page) rather than actual touched bytes, making the diagnostic output misleading. It also inflates HashMap<u64,u8> images collected from end_memory.iter_bytes() in test paths (trace_builder_tests.rs:902,1066; prove_elfs_tests.rs:3044,3283) by orders of magnitude, though build_init_page_data treats the extra zeros idempotently so traces are unaffected. The production continuation path is unaffected (uses PagedMem, not iter_bytes).

Evidence

Lines 199-205: the new iter_bytes does self.pages.iter().flat_map(|(&amp;base, page)| page.iter().enumerate().map(move |(i, &amp;b)| (base + i as u64, b))), iterating every byte of every boxed page. The old code iterated only stored 4-byte cells. bench_continuation.rs line 77-89 counts these pairs as 'touched bytes' and computes density from the count.

Suggested fix

Either (a) restore the old 'written bytes only' semantics by tracking a per-page dirty bitmap / written-set, or (b) update bench_continuation.rs's footprint mode to compute the metric from page count * page_size (acknowledging the new semantics) rather than counting iter_bytes output as 'touched bytes'. If the full-page iteration is acceptable for test paths, at minimum fix the bench's misleading 'touched bytes' label/metric.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 2
kimi openrouter/moonshotai/kimi-k2.7-code general success 3
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general success 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 6

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 3 8 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (8) — rejected by the verifier
  • StarkProofView::query and deep_poly_opening lack bounds checking (crypto/stark/src/proof/view.rs:416, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The query() and deep_poly_opening() methods at lines 416-437 of view.rs are pre-existing code completely unchanged by this PR's diff. The diff only adds MultiProofView and ProofViewSource below line 482; the StarkProofView methods remain untouched. The issue, even if valid as a latent footgun, was not introduced or exposed by this PR.
  • Archived proof view construction depends on exact byte-length equality (crypto/stark/src/proof/view.rs:480, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — No such ProofViewSource implementation for ArchivedVec<rkyv::Archived<T>> exists in the codebase. The three ProofViewSource impls in view.rs are for &[StarkProofView], &Vec<StarkProofView>, and MultiProofView. The claim about 'asserting byte length equals archived_size::<T>()' has no matching code — this finding appears fabricated.
  • Paged memory page-cap failure path lacks test coverage (executor/src/vm/memory.rs:187, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The executor code has no max_pages limit. Memory::store_byte calls get_or_insert_page which calls try_allocate_page using standard Vec try_reserve_exact — this fails on actual OOM, not on a page cap. A grep for 'max_pages' and 'PAGE_CAP' across executor/src/vm found no matches. The finding's premise that there is a page cap whose exhaustion path is untested is factually incorrect.
  • CLI does not exercise supplied-root continuation verifier variant (bin/cli/src/main.rs:840, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The CLI's cmd_verify_continuation calling verify_continuation (line 884) is correct design. The CLI is a standalone host verifier where recomputing DECODE/page genesis roots from the ELF is the trustless verifier path. verify_continuation_with_roots is designed for the recursion guest (which supplies roots via private input to skip in-VM recompute), not for the CLI. The new API has dedicated unit tests (test_verify_continuation_with_supplied_roots in continuation.rs). This is working as intended.
  • Djb2Hasher::write_u64 allocates temporary array (executor/src/vm/memory.rs:68, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — Djb2Hasher::write_u64 creates i.to_le_bytes() which produces [u8; 8] — a stack-allocated 8-byte array. This is essentially free: no heap allocation occurs, the compiler typically optimizes this to register operations. For a hash function used in HashMap operations (potentially hot path), the cost of 8 stack bytes is negligible. The finding misrepresents this as an allocation concern when it is a trivial stack temporary.
  • set_bytes_aligned trailing zeros can overwrite next page (executor/src/vm/memory.rs:431, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The trailing zeros loop at lines 431-433 writes at most 3 zero bytes (trailing_zeros = (4 - inputs.len() % 4) % 4). The documentation explicitly states: 'It may also overwrite existing bytes with zero if inputs is not divisible by 4.' The function is pub(crate) with a documented contract that it is 'only used to write to public output and private input where these limitations are not a problem.' The behavior is both intentional and documented. The concern about overwriting the next page is moot since at most 3 zero bytes would be written, and the function contract already covers this.
  • verify_epoch signature change not fully propagated to tests (prover/src/continuation.rs:732, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — verify_epoch's signature changed from -> bool to -> Result<bool, Error>. The only call site is in verify_continuation_view at continuation.rs line 1271, which correctly uses the ? operator. A grep for 'verify_epoch(' across the codebase shows no other callers. The test code at lines 2162 and 2219 mentions verify_epoch in comments (not actual calls). All consumers of the new return type are properly updated.
  • max_private_input_pages off-by-one vs executor limit (prover/src/tables/page.rs:217, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — max_private_input_pages() computes (MAX_PRIVATE_INPUT_SIZE + PRIVATE_INPUT_LENGTH_PREFIX_BYTES).div_ceil(DEFAULT_PAGE_SIZE) = 2049. This is an intentional, conservative upper bound used for sizing AIR arrays during verification. The executor limits the payload to MAX_PRIVATE_INPUT_SIZE - PRIVATE_INPUT_LENGTH_PREFIX_BYTES (536,870,908 bytes), and the +4 in the numerator accounts for the length prefix. Since PRIVATE_INPUT_START_INDEX (0xFF000000) is not page-aligned, the maximum span could indeed require 2049 pages depending on alignment — the ceiling division is correct for the worst case. The finding's claim of an off-by-one error misunderstands the purpose of the bound.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

Oppen added 6 commits July 17, 2026 19:14
…ate input

store_byte used infallible allocation while every other private-input
write path was fallible, and store_private_inputs could write 4 bytes
past MAX_PRIVATE_INPUT_SIZE (the length prefix wasn't accounted for in
the accepted payload size), corrupting the tail of a maximal-size
input on readback.

Rather than patch the private-input-only paged HashMap, drop the
special case entirely: back the whole address space with one
HashMap<page_idx, Box<page>>, so private input is just memory written
at a fixed address like anything else. This removes the page/region
alignment invariant the split design depended on, and every write
(private or not) now goes through the same fallible page allocator.
…e input for continuation verify (#844)

* feat(recursion): supply DECODE/global-memory-genesis roots via private input for continuation verify

Mirrors #782's monolithic mechanism for the continuation path: a caller
(the recursion guest) can supply the DECODE preprocessed root and each
touched data page's genesis root instead of the verifier recomputing
them from the ELF, skipping the in-VM FFT + Merkle build. Supplied
roots are used verbatim; the binding shifts to the consumer's
recompute-and-compare of the folded identity, exactly like the
monolithic path.

verify_global's genesis-page classification (ELF-backed vs zero-init)
is derived from ELF segment address ranges instead of materializing a
full byte-level image when roots are supplied, since the real bytes are
never read once a root covers that page. Page lookups are keyed by
page number (page_base >> log2(page_size)) rather than the raw
page-aligned address, since the low bits are always zero.

verify_continuation keeps its existing signature (trustless recompute);
verify_continuation_with_roots is the new supplied-roots entry point,
and continuation_precomputed_commitments lets a caller derive the roots
to supply for a given bundle.

* fix(recursion): reject spurious rejects/collisions and cover the supplied-roots page path

verify_global now hard-rejects a missing page_genesis_commitments entry
instead of relying on a debug_assert!, and validates alignment of the
caller-supplied bases the same way touched_page_bases already is.
Corrects two comments that claimed genesis "cannot be prover-chosen"
without qualifying the supplied-roots exception.

Drops the page-number rekeying (PAGE_SIZE_LOG2/page_number) added for a
HashMap that never needed it under std's SipHash; keys by raw page_base
like the monolithic path instead.

Adds a data_page_touch asm fixture (a real ELF .data page, unlike the
stack-only fixtures used elsewhere) so the supplied-roots test actually
exercises the ELF-data-page branch, with tamper/all-zero rejection
cases, plus a lock test asserting classify-only and byte-level page
classification agree.

* fix(recursion): bound num_private_input_pages in continuation_precomputed_commitments

Mirrors the check verify_continuation_with_roots already applies:
bundle is untrusted (rkyv-deserialized), and num_private_input_pages
feeds a page_size multiplication downstream in is_private_input_page.
* perf(prover): verify continuation proofs in place via rkyv

Adapts continuation verification to the same in-place rkyv pattern
verify_recursion_blob already uses for the monolithic path (#769):
verify_epoch/verify_global take a StarkProofView slice (owned or
archived) instead of an owned MultiProof/EpochProof, so the new guest
entry point (verify_continuation_and_attest_blob, via
verify_continuation_archived) reads every per-epoch/global STARK proof
straight out of the archive. Only small per-epoch metadata (table
counts, reg_fini, l2g_root, public output) is materialized - the
(large) per-epoch/global proof data is never copied into an owned
MultiProof just to verify it.

Adds the continuation guest's wire format (ContinuationGuestInput,
encode_continuation_guest_input) mirroring GuestInput's magic-prefixed
rkyv layout, and verify_continuation_and_attest[_blob] mirroring
verify_and_attest_blob's program_id fold.

replay_transcript_phase_a/compute_expected_commit_bus_balance (owned)
lose their last production caller to this refactor; deleted rather
than kept as compatibility wrappers now that every caller (production
and test) goes through the _view variants already introduced by #769.

* test(prover): cover continuation blob tamper path, drop dead API

Adds a negative test for the archived continuation path: encode a
bundle with a tampered epoch l2g_root, assert the blob verify rejects
it. Drops verify_continuation_and_attest_blob's now-unused owned-bundle
predecessor and renames the surviving function to
verify_continuation_and_attest, since the _blob suffix only existed to
disambiguate it from that owned variant. Also fixes a stale intra-doc
link left over from the deleted compute_expected_commit_bus_balance.

* fmt

* refactor(prover,stark): replace MultiProof field-explosion with borrowed views

verify_epoch/verify_global/verify_proof_parts had ballooned into
long parameter lists (proof_views, table_counts, runtime_page_ranges,
reg_fini, l2g_root, public_output, ...) built ad hoc at every call
site via `.iter().map(StarkProofView::Owned/Archived).collect()`, a
leftover from keeping owned and archived verify paths side by side.

Adds MultiProofView (crypto/stark) mirroring StarkProofView, and
EpochProofView/ContinuationProofView (prover) mirroring it one level
up, so callers pass one view instead of exploding a bundle into
loose fields. multi_verify_views, compute_expected_commit_bus_balance_view,
and replay_transcript_phase_a_view are now generic over a
ProofViewSource trait (impl'd for slices, Vecs, and MultiProofView)
and iterate in place - no Vec materialization anywhere in the path.

Collapses verify_continuation_with_roots/verify_continuation_archived
(two ~130-line near-duplicates) into one verify_continuation_view, and
unifies verify_l2g_commitment_binding/_views into one view-based fn.

* perf(stark): match Owned/Archived once per MultiProofView iteration, not per element

MultiProofView::iter() built each StarkProofView via get(i): a match
on Owned/Archived plus a bounds-checked index, redone for every
element on every one of multi_verify_views' several passes over the
same proof set, even though a given MultiProofView is homogeneously
one variant for its whole lifetime.

MultiProofViewIter now matches once, in iter() itself, and then
drives a plain slice::Iter for whichever representation was chosen -
no per-step bounds check, no re-matching against the source enum.

* Revert "perf(stark): match Owned/Archived once per MultiProofView iteration, not per element"

This reverts commit f0cd8abb8d5b05e656072d462efae5f80e92c9a4.

* perf(stark): force-inline MultiProofView's per-element accessors

Closes the ~0.04% guest-cycle regression the MultiProofView refactor
introduced (measured via scripts/bench_recursion_scaling.sh,
blowup4/txs=4): len/get/last/iter and the ProofViewSource impls'
view_len/view_iter cross a crate boundary (stark -> prover) and
apparently weren't getting inlined into multi_verify_views' hot loops.

An alternative fix - matching Owned/Archived once per MultiProofView
instead of once per element, via a hand-rolled slice::Iter-backed
enum iterator - was tried and measured WORSE (+2.3% cycles): it
defeats LLVM's specialized Range<usize>::map optimization the
closure-based iter() benefits from (see the previous two commits).
#[inline(always)] alone, keeping the closure-based iter(), measured
at parity with (very slightly better than) the pre-refactor baseline.

* fix(prover): cover L2G binding rejection, dedupe ELF re-parse in continuation verify

- Add tests that splice a different run's global proof onto valid epochs
  (same shape, different L2G data) so verify_l2g_commitment_binding_view's
  own reject branch actually executes, both for the owned and archived
  verify paths. The existing tampered-root tests were caught earlier by
  verify_epoch's per-epoch root check and never reached this binding.
- Thread entry_point out of verify_continuation_archived so
  verify_continuation_and_attest can fold program_id via
  program_id_from_digest instead of re-parsing the ELF with
  program_id_from_elf.
- Extract access_recursion_archive, sharing the aligned-fallback +
  rkyv::access boilerplate between verify_recursion_blob and
  verify_continuation_and_attest.

* fmt
Several separate, related issues:
- Using page index instead of address left many leading 0s
- SwissTables use leading bits for a tag that speeds up matching,
  collisions there force more linear probing
- Even without page index, bigger page size caused slowdown due to
  SwissTables
- SipHash is expensive for this input
- Use DJB2, a cheap hash consisting of a shift, an addition and an XOR
- Similar to SipHash in collision resistance for this simple and reduced
  input, much faster if ever executed in the guest, yet free of the
  leading 0s issue
- Page index reverted due to lack of real value, expected an improvement
  that ended up as regression
- Page size incremented to match prover, better memory/alloc overhead
  per entry
@Oppen

Oppen commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

/ai-review

@Oppen

Oppen commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

/bench

@Oppen

Oppen commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

/verify_bench

@Oppen

Oppen commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

/profile-recursion

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • High – Sparse guest writes can exhaust host memory. memory.rs allocates a 256 KiB page for every distinct page touched, even for one byte. A guest can issue sparse stores across arbitrary addresses, turning a few thousand writes into gigabytes of allocation; iter_bytes also scans every byte of those pages. Add a memory/page limit or use sparse storage within pages. The fallible page allocation alone does not bound usage, and HashMap growth can still abort on OOM.

@github-actions

Copy link
Copy Markdown

Benchmark — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 72979 MB 73612 MB +633 MB (+0.9%) ⚪
Prove time 37.791s 37.682s -0.109s (-0.3%) ⚪

✅ No significant change.

🔬 Looks like a small speedup (-0.3%) — below what 3 runs can confirm. Comment /bench-abba to run the drift-free ABBA tiebreaker (paired-t CI + exact Wilcoxon). Note: it occupies the bench server for ~30–40 min.
Optional pair count: /bench-abba 32 (20 resolves ~1%, 32 for ~0.6%).

✅ Low variance (time: 0.9%, heap: 1.3%)

Commit: 859b371 · Baseline: cached · Runner: self-hosted bench

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review — paged Box memory backing + continuation view refactor

Reviewed statically (the PR branch wasn't checked out in the sandbox, so this is against the diff). Overall this looks solid and unusually well-tested. The Memory paging is semantically faithful to the old per-word design: page_base/page_offset mask correctly, MEMORY_PAGE_SIZE (2^18) is a multiple of 2/4/8 so aligned half/word/dword loads never straddle a page, allocation is fallible (try_reserve_exactAllocationFailed, no host abort), and the executor page size correctly matches the prover's DEFAULT_PAGE_SIZE, so per-page granularity is consistent with the proving model. The MultiProofView/ProofViewSource refactor is mechanical and the supplied-roots continuation path keeps binding deferred to the consumer's recompute-and-compare exactly like the monolithic path, with an explicit reject when a classified data page lacks a supplied root. No Critical/High issues found.

A few Low items:

1. max_private_input_pages() is now off-by-one vs. the tightened cap (safe, but doc is stale).
This PR tightens store_private_inputs to accept len ≤ MAX_PRIVATE_INPUT_SIZE − PREFIX, so an honest max input now spans (PREFIX + (MAX−PREFIX)).div_ceil(PAGE) = 536,870,912.div_ceil(262,144) = 2048 pages. But page::max_private_input_pages() is unchanged at (MAX + PREFIX).div_ceil(PAGE) = 2049. The bound is now looser than any honest proof can reach — safe for AIR sizing (over-approximation), but its comment "no slack (an honest max-size input occupies exactly this many pages)" is no longer true. Consider tightening to MAX_PRIVATE_INPUT_SIZE.div_ceil(DEFAULT_PAGE_SIZE) (= 2048) or updating the comment.

2. iter_bytes now expands whole allocated pages, including never-written zero bytes.
Previously it yielded only touched 4-byte words; now a page touched even once yields all 262,144 byte addresses. Only tests and benches/bench_continuation.rs:77 consume it (no production caller), so this is not a correctness regression — but the bench's per-region byte counts now reflect full pages rather than touched bytes, and any future production use of this method would iterate 256 KiB per touched page. Worth a note in the doc comment that unwritten bytes within an allocated page are included.

Nice touches: fallible page allocation, the Djb2Hasher justification for the page-key hash pathology, and the added max-size roundtrip / oversized-reject / classify-only-equivalence / spliced-global-proof negative tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant