Skip to content

Let the guest's memcpy use unaligned word loads - #980

Open
jotabulacios wants to merge 4 commits into
mainfrom
perf/guest-unaligned-memcpy
Open

Let the guest's memcpy use unaligned word loads#980
jotabulacios wants to merge 4 commits into
mainfrom
perf/guest-unaligned-memcpy

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

compiler_builtins enables its mem-unaligned feature for x86, aarch64 and bpf only, so on RISC-V
memcpy/memmove reassemble every misaligned word with a load, two shifts, an or and a store. This
VM doesn't need that: #864 already builds guests with +unaligned-scalar-mem, since the executor
byte-assembles an unaligned doubleword host-side at the same one-instruction cost as an aligned one.
The rest of the guest has been emitting unaligned loads since July — these two functions were the
last holdouts. memcpy is the largest leaf in an ethrex block at 25% of guest cycles.

The feature isn't reachable through -Z build-std-features (that chain only forwards
compiler-builtins-mem), hence the --cfg. The key is scoped to the guest target, so host builds
take nothing from it.

Real mainnet block 25368371, continuations at 2^22 (what benchmark-pr.yml uses), EPYC 9454P
48c/96t, this branch against its base:

base this PR Δ
guest cycles 30,498,818 27,959,787 −8.32%
epochs 8 7 −1
proving time (median of 3) 109.854 s 101.623 s −7.49%
proof size 729,298,288 B 665,350,256 B −8.77%
verify 10.894 s 9.837 s −9.70%
base-field-equivalent cells 6,244,114,362 5,943,369,658 −4.82%

Ranges don't overlap (base 109.854/109.262/110.288, patched 101.623/101.425/102.249). Unaligned
accesses land on the pricier MEMW table rather than MEMW_A, so prover area was measured rather
than assumed — it still drops.

Correctness: ethrex-tests 6/6 including the two --ignored; all 262 guest ELFs build; local prover
suite 827/827; verify OK at two epoch sizes (2^22 and 2^21) — deliberate, since a known latent
issue can break the L2G table when a guest build moves the static layout. Measured on a second box
with a different clang: identical absolute saving of 2,539,031 cycles. All 29 Rust guests and the
four ethrex fixtures were measured both ways; nothing regresses.

The GPU job was not run locally — no GPU on the benchmark boxes.

compiler_builtins enables its mem-unaligned feature for x86, aarch64 and bpf
only, so on RISC-V every misaligned word inside memcpy and memmove is
reassembled with a load, two shifts, an or and a store. This VM does not need
that: #864 already builds guests with +unaligned-scalar-mem because the
executor byte-assembles an unaligned doubleword host-side at the same
one-instruction cost as an aligned one, so the rest of the guest has been
emitting unaligned loads since July and only these two functions avoided them.

memcpy is the largest leaf in an ethrex block profile at 25.15% of guest
cycles, and its biggest caller is rkyv deserializing the witness trie.

On the real mainnet block 25368371 this is -8.33% guest cycles, 8 epochs down
to 7, -6.33% proving time, -8.57% proof size, -9.47% verify and -4.78%
base-field-equivalent cells; keccak and ecsm call counts are unchanged.
tooling/ethrex-tests passes 6/6 including the two --ignored cases, and both
arms of every A/B verify OK. Unaligned accesses land on the more expensive
MEMW table rather than MEMW_A, so prover area was measured rather than
assumed.

The key is scoped to the guest target, so host builds read the file and take
nothing from it. Cargo joins rustflags arrays across ancestor config files, so
each guest crate keeps its own flags and gains this one. The feature cannot be
reached through -Z build-std-features: that chain only forwards
compiler-builtins-mem and no compiler-builtins-mem-unaligned exists.
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

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

continuations · epoch 2^22 · 7 epochs

Metric main PR Δ
Peak heap 46886 MB 52041 MB +5155 MB (+11.0%) 🔴
Prove time 110.675s 103.297s -7.378s (-6.7%) 🟢

-6.7% — beyond what 3 runs resolve. Use /bench-abba for a paired test of the same block (default 12 pairs, ~72 min, resolves ~1%).

Prove-time spread 1.2% (103.582s / 103.297s / 102.329s)

Commit: d97faba · Baseline: cached · Runner: self-hosted bench

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

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

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

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

Metric main PR Δ
Verify time (ABBA, 20 pairs) 2.515s 2.508s -0.30% 🟢
Proof size (exact, 1 reading) 102.33 MiB 102.33 MiB +0.00% ⚪
  pairs: 20   mean A (PR): 2.508s   mean B (main): 2.515s
  [parametric] paired-t   mean -0.30%   sd 0.58%   se 0.13%
               95% CI: [-0.57%, -0.03%]   (t df=19 = 2.093)
  [robust]     median -0.34%   Wilcoxon W+=50 W-=160  p(exact)=0.0400  (z=-2.03)

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

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

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

