Skip to content

Unify memcpy, memmove, memset and commit on one accelerator - #984

Open
nicole-graus wants to merge 62 commits into
mainfrom
exp/memmove-literal
Open

Unify memcpy, memmove, memset and commit on one accelerator#984
nicole-graus wants to merge 62 commits into
mainfrom
exp/memmove-literal

Conversation

@nicole-graus

@nicole-graus nicole-graus commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Description

One MEMMOVE table (39 cols) proves memcpy, memmove, memset and the commit byte loop. dma.rs and dma_set.rs are deleted. COMMIT keeps the sys_write ecall number and the x254 committed-length update, and defers its loop over a new CommitDefer bus.

Which functionality a row runs is decoded, never chosen by the caller: is_set is pinned to the syscall number inside the ECALL tuple, is_commit by which bus the first row receives from, and both ride inside the chain tuple so a chain cannot change functionality midway. memset is a propagating copy, the stub seeds eight bytes and calls with dst = src + 8, and the chip runs it with the read/write timestamps inverted. That gap is pinned in-circuit: without it, a dst == src call would leave the copied value unconstrained.

tail is now a free bit rather than pinned to count < 8, so the prover may take one-byte rows at any count and keep the body on the aligned MEMW_A path.

Performance

Measured against the separate accelerators (#874's DMA plus #896's DMA_SET). Real mainnet block, continuations at 2^22, median of 3 on the bench server:
107.3s → 111.8s (+4.2%).
Cycles +0.35%.
The cost is the shared table being wider than the two it replaces (memset rows go from 20 columns to 39) and it is accepted as the price of one chip instead of three.

jotabulacios and others added 30 commits July 29, 2026 11:10
Routes the guest's strong `memset` symbol through a bounded DMA ecall, the
same shape as the memcpy stub #874 added, and proves each chunk with a new
20-column DMA_SET table.

memset is cheaper than memcpy rather than a copy of it: there is no source to
read, so a row emits one MEMW write and no read (half the memory traffic per
byte), and every byte written is the same constant, so one `fill` column
replaces memcpy's eight value lanes. `fill_wide` is `fill` on eight-byte rows
and zero on one-byte tail rows, which lets one write tuple serve both widths.
`fill <= 255` is proven on the first row; the executor rejects wider values and
the guest stub masks a1, mirroring how the byte-count bound is handled.

Measured on real mainnet block 25368371 (50,781,394 cycles baseline):
  #874 memcpy alone   41,642,609  -17.99%
  + memset (this)     40,338,153  -20.57%
mem* routines fall from 24.41% to 4.84% of guest cycles.

No existing AIR changes: CPU stays at 38 columns and the new table only adds
senders to existing buses.
The DMA memcpy ecall already snapshots its entire source range before writing
(all reads at T+1, all writes at T+2), so one chunk has memmove semantics for
free. Chunking is what breaks it: copying [0,256) -> [4,260) clobbers source
bytes a later forward chunk still needs.

So the memmove stub walks chunks from the END backwards exactly when the
destination starts inside the source range (src < dst < src+n); every chunk
then reads bytes no earlier chunk has written. Disjoint regions, and dst below
src, keep forward chunking.

This costs one guest symbol and nothing else — no table, no syscall, no
constraint. Measured on real mainnet block 25368371:
  memcpy + memset      40,338,153
  + memmove (this)     39,867,443   -0.93%
Cumulative vs the 50,781,394 baseline: -21.49%.

The guest test covers both overlap directions at offsets either side of the
256-byte chunk boundary, plus exact aliasing.
Resolve the accelerator() conflict: the base gained DMA cycle counting
(DmaMemcpy => Some(Accelerator::Dma)) while this branch added DmaMemset and
classified both as None. Keep the counting semantics and extend them:
DmaMemcpy | DmaMemset => Some(Accelerator::Dma).

Two exhaustiveness follow-ups the merged tree needs to compile and pass:
- SyscallNumbers::raw() gets the DmaMemset arm (DMA_MEMSET_SYSCALL_NUMBER).
- The CLI's EXPECTED_ACCELERATORS gets a DmaMemset row, required by
  accelerator_of_mirrors_prover_classification's one-row-per-syscall check.
jotabulacios and others added 13 commits September 3, 2026 17:51
Two consumers were left describing the tables that are gone, and the disk-spill
lint pass is what caught the first of them.

auto_storage and TableLengths still carried dma_padded_rows and
dma_set_padded_rows, and the sizing replays for both tables had been deleted
without a replacement, so the whole disk-spill path failed to build. There is
now one replay for MEMMOVE, and it delegates to collect_memmove_ops rather than
re-deriving the schedule: the two used to be separate implementations pinned
together by an assertion, and the schedule is a function of dst as well as
count now, which is exactly the kind of thing that drifts. It costs one
allocation per ecall.

The drift test then caught two more: the pass still predicted COMMIT as
count + 1 rows when it is one row per ecall, and it never counted the commit
loop's MEMMOVE rows or its memory traffic at all.

The CLI's accelerator report called dma_memcpy_trace_rows, which assumes the
width comes from count alone. The schedule now reads the destination's
alignment, so the row count needs the address. memmove_row_width and
memmove_trace_rows move to the executor, where the CLI, the trace builder and
the sizing pass all reach the one definition, and the DMA ecalls log dst in
src2_val, which had no consumer, so the report can be exact rather than a bound.

The three executor memset tests drove the ecall with the old ABI, where a1 was
the fill byte. They now seed and propagate like the stub does.
dma_memset_rejects_fill_wider_than_a_byte has no counterpart — there is no fill
bound any more — and is replaced by a test that seeds a non-uniform pattern and
checks it spreads, which is a property a real fill could not produce.
DMA_MEMSET_MAX_FILL and DmaMemsetFillTooLarge go with it.

make lint passes all five.
The schedule aligned dst alone, which pushes src out of alignment on every call
whose residues differ — 59% of them on a real block — so the read side lost more
MEMW_A rows than the write side gained. Measured, that policy costs +0.442% of
committed cells; splitting only when src % 8 == dst % 8 costs −0.009%, because
then aligning one end aligns both or the split does not happen at all.

The row count consequently depends on both addresses, and the accelerator report
has only one free operand slot. Rather than log an address and re-derive, the
executor now computes the row count at the ecall, where src, dst and count are
all in hand, and logs that; the CLI only sums what it is given.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 2.5 ± 0.1 2.5 2.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 114.9 ± 2.1 111.4 118.3 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 129.6 ± 2.1 125.7 133.4 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 82.8 ± 0.5 82.2 83.5 1.00

@nicole-graus

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

Copy link
Copy Markdown

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

continuations · epoch 2^22 · 6 epochs

Metric main PR Δ
Peak heap 48142 MB 54750 MB +6608 MB (+13.7%) 🔴
Prove time 109.853s 92.993s -16.860s (-15.3%) 🟢

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

⚠️ Real-block prove-time spread: 4.8% (92.993s / 92.594s / 97.032s) — the median above is less trustworthy than usual.

Commit: 02f6408 · Baseline: cached · Runner: self-hosted bench

@nicole-graus

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.

@github-actions

Copy link
Copy Markdown

Verifier benchmark — 02f640890c 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.510s 2.507s -0.12% ⚪
Proof size (exact, 1 reading) 102.33 MiB 102.57 MiB +0.23% 🔴

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.507s   mean B (main): 2.510s
  [parametric] paired-t   mean -0.12%   sd 0.78%   se 0.17%
               95% CI: [-0.49%, +0.24%]   (t df=19 = 2.093)
  [robust]     median -0.16%   Wilcoxon W+=85 W-=125  p(exact)=0.4749  (z=-0.73)

  run-to-run jitter:    A CV 0.49%   B CV 0.43%        (lower = steadier)
  within-session drift: -0.23% over the run, 1st->2nd half -0.05%

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

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

Metric main PR Δ
Verify time (ABBA, 8 pairs, per-side) 3.163s 3.157s -0.18% ⚪
Proof size (exact, 1 reading) 177.07 MiB 177.91 MiB +0.48% 🔴

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.157s   mean B (main): 3.163s
  [parametric] paired-t   mean -0.18%   sd 0.60%   se 0.21%
               95% CI: [-0.68%, +0.33%]   (t df=7 = 2.365)
  [robust]     median -0.47%   Wilcoxon W+=11 W-=25  p(exact)=0.3828  (z=-0.91)

  run-to-run jitter:    A CV 0.47%   B CV 0.34%        (lower = steadier)
  within-session drift: -0.11% over the run, 1st->2nd half -0.32%

INCONCLUSIVE — effect not separable from 0 at n=8 (point estimate ~-0.47%). Add pairs to resolve.

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 331.9M +0.2M (+0.07%)
Keccak calls 3029 3058 +29
  baseline  origin/main  88a54cf88e  guest=recursion-min.elf
  PR        02f640890c542e5dea3bf81407537cbe143fd930  02f640890c  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=88a54cf88e2764429a3e9503ccaa4d0e7eb81e07 ref_b_elf=recursion-min.elf ref_b_cycles=331650509 ref_b_keccak=3029 ref_b_execute_wall_s=9
ref_a_sha=02f640890c542e5dea3bf81407537cbe143fd930 ref_a_elf=recursion-min.elf ref_a_cycles=331866670 ref_a_keccak=3058 ref_a_execute_wall_s=10
delta_cycles=216161 delta_keccak=29

ethrex 20-tx block · continuations, epoch 2^21 (main 2 / PR 1 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 2203.1M 1356.4M -846.7M (-38.43%)
Keccak calls 3250507 2315198 -935309
  baseline  origin/main  88a54cf88e  guest=recursion-cont-blowup2.elf
  PR        02f640890c542e5dea3bf81407537cbe143fd930  02f640890c  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=88a54cf88e2764429a3e9503ccaa4d0e7eb81e07 ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=2203090094 ref_b_keccak=3250507 ref_b_execute_wall_s=34
ref_a_sha=02f640890c542e5dea3bf81407537cbe143fd930 ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=1356403770 ref_a_keccak=2315198 ref_a_execute_wall_s=24
delta_cycles=-846686324 delta_keccak=-935309

@nicole-graus

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Medium — memset aborts on valid buffers crossing a 4 GiB boundary. entrypoint.rs:207 chunks only by length, but the executor rejects DMA memset chunks crossing a 32-bit address-limb boundary. For example, memset(0xFFFF_FFF0, value, 32) seeds eight bytes, then fails with DmaMemsetBadGap. Add a bytewise fallback around these boundaries so the standard symbol preserves memset semantics.

  • Low — COMMIT documentation describes constraints that were removed. commit.rs:15 claims obsolete columns remain and documents 19 columns, 15 bus interactions, and eight constraints. The implementation now has eight columns, six interactions, and one constraint. Update the module and function documentation to match, especially the claims about range checks.

Static review only; no builds or tests run.

@github-actions

Copy link
Copy Markdown

AI Review

PR #984 · 56 changed files

Warning: the diff was truncated before review.

Findings

Status Sev Location Finding Found by
confirmed low prover/src/tables/commit.rs:1 COMMIT table docstring is out of date kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low prover/src/tables/memmove.rs:46 MEMMOVE column count in docstring is wrong kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low prover/src/tables/trace_builder.rs:4325 CUDA preupload omits memmove and hint traces kimi
openrouter/moonshotai/kimi-k2.7-code

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

AI-005: COMMIT table docstring is out of date
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/commit.rs:1
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The module-level docstring in commit.rs describes the old per-byte COMMIT table: it claims 19 columns (address_incr, count_decr, first, end, value, etc.) and 15 bus interactions, but the code now implements a one-row-per-ecall table with 8 columns and 6 bus interactions. The mismatch makes the spec comments misleading and can confuse anyone updating the AIR or debugging bus balances.

Evidence

The docstring at the top of prover/src/tables/commit.rs still lists address_incr, count_decr, first, end, value columns and a 15-interaction bus inventory. The actual CommitOperation struct only has timestamp, index, address, count; cols::NUM_COLUMNS is 8; and bus_interactions() returns only ECALL receive, CommitDefer send, and four MEMW register sends.

Suggested fix

Rewrite the commit.rs module docstring to match the new one-row-per-ecall shape, listing the surviving 8 columns and 6 bus interactions and removing references to deleted columns/buses.

AI-006: MEMMOVE column count in docstring is wrong
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/memmove.rs:46
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The memmove.rs module docstring says the table has 39 columns, but cols::NUM_COLUMNS is 38. The column inventory in the docstring is off by one (likely an arithmetic slip in counting the helper/gate columns).

Evidence

At line ~46 of prover/src/tables/memmove.rs the docstring states ## Columns (39), but the actual column layout defined immediately below sums to 38: timestamp(2)+src(2)+src_incr(4)+dst(2)+dst_incr(4)+count(2)+count_decr(4)+first/end/tail(3)+value8+mu(1)+is_set/is_commit(2)+lt8(1)+f_ncommit/mu_com/mu_com_wide(3) = 38.

Suggested fix

Change the docstring from ## Columns (39) to ## Columns (38) to match cols::NUM_COLUMNS.

AI-007: CUDA preupload omits memmove and hint traces
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/trace_builder.rs:4325
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

Traces::preupload_main_traces does not add the new memmove or hint traces to the pre-upload list, so CUDA builds always fetch those tables over H2D even when LAMBDA_VM_TRACE_PREUPLOAD_MB is configured and the tables are large enough to qualify. Every other fixed-size table (decode, keccak, keccak_rnd, ecsm, ecdas) is pushed into the sorted pre-upload buffer.

Evidence

In prover/src/tables/trace_builder.rs the preupload function pushes cpus, lts, shifts, memws, memw_aligneds, memw_registers, loads, muls, dvrms, pages, branches, the auxiliary chips, decode, keccak, keccak_rnd, ecsm and ecdas, but never self.memmove or self.hint (lines ~4325-4330). For memcpy/memset-heavy guests memmove can be one of the larger fixed tables.

Suggested fix

Add tables.push(&amp;mut self.memmove); and tables.push(&amp;mut self.hint); alongside the other fixed-table pushes before sorting by size.

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 4
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 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 3 4 0

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

Discarded candidates (4) — rejected by the verifier
  • Missing 2^32 limb boundary check for DmaMemcpy (executor/src/vm/instruction/execution.rs:697, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The MEMMOVE table uses emit_add_pair_no_overflow (memmove.rs constraint 19) which constrains the full 64-bit addition SRC + step = SRC_INCR on every active non-terminal row. The low-word carry (carry_0) is boolean and can be 1, and the high-word addition properly accounts for it. The per-row addresses are stored in the SRC_0/SRC_1 columns and sent directly to MEMW — no single base+offset pattern like ECSM exists. The claim that base_addr[2] with offsets added to the low limb applies to DMA is incorrect: SRC_0/SRC_1 carry the full incrementing address, not a base+offset.
  • DmaMemset gap check overflows on large source addresses (executor/src/vm/instruction/execution.rs:748, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — The \|\| operator short-circuits in Rust. The left condition (src &amp; 0xFFFF_FFFF) + n + DMA_MEMSET_GAP &gt; 0xFFFF_FFFF always executes first. For any src where src + 8 would overflow (src &gt;= u64::MAX - 7), src &amp; 0xFFFF_FFFF &gt;= 0xFFFF_FFF8, making (src &amp; 0xFFFF_FFFF) + n + 8 &gt;= 0x1_0000_0000 &gt; 0xFFFF_FFFF — the left side catches it and the right side src + DMA_MEMSET_GAP is never evaluated. The overflow path is unreachable.
  • memmove_row_width ignores src/dst for commit rows (executor/src/vm/instruction/execution.rs:87, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The function memmove_row_width returns 8 for commit rows unconditionally, documented by the comment: 'A commit row's width has to follow from the global byte index alone: the verifier rebuilds the COMMIT tuples out of public_output, and it knows the index, not the guest buffer the bytes were read from.' The src and dst parameters are used for non-commit rows (alignment-based width selection). There is no bug — the parameters are genuinely needed for the non-commit branch and simply unused in the commit branch as a natural consequence of the commit domain having different constraints.
  • CLI help text for --cycles mentions 'Dma rows' but commit byte loop uses same table untallied (bin/cli/README.md:14, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The README.md documentation correctly explains that commit byte loop lives in the MEMMOVE table but is not tallied in CLI DMA stats. This is accurate: the CLI's accelerator_of correctly returns None for Commit (syscall number 64), and the commit path is not an accelerator. The note being 'slightly confusing' is a subjective documentation concern, not a code issue. The behavior is correct and the documentation accurately describes it.

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

@nicole-graus
nicole-graus marked this pull request as ready for review September 11, 2026 19:40
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.

4 participants