Skip to content

feat/dma memcpy - #874

Open
jotabulacios wants to merge 29 commits into
mainfrom
feat/dma-memcpy
Open

feat/dma memcpy#874
jotabulacios wants to merge 29 commits into
mainfrom
feat/dma-memcpy

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

The guest's out-of-line memcpy becomes a DMA ecall: the executor performs the copy
natively and a dedicated AIR table proves it. Copies are chunked at 256 bytes.

Why

A copy ran as a RISC-V loop — per eight bytes a load, a store, two pointer increments and
a branch, each one a CPU row plus its memory operations. The DMA table replaces all of
that with one row per eight bytes: the source read at T+1 and the destination write at T+2
share the same value columns, so a copied byte cannot change without unbalancing the memory
bus. Both sides enforce the 256-byte bound — the executor rejects larger ecalls, the AIR
proves count < 257 on every first row — so one guest instruction cannot add an unbounded
number of rows to a continuation epoch.

memcpy is 28% of the guest's cycles on a single-transaction block (execute --flamegraph), and its absolute cost survived every earlier optimization untouched. The
cheaper approach was tried first and failed: overriding the compiler builtins with
hand-written rv64 musl assembly regressed 13.9%, because compiler_builtins already
word-copies with shift-merge and a naive word-copy-with-byte-fallback loses on
mutually-misaligned buffers. Moving the copies off the CPU trace is what is left.

Impact

Same tree built twice, changing only the syscalls sources — once from main, once from
this branch — so the guest ELF is the only variable. Public output is byte-identical on
every fixture.

Fixture Guest cycles Committed trace elements
empty block 530,583 → 376,435 (−29.1%) 75,967,584 → 77,179,872 (+1.6%)
simple tx 783,162 → 581,616 (−25.7%) 119,990,332 → 93,419,452 (−22.1%)
10 transfers 1,700,148 → 1,333,757 (−21.6%) 207,839,372 → 191,930,380 (−7.7%)
20-tx block 3,656,482 → 2,858,600 (−21.8%) 424,897,388 → 354,008,044 (−16.7%)

Keccak permutations (411) and ECSM calls (80) are identical on both sides: this moves
memory traffic, it does not skip work. Cycles are deterministic — same figures on a laptop
and on the bench machine.

On the bench server, real mainnet block (ethrex_mainnet_25368371.bin), continuations at
epoch 2^22, median of 3: prove 137.700 s → 113.665 s (−17.5%), peak heap 47,352 →
54,404 MB (+14.9%). Prove time tracks committed elements, which fall in the same range.

The heap increase

It is real, it is expected, and about two thirds of it is the epoch scheduler rather than
this table.

Epochs are cut by cycle count alone. On the 20-tx block cycles fall 21.8% while committed
elements fall 16.7%, so elements per cycle rise 6.6%: a fixed 2^22-cycle epoch now
holds that much more of everything, and peak heap is close to linear in how full an epoch
is. The empty block is the extreme form — −29.1% cycles against +1.6% elements, so
elements per cycle rise 42.7%.

Per table, of that rise: roughly 70% is tables that do not shrink when cycles shrink (PAGE,
KECCAK_RND, BITWISE, ECDAS, DECODE, MEMW_A) — a denominator effect, nothing to do with the
copies. The remaining ~30% is trace this PR adds: the DMA table itself (+1.47
elements/cycle) and a doubling of the general MEMW table (+1.37), which is where eight-byte
chunks land when they are not aligned.

Two guest-side mitigations were implemented and measured, and both are discarded: emitting
a byte head so the bulk chunks start eight-byte aligned (+0.7% cycles, MEMW unmoved at
131,072 padded rows), and copying n < 8 inline instead of through the ecall (+0.5%
cycles, elements unchanged on two fixtures and worse on a third). Every table pads to a
power of two, so savings at that scale never reach the commitment.

Sizing epochs by rows rather than by cycles alone is the fix, and it also covers KECCAK,
ECSM and COMMIT — tracked separately. The same effect already shows on the keccak
precompile (49.1–52.2 GB against the same 47.3 GB baseline); memcpy shows it hardest
because it is called far more often.