Metric main PR Δ
Verify time (ABBA, 8 pairs) 3.161s 3.160s -0.01% ⚪
Proof size (exact, 1 reading) 177.07 MiB 177.07 MiB +0.00% ⚪
  pairs: 8   mean A (PR): 3.160s   mean B (main): 3.161s
  [parametric] paired-t   mean -0.01%   sd 0.68%   se 0.24%
               95% CI: [-0.58%, +0.56%]   (t df=7 = 2.365)
  [robust]     median +0.08%   Wilcoxon W+=19 W-=17  p(exact)=0.9453  (z=+0.07)

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

INCONCLUSIVE — effect not separable from 0 at n=8 (point estimate ~+0.08%). 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 32.1M 31.9M -0.2M (-0.71%)
Keccak calls 3029 3029 0
  baseline  origin/main  99d7567afe  guest=recursion-min.elf
  PR        d97faba020465838251c02d7965f4bf537804e53  d97faba020  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=99d7567afec95c672e78065bc2bacb4416fb7577 ref_b_elf=recursion-min.elf ref_b_cycles=32137866 ref_b_keccak=3029 ref_b_execute_wall_s=1
ref_a_sha=d97faba020465838251c02d7965f4bf537804e53 ref_a_elf=recursion-min.elf ref_a_cycles=31909978 ref_a_keccak=3029 ref_a_execute_wall_s=1
delta_cycles=-227888 delta_keccak=0

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 1910.0M 1893.9M -16.1M (-0.84%)
Keccak calls 3296811 3232935 -63876
  baseline  origin/main  99d7567afe  guest=recursion-cont-blowup2.elf
  PR        d97faba020465838251c02d7965f4bf537804e53  d97faba020  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=99d7567afec95c672e78065bc2bacb4416fb7577 ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=1909967824 ref_b_keccak=3296811 ref_b_execute_wall_s=31
ref_a_sha=d97faba020465838251c02d7965f4bf537804e53 ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=1893870267 ref_a_keccak=3232935 ref_a_execute_wall_s=30
delta_cycles=-16097557 delta_keccak=-63876

@jotabulacios
jotabulacios marked this pull request as ready for review September 10, 2026 15:37
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Medium — Guest ELF caches ignore the new build flags (.cargo/config.toml:13). The Rust and recursion ELF cache keys in .github/workflows/pr_main.yaml omit this new config file, and compilation is skipped on an exact cache hit. CI can therefore reuse guests built without mem-unaligned, leaving this PR’s behavior untested. Add .cargo/config.toml to every affected ELF cache key.

Comment thread .cargo/config.toml
#
# Cargo joins `rustflags` arrays across ancestor config files, so each guest
# crate's own `.cargo/config.toml` keeps its flags and gains this one.
rustflags = ["--cfg", "feature=\"mem-unaligned\""]

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.

Low — worth one more sentence in the comment: this sets the cfg for every crate in the guest graph, and it can silently become a no-op.

Two things the comment doesn't say:

  1. --cfg feature="mem-unaligned" is not scoped to compiler_builtins — it's set on every crate compiled for this target (core/alloc/std, libc, and all of the ethrex guest's third-party deps). Today only compiler-builtins reads that name, so it's harmless, but if some dep ever declares a feature by that name it gets enabled without Cargo activating its dependencies. Also, since the cfg is unexpected for every crate that doesn't declare it, expect unexpected_cfgs warnings across the guest graph; pairing it with "--check-cfg", "cfg(feature, values(\"mem-unaligned\"))" in the same array unions the value into the expected set and keeps the build output clean.

  2. Nothing fails loudly if the flag stops taking effect, and the failure mode is the 8% cycle regression coming back with a green build. Three ways that happens: the pinned nightly-2026-02-01 sysroot's compiler-builtins renames/drops the knob (it's build.rs-internal, no stability guarantee — worth naming the nightly pin in Makefile as the coupling); RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS is set in the environment, which replaces target.<triple>.rustflags rather than joining with it (unlike the ancestor-config joining the comment describes); or a guest is ever built from a cwd outside this repo tree.

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Review — no blocking issues

13-line, guest-only build flag. I checked the mechanism and the correctness exposure it widens; both hold up. One inline note (Low) on the config comment.

