Skip to content

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

Draft
Oppen wants to merge 3 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 3 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

Copy link
Copy Markdown

AI Review

PR #852 · 1 changed files

Findings

Status Sev Location Finding Found by
confirmed critical executor/src/vm/memory.rs:159 store_byte uses infallible allocation, inconsistent with other private-input write paths moonmath
zro/minimax-m3
nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
kimi
openrouter/moonshotai/kimi-k2.7-code
glm
openrouter/z-ai/glm-5.2
confirmed high executor/src/vm/memory.rs:476 set_private_bytes_aligned lacks region bounds check nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
kimi
openrouter/moonshotai/kimi-k2.7-code
moonmath
zro/minimax-m3
confirmed low executor/src/vm/memory.rs:94 Dead error path in try_allocate_private_page: try_into cannot fail here moonmath
zro/minimax-m3

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

AI-001: store_byte uses infallible allocation, inconsistent with other private-input write paths
  • Status: confirmed
  • Severity: critical
  • Location: executor/src/vm/memory.rs:159
  • Found by: moonmath:zro/minimax-m3, nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b, kimi:openrouter/moonshotai/kimi-k2.7-code, glm:openrouter/z-ai/glm-5.2
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

Memory::store_byte allocates a private-input page via Box::new([0u8; PRIVATE_INPUT_PAGE_SIZE]) inside or_insert_with. This is an infallible Rust allocation and will abort the host process under memory pressure. All other write paths (store_word, store_doubleword, store_half) route through get_or_insert_private_page and return MemoryError::AllocationFailed.

Evidence

The PR's design comment at lines 85-87 explicitly justifies try_allocate_private_page: "so a guest requesting a page near MAX_PRIVATE_INPUT_SIZE fails cleanly with MemoryError::AllocationFailed instead of aborting the host process." But store_byte at line 159 uses Box::new(...) directly via or_insert_with, bypassing that fallible path entirely. Since store_byte is reachable from any guest sb instruction (execution.rs:192) and from the misaligned fallbacks in store_word (line 241), store_doubleword (line 308), and store_half (lines 367-368), a guest can drive the host into OOM by writing bytes to distinct pages in [0xFF000000, 0xFF000000 + 512 MiB). The signature fn store_byte(..., value: u8) returns () so there is no Result to surface the failure — the host process aborts instead.

Suggested fix

Replace or_insert_with(|| Box::new([0u8; PRIVATE_INPUT_PAGE_SIZE])) with a fallible variant that calls try_allocate_private_page (or refactor store_byte to return Result&lt;(), MemoryError&gt; like the other store methods, and propagate the error up through the Store arm of Instruction::execute and the misaligned fallbacks in store_word/store_half/store_doubleword).

AI-005: set_private_bytes_aligned lacks region bounds check
  • Status: confirmed
  • Severity: high
  • Location: executor/src/vm/memory.rs:476
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b, kimi:openrouter/moonshotai/kimi-k2.7-code, moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

set_private_bytes_aligned does not verify that the write stays within MAX_PRIVATE_INPUT_SIZE, allowing allocation of pages beyond the 512 MiB region

Evidence

Lines 477-489: the loop only checks private_page_index(cur_addr) / private_page_offset(cur_addr) (purely arithmetic on the page table), with no is_private_input_addr guard, so cur_addr can grow beyond PRIVATE_INPUT_START_INDEX + MAX_PRIVATE_INPUT_SIZE. Today the only caller is store_private_inputs, which bounds inputs.len() &lt;= MAX_PRIVATE_INPUT_SIZE and then writes at +4, so 4 bytes overflow into the next page (consistent with the elf.rs reservation note). A future caller passing a larger inputs slice would silently grow the page table past the reserved region.

Suggested fix

Either reject inputs that would exceed PRIVATE_INPUT_START_INDEX + MAX_PRIVATE_INPUT_SIZE with a clear MemoryError, or document and enforce the existing 4-byte overflow window explicitly. The store_private_inputs check should also be tightened from &gt; MAX_PRIVATE_INPUT_SIZE to &gt; MAX_PRIVATE_INPUT_SIZE - PRIVATE_INPUT_LENGTH_PREFIX_BYTES as u64 if the only spill allowed is the length-prefix slot — currently inputs.len() == MAX_PRIVATE_INPUT_SIZE is accepted and 4 bytes do spill onto a new page beyond the region.

AI-011: Dead error path in try_allocate_private_page: try_into cannot fail here
  • Status: confirmed
  • Severity: low
  • Location: executor/src/vm/memory.rs:94
  • Found by: moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

buf.into_boxed_slice().try_into().map_err(|_| MemoryError::AllocationFailed) is unreachable. After try_reserve_exact(PRIVATE_INPUT_PAGE_SIZE) and resize(PRIVATE_INPUT_PAGE_SIZE, 0), the boxed slice length is exactly PRIVATE_INPUT_PAGE_SIZE, so the Box&lt;[u8]&gt;Box&lt;[u8; PRIVATE_INPUT_PAGE_SIZE]&gt; TryFrom always succeeds.

Evidence

Lines 88-96: the only way the inner try_into could fail is if buf.len() != PRIVATE_INPUT_PAGE_SIZE, but both try_reserve_exact and resize fix the length to PRIVATE_INPUT_PAGE_SIZE exactly. The map_err arm is therefore dead code that obscures intent.

Suggested fix

Drop the .map_err (and ideally the TryFrom round-trip entirely) — construct the boxed array directly, e.g. by collecting into a Vec&lt;u8&gt; and using .into_boxed_slice() plus an unsafe Box::from_raw cast, or by a small helper that does the try-reserve and array construction in one step. If a clean safe API is preferred, the simplest change is .try_into().expect("length pinned by resize") with a comment explaining why the expect is safe.

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 5
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 5

Verification Lanes

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

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

Discarded candidates (3) — rejected by the verifier
  • U64Hasher::write inconsistent with documented behavior (executor/src/vm/memory.rs:11, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The U64Hasher code (lines 4-26) is entirely pre-existing, appearing as unchanged context in the PR diff. The PR introduces the private-input paging logic but does not touch U64Hasher at all. The comment-implementation inconsistency, if any, was already present before this PR.
  • iter_bytes emits every zero byte in allocated private pages, bloating snapshots (executor/src/vm/memory.rs:185, found by kimi:openrouter/moonshotai/kimi-k2.7-code, nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b, glm:openrouter/z-ai/glm-5.2, moonmath:zro/minimax-m3) — The iter_bytes behavior for private pages (lines 185-193) is explicitly documented in the doc comment on lines 171-177: 'each allocated page expands into its byte addresses (unallocated pages contribute nothing, matching bytes never having been written).' This is an intentional design choice, not an oversight. The old non-private iter_bytes already had a similar expansion: each word cell yields 4 byte entries regardless of which bytes were written. The performance/bloat claim is speculative without benchmarks and the tradeoff is acknowledged in the code.
  • load_bytes does per-word hashmap lookup for private input (executor/src/vm/memory.rs:414, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — load_bytes iterates word-by-word (4 bytes at a time) performing hashmap lookups per iteration — this is how it worked before the PR as well (the non-private path does self.cells.get(&aligned) each iteration). The new private-input branch adds a parallel lookup but does not change the iteration structure. This is a performance concern about a pre-existing access pattern, not a bug introduced by this PR.

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

…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.
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