Breaking

Adds a fixed table: FIXED_TABLE_COUNT goes 11 → 12, so the set of tables in a proof
changes. Prover and verifier must be deployed together, and earlier binaries cannot verify
these proofs. (#876 landed first and took the constant 10 → 11; this branch is already
rebased onto 12.)

Conformance

Satisfies the clauses of the EF's "Accelerated Memory Operations" standard
(eth-act/zkevm-standards#32, merged) that an accelerator can satisfy on its own:

  • Semantics / Alignmentmemcpy is behaviourally identical to the C function for
    every input, including n == 0 and any alignment of dest, src or n.
  • Linking and symbol resolution — mechanism (1), "always-linked runtime": memcpy is
    defined in the object that defines _start, which every guest links unconditionally.
    Documented in docs/general_flow.md, as the standard requires. ethrex.elf carries a
    single memcpy definition, the accelerated one, with the compiler-builtins member
    never extracted.
  • Observability (recommended) — execute --cycles reports Dma calls, Dma bytes and
    Dma rows. The aligned/misaligned split the standard suggests is not reported; the
    reason is written down rather than claimed as done.

It does not satisfy the scope clause, and no memcpy change can. That clause says the
symbols "are exported from the vendor static library defined by the Static Library and
Linker Script standard". Lambda VM has no such library: the guest interface is a Rust rlib,
with no .a, no linker script, no _heap_start/_heap_end and no int main(void) ABI.
That standard is unimplemented repo-wide — the IO interface and the cryptographic
accelerators are in the same position — so adopting it is a repo-level decision rather than
one this PR can make.

Validation

The DMA guests prove and verify. Forgeries are rejected: altered copied byte, source row
skipped forward, early end, wrong row width. Plus a 256-case differential fuzz over
overlap, alignment and page crossings, a guest walking lengths 0–256 and a multi-chunk
copy, the length-drift test with a non-empty DMA table, and make lint.

A third guest (dma_memcpy_implicit) never names memcpy — its copies are only the ones
the compiler emits — and asserts the DMA ecall count stays above zero, so a silent fallback
to the weak compiler-builtins definition fails a test instead of quietly costing
performance. The guard is not vacuous: renaming the symbol by hand drops Dma calls 4 → 0
and raises cycles 12,670 → 13,316, and the test catches it.

Changes (44 files)

  • executor/src/vm/instruction/execution.rs: the ecall — operand validation, 256-byte
    bound, copy through a fixed scratch (snapshot semantics on overlap).
  • prover/src/tables/dma.rs: the table — rows chained through DmaNext, Zero for end
    detection, LT for the 1-vs-8-byte width and the per-call bound.
  • prover/src/constraints/templates.rs: emit_add_pair_no_overflow, so address
    transitions cannot wrap modulo 2^64.
  • syscalls/src/entrypoint.rs: strong assembly memcpy symbol that chunks into ecalls and
    preserves the C return value, defined beside _start so it wins symbol resolution
    without --whole-archive or any guest link flag.
  • bin/cli/src/main.rs: Dma bytes and Dma rows next to Dma calls, through the row
    formula that trace generation and the sizing pass also use.
  • docs/general_flow.md: the accelerated-memory-operations section — symbol resolution,
    observability, the aligned/misaligned cost difference, and the scope-clause deviation.
  • Tests and three guests (dma_memcpy_min, dma_memcpy_cases, dma_memcpy_implicit).

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Benchmark — real block (ethrex_mainnet_25368371.bin) (median of 3)

continuations · epoch 2^22 · 8 epochs

Metric main PR Δ
Peak heap 47352 MB 51778 MB +4426 MB (+9.3%) 🔴
Prove time 137.700s 112.402s -25.298s (-18.4%) 🟢

🎉 Improvement on the real block — prove time down 18.4%.

Prove-time spread 1.2% (112.402s / 112.546s / 111.161s)

Commit: 4d4d758 · Baseline: cached · Runner: self-hosted bench

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 2.2 ± 0.1 2.1 2.3 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 88.2 ± 1.4 85.5 90.3 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 99.5 ± 2.0 97.3 104.0 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 68.2 ± 0.8 67.4 69.5 1.00

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Benchmark Results for unmodified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
base binary_search 36.5 ± 0.9 35.6 38.2 1.00
head binary_search 41.1 ± 10.2 35.8 68.0 1.13 ± 0.28
Command Mean [ms] Min [ms] Max [ms] Relative
base bitwise_ops 36.1 ± 0.5 35.5 36.9 1.01 ± 0.03
head bitwise_ops 35.6 ± 0.9 34.9 37.1 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base fibonacci_26 38.2 ± 0.7 37.5 39.5 1.00 ± 0.02
head fibonacci_26 38.1 ± 0.5 37.5 38.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base matrix_multiply 37.5 ± 0.8 37.0 39.6 1.00
head matrix_multiply 37.9 ± 0.9 37.0 39.1 1.01 ± 0.03
Command Mean [ms] Min [ms] Max [ms] Relative
base modular_exp 36.1 ± 0.5 35.2 37.0 1.00 ± 0.02
head modular_exp 36.0 ± 0.5 35.5 36.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base quicksort 38.9 ± 0.6 38.3 40.3 1.01 ± 0.02
head quicksort 38.4 ± 0.4 37.8 38.8 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base sieve 39.5 ± 0.4 38.7 39.9 1.00
head sieve 40.0 ± 0.8 38.2 41.1 1.01 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base sum_array 45.9 ± 0.7 44.5 46.8 1.00
head sum_array 46.1 ± 0.9 44.8 47.6 1.00 ± 0.03

@jotabulacios jotabulacios changed the title experimental/dma memcpy feat/dma memcpy Jul 29, 2026
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

No actionable issues found in the PR changes. Static review only; no builds or tests run per instructions.

@jotabulacios
jotabulacios marked this pull request as ready for review July 29, 2026 15:30
@github-actions

Copy link
Copy Markdown

AI Review

PR #874 · 32 changed files

Findings

Status Sev Location Finding Found by
confirmed medium executor/src/vm/instruction/execution.rs:479 DMA ecall address overflow check is off-by-one kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low prover/src/constraints/templates.rs:376 Misleading docstring in emit_add_pair_no_overflow kimi
openrouter/moonshotai/kimi-k2.7-code

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

AI-001: DMA ecall address overflow check is off-by-one
  • Status: confirmed
  • Severity: medium
  • Location: executor/src/vm/instruction/execution.rs:479
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The DMA memcpy ecall rejects valid copies where the final accessed byte is exactly u64::MAX. It checks dst.checked_add(n) and src.checked_add(n), which require one-past-the-end to fit, but only bytes in [addr, addr+n) are accessed. The correct condition is that addr + (n-1) does not overflow.

Evidence

Lines 479-480 use dst.checked_add(n).ok_or(MemoryError::AddressOverflow)? and src.checked_add(n).ok_or(MemoryError::AddressOverflow)?. For n>0, the last byte accessed is at addr+n-1, so a copy with addr = u64::MAX - n + 1 is valid but is rejected because addr + n overflows. This matches the prover's per-row no-overflow constraints, so it is a completeness/consistency edge-case bug rather than a soundness issue.

Suggested fix

Change both checks to dst.checked_add(n.saturating_sub(1)).ok_or(MemoryError::AddressOverflow)? (and similarly for src), which is equivalent for n>0 and passes for n=0.

AI-003: Misleading docstring in emit_add_pair_no_overflow
  • Status: confirmed
  • Severity: low
  • Location: prover/src/constraints/templates.rs:376
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The docstring says the constraint fires "while active - end == 1", but the implementation computes active = main(active_column) - main(end_column) and callers pass active_column = MU, end_column = END. The phrase collides with the local variable name and does not describe the actual arithmetic condition (mu - end == 1).

Evidence

Lines 376-381 describe the condition as active - end == 1; lines 401-402 compute let active = b.main(0, active_column) - b.main(0, end_column); and in prover/src/tables/dma.rs the helper is invoked with active_column = cols::MU and end_column = cols::END.

Suggested fix

Reword the docstring to describe the actual condition, e.g. "while mu - end == 1 (i.e. active, non-terminal rows)", or rename the parameter/variable to avoid the collision.

Reviewer Lanes

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

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 2 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
  • New emit_add_pair_no_overflow constraint critical for address wrap prevention (prover/src/constraints/templates.rs:330, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — This finding does not identify any actual bug or issue. It merely describes what the new emit_add_pair_no_overflow constraint does ('constrains high carry to zero on active non-terminal rows'), notes it is security-critical, and states it 'must be correct.' Both the claim and evidence acknowledge the logic 'appears correct.' This is an observation, not an actionable finding.
  • Register timestamp advancement in collect_dma_memcpy_ops may affect subsequent register accesses (prover/src/tables/trace_builder.rs:1080, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The finding acknowledges the behavior is correct: the register_state.write calls update timestamps properly for the MEMW register read protocol. The evidence even states 'This is correct for MEMW register read protocol.' The CPU's collect_register_ops_from_cpu does not generate duplicate MEMW ops for ecall arguments because decode doesn't mark them as read_register — so DMA's explicit reads and timestamp updates are the sole source of these MEMW entries, making the design consistent. No bug is identified; this is a design remark, not an issue.
  • Assembly memcpy const interpolation relies on compile-time constant evaluation (syscalls/src/syscalls.rs:185, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — This is a statement about Rust language semantics, not an actual code issue. The DMA_MEMCPY_SYSCALL_NUMBER and DMA_MEMCPY_MAX_BYTES are both defined as const usize at lines 147-148, and the global_asm! macro at lines 235-236 correctly uses const interpolation to reference them. The claim that 'any change to make them non-const would break the assembly' is a tautology about Rust's const evaluation — it's not identifying a problem with the current code, which is correct as written.

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

@nicole-graus

Copy link
Copy Markdown
Collaborator

/bench-verify

@github-actions

Copy link
Copy Markdown

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.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Verifier benchmark — ca0278fbab vs main (20 pairs, monolithic + continuations)

ethrex 20-tx block · monolithic · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 20 pairs, per-side) 2.560s 2.571s +0.40% ⚪
Proof size (exact, 1 reading) 115.67 MiB 116.01 MiB +0.29% 🔴

Per-side (⚠️ PR REJECTS the baseline's valid proof — likely a VERIFY REGRESSION, not a format change): A/B/B/A cancels machine drift but not proof-specific variance — read the Verify-time Δ as approximate.

  pairs: 20   mean A (PR): 2.571s   mean B (main): 2.560s
  [parametric] paired-t   mean +0.40%   sd 1.23%   se 0.27%
               95% CI: [-0.18%, +0.97%]   (t df=19 = 2.093)
  [robust]     median +0.28%   Wilcoxon W+=145 W-=65  p(exact)=0.1429  (z=+1.47)

  run-to-run jitter:    A CV 0.74%   B CV 0.74%        (lower = steadier)
  within-session drift: -0.29% over the run, 1st->2nd half -0.18%

INCONCLUSIVE — effect not separable from 0 at n=20 (point estimate ~+0.28%). Add pairs to resolve.

ethrex 20-tx block · continuations, epoch 2^20 (4 epochs) · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 8 pairs, per-side) 3.977s 3.946s -0.79% 🟢
Proof size (exact, 1 reading) 231.37 MiB 232.73 MiB +0.59% 🔴

Per-side (⚠️ PR REJECTS the baseline's valid proof — likely a VERIFY REGRESSION, not a format change): A/B/B/A cancels machine drift but not proof-specific variance — read the Verify-time Δ as approximate.

  pairs: 8   mean A (PR): 3.946s   mean B (main): 3.977s
  [parametric] paired-t   mean -0.79%   sd 0.32%   se 0.11%
               95% CI: [-1.06%, -0.52%]   (t df=7 = 2.365)
  [robust]     median -0.71%   Wilcoxon W+=0 W-=36  p(exact)=0.0078  (z=-2.45)

  run-to-run jitter:    A CV 0.31%   B CV 0.36%        (lower = steadier)
  within-session drift: +0.28% over the run, 1st->2nd half -0.06%

🟢 REAL IMPROVEMENT — PR verifies ~0.79% faster (paired-t and Wilcoxon agree).

Verify-time rows only: drift-free interleaved A/B/B/A, with paired-t and exact Wilcoxon — trust the verdict when the two agree. Proof sizes are single exact readings (no averaging). - = PR faster.


Recursion guest cycles — verifier running INSIDE the VM (main vs PR)

empty program · monolithic · blowup=2, 1 query (diagnostic — NOT a real verifier cost)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 331.7M 332.3M +0.6M (+0.18%)
Keccak calls 3029 3061 +32
  baseline  origin/main  8064a8efee  guest=recursion-min.elf
  PR        ca0278fbabda0459ddbcd464b6c367af389c7921  ca0278fbab  guest=recursion-min.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=8064a8efee4bd3edc9f064337d4e1d8bad54ae1a ref_b_elf=recursion-min.elf ref_b_cycles=331650450 ref_b_keccak=3029 ref_b_execute_wall_s=9
ref_a_sha=ca0278fbabda0459ddbcd464b6c367af389c7921 ref_a_elf=recursion-min.elf ref_a_cycles=332258250 ref_a_keccak=3061 ref_a_execute_wall_s=10
delta_cycles=607800 delta_keccak=32

ethrex 20-tx block · continuations, epoch 2^21 (2 epochs) · blowup=2, 219 queries (128-bit)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 2280.0M 2221.8M -58.2M (-2.55%)
Keccak calls 3538646 3403176 -135470
  baseline  origin/main  8064a8efee  guest=recursion-cont-blowup2.elf
  PR        ca0278fbabda0459ddbcd464b6c367af389c7921  ca0278fbab  guest=recursion-cont-blowup2.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=8064a8efee4bd3edc9f064337d4e1d8bad54ae1a ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=2280036712 ref_b_keccak=3538646 ref_b_execute_wall_s=37
ref_a_sha=ca0278fbabda0459ddbcd464b6c367af389c7921 ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=2221793621 ref_a_keccak=3403176 ref_a_execute_wall_s=36
delta_cycles=-58243091 delta_keccak=-135470

@nicole-graus

Copy link
Copy Markdown
Collaborator

/bench-verify

@github-actions

Copy link
Copy Markdown

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.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-verify

@github-actions

Copy link
Copy Markdown

Benchmark started on the bench server. Two verifier arms (monolithic + continuations over an ethrex 20-tx block), then the recursion-guest cycle comparison, which adds guest builds on top — longer on a cold runner. The bench server is occupied until it finishes.

@Oppen

Oppen commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Automated review pass (high-effort, adversarially verified). Findings:

  • bug: executor/src/vm/instruction/execution.rs SyscallNumbers::accelerator() returns None for DmaMemcpy. Add Some(Accelerator::Dma) (or equivalent) — cli execute --cycles currently attributes every 256-byte DMA copy to one ordinary cycle, hiding 33 DMA + 67 MEMW rows/call.
  • bug: Makefiletest-prover/test-fast/test-prover-debug/test-prover-all depend on compile-recursion-elfs only, not $(RUST_ARTIFACTS). Clean checkout panics "elf not found" for all nine new DMA tests.
  • risk: prover/src/auto_storage.rs:182 DMA table has no max_rows entry, unlike CPU/MEMW/LOAD. Height is proportional to user data; at epoch_size_log2=23 a pathological table projects ~17GB before blowup.
  • risk: epoch sizing (syscalls/src/syscalls.rs:210) is cycle-count-only. One DMA ecall can blow an epoch's row budget with zero cycle-count change.
  • risk: syscalls/src/syscalls.rs:212 .text.memcpy has no .p2align 2 (sh_addralign=1 measured). Survives today only by luck; one unrelated change lands memcpy at an odd alignment and the guest dies with an undecodable-instruction error.
  • risk: executor/src/vm/instruction/execution.rs:479 bounds guard dst.checked_add(n) / src.checked_add(n) is off by one vs. highest touched byte n-1. Stricter than the load/store sequence it replaces (no bounds check, succeeds at u64::MAX).
  • risk: syscalls/src/syscalls.rs:38 guest re-declares DMA_MEMCPY_SYSCALL_NUMBER/_MAX_BYTES as literals with a "must match" comment instead of importing the executor's.
  • risk: prover/src/tables/trace_builder.rs:979 assert!(count <= DMA_MEMCPY_MAX_BYTES) panics where sibling callers use Result/Error::Execution for the same class of guard.

Cross-cutting with 876/896: accelerator()None for a new chip is now 2-for-2 (874, 876); consider making the CLI parity test enumerate variants so a new one fails by default.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

bug: executor/src/vm/instruction/execution.rs SyscallNumbers::accelerator() returns None for DmaMemcpy. Add Some(Accelerator::Dma) (or equivalent) — cli execute --cycles currently attributes every 256-byte DMA copy to one ordinary cycle, hiding 33 DMA + 67 MEMW rows/call.

Fixed in 2f902226 — there's now a Dma calls line next to Keccak calls / Ecsm calls.

One thing about the diagnosis, since it matters for what the counter means: the DMA ecall really is one cycle, so nothing was being mis-attributed, and --cycles has never reported row counts for any chip. What was missing was just the report line, so a DMA-heavy guest looked like it made no accelerator calls at all.


risk: syscalls/src/syscalls.rs:212 .text.memcpy has no .p2align 2 (sh_addralign=1 measured). Survives today only by luck; one unrelated change lands memcpy at an odd alignment and the guest dies with an undecodable-instruction error.

Fixed in d94561ac, and you were right about the "only by luck" part. I checked it against a real build to be sure: assembling the section as it was gives sh_addralign = 1, with the directive it gives 4. What saved us so far is that the linker merges into .text (align 4) and memcpy happened to land aligned anyway.


Cross-cutting with 876/896: accelerator()None for a new chip is now 2-for-2 (874, 876); consider making the CLI parity test enumerate variants so a new one fails by default.

Agreed that the pattern is the actual problem, so I went after it structurally instead of adding the missing arm and moving on (2f902226): SyscallNumbers is declared through a macro_rules! that generates ALL from the same variant list, and the CLI tallies with an exhaustive match on Accelerator. A new syscall variant now produces 3 compile errors, and a new Accelerator variant stops the CLI from compiling — I checked both by actually adding a variant.

One trap worth flagging if you suggest this elsewhere: the first guard I wrote sized a covered array by ALL.len(), and it still passed when I deleted the last variant of ALL — the array shrinks along with the list, so no hole is left behind. Without strum in the workspace, generating the list from a macro seems to be the only version that can't be fooled that way.


risk: prover/src/auto_storage.rs:182 DMA table has no max_rows entry, unlike CPU/MEMW/LOAD. Height is proportional to user data; at epoch_size_log2=23 a pathological table projects ~17GB before blowup.

risk: epoch sizing (syscalls/src/syscalls.rs:210) is cycle-count-only. One DMA ecall can blow an epoch's row budget with zero cycle-count change.

Quoting these together because they're the same gap from two sides, and I think both are real — I just don't think DMA is where to fix them. The whole accelerator family behaves this way:

  • KECCAK (keccak.rs:99), ECSM (ecsm.rs:153), COMMIT (commit.rs:164) and DMA (dma.rs:120) all pad with the same n.next_power_of_two().max(4). No max_rows, no chunking. max_rows covers the 14 core chips only.
  • Cycle-only epoch sizing already breaks rows ≈ cycles for keccak and ecsm today, so DMA isn't introducing that either.
  • One more I ran into while checking the above: KECCAK and ECSM don't appear in auto_storage's projection at all, so it under-projects them today.

Your arithmetic holds, by the way — DMA_COLS = 32, ~33 rows/ecall, ~8 cycles/ecall → a saturated 2^23 epoch is ~2^26 rows ≈ 17 GB pre-blowup, and ~4× that adversarially. Two things I'd add to it: per-ecall area lands within ~1.6× of keccak's, because keccak packs 511 columns into its single row, so the 33 rows aren't as dramatic as they look; and chunking DMA isn't a one-liner, since the DmaNext bus chains row→row inside a single copy. What does make DMA the first reachable case is call frequency — any guest memcpy triggers it — which is why I'd rather it drive a fix that also covers keccak/ecsm/commit than get a DMA-shaped cap. Opening that as its own issue and linking it here.


bug: Makefiletest-prover/test-fast/test-prover-debug/test-prover-all depend on compile-recursion-elfs only, not $(RUST_ARTIFACTS). Clean checkout panics "elf not found" for all nine new DMA tests.

The behaviour you describe is real, but it isn't new here: main already has 6 prover tests reading program_artifacts/rust/*.elf under those same four targets (prove_elfs_tests.rs:1189 ecsm, plus allocator, pure_commit, ef_io_demo, commit_sum, ethrex), each with the same .expect("… run make compile-programs-rust"). So a clean checkout fails there without this PR; the DMA tests join an existing convention rather than breaking a working target.

The reason it's set up that way is cost: the .elf rules are FORCE (Makefile:217, and the comment at :184 explains that cargo owns the dep graph), so hanging $(RUST_ARTIFACTS) off those targets rebuilds all 35 Rust guests on every local test run. CI calls make compile-programs-rust explicitly instead.

That said, I do think the local target should work out of the box, and a shared test-elfs prerequisite would do it. I'd just rather do it once for all of those tests than only for the DMA ones — happy to open it, or to take it here if you'd prefer it not wait.


risk: executor/src/vm/instruction/execution.rs:479 bounds guard dst.checked_add(n) / src.checked_add(n) is off by one vs. highest touched byte n-1. Stricter than the load/store sequence it replaces (no bounds check, succeeds at u64::MAX).

The off-by-one is there, agreed. The only input it turns away is a copy whose last byte sits exactly at u64::MAX, so it errors instead of accepting, and it's still stricter than the unchecked load/store sequence it replaces. I'd keep it unless you can see a legitimate copy that ends there — loosening it to n-1 would buy that one address back, and I'd rather a new ecall err on the closed side.


risk: syscalls/src/syscalls.rs:38 guest re-declares DMA_MEMCPY_SYSCALL_NUMBER/_MAX_BYTES as literals with a "must match" comment instead of importing the executor's.

This one I'd push back on: importing them would make lambda-vm-syscalls depend on executor, and that dependency points the wrong way — the guest-side crate is meant to be buildable without the host VM. KECCAK and ECSM are declared the same way two lines above, for the same reason, and a mismatch shows up immediately in the end-to-end guest tests rather than silently.


risk: prover/src/tables/trace_builder.rs:979 assert!(count <= DMA_MEMCPY_MAX_BYTES) panics where sibling callers use Result/Error::Execution for the same class of guard.

I'd argue it isn't the same class. The sibling guards validate values that can genuinely arrive out of range; this one restates an invariant the executor already enforced via DmaMemcpyChunkTooLarge before the log the trace builder reads exists. So it's marking an unreachable state, not checking input, and an assert! says that more honestly than a Result the caller would have to pretend to handle. If you'd still rather it be uniform with the neighbours, it's a small change — I just didn't want to imply the trace builder is validating something it can't see.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-verify

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Benchmark started on the bench server. Two verifier arms (monolithic + continuations over an ethrex 20-tx block), then the recursion-guest cycle comparison, which adds guest builds on top — longer on a cold runner. The bench server is occupied until it finishes.

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.

6 participants