Mechanism — verified

  • Every guest .cargo/config.toml in the repo (all 30+ under executor/programs/{rust,bench} and bench_vs/lambda) uses [target.riscv64im-lambda-vm-elf].rustflags; none uses a [build] table. So the arrays really do join and no existing flag (notably -C passes=lower-atomic) gets shadowed — that was the main way this could have gone wrong silently.
  • build_guest_elf in Makefile:198 cds into the crate dir, so the repo-root config is an ancestor for every guest build. bench_vs/run.sh/run_elements.sh also cd into in-repo crate dirs, and scripts/bench_recursion_cycles.sh uses git worktrees (full per-ref checkouts), so no build path loses the file. The only CARGO_ENCODED_RUSTFLAGS in the tree (bench_vs/run_elements.sh:209) is scoped to the SP1 guest, so no conflict.
  • -C passes=lower-atomic already has to reach the build-std units for guests to link, which confirms target rustflags propagate to compiler_builtins.

Correctness exposure — the interesting part. This makes 8-byte unaligned accesses common inside memcpy/memmove, including across a 256 KB page boundary (previously aligned words never straddled one). Byte-granular handling holds end to end:

  • executor/src/vm/memory.rs:130/:149 — unaligned LD/SD fall back to per-byte assembly with a checked_add overflow guard.
  • prover/src/tables/memw.rs — MEMW is per-byte (16 memory-bus tokens, base_address + i with carries), so an access spanning two 4-byte cells or two pages is expressed correctly.
  • Page attribution comes from CellBoundary.address (prover/src/continuation.rs:268), i.e. per byte address, not the access base — so a page-crossing access marks both pages touched. That was my main worry and it's covered.
  • compiler_builtins' mem-unaligned word loop is bounded by n / WORD_SIZE plus a byte tail, so no over-read past the source buffer.

GPU/device memory. peak_bytes (prover/src/auto_storage.rs:240) derives its estimate from the measured TableLengths, so the MEMW_A→MEMW shift is accounted for automatically; MEMW's per-chunk geometry (2^19 x 49) is unchanged, so peak per-chunk allocation doesn't grow. CI's GPU job is the remaining confirmation, as the description notes.

Benchmark methodology (three-run ranges, prover area measured rather than assumed, second box with a different clang, 262 ELFs + 827 prover tests) is more than sufficient for the claim.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

AI Review

PR #980 · 1 changed files

Findings

Status Sev Location Finding Found by
confirmed low .github/workflows/pr_main.yaml:106 CI cache keys ignore workspace .cargo/config.toml kimi
openrouter/moonshotai/kimi-k2.7-code

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

AI-003: CI cache keys ignore workspace .cargo/config.toml
  • Status: confirmed
  • Severity: low
  • Location: .github/workflows/pr_main.yaml:106
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The rust-elf and recursion artifact cache keys do not include the new root .cargo/config.toml, so changes to global guest rustflags will restore stale cached artifacts that Cargo must rebuild unnecessarily.

Evidence

The cache key at line 106 (and other similar keys in the workflow) hashes executor/programs/rust/, executor/programs/riscv64im-lambda-vm-elf.json, syscalls/, and Makefile, but omits .cargo/config.toml, which is now an input to every guest build.

Suggested fix

Add '.cargo/config.toml' to the hashFiles(...) expressions for rust-elf-artifacts and recursion-elf-artifacts cache keys.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 1
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: opencode failed (provider/auth/runtime error) and no findings were submitted 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 0

Verification Lanes

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

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

Discarded candidates (2) — rejected by the verifier
  • Root rustflag may be shadowed by per-crate .cargo/config.toml (verify Cargo merges per-target rustflags) (.cargo/config.toml:13, found by glm:openrouter/z-ai/glm-5.2) — The PR comment correctly states documented Cargo behavior: rustflags from different config files are joined together, not replaced. The Cargo reference documentation explicitly says 'rustflags from different config files are joined together.' The finding is purely speculative, suggesting the documented behavior might be wrong without any evidence of a 'common Cargo merge gotcha' specific to per-target rustflags. The guest crate .cargo/config.toml files (e.g., memory, keccak, add) all define [target.riscv64im-lambda-vm-elf].rustflags and Cargo will join the root file's rustflags with them.
  • Manual --cfg feature rustflag is fragile for build-std features (.cargo/config.toml:13, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — Using --cfg feature="mem-unaligned" is a standard, long-standing Rust mechanism for setting conditional compilation feature flags. The compiler_builtins crate specifically uses cfg(feature = "mem-unaligned") to gate its unaligned memory access support — this is exactly how you enable such features for custom targets in build-std scenarios. The evidence cited (guest configs using non-feature cfg values like getrandom_backend="custom") is irrelevant — those are application-level custom cfg values, not Cargo feature flags. The claim that this 'may be ignored or rejected in future versions' is pure speculation with no basis in Rust's stability guarantees.

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

@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

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.

2 participants