diff --git a/Cargo.lock b/Cargo.lock index 93fd6b417..56cf0e6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,7 @@ dependencies = [ "ecsm", "k256", "lambda-vm-syscalls", + "proptest", "rustc-demangle", "serde", "serde_json", diff --git a/bin/cli/README.md b/bin/cli/README.md index 5ef3cf40d..733cbd6d7 100644 --- a/bin/cli/README.md +++ b/bin/cli/README.md @@ -41,7 +41,7 @@ cargo run -p cli --release -- execute [--private-input ] [-- |---|---| | `--private-input ` | Pass private input bytes to the guest (read via `get_private_input()`). | | `--flamegraph ` | Generate folded-stack flamegraph output. See [Guest Program Flamegraphs](#guest-program-flamegraphs). | -| `--cycles` | Count instructions during execution and print the dynamic instruction count. Also reports `Keccak calls` / `Ecsm calls` (accelerator syscall invocations). Combined with `--flamegraph`, the accelerator lines are omitted (the flamegraph path exposes no per-log data). | +| `--cycles` | Count instructions during execution and print the dynamic instruction count. Also reports `Keccak calls` / `Ecsm calls` / `Dma calls` (accelerator syscall invocations), plus `Dma bytes` copied and the `Dma rows` those copies add to the trace before its power-of-two padding. One guest `memcpy` is chunked into several DMA ecalls, so the byte and row lines, not the call count, are what the copies cost. The lines cover `memcpy`, `memmove` and `memset`, which share one ecall path and one table; the commit byte loop lives in that same table but is not tallied here. Combined with `--flamegraph`, the accelerator lines are omitted (the flamegraph path exposes no per-log data). | ### Prove diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 656c05966..c3002f878 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -191,9 +191,12 @@ enum Commands { cycle_budget: Option, /// Print the dynamic instruction (cycle) count, plus `Keccak calls` / - /// `Ecsm calls` (accelerator syscall invocations). The accelerator lines - /// are omitted when combined with --flamegraph (that path has no per-log - /// data). + /// `Ecsm calls` / `Dma calls` (accelerator syscall invocations), and for + /// DMA the `Dma bytes` copied and the `Dma rows` those copies add to the + /// trace before its power-of-two padding. One `memcpy` is chunked into + /// several DMA ecalls, so the byte and row lines, not the call count, are + /// what the copies cost. The accelerator lines are omitted when combined + /// with --flamegraph (that path has no per-log data). #[arg(long)] cycles: bool, }, @@ -408,6 +411,41 @@ struct FlamegraphCliOptions { checkpoint_cycles: Option, } +/// One tally per [`Accelerator`] variant, printed by `execute --cycles`. +#[derive(Default)] +struct AccelCounts { + keccak: u64, + ecsm: u64, + dma: u64, + /// Bytes copied and DMA table rows those copies consume. Keccak and ECSM + /// cost the same per call, so DMA is the only accelerator whose report needs + /// a size next to its count: one `memcpy` becomes as many ecalls as the + /// guest stub chunks it into, which makes `dma` alone a poor cost proxy. + dma_bytes: u64, + dma_rows: u64, +} + +impl AccelCounts { + /// Exhaustive `match`: a new `Accelerator` variant is a compile error here, + /// so it cannot be executed without also being reported. `dst_val` is the + /// ECALL's logged operands: `dst_val` is the chunk's byte count for DMA and + /// unused for the others; `rows` is the COPY row count the executor derived + /// at the ecall. The width follows from `count` alone today, so the row count is + /// recoverable from it; the slot is kept because the AIR permits a schedule that + /// reads both ends' residues and a future one may. + fn tally(&mut self, accelerator: Accelerator, dst_val: u64, rows: u64) { + match accelerator { + Accelerator::Keccak => self.keccak += 1, + Accelerator::Ecsm => self.ecsm += 1, + Accelerator::Dma => { + self.dma += 1; + self.dma_bytes += dst_val; + self.dma_rows += rows; + } + } + } +} + /// Classifies one executed instruction as an accelerator syscall invocation. /// /// Delegates to the executor's canonical `SyscallNumbers::accelerator()` so the @@ -461,7 +499,7 @@ fn cmd_execute( // below (the flamegraph path drives execution inside the executor and does // not expose per-log data). `None` means "not counted", so the accel lines // are omitted rather than printed as misleading zeros. - let mut accel_counts: Option<(u64, u64)> = None; + let mut accel_counts: Option = None; let cycle_count = if let Some(ref output_path) = flamegraph.path { // Shared execute+flamegraph path (executor::flamegraph) instead of @@ -527,14 +565,13 @@ fn cmd_execute( }; let mut cycle_count: u64 = 0; - let mut keccak_calls: u64 = 0; - let mut ecsm_calls: u64 = 0; - // Reused per chunk: `(current_pc, a7)` for logs whose a7 matches an - // accelerator syscall number. This is a cheap superset — a non-ECALL + let mut counts = AccelCounts::default(); + // Reused per chunk: `(current_pc, a7, dst_val)` for logs whose a7 matches + // an accelerator syscall number. This is a cheap superset — a non-ECALL // instruction can hold the same value in src1 — that `accelerator_of` // confirms below, once the chunk's `&Log` borrow (tied to the executor's // `&mut`) is released so the instruction cache can be read again. - let mut accel_candidates: Vec<(u64, u64)> = Vec::new(); + let mut accel_candidates: Vec<(u64, u64, u64, u64)> = Vec::new(); loop { let logs = match executor.resume_budgeted(cycle_count, cycle_budget) { Ok(logs) => logs, @@ -551,17 +588,20 @@ fn cmd_execute( .map(|s| s.accelerator().is_some()) .unwrap_or(false) { - accel_candidates.push((log.current_pc, log.src1_val)); + accel_candidates.push(( + log.current_pc, + log.src1_val, + log.dst_val, + log.src2_val, + )); } } } // `logs` is no longer used, so the executor's `&mut` borrow is free // and the instruction cache can be read to confirm each candidate. - for (pc, a7) in accel_candidates.drain(..) { - match accelerator_of(executor.instructions.get(pc), a7) { - Some(Accelerator::Keccak) => keccak_calls += 1, - Some(Accelerator::Ecsm) => ecsm_calls += 1, - None => {} + for (pc, a7, dst_val, rows) in accel_candidates.drain(..) { + if let Some(accelerator) = accelerator_of(executor.instructions.get(pc), a7) { + counts.tally(accelerator, dst_val, rows); } } if cycle_budget.is_some_and(|budget| cycle_count >= budget) { @@ -575,16 +615,19 @@ fn cmd_execute( } if cycles { - accel_counts = Some((keccak_calls, ecsm_calls)); + accel_counts = Some(counts); } cycle_count }; if cycles { println!("Cycles: {}", cycle_count); - if let Some((keccak_calls, ecsm_calls)) = accel_counts { - println!("Keccak calls: {}", keccak_calls); - println!("Ecsm calls: {}", ecsm_calls); + if let Some(counts) = accel_counts { + println!("Keccak calls: {}", counts.keccak); + println!("Ecsm calls: {}", counts.ecsm); + println!("Dma calls: {}", counts.dma); + println!("Dma bytes: {}", counts.dma_bytes); + println!("Dma rows: {}", counts.dma_rows); } } @@ -1151,43 +1194,115 @@ mod tests { assert_eq!(continuation_epoch_size(20).unwrap(), 1 << 20); } + /// The chip each syscall must drive, written out here rather than read back + /// from `SyscallNumbers::accelerator()`. Comparing the CLI against the + /// executor alone would pass if both agreed on the wrong answer — a chip + /// demoted to `None` has to fail somewhere, and this is that somewhere. + /// + /// Cross-checked row by row against `SyscallNumbers::ALL`, which the + /// executor's macro generates from the enum, so a new syscall fails the test + /// until it gets a row here. + const EXPECTED_ACCELERATORS: &[(SyscallNumbers, Option)] = &[ + (SyscallNumbers::KeccakPermute, Some(Accelerator::Keccak)), + (SyscallNumbers::Ecsm, Some(Accelerator::Ecsm)), + (SyscallNumbers::DmaMemcpy, Some(Accelerator::Dma)), + (SyscallNumbers::DmaMemset, Some(Accelerator::Dma)), + (SyscallNumbers::Print, None), + (SyscallNumbers::Panic, None), + (SyscallNumbers::Commit, None), + (SyscallNumbers::Halt, None), + // `hint` drives its own HINT table, but the executor maps it to no + // `Accelerator`: the ecall adds no correctness constraint, so there is no + // accelerated work to attribute. `execute --cycles` reports no hint line. + (SyscallNumbers::Hint, None), + ]; + // `accelerator_of` must match the prover's `CpuOperation::from_log`: count an // invocation only when the instruction is an ECALL AND a7 is the accelerator - // syscall number. Covers both accelerators, the non-accelerator syscalls, a - // non-ECALL whose src1 collides with an accelerator number, and a cache miss. + // syscall number. #[test] fn accelerator_of_mirrors_prover_classification() { - use executor::vm::instruction::execution::{ECSM_SYSCALL_NUMBER, KECCAK_SYSCALL_NUMBER}; - let ecall = Instruction::EcallEbreak; - assert_eq!( - accelerator_of(Some(&ecall), KECCAK_SYSCALL_NUMBER), - Some(Accelerator::Keccak) - ); - assert_eq!( - accelerator_of(Some(&ecall), ECSM_SYSCALL_NUMBER), - Some(Accelerator::Ecsm) - ); + for &syscall in SyscallNumbers::ALL { + let rows = EXPECTED_ACCELERATORS + .iter() + .filter(|(listed, _)| *listed == syscall) + .count(); + assert_eq!( + rows, 1, + "{syscall:?} needs exactly one row in EXPECTED_ACCELERATORS" + ); + } - // Non-accelerator syscalls (Commit=64, Halt=93) count as neither. - assert_eq!( - accelerator_of(Some(&ecall), SyscallNumbers::Commit as u64), - None - ); - assert_eq!( - accelerator_of(Some(&ecall), SyscallNumbers::Halt as u64), - None - ); + for &(syscall, expected) in EXPECTED_ACCELERATORS { + assert_eq!( + accelerator_of(Some(&ecall), syscall.raw()), + expected, + "ECALL with a7 of {syscall:?} must classify as {expected:?}" + ); + // A non-ECALL instruction whose src1 happens to equal a syscall a7 + // must not count — this is the `f.ecall &&` guard the prover applies. + assert_eq!( + accelerator_of(Some(&Instruction::Fence), syscall.raw()), + None, + "non-ECALL with a7 of {syscall:?} must not count" + ); + // No decoded instruction at the pc (cache miss) counts as neither. + assert_eq!(accelerator_of(None, syscall.raw()), None); + } + } - // A non-ECALL instruction whose src1 happens to equal an accelerator a7 - // must not count — this is the `f.ecall &&` guard the prover applies. - assert_eq!( - accelerator_of(Some(&Instruction::Fence), KECCAK_SYSCALL_NUMBER), - None - ); + // Every tallied accelerator gets its own counter: no two variants may share + // a field, and each must land in the one the report prints. + #[test] + fn accel_counts_tallies_each_accelerator_separately() { + for &(_, expected_accelerator) in EXPECTED_ACCELERATORS { + let Some(accelerator) = expected_accelerator else { + continue; + }; + let mut counts = AccelCounts::default(); + counts.tally(accelerator, 0, 0); + assert_eq!( + counts.keccak + counts.ecsm + counts.dma, + 1, + "{accelerator:?} must increment exactly one counter" + ); + let expected = match accelerator { + Accelerator::Keccak => counts.keccak, + Accelerator::Ecsm => counts.ecsm, + Accelerator::Dma => counts.dma, + }; + assert_eq!( + expected, 1, + "{accelerator:?} must increment its own counter" + ); + } + } + + // The byte and row lines are what make the DMA report a cost figure rather + // than a call count, so they must accumulate across chunked ecalls and use + // the executor's row formula — the same one trace generation sizes with. + #[test] + fn accel_counts_sizes_dma_calls() { + let mut counts = AccelCounts::default(); + // Rows as the executor derives them at each ecall. + for bytes in [256, 256, 8, 3, 0] { + let rows = + executor::vm::instruction::execution::copy_trace_rows(0x1000, 0x2000, bytes, false); + counts.tally(Accelerator::Dma, bytes, rows); + } - // No decoded instruction at the pc (cache miss) counts as neither. - assert_eq!(accelerator_of(None, KECCAK_SYSCALL_NUMBER), None); + assert_eq!(counts.dma, 5, "every DMA ecall counts as one call"); + assert_eq!(counts.dma_bytes, 523); + // The rows are the executor's, derived where src, dst and count are all known; + // the report only sums them. + assert_eq!(counts.dma_rows, 32 + 32 + 1 + 3 + 1); + + // The other accelerators must leave the DMA size lines alone. + let mut others = AccelCounts::default(); + others.tally(Accelerator::Keccak, 200, 0); + others.tally(Accelerator::Ecsm, 32, 0); + assert_eq!((others.dma, others.dma_bytes, others.dma_rows), (0, 0, 0)); } } diff --git a/docs/continuations_design.md b/docs/continuations_design.md index 0c1095cb0..cfc821dfc 100644 --- a/docs/continuations_design.md +++ b/docs/continuations_design.md @@ -435,7 +435,7 @@ dropped the "chain is complete" property both rely on. Registers must also carry across epochs: epoch *i+1* must start from epoch *i*'s final register file. Unlike memory, the register file is **small and fixed** (34 -registers / 67 word-addresses, all present every epoch), so it needs no L2G / +registers / 68 word-addresses, all present every epoch), so it needs no L2G / global telescoping — we bind the whole snapshot directly. **Mechanism (no new bus).** The REGISTER table is the register analog of PAGE — it @@ -474,13 +474,13 @@ has no verifier-known final state) and preprocesses 2 columns, not 3. ### Commit index (x254) -The COMMIT chip's running output index lives in a synthetic single-word register -**x254** (word-address 508), so it rides the **same** register binding above — +The COPY chip's running output index lives in the synthetic register **x254** +(word-addresses 508-509), so it rides the **same** register binding above — epoch *i*'s `FINI[x254]` becomes epoch *i+1*'s `INIT[x254]`, pinned by the two locks like any register. Each epoch therefore indexes its committed bytes from the *carried* value, not from `0`: -- the COMMIT trace seeds `current_commit_index` from x254 +- the COPY trace seeds `current_commit_index` from x254 (`register_state.read_index()` in `trace_builder.rs`), with a debug-assert pinning the two in sync every step; - the verifier's commit-bus offset (`compute_commit_bus_offset`'s `start_index`) diff --git a/docs/general_flow.md b/docs/general_flow.md index deee5e4fe..155a96ead 100644 --- a/docs/general_flow.md +++ b/docs/general_flow.md @@ -7,7 +7,7 @@ The Lambda VM proves correct execution of a RISC-V (RV64IM) program against an i 1. **Source code** — high-level Rust (using [`syscalls/`](../syscalls/) for guest-host I/O) or RISC-V assembly. 2. **ELF binary** — the program in the VM's ISA, ready to load. 3. **Execution record** — per-instruction logs emitted by running the ELF on the VM. -4. **Witness** — a set of trace tables (CPU, decode, MEMW, LOAD, bitwise, branch, LT, shift, MUL, DVRM, page, register, halt, commit, keccak, …) derived from the execution record. Each table is an AIR (Algebraic Intermediate Representation); tables are linked by LogUp lookup arguments. +4. **Witness** — a set of trace tables (CPU, decode, MEMW, LOAD, bitwise, branch, LT, shift, MUL, DVRM, page, register, halt, copy, keccak, …) derived from the execution record. Each table is an AIR (Algebraic Intermediate Representation); tables are linked by LogUp lookup arguments. 5. **Proof** — a multi-table STARK proof (transparent, hash-based, post-quantum secure) that the witness satisfies all AIR constraints and lookup arguments. Low-degree of the witness polynomials is verified via FRI. ## Transformations @@ -18,3 +18,17 @@ The Lambda VM proves correct execution of a RISC-V (RV64IM) program against an i 4. **Proof system** ([`crypto/stark/`](../crypto/stark/)) — commits to each table's trace via Merkle trees, samples challenges via Fiat-Shamir, and runs FRI for the low-degree test. Produces a `MultiProof`; the verifier replays the transcript and checks all AIR and lookup constraints. For a deeper dive into each component see the [proof system overview](./cryptography/proof_system.md). + +## Accelerated memory operations + +`memcpy` is accelerated: `lambda-vm-syscalls` exports it under its standard, unmangled C name, so both explicit calls and the copies the compiler emits implicitly (struct moves, slice copies, `Vec` growth) reach the DMA ecall with no guest source changes. Behaviour is identical to the C function for every input, including `n == 0` and any alignment of `dest`, `src` or `n`. `memmove` and `memset` are accelerated too, through the same chip: `memmove` reuses the copy ecall and chunks backwards when the ranges overlap, and `memset` is expressed as a *propagating* copy — the stub seeds eight bytes with ordinary stores and then calls the accelerator with `dst = seed_end, src = seed_start`, which the chip runs with the read/write timestamp order inverted so the seed walks across the range. Fills shorter than sixteen bytes take a plain store loop instead. `memcmp` is not accelerated and falls back to the toolchain's `compiler-builtins` definition. + +**Observability.** `cli execute --cycles` reports `Dma calls`, `Dma bytes` and `Dma rows`. The call line confirms the accelerator is engaged at all; the byte and row lines are the cost, since the guest stub chunks one `memcpy` into as many ecalls as it needs and each ecall adds one row per chunk, where a chunk is eight bytes or one (see the alignment note below). There is no terminal row: the chain stops on the row that moves the last bytes, so a zero-length call is the only one whose single row moves nothing. `Dma rows` is the raw row count the copies contribute, before the DMA trace is padded to a power-of-two height — for a guest with few copies the padded table is larger than the reported figure. + +**Aligned vs misaligned.** Each eight-byte chunk emits two width-8 memory operations, one reading the source and one writing the destination, each at its address as given, and the memory argument routes each one by that address: an 8-aligned window sharing one old timestamp reaches MEMW_A (29 columns, one ALU `LT` range check), and anything else falls to the general MEMW table (49 columns, eight `LT` rows). The two sides are independent, so a copy can take the fast path on one end and not the other. The AIR leaves the width a free per-row choice, so a schedule *may* walk one-byte rows until the destination reaches eight-alignment and take eight-byte rows through the body. The schedule does not: measured on a real mainnet block, aligning one end cost more in extra one-byte rows than the MEMW_A rows it bought, so it takes eight-byte rows while eight remain and one byte a row through the tail, whatever the alignment. A misaligned copy therefore commits strictly more cells than an aligned copy of the same length, which is what makes the aligned/misaligned split the standard recommends informative here. It is not reported: the accelerator statistics are derived from `Log`, whose two operand slots are already taken (`dst_val = n`, and `src2_val` carries the row count; the width follows from `count` alone today, so it *is* recoverable from `n` — the slot is kept because the AIR permits a schedule that reads the addresses and a future one may). Reporting the aligned/misaligned split needs another slot or a dedicated counter. Left as a follow-up, and stated here rather than claimed as done. + +**Symbol resolution.** `compiler-builtins` defines `memcpy` *weakly*, and a linker extracts a static-archive member only to satisfy an *undefined* symbol — a weak definition already satisfies the reference, so a strong definition that lives in a member nothing else pulls in is dropped silently, with no duplicate-symbol diagnostic. Lambda VM therefore defines `memcpy` in [`syscalls/src/entrypoint.rs`](../syscalls/src/entrypoint.rs), the same object that defines `_start`, which every guest links unconditionally. That object is always extracted, so the strong definition is in the link graph from the start and overrides the weak one. No `--whole-archive` and no guest link flag is required, and resolution does not depend on archive order. + +**Deviation from the standard's scope clause.** The standard says the accelerated 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 (`lambda-vm-syscalls`), and `memcpy` is exported from its always-linked entrypoint object. The linking clause above is satisfied by mechanism (1); the packaging the scope clause assumes is not, and adopting it is a repo-wide decision rather than one this accelerator can make. + +Placing `memcpy` beside `_start` is insurance rather than a repair: defined in `syscalls.rs` it also resolved correctly, and not by luck — `_start` calls `sys_halt` from that module and it is not `#[inline]`, so every guest carries an undefined reference that forces the object out of the archive, whatever the guest itself names. What the move removes is the two things that guarantee rested on: `_start` continuing to call into `syscalls.rs`, and rustc's codegen-unit merging keeping the two modules together. Co-locating with `_start` — the one symbol the linker is obliged to resolve — makes the guarantee local instead, and `test_dma_memcpy_compiler_emitted_copies` (a guest that never names `memcpy`, asserting the DMA ecall count stays above zero) is what detects a regression, since a guest that falls back to the weak definition still produces correct output. diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 91ae64ae9..b37bdc081 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -14,6 +14,7 @@ ecsm = { path = "../crypto/ecsm" } k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } [dev-dependencies] +proptest = "1.9" # Test-only: the guest-side syscall crate re-declares the `hint` selectors as `usize` # and they must stay equal to the `u64` copies here (see `hint_selectors_match_the_guest`). # Unlike `crypto/crypto`'s and `ethrex-crypto`'s copies of this dep, it is NOT diff --git a/executor/programs/rust/dma_memcpy_cases/.cargo/config.toml b/executor/programs/rust/dma_memcpy_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memcpy_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memcpy_cases/Cargo.lock b/executor/programs/rust/dma_memcpy_cases/Cargo.lock new file mode 100644 index 000000000..5f1da6b2b --- /dev/null +++ b/executor/programs/rust/dma_memcpy_cases/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memcpy_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memcpy_cases/Cargo.toml b/executor/programs/rust/dma_memcpy_cases/Cargo.toml new file mode 100644 index 000000000..86baaa9d2 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memcpy_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memcpy_cases/src/main.rs b/executor/programs/rust/dma_memcpy_cases/src/main.rs new file mode 100644 index 000000000..b8472eb96 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_cases/src/main.rs @@ -0,0 +1,77 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memcpy(dst: *mut u8, src: *const u8, count: usize) -> *mut u8; +} + +#[inline(never)] +fn dma_copy(dst: *mut u8, src: *const u8, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memcpy(dst, src, count) } +} + +fn fill_pattern(bytes: &mut [u8], seed: u8) { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37).wrapping_add(seed); + } +} + +pub fn main() { + let mut source = [0u8; 777]; + let mut destination = [0xA5u8; 777]; + fill_pattern(&mut source, 11); + + for count in [0usize, 1, 7, 8, 9, 31, 32, 33, 127, 128, 255, 256] { + destination.fill(0xA5); + let returned = dma_copy(destination.as_mut_ptr(), source.as_ptr(), count); + assert_eq!(returned, destination.as_mut_ptr()); + assert_eq!(&destination[..count], &source[..count]); + assert!(destination[count..].iter().all(|&byte| byte == 0xA5)); + } + + // More than one chunk: 777 bytes becomes four bounded DMA ecalls. + destination.fill(0); + dma_copy(destination.as_mut_ptr(), source.as_ptr(), source.len()); + assert_eq!(destination, source); + + // Snapshot semantics in both overlap directions. + let mut forward = [0u8; 320]; + fill_pattern(&mut forward, 23); + let forward_before = forward; + dma_copy( + unsafe { forward.as_mut_ptr().add(17) }, + forward.as_ptr(), + 256, + ); + assert_eq!(&forward[17..273], &forward_before[..256]); + + let mut backward = [0u8; 320]; + fill_pattern(&mut backward, 41); + let backward_before = backward; + dma_copy( + backward.as_mut_ptr(), + unsafe { backward.as_ptr().add(17) }, + 256, + ); + assert_eq!(&backward[..256], &backward_before[17..273]); + + // Force both operands to cross a 4 KiB page boundary. + let mut page_source = [0u8; 8192]; + let mut page_destination = [0u8; 8192]; + fill_pattern(&mut page_source, 67); + let src_to_boundary = 4096 - (page_source.as_ptr() as usize & 4095); + let dst_to_boundary = 4096 - (page_destination.as_ptr() as usize & 4095); + let src_offset = src_to_boundary.saturating_sub(3); + let dst_offset = dst_to_boundary.saturating_sub(5); + dma_copy( + unsafe { page_destination.as_mut_ptr().add(dst_offset) }, + unsafe { page_source.as_ptr().add(src_offset) }, + 256, + ); + assert_eq!( + &page_destination[dst_offset..dst_offset + 256], + &page_source[src_offset..src_offset + 256] + ); + + syscalls::syscalls::commit(b"dma-cases-ok"); +} diff --git a/executor/programs/rust/dma_memcpy_implicit/.cargo/config.toml b/executor/programs/rust/dma_memcpy_implicit/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memcpy_implicit/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memcpy_implicit/Cargo.lock b/executor/programs/rust/dma_memcpy_implicit/Cargo.lock new file mode 100644 index 000000000..3b4049770 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_implicit/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memcpy_implicit" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memcpy_implicit/Cargo.toml b/executor/programs/rust/dma_memcpy_implicit/Cargo.toml new file mode 100644 index 000000000..85068fca5 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_implicit/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memcpy_implicit" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memcpy_implicit/src/main.rs b/executor/programs/rust/dma_memcpy_implicit/src/main.rs new file mode 100644 index 000000000..d24903c9c --- /dev/null +++ b/executor/programs/rust/dma_memcpy_implicit/src/main.rs @@ -0,0 +1,34 @@ +//! Every copy here is emitted by the compiler: nothing declares or names +//! `memcpy`. The guest computes the same output whether or not the strong +//! `memcpy` symbol won the guest's link, so its DMA ecall count — not its +//! output — is what pins the symbol resolution. + +use lambda_vm_syscalls as syscalls; + +#[inline(never)] +fn copy_slice(destination: &mut [u8], source: &[u8]) { + destination.copy_from_slice(source); +} + +fn fill_pattern(bytes: &mut [u8], seed: u8) { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(31).wrapping_add(seed); + } +} + +pub fn main() { + let mut source = [0u8; 512]; + fill_pattern(&mut source, 7); + // A runtime-sized length keeps LLVM from lowering the copies inline. + let length = core::hint::black_box(source.len()); + + let mut destination = [0u8; 512]; + copy_slice(&mut destination[..length], &source[..length]); + assert_eq!(destination, source); + + let mut grown = Vec::new(); + grown.extend_from_slice(&source[..length]); + assert_eq!(grown.as_slice(), &source[..]); + + syscalls::syscalls::commit(b"dma-implicit-ok"); +} diff --git a/executor/programs/rust/dma_memcpy_min/.cargo/config.toml b/executor/programs/rust/dma_memcpy_min/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memcpy_min/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memcpy_min/Cargo.lock b/executor/programs/rust/dma_memcpy_min/Cargo.lock new file mode 100644 index 000000000..06556e1d2 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_min/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memcpy_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memcpy_min/Cargo.toml b/executor/programs/rust/dma_memcpy_min/Cargo.toml new file mode 100644 index 000000000..a791f7824 --- /dev/null +++ b/executor/programs/rust/dma_memcpy_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memcpy_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memcpy_min/src/main.rs b/executor/programs/rust/dma_memcpy_min/src/main.rs new file mode 100644 index 000000000..fb33e33fc --- /dev/null +++ b/executor/programs/rust/dma_memcpy_min/src/main.rs @@ -0,0 +1,16 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memcpy(dst: *mut u8, src: *const u8, count: usize) -> *mut u8; +} + +pub fn main() { + let source = *b"DMA copies eight-byte rows and a short tail"; + let mut destination = [0u8; 43]; + let count = core::hint::black_box(destination.len()); + + unsafe { + memcpy(destination.as_mut_ptr(), source.as_ptr(), count); + } + syscalls::syscalls::commit(&destination); +} diff --git a/executor/programs/rust/dma_memmove_cases/.cargo/config.toml b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.lock b/executor/programs/rust/dma_memmove_cases/Cargo.lock new file mode 100644 index 000000000..3e0c1c056 --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.lock @@ -0,0 +1,251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memmove_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memmove_cases/Cargo.toml b/executor/programs/rust/dma_memmove_cases/Cargo.toml new file mode 100644 index 000000000..b81ea25a9 --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memmove_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memmove_cases/src/main.rs b/executor/programs/rust/dma_memmove_cases/src/main.rs new file mode 100644 index 000000000..45ecdb0de --- /dev/null +++ b/executor/programs/rust/dma_memmove_cases/src/main.rs @@ -0,0 +1,70 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memmove(dst: *mut u8, src: *const u8, count: usize) -> *mut u8; +} + +#[inline(never)] +fn dma_move(dst: *mut u8, src: *const u8, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memmove(dst, src, count) } +} + +fn fill_pattern(bytes: &mut [u8], seed: u8) { + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37).wrapping_add(seed); + } +} + +pub fn main() { + // Disjoint regions behave like memcpy. + let mut source = [0u8; 777]; + let mut destination = [0xA5u8; 777]; + fill_pattern(&mut source, 11); + for count in [0usize, 1, 7, 8, 255, 256, 257, 777] { + destination.fill(0xA5); + let returned = dma_move(destination.as_mut_ptr(), source.as_ptr(), count); + assert_eq!(returned, destination.as_mut_ptr()); + assert_eq!(&destination[..count], &source[..count]); + assert!(destination[count..].iter().all(|&b| b == 0xA5)); + } + + // Forward overlap (dst inside [src, src+n)) is the case that needs BACKWARD + // chunking; a forward-chunked copy corrupts it once n exceeds one chunk. + // Offsets below and above 256 exercise both sides of the chunk boundary. + for (offset, count) in [(1usize, 600usize), (17, 600), (255, 600), (256, 600), (300, 700), (4, 8)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 23); + let before = buffer; + dma_move( + unsafe { buffer.as_mut_ptr().add(offset) }, + buffer.as_ptr(), + count, + ); + assert_eq!(&buffer[offset..offset + count], &before[..count]); + // Bytes below the destination must be untouched. + assert_eq!(&buffer[..offset], &before[..offset]); + } + + // Backward overlap (dst below src) stays forward-chunked. + for (offset, count) in [(1usize, 600usize), (17, 600), (300, 700)] { + let mut buffer = [0u8; 1600]; + fill_pattern(&mut buffer, 41); + let before = buffer; + dma_move( + buffer.as_mut_ptr(), + unsafe { buffer.as_ptr().add(offset) }, + count, + ); + assert_eq!(&buffer[..count], &before[offset..offset + count]); + } + + // Exact aliasing must be a no-op. + let mut same = [0u8; 300]; + fill_pattern(&mut same, 7); + let before = same; + dma_move(same.as_mut_ptr(), same.as_ptr(), 300); + assert_eq!(same, before); + + syscalls::syscalls::commit(b"dma-memmove-ok"); +} diff --git a/executor/programs/rust/dma_memset_cases/.cargo/config.toml b/executor/programs/rust/dma_memset_cases/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_cases/Cargo.lock b/executor/programs/rust/dma_memset_cases/Cargo.lock new file mode 100644 index 000000000..471c82c00 --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.lock @@ -0,0 +1,251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memset_cases" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memset_cases/Cargo.toml b/executor/programs/rust/dma_memset_cases/Cargo.toml new file mode 100644 index 000000000..de5dc5ede --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_cases" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_cases/src/main.rs b/executor/programs/rust/dma_memset_cases/src/main.rs new file mode 100644 index 000000000..0625769fa --- /dev/null +++ b/executor/programs/rust/dma_memset_cases/src/main.rs @@ -0,0 +1,63 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +/// `black_box` on the count keeps LLVM from turning these into inline stores, +/// so every call really does reach the strong `memset` symbol and the DMA ecall. +#[inline(never)] +fn dma_set(dst: *mut u8, fill: i32, count: usize) -> *mut u8 { + let count = core::hint::black_box(count); + unsafe { memset(dst, fill, count) } +} + +pub fn main() { + let mut buffer = [0u8; 777]; + + // Every row-schedule boundary: empty, sub-tail, exact widths, the 256-byte + // per-ecall cap, and one length that forces several chunked ecalls. + for count in [0usize, 1, 7, 8, 9, 31, 32, 33, 127, 128, 255, 256] { + buffer.fill(0xA5); + let returned = dma_set(buffer.as_mut_ptr(), 0x3C, count); + assert_eq!(returned, buffer.as_mut_ptr()); + assert!(buffer[..count].iter().all(|&byte| byte == 0x3C)); + assert!(buffer[count..].iter().all(|&byte| byte == 0xA5)); + } + + // More than one chunk: 777 bytes becomes four bounded DMA ecalls. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x5A, buffer.len()); + assert!(buffer.iter().all(|&byte| byte == 0x5A)); + + // Zero is the fill almost every real caller passes (`vec![0; n]` and the + // allocator's `alloc_zeroed`), and it is the one value a dropped write is + // indistinguishable from on a fresh buffer — so start from 0xA5. + buffer.fill(0xA5); + dma_set(buffer.as_mut_ptr(), 0, 100); + assert!(buffer[..100].iter().all(|&byte| byte == 0)); + assert!(buffer[100..].iter().all(|&byte| byte == 0xA5)); + + // The seeding `sb` writes the low byte of its source register, so a wide fill + // truncates as C's `memset(void*, int, size_t)` requires: `(unsigned char)c`. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), 0x1FF, 64); + assert!(buffer[..64].iter().all(|&byte| byte == 0xFF)); + + // A negative int sign-extends to 0xFFFF_FFFF_FFFF_FFFF under lp64, and `sb` + // takes its low byte, so the fill is 0xFF. + buffer.fill(0); + dma_set(buffer.as_mut_ptr(), -1, 32); + assert!(buffer[..32].iter().all(|&byte| byte == 0xFF)); + + // Unaligned destination that also crosses a 4 KiB page boundary. + let mut page_buffer = [0u8; 8192]; + let to_boundary = 4096 - (page_buffer.as_ptr() as usize & 4095); + let offset = to_boundary.saturating_sub(5); + dma_set(unsafe { page_buffer.as_mut_ptr().add(offset) }, 0x77, 256); + assert!(page_buffer[offset..offset + 256] + .iter() + .all(|&byte| byte == 0x77)); + + syscalls::syscalls::commit(b"dma-memset-ok"); +} diff --git a/executor/programs/rust/dma_memset_min/.cargo/config.toml b/executor/programs/rust/dma_memset_min/.cargo/config.toml new file mode 100644 index 000000000..8ef8239bb --- /dev/null +++ b/executor/programs/rust/dma_memset_min/.cargo/config.toml @@ -0,0 +1,9 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] + +[env] +CC_riscv64im_lambda_vm_elf = "clang" +CFLAGS_riscv64im_lambda_vm_elf = "--target=riscv64 -march=rv64im -mabi=lp64 --sysroot=/opt/lambda-vm-sysroot" diff --git a/executor/programs/rust/dma_memset_min/Cargo.lock b/executor/programs/rust/dma_memset_min/Cargo.lock new file mode 100644 index 000000000..45bef20ca --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.lock @@ -0,0 +1,251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "dma_memset_min" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/dma_memset_min/Cargo.toml b/executor/programs/rust/dma_memset_min/Cargo.toml new file mode 100644 index 000000000..3a98a947c --- /dev/null +++ b/executor/programs/rust/dma_memset_min/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "dma_memset_min" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/dma_memset_min/src/main.rs b/executor/programs/rust/dma_memset_min/src/main.rs new file mode 100644 index 000000000..ef8150e54 --- /dev/null +++ b/executor/programs/rust/dma_memset_min/src/main.rs @@ -0,0 +1,18 @@ +use lambda_vm_syscalls as syscalls; + +unsafe extern "C" { + fn memset(dst: *mut u8, fill: i32, count: usize) -> *mut u8; +} + +pub fn main() { + // 43 bytes: the stub seeds the first 8 with stores and hands the chip the other + // 35, which it moves as four eight-byte rows and three one-byte rows. The chain + // ends on the row that moves the last byte; there is no terminal row. + let mut buffer = [0u8; 43]; + let count = core::hint::black_box(buffer.len()); + + unsafe { + memset(buffer.as_mut_ptr(), 0x3C, count); + } + syscalls::syscalls::commit(&buffer); +} diff --git a/executor/src/tests/commit_ecall_tests.rs b/executor/src/tests/commit_ecall_tests.rs new file mode 100644 index 000000000..5a847df97 --- /dev/null +++ b/executor/src/tests/commit_ecall_tests.rs @@ -0,0 +1,81 @@ +//! The `write(2)` ecall's register ABI, which the COPY chip proves. +//! +//! `copy:c:read_fd` pins the old value of `x10` to the file descriptor `1` and +//! returns `count` there, so the executor has to agree on both halves or the +//! register token the chip emits has no counterpart and an honest program becomes +//! unprovable. Neither half had a test before. + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ExecutionError, SyscallNumbers}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; + +/// Drive one `write` ecall and hand back the register file it left behind. +fn run_commit( + memory: &mut Memory, + fd: u64, + buf_addr: u64, + count: u64, +) -> Result { + let mut registers = Registers::default(); + let mut pc = 0; + registers.write(17, SyscallNumbers::Commit as u64)?; + registers.write(10, fd)?; + registers.write(11, buf_addr)?; + registers.write(12, count)?; + Instruction::EcallEbreak.run(&mut pc, &mut registers, memory)?; + Ok(registers) +} + +#[test] +fn commit_returns_the_byte_count_in_a0() { + // The AIR writes `count` back to x10 on every `write`. If the executor leaves + // `fd` there instead, the two register mirrors diverge at this ecall and the + // next instruction that *reads* a0 without writing it first emits a token the + // COPY chip never produced -- a bus imbalance on an honest program. + let mut memory = Memory::default(); + memory.store_byte(0x100, b'a'); + memory.store_byte(0x101, b'b'); + + let registers = run_commit(&mut memory, 1, 0x100, 2).expect("commit should succeed"); + assert_eq!( + registers.read(10).expect("a0 readable"), + 2, + "a0 must hold the number of bytes written, not the file descriptor" + ); + // The operand registers are untouched. + assert_eq!(registers.read(11).expect("a1"), 0x100); + assert_eq!(registers.read(12).expect("a2"), 2); + assert_eq!(memory.read_return_value().expect("output"), b"ab".to_vec()); +} + +#[test] +fn commit_returns_zero_for_an_empty_slice() { + // `syscalls::commit` passes `slice.len()` straight through, so `commit(b"")` is + // reachable from any guest. It is the one shape that produces the degenerate + // COPY row (`count = 0`, `first = end = 1`, no memory access at all). + let mut memory = Memory::default(); + let registers = run_commit(&mut memory, 1, 0x100, 0).expect("empty commit should succeed"); + assert_eq!(registers.read(10).expect("a0"), 0); + assert!( + memory.read_return_value().expect("output").is_empty(), + "an empty commit must contribute no output" + ); +} + +#[test] +fn commit_rejects_every_file_descriptor_but_stdout() { + // Only stdout is supported, and the AIR says so by pinning the old value of x10 + // to the constant 1 in `copy:c:read_fd`. The executor must accept exactly that + // set, or it admits an execution no prover can build. + for fd in [0u64, 2, 3, u64::MAX] { + let mut memory = Memory::default(); + assert!( + matches!( + run_commit(&mut memory, fd, 0x100, 2), + Err(ExecutionError::InvalidCommitFd(got)) if got == fd + ), + "fd {fd} must be rejected" + ); + } +} diff --git a/executor/src/tests/dma_tests.rs b/executor/src/tests/dma_tests.rs new file mode 100644 index 000000000..495262972 --- /dev/null +++ b/executor/src/tests/dma_tests.rs @@ -0,0 +1,315 @@ +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ + DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_GAP, DMA_MEMSET_SYSCALL_NUMBER, + ExecutionError, copy_row_width, copy_trace_rows, +}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; +use proptest::prelude::*; + +fn run_dma(memory: &mut Memory, dst: u64, src: u64, count: u64) -> Result<(), ExecutionError> { + let mut registers = Registers::default(); + let mut pc = 0; + registers.write(17, DMA_MEMCPY_SYSCALL_NUMBER)?; + registers.write(10, dst)?; + registers.write(11, src)?; + registers.write(12, count)?; + Instruction::EcallEbreak.run(&mut pc, &mut registers, memory)?; + Ok(()) +} + +#[test] +fn dma_memcpy_copies_unaligned_body_and_tail() { + let mut memory = Memory::default(); + let input: Vec = (0..27).map(|i| (i * 7 + 3) as u8).collect(); + for (i, &byte) in input.iter().enumerate() { + memory.store_byte(0x1003 + i as u64, byte); + } + + run_dma(&mut memory, 0x2005, 0x1003, input.len() as u64).unwrap(); + assert_eq!( + memory.load_bytes(0x2005, input.len() as u64).unwrap(), + input + ); +} + +#[test] +fn dma_memcpy_has_snapshot_semantics_for_overlap() { + let mut memory = Memory::default(); + let input: Vec = (0..32).map(|i| i as u8).collect(); + for (i, &byte) in input.iter().enumerate() { + memory.store_byte(0x3000 + i as u64, byte); + } + + run_dma(&mut memory, 0x3004, 0x3000, 24).unwrap(); + assert_eq!( + memory.load_bytes(0x3004, 24).unwrap(), + input[..24], + "overlap must read the complete source snapshot before writing" + ); +} + +#[test] +fn dma_memcpy_rejects_wrapping_ranges() { + let mut memory = Memory::default(); + assert!(run_dma(&mut memory, 0x1000, u64::MAX - 3, 8).is_err()); + assert!(run_dma(&mut memory, u64::MAX - 3, 0x1000, 8).is_err()); +} + +#[test] +fn dma_memcpy_rejects_oversized_direct_ecall() { + let mut memory = Memory::default(); + assert!(matches!( + run_dma( + &mut memory, + 0x2000, + 0x1000, + DMA_MEMCPY_MAX_BYTES + 1 + ), + Err(ExecutionError::DmaChunkTooLarge(n)) + if n == DMA_MEMCPY_MAX_BYTES + 1 + )); +} + +/// The row helpers are what the trace builder sizes the DMA trace with and what +/// the CLI reports as the accelerator's cost, so pin them to the chunking rule +/// the trace builder actually walks rather than to the closed form itself. +#[test] +fn dma_row_helpers_match_the_chunk_loop() { + for count in 0..=DMA_MEMCPY_MAX_BYTES { + // The width no longer depends on either end's alignment, but the pairs are + // kept so the row count stays pinned if a future schedule reintroduces it. + for (src, dst) in [(0u64, 0u64), (5, 5), (7, 7), (0, 5), (2, 5), (8, 16)] { + let mut chunks = 0u64; + let mut remaining = count; + let mut offset = 0u64; + while remaining != 0 { + let width = u64::from(copy_row_width(src, dst, offset, remaining, false)); + remaining -= width; + offset += width; + chunks += 1; + } + assert_eq!( + copy_trace_rows(src, dst, count, false), + chunks.max(1), + "src {src}, dst {dst}, count {count}: one row per chunk, and a \ + zero-length call still takes the degenerate row" + ); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Differentially compare the DMA snapshot semantics against a byte-vector + /// oracle. The generated ranges cover unaligned copies, both overlap + /// directions, zero/small/tail lengths, full chunks, and page crossings. + #[test] + fn dma_memcpy_matches_snapshot_oracle( + src_offset in 0usize..768, + dst_offset in 0usize..768, + count in 0usize..=DMA_MEMCPY_MAX_BYTES as usize, + seed in any::(), + ) { + const BASE: u64 = 0x0F00; + const REGION: usize = 1024; + + let mut initial = vec![0u8; REGION]; + let mut state = seed; + for byte in &mut initial { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state as u8; + } + + let mut expected = initial.clone(); + let snapshot = expected[src_offset..src_offset + count].to_vec(); + expected[dst_offset..dst_offset + count].copy_from_slice(&snapshot); + + let mut memory = Memory::default(); + for (i, &byte) in initial.iter().enumerate() { + memory.store_byte(BASE + i as u64, byte); + } + run_dma( + &mut memory, + BASE + dst_offset as u64, + BASE + src_offset as u64, + count as u64, + ) + .unwrap(); + + let actual = memory.load_bytes(BASE, REGION as u64).unwrap(); + prop_assert_eq!(actual, expected); + } +} + +/// Drives the memset ecall directly. `a1` is a *source address* now, not a fill +/// byte: the accelerator performs a propagating copy, and the guest stub is what +/// seeds the first bytes. +fn run_memset(memory: &mut Memory, dst: u64, src: u64, count: u64) -> Result<(), ExecutionError> { + let mut registers = Registers::default(); + let mut pc = 0; + registers.write(17, DMA_MEMSET_SYSCALL_NUMBER)?; + registers.write(10, dst)?; + registers.write(11, src)?; + registers.write(12, count)?; + Instruction::EcallEbreak.run(&mut pc, &mut registers, memory)?; + Ok(()) +} + +/// What the guest stub does before the ecall: seed eight bytes with the fill. +fn seed(memory: &mut Memory, dst: u64, fill: u8) { + for i in 0..8 { + memory.store_byte(dst + i, fill); + } +} + +#[test] +fn dma_memset_fills_unaligned_body_and_tail() { + let mut memory = Memory::default(); + // 27 bytes at an unaligned base: the stub seeds eight and the ecall propagates + // the remaining nineteen from them. + seed(&mut memory, 0x2005, 0x3C); + run_memset(&mut memory, 0x2005 + 8, 0x2005, 27 - 8).unwrap(); + + assert_eq!(memory.load_bytes(0x2005, 27).unwrap(), vec![0x3Cu8; 27]); + // Neighbours must be untouched. + assert_eq!(memory.load_byte(0x2004), 0); + assert_eq!(memory.load_byte(0x2005 + 27), 0); +} + +#[test] +fn dma_memset_zero_count_writes_nothing() { + let mut memory = Memory::default(); + memory.store_byte(0x3000, 0x11); + run_memset(&mut memory, 0x3008, 0x3000, 0).unwrap(); + assert_eq!(memory.load_byte(0x3000), 0x11); +} + +#[test] +fn dma_memset_rejects_wrapping_range() { + let mut memory = Memory::default(); + assert!(run_memset(&mut memory, u64::MAX - 3, 0x2000, 8).is_err()); +} + +#[test] +fn dma_memset_rejects_oversized_chunk() { + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, 0x4000, DMA_MEMCPY_MAX_BYTES + 1), + Err(ExecutionError::DmaChunkTooLarge(n)) if n == DMA_MEMCPY_MAX_BYTES + 1 + )); +} + +#[test] +fn dma_memset_propagates_rather_than_filling() { + // The distinguishing property: the ecall is an overlapping *copy* walked + // forward, so whatever the stub seeded spreads across the range. Seeding a + // non-uniform pattern makes that visible — a real fill could not produce it. + let mut memory = Memory::default(); + for i in 0..8u64 { + memory.store_byte(0x2000 + i, i as u8); + } + run_memset(&mut memory, 0x2008, 0x2000, 16).unwrap(); + + assert_eq!( + memory.load_bytes(0x2000, 24).unwrap(), + (0..24u8).map(|i| i % 8).collect::>(), + "each step must observe the previous step's write" + ); +} + +proptest! { + /// The stub-plus-ecall pair must reproduce a reference fill for any length and + /// any destination alignment: the stub seeds `min(8, count)` bytes and the ecall + /// propagates the rest from them. + #[test] + fn dma_memset_matches_reference_fill( + dst_offset in 0usize..64, + count in 0usize..=(DMA_MEMCPY_MAX_BYTES as usize + 8), + fill in 0u8..=255, + ) { + const BASE: u64 = 0x9000; + const REGION: usize = 400; + + let mut expected = vec![0u8; REGION]; + expected[dst_offset..dst_offset + count].fill(fill); + + let mut memory = Memory::default(); + let dst = BASE + dst_offset as u64; + let seeded = count.min(8); + for i in 0..seeded as u64 { + memory.store_byte(dst + i, fill); + } + if count > seeded { + run_memset(&mut memory, dst + seeded as u64, dst, (count - seeded) as u64).unwrap(); + } + + let actual = memory.load_bytes(BASE, REGION as u64).unwrap(); + prop_assert_eq!(actual, expected); + } +} + +/// The operand contract is what pins `value` on an `is_set` row, so the executor +/// must accept exactly the shapes the AIR can prove -- no wider, or an honest +/// execution becomes unprovable, and no narrower, or the AIR admits executions +/// that never happened. +#[test] +fn dma_memset_rejects_every_gap_but_one() { + for (dst, src, why) in [ + ( + 0x2000u64, + 0x2000u64, + "dst == src leaves the value lanes unconstrained", + ), + (0x2004, 0x2000, "a gap under one row width"), + (0x2020, 0x2000, "a gap over one row width"), + (0x2000, 0x2008, "dst below src propagates the wrong way"), + ] { + let mut memory = Memory::default(); + assert!( + matches!( + run_memset(&mut memory, dst, src, 16), + Err(ExecutionError::DmaMemsetBadGap { .. }) + ), + "{why} (src {src:#x}, dst {dst:#x})" + ); + } + + // Rejected for count 0 too. A zero-length call with a *correct* gap is fine and + // provable (one row, first = end = 1, no read, no write); what this case exercises + // is `dst == src`, which the guard refuses regardless of count. + let mut memory = Memory::default(); + assert!(matches!( + run_memset(&mut memory, 0x2000, 0x2000, 0), + Err(ExecutionError::DmaMemsetBadGap { .. }) + )); + + // A range crossing the 2^32 limb boundary is accepted, and must be: the AIR's + // `copy:c:set_gap` is one 64-bit `ADD`, so `dst = src + 8` holds with the carry + // out of the low limb and every row of the chain is provable. The old limb-wise + // pair of constraints could not express that carry, and the guard that used to + // refuse these calls went with them. + let mut memory = Memory::default(); + let src = 0xFFFF_FFF8u64; + seed(&mut memory, src, 0x3C); + run_memset(&mut memory, src + DMA_MEMSET_GAP, src, 8).unwrap(); + assert_eq!(memory.load_byte(0x1_0000_0007), 0x3C); + + // ...including a chain that starts clear of the boundary and walks into it. + let mut memory = Memory::default(); + let src = 0xFFFF_FF00u64; + seed(&mut memory, src, 0x5C); + run_memset(&mut memory, src + DMA_MEMSET_GAP, src, 256).unwrap(); + assert_eq!(memory.load_byte(src + 263), 0x5C); + + // And the one legal shape still works. + let mut memory = Memory::default(); + seed(&mut memory, 0x2000, 0x5A); + run_memset(&mut memory, 0x2008, 0x2000, 8).unwrap(); + for addr in 0x2000..0x2010 { + assert_eq!(memory.load_byte(addr), 0x5A, "byte at {addr:#x}"); + } +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 244447b22..fe2f6abc8 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,5 +1,8 @@ +pub mod commit_ecall_tests; +pub mod dma_tests; pub mod ecsm_tests; pub mod flamegraph_tests; pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; +pub mod syscall_tests; diff --git a/executor/src/tests/syscall_tests.rs b/executor/src/tests/syscall_tests.rs new file mode 100644 index 000000000..31fa6e2d6 --- /dev/null +++ b/executor/src/tests/syscall_tests.rs @@ -0,0 +1,28 @@ +use crate::vm::instruction::execution::SyscallNumbers; + +/// `raw()` is the inverse of `TryFrom`: the number the guest puts in `a7` +/// must decode back to the variant it came from. Runs over `ALL`, so a syscall +/// whose `raw()` collides with another's is caught here rather than by a guest +/// silently taking the wrong ecall path. +#[test] +fn raw_round_trips_through_try_from() { + for &syscall in SyscallNumbers::ALL { + assert_eq!( + SyscallNumbers::try_from(syscall.raw()), + Ok(syscall), + "a7 = {} must decode back to {syscall:?}", + syscall.raw() + ); + } +} + +/// Two syscalls sharing an `a7` would make `TryFrom` pick one and leave the other +/// unreachable, and `ALL` is what the CLI parity test enumerates. +#[test] +fn every_syscall_has_a_distinct_a7() { + let mut raws: Vec = SyscallNumbers::ALL.iter().map(|s| s.raw()).collect(); + let listed = raws.len(); + raws.sort_unstable(); + raws.dedup(); + assert_eq!(raws.len(), listed, "two syscalls share an a7 value"); +} diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 592af95e8..f8424ffb3 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -7,18 +7,44 @@ use crate::vm::{ const REGULAR_PC_UPDATE: u64 = 4; -pub enum SyscallNumbers { - // Placeholder discriminant. The actual syscall value is KECCAK_SYSCALL_NUMBER. +/// Declares `SyscallNumbers` and derives `ALL` from the same variant list, so a +/// syscall added to the enum is enumerated by everything driven off `ALL` (the +/// CLI's accelerator-parity test) without a second list to keep in sync. +macro_rules! syscall_numbers { + ($($(#[$meta:meta])* $variant:ident = $discriminant:literal,)+) => { + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub enum SyscallNumbers { + $($(#[$meta])* $variant = $discriminant,)+ + } + + impl SyscallNumbers { + /// Every variant, generated alongside the enum. + pub const ALL: &'static [SyscallNumbers] = &[$(SyscallNumbers::$variant,)+]; + } + }; +} + +syscall_numbers! { + /// Placeholder discriminant. The actual syscall value is `KECCAK_SYSCALL_NUMBER`. KeccakPermute = 0, Print = 1, Panic = 2, Commit = 64, Halt = 93, - // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. + /// Placeholder discriminant. The actual syscall value is `ECSM_SYSCALL_NUMBER`. Ecsm = 94, - // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. - // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). + /// Placeholder discriminant. The actual syscall value is + /// `HINT_SYSCALL_NUMBER`. Non-constraining hint (host computes modular + /// inverse/sqrt, guest verifies). Hint = 95, + /// Placeholder discriminant. The actual syscall value is + /// `DMA_MEMCPY_SYSCALL_NUMBER`. `memcpy` and `memmove` chunks are proven by + /// the COPY table. + DmaMemcpy = 96, + /// Placeholder discriminant. The actual syscall value is + /// `DMA_MEMSET_SYSCALL_NUMBER`. `memset` chunks are proven by the same + /// COPY table, which derives the inverted timestamp order from this number. + DmaMemset = 97, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -34,6 +60,104 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; +/// Syscall number for the copy accelerator, serving `memcpy` and `memmove`. +/// +/// The spec uses ECALL number `-30`, i.e. `u64::MAX - 29 = 0xFFFF_FFFF_FFFF_FFE2`, +/// which the COPY table puts on the `Ecall` bus as +/// `[lo32, hi32] = [2^32 - 30, 2^32 - 1]`. +/// +/// It starts a new group deliberately. `-1` through `-10` are reserved for hash +/// accelerators (`-1` SHA256, `-2` KECCAK today), and the earlier `-3`/`-4` pair sat +/// inside that range. Must match `syscalls/src/syscalls.rs`. +pub const DMA_MEMCPY_SYSCALL_NUMBER: u64 = u64::MAX - 29; +/// Maximum bytes accepted by one DMA ecall. The guest `memcpy` stub chunks larger +/// copies to stay under it, and it sizes the fixed scratch buffer the snapshot below +/// copies through. The AIR does *not* enforce it — the spec places no bound on +/// `count` — so the executor is strictly stricter than the chip here, and this limit +/// is the side to relax if the two are ever made to agree. +pub const DMA_MEMCPY_MAX_BYTES: u64 = 256; + +/// Width of one COPY row: eight bytes while at least eight remain, then one per +/// remaining byte. +/// +/// The AIR does not require this schedule. `single` is a free bit, so a one-byte row +/// is legal at any count and a prover may walk one-byte rows to reach eight-byte +/// alignment and keep the body on the cheaper `MEMW_A` table. This function simply +/// declines to. +/// +/// It used to do exactly that, splitting whenever `src` and `dst` shared a residue +/// mod 8. Measured on a real mainnet block, that cost more than it saved: prove time +/// 109.169s without the split against 111.780s with it, with row counts lower and +/// committed elements unchanged on three of four fixtures and lower on the fourth. +/// The reason is that the chip has only two widths. Aligning an end costs up to seven +/// one-byte rows at the head and shifts the tail into up to seven more, so buying two +/// or three `MEMW_A` rows costs a dozen rows on the wider `MEMW` table. A schedule +/// gated on a minimum body length could still win; the AIR already permits it, so it +/// can be added later without touching the constraint system or the proof format. +/// +/// `src`, `dst`, `offset` and `is_write` stay in the signature for that reason: every +/// consumer — the executor's row count, the trace builder, the sizing pass and the CLI +/// report — already routes through here, so a future schedule needs no new plumbing. +pub fn copy_row_width(src: u64, dst: u64, offset: u64, remaining: u64, is_write: bool) -> u8 { + if remaining < 8 { + return 1; + } + let _ = (src, dst, offset, is_write); + 8 +} + +/// Total COPY rows one ecall produces. +/// +/// There is no separate terminal row: the chain ends on the row that moves the last +/// bytes, which is the row where `count == step`. A zero-length call still needs one +/// row, the degenerate `(count, first) = (0, 1)` state the AIR admits. +/// +/// The width follows from `count` alone today, so this is `count / 8 + count % 8`, +/// floored at one. It is still written as a loop over `copy_row_width` because that +/// function is what the trace builder and the sizing pass call, and a future schedule +/// that reads the addresses would otherwise silently diverge from the rows actually +/// built. Every consumer that needs a row count goes through here for the same reason. +pub fn copy_trace_rows(src: u64, dst: u64, count: u64, is_write: bool) -> u64 { + let mut rows = 0; + let mut offset = 0u64; + let mut remaining = count; + while remaining != 0 { + let width = u64::from(copy_row_width(src, dst, offset, remaining, is_write)); + offset += width; + remaining -= width; + rows += 1; + } + rows.max(1) +} +/// Syscall number for `memset`, the same accelerator run with the read/write +/// timestamp order inverted. +/// +/// The spec uses ECALL number `-31`, i.e. `u64::MAX - 30 = 0xFFFF_FFFF_FFFF_FFE1`, +/// which the COPY table puts on the `Ecall` bus as +/// `[lo32, hi32] = [2^32 - 31, 2^32 - 1]`. Must match `syscalls/src/syscalls.rs`. +/// +/// COPY decodes the functionality by receiving the syscall number as a line in +/// `is_set` and `is_write`, so the three accepted numbers are exactly `64`, `-30` +/// and `-31` and nothing else is reachable. The bitness of the two flags is what +/// carries the decoding argument. +pub const DMA_MEMSET_SYSCALL_NUMBER: u64 = u64::MAX - 30; + +/// The one operand shape a DMA memset ecall may have: the destination trails the +/// source by exactly one wide row. +/// +/// This is not a convention, it is what makes the call sound. The accelerator runs +/// an `is_set` call with the write at `T` and the read at `T+1`, so a row's read +/// observes writes the same call already made — which is what propagates the seed. +/// That pins the copied value only while the read resolves to a *different*, +/// already-written address. With `dst == src` the read and the write address the +/// same cell at adjacent timestamps, the memory argument is satisfied by +/// `value == value`, and the eight value lanes become free field elements: a prover +/// could put anything it liked into RAM. The AIR therefore pins `dst = src + 8` on +/// every `is_set` row — as one `ADD`, so the relation holds across the 2^32 limb +/// boundary too — and this constant is what both sides import so the two bounds +/// cannot drift. +pub const DMA_MEMSET_GAP: u64 = 8; + /// Syscall number for the non-constraining `Hint` ecall. /// /// The host computes a modular inverse or square root and writes it back to the @@ -41,7 +165,10 @@ pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; /// verification failure. The ecall adds no in-circuit correctness constraint of its /// own — it lets the guest replace an expensive computation with a cheap check, /// without letting the (prover-chosen) hinted value change the guest's result. -pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 30; +/// ECALL number `-32`, i.e. `u64::MAX - 31`. The spec's ECALL list does not define +/// `hint`, so it takes the first number below the copy group (`-30` memcpy/memmove, +/// `-31` memset) rather than sitting between them. +pub const HINT_SYSCALL_NUMBER: u64 = u64::MAX - 31; /// Hint operation selector passed in `a0`. pub const HINT_FIELD_INV: u64 = 0; // secp256k1 base-field inverse (mod p) @@ -88,6 +215,8 @@ impl TryFrom for SyscallNumbers { 93 => Ok(SyscallNumbers::Halt), v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), + v if v == DMA_MEMCPY_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemcpy), + v if v == DMA_MEMSET_SYSCALL_NUMBER => Ok(SyscallNumbers::DmaMemset), v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } @@ -99,9 +228,26 @@ impl TryFrom for SyscallNumbers { pub enum Accelerator { Keccak, Ecsm, + Dma, } impl SyscallNumbers { + /// The raw `a7` value this syscall is invoked with. The accelerator numbers + /// exceed `isize::MAX`, so they can't be enum discriminants. + pub fn raw(self) -> u64 { + match self { + SyscallNumbers::KeccakPermute => KECCAK_SYSCALL_NUMBER, + SyscallNumbers::Ecsm => ECSM_SYSCALL_NUMBER, + SyscallNumbers::DmaMemcpy => DMA_MEMCPY_SYSCALL_NUMBER, + SyscallNumbers::DmaMemset => DMA_MEMSET_SYSCALL_NUMBER, + SyscallNumbers::Hint => HINT_SYSCALL_NUMBER, + SyscallNumbers::Print => SyscallNumbers::Print as u64, + SyscallNumbers::Panic => SyscallNumbers::Panic as u64, + SyscallNumbers::Commit => SyscallNumbers::Commit as u64, + SyscallNumbers::Halt => SyscallNumbers::Halt as u64, + } + } + /// The accelerator this syscall drives, if any. Exhaustive `match self`: /// adding a `SyscallNumbers` variant is a compile error here, so a new /// accelerator can't be silently missed by counters that consume this. @@ -109,6 +255,7 @@ impl SyscallNumbers { match self { SyscallNumbers::KeccakPermute => Some(Accelerator::Keccak), SyscallNumbers::Ecsm => Some(Accelerator::Ecsm), + SyscallNumbers::DmaMemcpy | SyscallNumbers::DmaMemset => Some(Accelerator::Dma), SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit @@ -478,7 +625,8 @@ impl Instruction { } SyscallNumbers::Commit => { // commit: write(fd, buf_addr, count) per POSIX convention - // x10 = fd (must be 1 for stdout) + // x10 = fd (must be 1 for stdout) on the way in, and the + // number of bytes written on the way out // x11 = buf_addr (buffer address in memory) // x12 = count (number of bytes to write) let fd = registers.read(10)?; @@ -488,6 +636,13 @@ impl Instruction { let buf_addr = registers.read(11)?; let count = registers.read(12)?; memory.commit_public_output(buf_addr, count)?; + // `write(2)` returns the byte count in A0, and `copy:c:read_fd` + // proves that write-back happened. Without it the executor's + // register file keeps `fd` where the AIR says `count` is, and + // the next instruction to read A0 without first writing it + // emits a register token COPY never produced — an honest + // program that the prover cannot prove. + registers.write(10, count)?; src2_val = buf_addr; dst_val = count; } @@ -550,6 +705,66 @@ impl Instruction { src2_val = addr_xg; dst_val = addr_k; } + SyscallNumbers::DmaMemcpy => { + // memcpy(dst = x10, src = x11, n = x12). Snapshot the input + // before writing, which also gives this ecall well-defined + // memmove semantics when the regions overlap. The COPY trace + // authenticates the same read-at-T/write-at-T+1 relation. + let dst = registers.read(10)?; + let src = registers.read(11)?; + let n = registers.read(12)?; + if n > DMA_MEMCPY_MAX_BYTES { + return Err(ExecutionError::DmaChunkTooLarge(n)); + } + dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + src.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + + // The fixed-size scratch avoids a heap allocation on every + // hot-path ecall while preserving snapshot semantics. + let mut bytes = [0u8; DMA_MEMCPY_MAX_BYTES as usize]; + for (i, byte) in bytes[..n as usize].iter_mut().enumerate() { + *byte = memory.load_byte(src + i as u64); + } + for (i, &byte) in bytes[..n as usize].iter().enumerate() { + memory.store_byte(dst + i as u64, byte); + } + src2_val = copy_trace_rows(src, dst, n, false); + dst_val = n; + } + SyscallNumbers::DmaMemset => { + // memset(dst = x10, src = x11, n = x12) — a *propagating* copy, + // not a fill. The stub seeds the first eight bytes with an + // ordinary store and calls with `dst = seed_end`, + // `src = seed_start`, so this is a plain overlapping memmove + // and only the timestamp order distinguishes it: the accelerator + // writes at T and reads at T+1, so each step observes the + // previous step's write and the seed propagates across the range. + // A forward byte walk is exactly that semantics. + let dst = registers.read(10)?; + let src = registers.read(11)?; + let n = registers.read(12)?; + if n > DMA_MEMCPY_MAX_BYTES { + return Err(ExecutionError::DmaChunkTooLarge(n)); + } + dst.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + src.checked_add(n).ok_or(MemoryError::AddressOverflow)?; + + // The operand contract, enforced unconditionally so that the + // executions this accepts are exactly the ones the AIR can + // prove. `copy:c:set_gap` is a full 64-bit `ADD`, carries and + // all, so a range whose low limb carries into the high one is + // provable and needs no guard of its own here. + if src.checked_add(DMA_MEMSET_GAP) != Some(dst) { + return Err(ExecutionError::DmaMemsetBadGap { src, dst }); + } + + for i in 0..n { + let byte = memory.load_byte(src + i); + memory.store_byte(dst + i, byte); + } + src2_val = copy_trace_rows(src, dst, n, false); + dst_val = n; + } SyscallNumbers::Hint => { // Non-constraining hint: host computes a modular inverse/sqrt // and writes it to the guest, which verifies it (and falls back @@ -766,6 +981,10 @@ pub enum ExecutionError { EcsmAddressOverflow, #[error("ECSM xG and k operand ranges overlap")] EcsmOperandOverlap, + #[error("DMA chunk has {0} bytes; maximum per ecall is {DMA_MEMCPY_MAX_BYTES}")] + DmaChunkTooLarge(u64), + #[error("DMA memset needs dst == src + {DMA_MEMSET_GAP}; got src {src:#x}, dst {dst:#x}")] + DmaMemsetBadGap { src: u64, dst: u64 }, #[error("Hint address range overflows the lower 32-bit limb")] HintAddressOverflow, #[error("Unknown hint selector: {0}")] diff --git a/executor/src/vm/logs.rs b/executor/src/vm/logs.rs index de6b73d0b..65ee268c0 100644 --- a/executor/src/vm/logs.rs +++ b/executor/src/vm/logs.rs @@ -9,8 +9,18 @@ /// For ECALL instructions, these fields are repurposed (since decode sets read_register1/2=false, /// write_register=false, so src/dst are unconstrained): /// - `src1_val` = syscall number (from x17): 64=Commit, 93=Halt, etc. -/// - `src2_val` = buf_addr (x11) for Commit, 0 otherwise -/// - `dst_val` = count (x12) for Commit, 0 otherwise +/// - `src2_val` = Commit: buf_addr (x11); Keccak: state_addr; ECSM: addr_xG; +/// Hint: input addr; DMA memcpy and DMA memset: the number of COPY rows the +/// call produces. 0 for every other syscall. +/// - `dst_val` = Commit: count (x12); ECSM: addr_k; Hint: output addr; +/// DMA memcpy and DMA memset: byte count. 0 for every other syscall, Keccak +/// included. +/// +/// The row count is carried rather than recomputed downstream so that trace sizing +/// and the CLI's accelerator report share one formula, `copy_trace_rows`, which the +/// executor evaluates where `src`, `dst` and `count` are all known. Today a row's +/// width follows from the remaining count alone (eight bytes, or one for the last +/// seven), so the CLI report just sums the carried counts. #[derive(Debug, Clone)] pub struct Log { /// PC before instruction execution (use this to look up the instruction) @@ -21,9 +31,9 @@ pub struct Log { /// For ECALL: syscall number from x17. pub src1_val: u64, /// Value of src2 register before execution (if used by the instruction). - /// For ECALL Commit: buf_addr from x11. + /// For ECALL: see the per-syscall table above. pub src2_val: u64, /// Value of dst register after execution (if used by the instruction). - /// For ECALL Commit: count from x12. + /// For ECALL: see the per-syscall table above. pub dst_val: u64, } diff --git a/executor/src/vm/memory.rs b/executor/src/vm/memory.rs index e1a269a01..b5e6d72cf 100644 --- a/executor/src/vm/memory.rs +++ b/executor/src/vm/memory.rs @@ -39,7 +39,7 @@ impl BuildHasher for U64BuildHasher { pub type U64HashMap = HashMap; /// Total cap on public output bytes across all `commit_public_output` calls. -/// The COMMIT AIR concatenates calls via the running `x254` index, so this +/// The COPY chip's commit rows concatenate calls via the running `x254` index, so this /// is enforced as a running-total budget rather than a per-call limit. pub const MAX_PUBLIC_OUTPUT_TOTAL_SIZE: u64 = 1024 * 1024; /// Maximum size of the private input memory region (in bytes). 512 MiB so a @@ -60,7 +60,7 @@ pub const PRIVATE_INPUT_LENGTH_PREFIX_BYTES: usize = size_of::(); pub struct Memory { cells: U64HashMap<[u8; 4]>, /// Bytes committed to public output via `commit_public_output`. The - /// COMMIT AIR doesn't write to a fixed memory region (it streams bytes + /// COPY chip's commit rows don't write to a fixed memory region (it streams bytes /// onto the Commit bus by `index`), so this buffer is purely the /// executor's view used by `read_return_value` and CLI display. public_output: Vec, @@ -202,8 +202,8 @@ impl Memory { } /// Append `length` bytes from guest memory starting at `address` to the - /// public output. The COMMIT AIR concatenates calls via the running - /// `x254` index, and the trace builder accumulates `commit_ops` into + /// public output. The COPY chip's commit rows concatenate calls via the + /// running `x254` index, and the trace builder collects their bytes into /// `VmProof.public_output`; this method maintains the executor's view /// of the same byte stream so `read_return_value` matches. pub fn commit_public_output(&mut self, address: u64, length: u64) -> Result<(), MemoryError> { diff --git a/executor/tests/rust.rs b/executor/tests/rust.rs index 1c13ad1a5..597cb6b1c 100644 --- a/executor/tests/rust.rs +++ b/executor/tests/rust.rs @@ -1,6 +1,10 @@ use executor::{ elf::Elf, - vm::execution::{Executor, ReturnValues}, + vm::execution::{ExecutionResult, Executor, ReturnValues}, + vm::instruction::{ + decoding::Instruction, + execution::{DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER}, + }, }; // NOTE: These tests require 64-bit RISC-V ELF files (RV64IM). @@ -117,6 +121,112 @@ fn test_vector() { ); } +fn run_guest(path: &str) -> ExecutionResult { + let elf_data = std::fs::read(path).unwrap(); + let program = Elf::load(&elf_data).unwrap(); + Executor::new(&program, vec![]).unwrap().run().unwrap() +} + +/// DMA ecalls the guest actually executed. Zero means the copies were served by +/// `compiler_builtins` rather than by the accelerated `memcpy`. +fn dma_ecall_count(result: &ExecutionResult) -> usize { + result + .logs + .iter() + .filter(|log| { + log.src1_val == DMA_MEMCPY_SYSCALL_NUMBER + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }) + .count() +} + +#[test] +fn test_dma_memcpy() { + let result = run_guest("./program_artifacts/rust/dma_memcpy_min.elf"); + + assert_eq!( + result.return_values.memory_values, + b"DMA copies eight-byte rows and a short tail" + ); + assert!( + dma_ecall_count(&result) > 0, + "the strong memcpy symbol must execute at least one DMA ecall" + ); +} + +#[test] +fn test_dma_memcpy_cases() { + run_program_and_check_public_output( + "./program_artifacts/rust/dma_memcpy_cases.elf", + b"dma-cases-ok".to_vec(), + vec![], + ); +} + +/// The guests above declare `memcpy` themselves, which leaves the symbol +/// undefined in their objects and forces the linker to resolve it. This guest +/// never names `memcpy`: its copies are the ones the compiler emits, which is +/// the case that silently degrades if the strong definition ever stops winning +/// symbol resolution — the guest keeps producing the right output and only the +/// ecall count drops to zero. +#[test] +fn test_dma_memcpy_compiler_emitted_copies() { + let result = run_guest("./program_artifacts/rust/dma_memcpy_implicit.elf"); + + assert_eq!(result.return_values.memory_values, b"dma-implicit-ok"); + assert!( + dma_ecall_count(&result) > 0, + "compiler-emitted copies must reach the DMA ecall; a zero count means the \ + guest fell back to the weak compiler_builtins memcpy" + ); +} + +/// `memmove` shares the copy ecall with `memcpy`, so the only thing distinguishing it +/// is the stub's backward chunking when the ranges overlap with `dst` above `src`. +/// The guest walks both directions across the 256-byte chunk boundary; without this +/// the symbol was exercised only in `prove_elfs_tests`, where a failure reads as a +/// proving bug rather than an execution one. +#[test] +fn test_dma_memmove_cases() { + let elf_data = std::fs::read("./program_artifacts/rust/dma_memmove_cases.elf").unwrap(); + let program = Elf::load(&elf_data).unwrap(); + let result = Executor::new(&program, vec![]).unwrap().run().unwrap(); + + assert_eq!(result.return_values.memory_values, b"dma-memmove-ok"); + assert!( + result.logs.iter().any(|log| { + log.src1_val == DMA_MEMCPY_SYSCALL_NUMBER + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }), + "the strong memmove symbol must execute at least one copy ecall" + ); +} + +#[test] +fn test_dma_memset_cases() { + let elf_data = std::fs::read("./program_artifacts/rust/dma_memset_cases.elf").unwrap(); + let program = Elf::load(&elf_data).unwrap(); + let result = Executor::new(&program, vec![]).unwrap().run().unwrap(); + + assert_eq!(result.return_values.memory_values, b"dma-memset-ok"); + assert!( + result.logs.iter().any(|log| { + log.src1_val == DMA_MEMSET_SYSCALL_NUMBER + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }), + "the strong memset symbol must execute at least one DMA ecall" + ); +} + #[test] fn test_hashmap() { run_program_and_check_output("./program_artifacts/rust/hashmap.elf", 3, vec![]); @@ -164,7 +274,7 @@ fn test_commit() { fn test_ef_io_demo_concatenates_writes() { // Demo guest reads its private input via EF `read_input`, then emits it // back as the public output via TWO `write_output` calls (split in halves). - // The COMMIT AIR concatenates the two calls; the executor's + // The COPY chip's commit rows concatenate the two calls; the executor's // `commit_public_output` appends in the same order. let input: Vec = b"hello world!".to_vec(); run_program_and_check_public_output( diff --git a/prover/src/auto_storage.rs b/prover/src/auto_storage.rs index 8cc437edd..7fa0ffd15 100644 --- a/prover/src/auto_storage.rs +++ b/prover/src/auto_storage.rs @@ -7,7 +7,7 @@ use crate::tables::bitwise::{ NUM_ROWS as BITWISE_ROWS, bus_interactions as bitwise_buses, cols::NUM_COLUMNS as BITWISE_COLS, }; use crate::tables::branch::{bus_interactions as branch_buses, cols::NUM_COLUMNS as BRANCH_COLS}; -use crate::tables::commit::{bus_interactions as commit_buses, cols::NUM_COLUMNS as COMMIT_COLS}; +use crate::tables::copy::{bus_interactions as copy_buses, cols::NUM_COLUMNS as COPY_COLS}; use crate::tables::cpu::{bus_interactions as cpu_buses, cols::NUM_COLUMNS as CPU_COLS}; use crate::tables::decode::{bus_interactions as decode_buses, cols::NUM_COLUMNS as DECODE_COLS}; use crate::tables::dvrm::{bus_interactions as dvrm_buses, cols::NUM_COLUMNS as DVRM_COLS}; @@ -178,9 +178,9 @@ fn table_specs(lengths: &TableLengths) -> Vec { 1, ), ( - lengths.commit_padded_rows, - COMMIT_COLS as u64, - aux_cols(commit_buses().len()), + lengths.copy_padded_rows, + COPY_COLS as u64, + aux_cols(copy_buses().len()), 1, ), // BITWISE / DECODE / PAGE / REGISTER take the preprocessed-trace commit diff --git a/prover/src/constraints/templates.rs b/prover/src/constraints/templates.rs index 04932eab8..d911cdf92 100644 --- a/prover/src/constraints/templates.rs +++ b/prover/src/constraints/templates.rs @@ -322,6 +322,26 @@ fn add_operand_hi>( } } +/// The high carry of an `emit_add_pair` relation as a bare expression: `1` exactly +/// when `lhs + rhs` overflows 64 bits, `0` otherwise, given the same operands the +/// `ADD` itself was emitted with. +/// +/// `emit_add_pair` only range-checks that carry as a bit, which is what the spec's +/// `ADD` template does and which leaves wraparound provable. A caller that must +/// forbid the wraparound multiplies this by its gate and emits the product as a +/// constraint of its own, the way `ADDNW` used to. +pub fn add_pair_carry_hi>( + b: &B, + lhs: &AddOperand, + rhs: &AddOperand, + sum: &AddOperand, +) -> B::Expr { + let inv_2_32 = b.const_base(INV_SHIFT_32); + let carry_0 = (add_operand_lo(b, lhs) + add_operand_lo(b, rhs) - add_operand_lo(b, sum)) + * inv_2_32.clone(); + (add_operand_hi(b, lhs) + add_operand_hi(b, rhs) + carry_0 - add_operand_hi(b, sum)) * inv_2_32 +} + /// The ADD carry pair, emitted from ONE body at `idx` and `idx + 1`: /// /// ```text diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 5b16fcd29..ccee7f0f1 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -39,7 +39,7 @@ //! //! The x254 commit index is carried across epochs by that same register binding, //! so a continuation epoch indexes its commits from the carried value: both the -//! COMMIT trace (`current_commit_index` seeded from x254) and the verifier's +//! COPY trace (`current_commit_index` seeded from x254) and the verifier's //! `compute_commit_bus_offset` (a `start_index` parameter) count from it, and the //! driver concatenates each epoch's committed bytes into the run-wide output. //! @@ -1904,7 +1904,7 @@ mod tests { // `test_commit_split` issues two Commit syscalls, one early and one late, so a // small epoch puts the second commit in a later epoch. That epoch starts with // x254 > 0 (the carried commit index), which exercises the cross-epoch commit - // indexing: both the COMMIT trace and the verifier's `compute_commit_bus_offset` + // indexing: both the COPY trace and the verifier's `compute_commit_bus_offset` // index from the carried x254 rather than 0. Regression test for that fix. #[test] fn test_commit_across_epochs_verifies() { @@ -2013,6 +2013,34 @@ mod tests { ); } + #[test] + fn test_dma_memcpy_across_continuation_epochs() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = std::fs::read( + workspace_root.join("executor/program_artifacts/rust/dma_memcpy_min.elf"), + ) + .expect("dma_memcpy_min.elf not found — build its make target"); + let opts = ProofOptions::default_test_options(); + + let bundle = prove_continuation(&elf_bytes, &[], 6, &opts) + .expect("DMA continuation proof generation"); + assert!( + bundle.num_epochs() > 1, + "64-cycle epochs must split the DMA guest" + ); + + let output = verify_continuation(&elf_bytes, &bundle, &opts) + .expect("DMA continuation verification") + .expect("honest DMA continuation must verify"); + assert_eq!( + output, b"DMA copies eight-byte rows and a short tail", + "continuation output must match the copied bytes" + ); + } + /// Each epoch drops the chips it never reaches, and it decides that on its /// own: a table missing from one epoch still shows up in another that does /// use it. The skip is not a property of the run, it is a property of the @@ -2057,7 +2085,6 @@ mod tests { ("ecsm", c.ecsm), ("ecdas", c.ecdas), ("hint", c.hint), - ("commit", c.commit), ] }) .collect(); diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 2a1772d0d..c1b0cec51 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -51,7 +51,7 @@ use crate::tables::trace_builder::Traces; use crate::tables::trace_builder::count_table_lengths; use crate::tables::types::BusId; use crate::test_utils::{ - E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, + E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_copy_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, @@ -81,18 +81,19 @@ pub struct RuntimePageRange { } /// Number of tables that contribute exactly one sub-proof regardless of -/// `TableCounts`: bitwise, decode, halt, keccak_rc, register. The accelerator -/// chips are counted instead — a run that never calls one carries no table for -/// it. +/// `TableCounts`: bitwise, decode, halt, keccak_rc, register, copy. The other +/// accelerator chips are counted instead — a run that never calls one carries no +/// table for it. COPY stays fixed because it receives the `write` ECALL itself, so +/// any run that produces output reaches it. /// /// HALT is the exception, and the reason this is not simply "always": a /// continuation epoch carries it only when it is the final one, so `verify_epoch` /// sizes non-final epochs with `FIXED_TABLE_COUNT - 1`. Any caller computing an /// expected sub-proof count for a continuation epoch must do the same. -pub const FIXED_TABLE_COUNT: usize = 5; +pub const FIXED_TABLE_COUNT: usize = 6; /// How many sub-proofs each counted table contributes. Chunked chips report -/// their chunk count; the six accelerators are not chunked and report 0 or 1 +/// their chunk count; the five accelerators are not chunked and report 0 or 1 /// (see `validate`). The verifier needs this to reconstruct matching AIRs. #[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub struct TableCounts { @@ -119,7 +120,6 @@ pub struct TableCounts { pub ecsm: usize, pub ecdas: usize, pub hint: usize, - pub commit: usize, } impl TableCounts { @@ -151,7 +151,6 @@ impl TableCounts { self.ecsm, self.ecdas, self.hint, - self.commit, ] .into_iter() .try_fold(0usize, usize::checked_add) @@ -211,7 +210,6 @@ impl TableCounts { ("ecsm", self.ecsm), ("ecdas", self.ecdas), ("hint", self.hint), - ("commit", self.commit), ]; for (name, count) in at_most_one { if count > 1 { @@ -276,12 +274,14 @@ pub struct GuestInput { /// 4-byte magic identifying a lambda-vm recursion input blob ("LVMR"). pub const RECURSION_INPUT_MAGIC: [u8; 4] = *b"LVMR"; -/// Wire-format version of the recursion input blob. v3: `TableCounts` gained the -/// six accelerator fields, which moves every field after it in the archive. v2: -/// rkyv pointer_width_64 (64-bit rel-ptrs) — v1 archives use 32-bit offsets. +/// Wire-format version of the recursion input blob. v4: `TableCounts` lost its +/// `commit` field when COPY absorbed the COMMIT table, which shortens the +/// archive. v3: `TableCounts` gained the six accelerator fields, which moves +/// every field after it in the archive. v2: rkyv pointer_width_64 (64-bit +/// rel-ptrs) — v1 archives use 32-bit offsets. /// Any change to a type reachable from `GuestInput` belongs here: the guest /// reads the archive in place, so a stale blob is misread rather than rejected. -pub const RECURSION_INPUT_VERSION: u32 = 3; +pub const RECURSION_INPUT_VERSION: u32 = 4; /// Required alignment (bytes) of the archive's first byte in guest memory. pub const RECURSION_INPUT_ALIGN: usize = 16; @@ -586,13 +586,13 @@ pub(crate) struct VmAirs { pub dvrms: Vec, pub branches: Vec, pub halt: VmAir, - pub commits: Vec, pub keccaks: Vec, pub keccak_rnds: Vec, pub keccak_rc: VmAir, pub ecsms: Vec, pub ecdases: Vec, pub hints: Vec, + pub copy: VmAir, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -624,7 +624,6 @@ impl VmAirs { self.muls.len(), self.dvrms.len(), self.branches.len(), - self.commits.len(), self.keccaks.len(), self.keccak_rnds.len(), self.ecsms.len(), @@ -647,7 +646,6 @@ impl VmAirs { traces.muls.len(), traces.dvrms.len(), traces.branches.len(), - traces.commits.len(), traces.keccaks.len(), traces.keccak_rnds.len(), traces.ecsms.len(), @@ -666,14 +664,12 @@ impl VmAirs { (self.bitwise.as_ref(), &mut traces.bitwise, &()), (self.decode.as_ref(), &mut traces.decode, &()), (self.keccak_rc.as_ref(), &mut traces.keccak_rc, &()), + (self.copy.as_ref(), &mut traces.copy, &()), (self.register.as_ref(), &mut traces.register, &()), ]; if self.include_halt { pairs.push((self.halt.as_ref(), &mut traces.halt, &())); } - for (air, trace) in self.commits.iter().zip(traces.commits.iter_mut()) { - pairs.push((air.as_ref(), trace, &())); - } for (air, trace) in self.keccaks.iter().zip(traces.keccaks.iter_mut()) { pairs.push((air.as_ref(), trace, &())); } @@ -753,14 +749,12 @@ impl VmAirs { self.bitwise.as_ref(), self.decode.as_ref(), self.keccak_rc.as_ref(), + self.copy.as_ref(), self.register.as_ref(), ]; if self.include_halt { refs.push(self.halt.as_ref()); } - for air in &self.commits { - refs.push(air.as_ref()); - } for air in &self.keccaks { refs.push(air.as_ref()); } @@ -933,12 +927,6 @@ impl VmAirs { }) .collect(); let halt: VmAir = Box::new(create_halt_air(proof_options)); - let commits: Vec<_> = (0..table_counts.commit) - .map(|i| { - Box::new(create_commit_air(proof_options).with_name(&format!("COMMIT[{i}]"))) - as VmAir - }) - .collect(); let keccaks: Vec<_> = (0..table_counts.keccak) .map(|i| { Box::new(create_keccak_air(proof_options).with_name(&format!("KECCAK[{i}]"))) @@ -971,6 +959,7 @@ impl VmAirs { Box::new(create_hint_air(proof_options).with_name(&format!("HINT[{i}]"))) as VmAir }) .collect(); + let copy: VmAir = Box::new(create_copy_air(proof_options)); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -1085,13 +1074,13 @@ impl VmAirs { dvrms, branches, halt, - commits, keccaks, keccak_rnds, keccak_rc, ecsms, ecdases, hints, + copy, register, pages, memw_registers, @@ -1110,6 +1099,11 @@ impl VmAirs { /// Compute the bus balance offset for the COMMIT[index, value] bus. /// +/// The COPY chip commits eight bytes per row but sends them as eight +/// `(index, value)` pairs, one per byte, so this rebuild is independent of the +/// prover's row schedule — which it has to be: the schedule restarts at every +/// commit ECALL and the verifier sees only the concatenated `public_output`. +/// /// For each public output byte at index `i` with value `v`: /// `fingerprint = z - (BusId::Commit * α^0 + i * α^1 + v * α^2)` /// `term = +1 / fingerprint` diff --git a/prover/src/statement.rs b/prover/src/statement.rs index edac11b90..fdc86a7b4 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -117,7 +117,6 @@ pub(crate) fn absorb_statement_with_digest( ecsm, ecdas, hint, - commit, } = table_counts; for count in [ cpu, @@ -139,7 +138,6 @@ pub(crate) fn absorb_statement_with_digest( ecsm, ecdas, hint, - commit, ] { t.append_bytes(&(count as u64).to_le_bytes()); } diff --git a/prover/src/tables/commit.rs b/prover/src/tables/commit.rs deleted file mode 100644 index 65e74f182..000000000 --- a/prover/src/tables/commit.rs +++ /dev/null @@ -1,772 +0,0 @@ -//! COMMIT (ECALL) table for writing bytes to stdout. -//! -//! This table handles the `write` syscall (ECALL #64): writing bytes from a memory -//! buffer to stdout. It uses a **recursive design** — each row commits one byte, -//! and rows are linked via a self-referencing "CommitNextByte" bus. -//! -//! Only the first row of each commit sequence receives from the CPU's ECALL bus; -//! subsequent rows receive from the previous commit row via the CommitNextByte bus. -//! -//! ## Columns (19 total) -//! - `timestamp`: DWordWL (2 cols) — timestamp of the ECALL -//! - `index`: BaseField (1 col) — global byte index for this committed value -//! - `address`: DWordWL (2 cols) — current buffer address -//! - `address_incr`: DWordHL (4 cols) — address + 1, as 4 halfwords -//! - `count`: DWordWL (2 cols) — remaining byte count -//! - `count_decr`: DWordHL (4 cols) — count - 1 as 4 halfwords (or all 0xFFFF when count=0) -//! - `first`: Bit — first row in a commit sequence -//! - `end`: Bit — last row (count was 0) -//! - `value`: Byte — the byte being committed -//! - `mu`: Bit — multiplicity (1 for real rows, 0 for padding) -//! -//! ## Bus Interactions (18 total) -//! - **Receiver**: Ecall bus — receives `[timestamp_lo, timestamp_hi, constant(64), constant(0)]` from CPU (mult = first) -//! - **Sender**: CommitNextByte bus — sends to next row (mult = mu - end) -//! - **Receiver**: CommitNextByte bus — receives from prev row (mult = mu - first) -//! - **Sender**: IsHalfword bus — range checks for count_decr halfwords (×4, mult = mu) -//! - **Sender**: IsHalfword bus — range checks for address_incr halfwords (×4, mult = mu) -//! - **Sender**: Zero bus — end detection via count_decr (mult = mu) -//! - **Sender**: Memw bus — read+write x10 register (fd=1→count) at ts (mult = first) -//! - **Sender**: Memw bus — read x11 register (buf_addr) at ts (mult = first) -//! - **Sender**: Memw bus — read x12 register (count) at ts (mult = first) -//! - **Sender**: Memw bus — read+write x254 commit index at ts (mult = first) -//! - **Sender**: Memw bus — read memory byte at ts (mult = mu - end) -//! - **Sender**: Commit bus — sends committed `(index, value)` pairs (mult = mu - end) -//! -//! ## Constraints (8 total) -//! - `range_first`: first * (1 - first) = 0 (degree 2) -//! - `range_end`: end * (1 - end) = 0 (degree 2) -//! - `range_mu`: mu * (1 - mu) = 0 (degree 2) -//! - `first_or_end_implies_mu`: (first + end) * (1 - mu) = 0 (degree 2) -//! - `address_incr_carry_0`: ADD template carry_0 for address + 1 = address_incr (degree 2) -//! - `address_incr_carry_1`: ADD template carry_1 for address + 1 = address_incr (degree 2) -//! - `count_decr_carry_0`: SUB template carry_0 for count_decr + 1 = count (degree 2) -//! - `count_decr_carry_1`: SUB template carry_1 for count_decr + 1 = count (degree 2) -//! -use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; -use stark::trace::TraceTable; - -use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; - -use crate::constraints::templates::{AddOperand, emit_add_pair, emit_is_bit}; - -use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; - -// ========================================================================= -// Column indices for COMMIT table -// ========================================================================= - -/// Column definitions for the COMMIT table. -/// -/// Layout follows the spec order: timestamp, index, address, address_incr, -/// count, count_decr, first, end, value, mu. -pub mod cols { - // Timestamp (DWordWL: 2 cols) - /// timestamp[0]: low 32 bits - pub const TIMESTAMP_0: usize = 0; - /// timestamp[1]: high 32 bits - pub const TIMESTAMP_1: usize = 1; - - // Commit index (BaseField: 1 col) - /// index: global byte index of the committed value - pub const INDEX: usize = 2; - - // Buffer address (DWordWL: 2 cols) - /// address[0]: low 32 bits - pub const ADDRESS_0: usize = 3; - /// address[1]: high 32 bits - pub const ADDRESS_1: usize = 4; - - // address + 1 (DWordHL: 4 halfword cols) - /// address_incr[0]: halfword 0 (bits 0-15) - pub const ADDRESS_INCR_0: usize = 5; - /// address_incr[1]: halfword 1 (bits 16-31) - pub const ADDRESS_INCR_1: usize = 6; - /// address_incr[2]: halfword 2 (bits 32-47) - pub const ADDRESS_INCR_2: usize = 7; - /// address_incr[3]: halfword 3 (bits 48-63) - pub const ADDRESS_INCR_3: usize = 8; - - // Remaining byte count (DWordWL: 2 cols) - /// count[0]: low 32 bits - pub const COUNT_0: usize = 9; - /// count[1]: high 32 bits - pub const COUNT_1: usize = 10; - - // count - 1 (DWordHL: 4 halfword cols) - // When count > 0: count_decr = count - 1 - // When count = 0: count_decr = 0xFFFF_FFFF_FFFF_FFFF (all halfwords = 0xFFFF) - /// count_decr[0]: halfword 0 (bits 0-15) - pub const COUNT_DECR_0: usize = 11; - /// count_decr[1]: halfword 1 (bits 16-31) - pub const COUNT_DECR_1: usize = 12; - /// count_decr[2]: halfword 2 (bits 32-47) - pub const COUNT_DECR_2: usize = 13; - /// count_decr[3]: halfword 3 (bits 48-63) - pub const COUNT_DECR_3: usize = 14; - - // Control bits - /// first: 1 if this is the first row of a commit sequence - pub const FIRST: usize = 15; - /// end: 1 if this is the last row (count was 0) - pub const END: usize = 16; - - // Byte value being committed - /// value: the byte [0, 256) being committed at this row - pub const VALUE: usize = 17; - - /// mu: multiplicity bit (1 for real rows, 0 for padding) - pub const MU: usize = 18; - - /// Total number of columns - pub const NUM_COLUMNS: usize = 19; -} - -// ========================================================================= -// Operation type -// ========================================================================= - -/// A single row in the COMMIT table. -/// -/// Each row represents one byte being committed from a buffer. Rows are linked -/// via the CommitNextByte bus to form a chain for each commit ECALL. -#[derive(Debug, Clone)] -pub struct CommitOperation { - /// Timestamp of the originating ECALL - pub timestamp: u64, - /// Global commit index for this byte - pub index: u64, - /// Current buffer address for this byte - pub address: u64, - /// Remaining byte count (including this byte, 0 on end row) - pub count: u64, - /// Whether this is the first row of a commit sequence - pub first: bool, - /// Whether this is the end row (count was 0, no byte committed) - pub end: bool, - /// The byte value being committed (0 on end row) - pub value: u8, -} - -// ========================================================================= -// Trace generation -// ========================================================================= - -/// Generates the COMMIT trace table from a list of operations. -/// -/// Each operation becomes one row. The table is padded to the next power of 2 (min 4). -/// Padding rows use spec-defined values: count=1, address_incr=[1,0,0,0] to satisfy -/// the unconditional ADD/SUB template constraints. -pub fn generate_commit_trace( - ops: &[CommitOperation], -) -> TraceTable { - let n = ops.len(); - let num_rows = n.next_power_of_two().max(4); - let mut trace = TraceTable::new_main( - crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), - cols::NUM_COLUMNS, - 1, - ); - let table = &mut trace.main_table; - - for (row_idx, op) in ops.iter().enumerate() { - // Timestamp (DWordWL) - table.set_dword_wl(row_idx, cols::TIMESTAMP_0, op.timestamp); - - // Index (BaseField) - table.set_u64(row_idx, cols::INDEX, op.index); - - // Address (DWordWL) - table.set_dword_wl(row_idx, cols::ADDRESS_0, op.address); - - // address_incr = address + 1 (DWordHL: 4 halfwords) - let address_incr = op.address.wrapping_add(1); - table.set_dword_hl(row_idx, cols::ADDRESS_INCR_0, address_incr); - - // Count (DWordWL) - table.set_dword_wl(row_idx, cols::COUNT_0, op.count); - - // count_decr: if count == 0, use 0xFFFF_FFFF_FFFF_FFFF; else count - 1 - let count_decr = if op.count == 0 { - u64::MAX - } else { - op.count - 1 - }; - table.set_dword_hl(row_idx, cols::COUNT_DECR_0, count_decr); - - // Control bits - table.set_bool(row_idx, cols::FIRST, op.first); - table.set_bool(row_idx, cols::END, op.end); - - // Value - table.set_byte(row_idx, cols::VALUE, op.value); - - // mu = 1 for all real rows (first, middle, and end rows) - table.set_fe(row_idx, cols::MU, FE::one()); - } - - // Padding rows: spec requires count=1 and address_incr=[1,0,0,0] so - // the unconditional ADD/SUB templates have valid carry values. - // count=1 → count_decr=0 (all halfwords zero), address=0 → address_incr=1. - for row_idx in n..num_rows { - // count = 1 (low word) - table.set_fe(row_idx, cols::COUNT_0, FE::one()); - // address_incr halfword 0 = 1 (address=0, so address+1 = 1) - table.set_fe(row_idx, cols::ADDRESS_INCR_0, FE::one()); - // All other fields remain zero: timestamp=0, address=0, count_1=0, - // count_decr=[0,0,0,0], first=0, end=0, value=0, mu=0, - // address_incr_1..3=0 - } - - trace -} - -// ========================================================================= -// Bus interactions -// ========================================================================= - -/// Creates all bus interactions for the COMMIT table (18 total). -/// -/// The COMMIT table: -/// - **Receives** Ecall from CPU with `[timestamp_lo, timestamp_hi, constant(64), constant(0)]` (mult = first) -/// - **Sends** to CommitNextByte with `[timestamp, index + 1, address_incr, count_decr]` (mult = mu - end) -/// - **Receives** from CommitNextByte with `[timestamp, index, address, count]` (mult = mu - first) -/// - **Sends** to IsHalfword for count_decr range checks (×4, mult = mu) -/// - **Sends** to IsHalfword for address_incr range checks (×4, mult = mu) -/// - **Sends** to Zero for end detection (mult = mu) -/// - **Sends** to Memw for register/memory accesses (×5, mult varies) -pub fn bus_interactions() -> Vec { - // Reusable multiplicity expressions - let mu_minus_end = Multiplicity::Diff(cols::MU, cols::END); - let mu_minus_first = Multiplicity::Diff(cols::MU, cols::FIRST); - - vec![ - // 1. Receive ECALL from CPU (mult = first) - // Payload: [timestamp_lo, timestamp_hi, syscall_lo32, syscall_hi32] - BusInteraction::receiver( - BusId::Ecall, - Multiplicity::Column(cols::FIRST), - vec![ - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - BusValue::constant(64), // syscall number lo32 = Commit (64) - BusValue::constant(0), // syscall number hi32 = 0 - ], - ), - // 2. Send to CommitNextByte (mult = mu - end) - // Sends: [timestamp, index + 1, address_incr(as DWordWL), count_decr(as DWordWL)] - BusInteraction::sender( - BusId::CommitNextByte, - mu_minus_end.clone(), - vec![ - // timestamp (DWordWL: 2 Direct elements) - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // index + 1 (BaseField) - BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::INDEX, - }, - LinearTerm::Constant(1), - ]), - // address_incr (DWordHL → 2 bus elements via DWordHL packing) - BusValue::Packed { - start_column: cols::ADDRESS_INCR_0, - packing: Packing::DWordHL, - }, - // count_decr (DWordHL → 2 bus elements via DWordHL packing) - BusValue::Packed { - start_column: cols::COUNT_DECR_0, - packing: Packing::DWordHL, - }, - ], - ), - // 3. Receive from CommitNextByte (mult = mu - first) - // Receives: [timestamp, index, address, count] - BusInteraction::receiver( - BusId::CommitNextByte, - mu_minus_first, - vec![ - // timestamp (DWordWL) - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // index (BaseField) - BusValue::Packed { - start_column: cols::INDEX, - packing: Packing::Direct, - }, - // address (DWordWL) - BusValue::Packed { - start_column: cols::ADDRESS_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::ADDRESS_1, - packing: Packing::Direct, - }, - // count (DWordWL → 2 bus elements) - BusValue::Packed { - start_column: cols::COUNT_0, - packing: Packing::DWordWL, - }, - ], - ), - // 4-7. IsHalfword for count_decr (×4, mult = mu) - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::COUNT_DECR_0, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::COUNT_DECR_1, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::COUNT_DECR_2, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::COUNT_DECR_3, - packing: Packing::Direct, - }], - ), - // 8-11. IsHalfword for address_incr (×4, mult = mu) - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::ADDRESS_INCR_0, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::ADDRESS_INCR_1, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::ADDRESS_INCR_2, - packing: Packing::Direct, - }], - ), - BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::ADDRESS_INCR_3, - packing: Packing::Direct, - }], - ), - // 12. ZERO bus for end detection (mult = mu) - // Input: (65535 - cd_0) + (65535 - cd_1) + (65535 - cd_2) + (65535 - cd_3) - // Output: end (1 when all count_decr halfwords are 0xFFFF, i.e., count was 0) - BusInteraction::sender( - BusId::Zero, - Multiplicity::Column(cols::MU), - vec![ - BusValue::linear(vec![ - LinearTerm::Constant(4 * 65535), - LinearTerm::Column { - coefficient: -1, - column: cols::COUNT_DECR_0, - }, - LinearTerm::Column { - coefficient: -1, - column: cols::COUNT_DECR_1, - }, - LinearTerm::Column { - coefficient: -1, - column: cols::COUNT_DECR_2, - }, - LinearTerm::Column { - coefficient: -1, - column: cols::COUNT_DECR_3, - }, - ]), - BusValue::Packed { - start_column: cols::END, - packing: Packing::Direct, - }, - ], - ), - // 13. MEMW read+write x10 (fd=1 → count) at ts (mult = first) - // CO24 format: [old[8], is_register, base_addr[2], value[8], ts[2], w2, w4, w8] - // old = [1,0,...,0] (asserts x10=1=fd), value = [count_0, count_1, 0,...,0] (writes count) - BusInteraction::sender( - BusId::Memw, - Multiplicity::Column(cols::FIRST), - vec![ - // old[0..7] = [1, 0, 0, 0, 0, 0, 0, 0] - BusValue::constant(1), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // is_register = 1 - BusValue::constant(1), - // base_address = [20, 0] (x10 → addr 2*10 = 20) - BusValue::constant(20), - BusValue::constant(0), - // value[0..7] = [COUNT_0, COUNT_1, 0, 0, 0, 0, 0, 0] - BusValue::Packed { - start_column: cols::COUNT_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::COUNT_1, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // timestamp = [TIMESTAMP_0, TIMESTAMP_1] - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // w2=1, w4=0, w8=0 (register = 2 words) - BusValue::constant(1), - BusValue::constant(0), - BusValue::constant(0), - ], - ), - // 14. MEMW read x11 (buf_addr) at ts (mult = first) - BusInteraction::sender( - BusId::Memw, - Multiplicity::Column(cols::FIRST), - vec![ - // old[0..7] = [ADDRESS_0, ADDRESS_1, 0, 0, 0, 0, 0, 0] - BusValue::Packed { - start_column: cols::ADDRESS_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::ADDRESS_1, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // is_register = 1 - BusValue::constant(1), - // base_address = [22, 0] (x11 → addr 2*11 = 22) - BusValue::constant(22), - BusValue::constant(0), - // value[0..7] = same as old (read) - BusValue::Packed { - start_column: cols::ADDRESS_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::ADDRESS_1, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // timestamp = [TIMESTAMP_0, TIMESTAMP_1] - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // w2=1, w4=0, w8=0 - BusValue::constant(1), - BusValue::constant(0), - BusValue::constant(0), - ], - ), - // 15. MEMW read x12 (count) at ts (mult = first) - BusInteraction::sender( - BusId::Memw, - Multiplicity::Column(cols::FIRST), - vec![ - // old[0..7] = [COUNT_0, COUNT_1, 0, 0, 0, 0, 0, 0] - BusValue::Packed { - start_column: cols::COUNT_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::COUNT_1, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // is_register = 1 - BusValue::constant(1), - // base_address = [24, 0] (x12 → addr 2*12 = 24) - BusValue::constant(24), - BusValue::constant(0), - // value[0..7] = same as old (read) - BusValue::Packed { - start_column: cols::COUNT_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::COUNT_1, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // timestamp = [TIMESTAMP_0, TIMESTAMP_1] - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // w2=1, w4=0, w8=0 - BusValue::constant(1), - BusValue::constant(0), - BusValue::constant(0), - ], - ), - // 16. MEMW x254 read+write commit index at ts (mult = first) - // Single-word synthetic register per spec: width=1, base address 508. - BusInteraction::sender( - BusId::Memw, - Multiplicity::Column(cols::FIRST), - vec![ - // old[0..7] = [INDEX, 0, 0, 0, 0, 0, 0, 0] - BusValue::Packed { - start_column: cols::INDEX, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // is_register = 1 - BusValue::constant(1), - // base_address = [508, 0] - BusValue::constant(508), - BusValue::constant(0), - // value[0..7] = [INDEX + cast(count, BaseField), 0, ...] - BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::INDEX, - }, - LinearTerm::ColumnUnsigned { - coefficient: 1, - column: cols::COUNT_0, - }, - LinearTerm::ColumnUnsigned { - coefficient: super::types::SHIFT_32, - column: cols::COUNT_1, - }, - ]), - // value[1..7] = 0 - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // timestamp = [TIMESTAMP_0, TIMESTAMP_1] - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // w2=0, w4=0, w8=0 (single-word access) - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - ], - ), - // 17. MEMW read byte at ts (mult = mu - end) - BusInteraction::sender( - BusId::Memw, - mu_minus_end.clone(), - vec![ - // old[0..7] = [VALUE, 0, 0, 0, 0, 0, 0, 0] - BusValue::Packed { - start_column: cols::VALUE, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // is_register = 0 - BusValue::constant(0), - // base_address = [ADDRESS_0, ADDRESS_1] - BusValue::Packed { - start_column: cols::ADDRESS_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::ADDRESS_1, - packing: Packing::Direct, - }, - // value[0..7] = [VALUE, 0, 0, 0, 0, 0, 0, 0] (read: same as old) - BusValue::Packed { - start_column: cols::VALUE, - packing: Packing::Direct, - }, - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - // timestamp = [TIMESTAMP_0, TIMESTAMP_1] - BusValue::Packed { - start_column: cols::TIMESTAMP_0, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::TIMESTAMP_1, - packing: Packing::Direct, - }, - // w2=0, w4=0, w8=0 (width=1 byte) - BusValue::constant(0), - BusValue::constant(0), - BusValue::constant(0), - ], - ), - // 18. COMMIT[index, value] (mult = mu - end) - BusInteraction::sender( - BusId::Commit, - mu_minus_end, - vec![ - BusValue::Packed { - start_column: cols::INDEX, - packing: Packing::Direct, - }, - BusValue::Packed { - start_column: cols::VALUE, - packing: Packing::Direct, - }, - ], - ), - ] -} - -// ========================================================================= -// Single-source constraint set (ConstraintBuilder front-end) -// ========================================================================= - -/// The COMMIT table's 8 transition constraints as a single [`ConstraintSet`]: -/// - idx 0-2: `IS_BIT` on `first`, `end`, `μ`; -/// - idx 3: `(first + end)·(1 − μ) = 0` (first/end ⇒ μ); -/// - idx 4,5: `ADD` pair `address + 1 = address_incr` (unconditional); -/// - idx 6,7: `ADD` pair `count_decr + 1 = count` (unconditional). -#[derive(Clone, Copy)] -pub struct CommitConstraints; - -impl ConstraintSet for CommitConstraints { - fn eval>(&self, b: &mut B) { - // idx 0-2: IS_BIT for first, end, mu - emit_is_bit(b, 0, cols::FIRST, None); - emit_is_bit(b, 1, cols::END, None); - emit_is_bit(b, 2, cols::MU, None); - - // idx 3: (first + end) * (1 - mu) - let one = b.one(); - let first = b.main(0, cols::FIRST); - let end = b.main(0, cols::END); - let mu = b.main(0, cols::MU); - b.emit_base(3, (first + end) * (one - mu)); - - // idx 4,5: ADD template for address + 1 = address_incr (unconditional) - emit_add_pair( - b, - 4, - &[], - &AddOperand::dword(cols::ADDRESS_0), - &AddOperand::constant(1), - &AddOperand::from_dword_hl(cols::ADDRESS_INCR_0), - ); - - // idx 6,7: SUB via ADD: count_decr + 1 = count (unconditional) - emit_add_pair( - b, - 6, - &[], - &AddOperand::from_dword_hl(cols::COUNT_DECR_0), - &AddOperand::constant(1), - &AddOperand::dword(cols::COUNT_0), - ); - } -} diff --git a/prover/src/tables/copy.rs b/prover/src/tables/copy.rs new file mode 100644 index 000000000..98caeb1a8 --- /dev/null +++ b/prover/src/tables/copy.rs @@ -0,0 +1,1154 @@ +//! COPY table — one streaming copy primitive for `memcpy`/`memmove`, `memset` +//! and the `write` syscall. +//! +//! Replaces the COMMIT table. A row copies `1` or `8` +//! bytes from `src` to `dst` and chains through [`BusId::CopyNext`] until the row +//! where `count == step`. +//! +//! ## The three functionalities +//! +//! One-hot over `is_set` and `is_write`; `is_cpy = mu - is_set - is_write` is linear +//! and costs no column. Neither the memory domain nor the timestamp order is chosen +//! by the caller: both are *derived* from the selector, and the selector is pinned to +//! the ecall the row receives, because the ECALL number is itself a linear function +//! of the two flags. +//! +//! | op | ecall | domains | timestamp order | arguments | +//! |---|---|---|---|---| +//! | memcpy / memmove | `-30` | RAM → RAM | read `T`, write `T+1` | `x10`, `x11`, `x12` | +//! | memset | `-31` | RAM → RAM | **write `T`, read `T+1`** | `x10`, `x11`, `x12` | +//! | write | `64` | RAM → COMMIT | read `T`, write `T+1` | `x10`, `x11`, `x12`, `x254` | +//! +//! ## Timestamp order +//! +//! ```text +//! read_ts = T + is_set +//! write_ts = T + 1 - is_set +//! ``` +//! +//! Both are linear in a bit column. With the normal order every read of a call +//! happens at one timestamp and every write at a later one, so a whole chunk is a +//! snapshot — that is what gives `memmove` its overlap semantics for free. With the +//! order inverted, a row's read observes the *previous* row's write, so a self-copy +//! propagates its first bytes across the range: `memset`. `old_ts < ts` holds strictly +//! either way, so the memory argument is undisturbed. +//! +//! `memset` reuses the copy path, but not without a constraint of its own. Its stub +//! seeds the first eight bytes with an ordinary store and calls with +//! `(dst = seed_end, src = seed_start, count = n - 8)`, so the chip sees an +//! overlapping memmove -- and `copy:c:set_gap` is what pins that overlap to exactly +//! `dst = src + 8`. Drop it and `dst == src` becomes provable, where a row's read and +//! its write hit the same cell at adjacent timestamps and all eight lanes go free: +//! an arbitrary memory write. See the comment on `set_gap` in `eval`. +//! +//! ## Return value +//! +//! All four operations share the `write(2)` ABI: `x10`, `x11`, `x12` hold the three +//! operands and `x10` receives the return value. `copy:c:read_dst` therefore reads +//! `dst` from `x10 + 244·is_write` — `x10` for the `mem*` calls, `x254` (the running +//! commit index) for `write` — and writes `dst_res = dst + count·is_write` back. One +//! interaction covers both the `void*` the `mem*` calls return and the commit-index +//! update `write` needs. `copy:c:read_fd` additionally pins `x10 == 1` on the first +//! row of a `write` (only `stdout` is supported) and returns `count` there. +//! +//! ## Width is chosen per row +//! +//! `single` is free upward: a one-byte row is legal at any count. Downward it is +//! pinned exactly, and locally. A wide row with `count < 8` borrows out of +//! `count - step`, and `copy:c:borrow_implies_count_is_zero` then demands +//! `count == 0` — a contradiction. So a chain can never overshoot, and the prover is +//! *forced* to take narrow rows for the last seven bytes. That is what replaced the +//! `count < 8` ALU lookup, and it is stronger than the lookup was. +//! +//! The `count <= 256` lookup went for an unrelated reason: the spec places no bound +//! on `count` and leaves the chain length to the prover. The executor still refuses +//! an ecall above 256 bytes, so it is now strictly stricter than this chip — an +//! intentional acceptance-set gap, benign (the same bytes, in fewer ecalls), with +//! the executor's limit the side to relax. +//! +//! ## Terminating the chain +//! +//! There is no separate terminal row. The chain stops on the row that moves the last +//! bytes, and `end` is pinned to `count == step or (count == 0 and first)` by the +//! `restrict_end` group, using the borrow out of `count - step` as the proxy for the +//! second (zero-length) state. The old `ZERO` lookup over `count_decr` is gone. +//! +//! ## Columns (41) +//! +//! - `timestamp` DWordWL (2), `value` DWordBL (8) +//! - `src` DWordWL (2), `src_incr` DWordHL (4) +//! - `dst` DWordWL (2) — for `write` this is the COMMIT-domain address, i.e. the +//! running global byte index — `dst_incr` DWordHL (4) +//! - `count` DWordWL (2), `count_decr` DWordHL (4) +//! - `dst_res` DWordHL (4) +//! - `first`, `end`, `single`, `is_set`, `is_write`, `first_write`, `mu` +//! - `mu_com = (mu - count_borrows[1]) * is_write`, +//! `mu_com_wide = mu_com * (1 - single)` — multiplicities are strictly linear in +//! this framework, so an op-specific gate that is not already linear needs a column +//! and a degree-2 constraint. The RAM write needs none: it rides +//! `mu - count_borrows[1] - mu_com` directly, which is the same product expanded. +//! +//! Three divergences in the columns from the spec's 34, all representational and all +//! recorded in `shape_tests`: the spec types `timestamp` as a `Word` where this code uses a +//! `DWordWL`; it types the three increments and `dst_res` as `DWordWHH` +//! (`Half, Half, Word`) where this code uses `DWordHL` (four `Half`s). `IS_WHH` +//! decomposes into an `IS_WH` chip this prover does not have; four `IS_HALF` +//! lookups prove the identical bound with the primitive it does have, at one extra +//! column each. `mu_com`/`mu_com_wide` are the COMMIT-bus gate the spec does not +//! need because it writes the committed bytes through `MEMW` in domain `2·is_write`. +//! +//! A fourth divergence is not representational: constraints 37, 38 and 39 pin the +//! carry out of `src + step`, `dst + step` and the `memset` gap `src + 8` to zero. +//! The spec has none of the three -- its `ADD` template range-checks both carries as +//! bits and stops there, and the `ADDNW` template that used to say otherwise is gone +//! from the spec entirely -- and without them the chip accepts operands that wrap the +//! address space, which the executor refuses outright. Extra constraints only ever +//! narrow the acceptance set, so this is soundness-safe; it narrows it to the +//! executor's. +//! +//! One consequence of that last column divergence is worth stating here rather than +//! leaving for a reader to find: the `Commit` bus carries the index as `dst[0]` +//! alone, while the verifier enumerates `start_index + i` over a `u64`. The two +//! agree only while the running commit index stays below `2^32`, and `x254`'s high +//! word is pinned to zero by the trace builder rather than by a constraint. +//! +//! What keeps that safe is not the prover's own range check -- that is a backstop +//! for a bug, and it is unreachable. It is the executor: `commit_public_output` +//! caps the total committed output at `MAX_PUBLIC_OUTPUT_TOTAL_SIZE`, one mebibyte, +//! so the index of an honest run stays four orders of magnitude below `2^32`. A run +//! that tried to pass the cap fails in the executor, long before any trace exists. +//! Raising that cap is what would make this a live question, and the domain-2 write +//! is what retires it. +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use crate::constraints::templates::{ + AddLinearTerm, AddOperand, add_pair_carry_hi, emit_add_pair, emit_is_bit, +}; + +use executor::vm::instruction::execution::{ + DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_GAP, DMA_MEMSET_SYSCALL_NUMBER, +}; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; + +/// `write(2)`, the one ECALL number this chip serves that is not an extension. +const WRITE_SYSCALL_NUMBER: u64 = 64; + +const MEMCPY_LO32: i64 = (DMA_MEMCPY_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; +const MEMCPY_HI32: i64 = (DMA_MEMCPY_SYSCALL_NUMBER >> 32) as i64; +const MEMSET_LO32: i64 = (DMA_MEMSET_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; +const MEMSET_HI32: i64 = (DMA_MEMSET_SYSCALL_NUMBER >> 32) as i64; +const WRITE_LO32: i64 = (WRITE_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; +const WRITE_HI32: i64 = (WRITE_SYSCALL_NUMBER >> 32) as i64; + +/// `x10 + 244·is_write`: the register `dst` is read from, and `dst_res` written to. +const DST_REGISTER_BASE: u8 = 10; +const DST_REGISTER_WRITE_OFFSET: i64 = 244; + +pub mod cols { + pub const TIMESTAMP_0: usize = 0; + pub const TIMESTAMP_1: usize = 1; + + pub const VALUE_0: usize = 2; + pub const VALUE: [usize; 8] = [ + VALUE_0, + VALUE_0 + 1, + VALUE_0 + 2, + VALUE_0 + 3, + VALUE_0 + 4, + VALUE_0 + 5, + VALUE_0 + 6, + VALUE_0 + 7, + ]; + + pub const SRC_0: usize = 10; + pub const SRC_1: usize = 11; + /// `src + step`, four halfwords. The `ADD` leaves the carry out of the high limb + /// free; constraints 37 and 38 pin it to zero on every row that moves a byte, so + /// a chain cannot walk past the top of the address space. See the end of `eval`. + pub const SRC_INCR_0: usize = 12; + + pub const DST_0: usize = 16; + pub const DST_1: usize = 17; + /// `dst + step`; see [`SRC_INCR_0`]. + pub const DST_INCR_0: usize = 18; + + pub const COUNT_0: usize = 22; + pub const COUNT_1: usize = 23; + /// `count - step`, four halfwords. The zero-length call relies on the wraparound + /// to `-1`, and that borrow is `count_borrows[1]`, the proxy for `end`. + pub const COUNT_DECR_0: usize = 24; + + /// `dst + count · is_write`, four halfwords: the value written back to + /// `x10 + 244·is_write`. + pub const DST_RES_0: usize = 28; + + pub const FIRST: usize = 32; + pub const END: usize = 33; + /// `1` when this row moves a single byte rather than eight. + pub const SINGLE: usize = 34; + + /// Decoded functionality. `is_cpy = mu - is_set - is_write` is implied. + pub const IS_SET: usize = 35; + pub const IS_WRITE: usize = 36; + /// `first · is_write` — the `x10` file-descriptor check. + pub const FIRST_WRITE: usize = 37; + + pub const MU: usize = 38; + + /// `(mu - count_borrows[1]) · is_write` — the COMMIT-domain write. + pub const MU_COM: usize = 39; + /// `mu_com · (1 - single)` — lanes 1..7 of the COMMIT-domain write. Without it a + /// one-byte `write` row would send seven spurious `(index, 0)` pairs and corrupt + /// the public-output fingerprint. + pub const MU_COM_WIDE: usize = 40; + + pub const NUM_COLUMNS: usize = 41; +} + +/// Which functionality a row is running. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Functionality { + /// `memcpy` / `memmove`: RAM → RAM, snapshot order. + Copy, + /// `memset`: RAM → RAM, inverted order, so the fill propagates. + Set, + /// `write`: RAM → COMMIT domain, snapshot order. + Write, +} + +/// One row: `1` or `8` bytes, or the degenerate zero-length row. +#[derive(Debug, Clone)] +pub struct CopyOperation { + pub timestamp: u64, + pub src: u64, + /// For `Write` this is the COMMIT-domain address (the global byte index). + pub dst: u64, + /// Remaining byte count including this row's bytes; `0` only on a zero-length call. + pub count: u64, + pub width: u8, + pub first: bool, + pub end: bool, + pub functionality: Functionality, + /// The bytes moved, zero-padded past `width`. + pub value: [u8; 8], +} + +impl CopyOperation { + /// `read_ts = T + is_set`, `write_ts = T + 1 - is_set`. + pub fn read_timestamp(&self) -> u64 { + self.timestamp + u64::from(self.functionality == Functionality::Set) + } + + pub fn write_timestamp(&self) -> u64 { + self.timestamp + 1 - u64::from(self.functionality == Functionality::Set) + } + + /// `dst + count · is_write`, the value written back to `x10`/`x254`. + pub fn dst_res(&self) -> u64 { + match self.functionality { + Functionality::Write => self.dst.wrapping_add(self.count), + _ => self.dst, + } + } +} + +/// Generates the COPY trace. One row per operation, padded to the next power of +/// two (min 4). Padding rows carry `count = 8` and `src_incr = dst_incr = 8`, which +/// is what keeps the unconditional address/count relations and both borrows at zero. +pub fn generate_copy_trace( + ops: &[CopyOperation], +) -> TraceTable { + let n = ops.len(); + let num_rows = n.next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row_idx, op) in ops.iter().enumerate() { + let width = u64::from(op.width); + table.set_dword_wl(row_idx, cols::TIMESTAMP_0, op.timestamp); + + for (column, &byte) in cols::VALUE.iter().zip(&op.value) { + table.set_byte(row_idx, *column, byte); + } + + table.set_dword_wl(row_idx, cols::SRC_0, op.src); + table.set_dword_hl(row_idx, cols::SRC_INCR_0, op.src.wrapping_add(width)); + + table.set_dword_wl(row_idx, cols::DST_0, op.dst); + table.set_dword_hl(row_idx, cols::DST_INCR_0, op.dst.wrapping_add(width)); + + table.set_dword_wl(row_idx, cols::COUNT_0, op.count); + table.set_dword_hl(row_idx, cols::COUNT_DECR_0, op.count.wrapping_sub(width)); + + table.set_dword_hl(row_idx, cols::DST_RES_0, op.dst_res()); + + table.set_bool(row_idx, cols::FIRST, op.first); + table.set_bool(row_idx, cols::END, op.end); + table.set_bool(row_idx, cols::SINGLE, op.width == 1); + table.set_fe(row_idx, cols::MU, FE::one()); + + let is_set = op.functionality == Functionality::Set; + let is_write = op.functionality == Functionality::Write; + table.set_bool(row_idx, cols::IS_SET, is_set); + table.set_bool(row_idx, cols::IS_WRITE, is_write); + table.set_bool(row_idx, cols::FIRST_WRITE, op.first && is_write); + + // `mu - count_borrows[1]`: the row copies unless it is the degenerate + // zero-length row, which is the only one that borrows out of `count - step`. + let copies = op.count != 0; + table.set_bool(row_idx, cols::MU_COM, copies && is_write); + table.set_bool( + row_idx, + cols::MU_COM_WIDE, + copies && is_write && op.width == 8, + ); + } + + for row_idx in n..num_rows { + // `single = 0` on padding, so `step = 8` and the three unconditional ADD + // relations close with `count = src_incr = dst_incr = 8` and everything else + // zero. Both borrows are then zero, so no padding row touches the memory bus. + table.set_fe(row_idx, cols::COUNT_0, FE::from(8u64)); + table.set_fe(row_idx, cols::SRC_INCR_0, FE::from(8u64)); + table.set_fe(row_idx, cols::DST_INCR_0, FE::from(8u64)); + } + + trace +} + +/// `-1 / 2^32`, `-1 / 2^16` and friends, for the two borrows of `count - step`. +/// +/// `count_borrows` is virtual in the spec — an expression, not a column — and the +/// read/write multiplicity `mu - count_borrows[1]` therefore has to be written out +/// as a linear form over the columns it is built from. +mod borrow_consts { + use crate::constraints::templates::INV_SHIFT_32; + const P: u128 = 0xFFFF_FFFF_0000_0001; + const fn neg(x: u64) -> u64 { + (P - x as u128) as u64 + } + const fn mul(a: u64, b: u64) -> u64 { + ((a as u128 * b as u128) % P) as u64 + } + /// `2^-32` + pub const INV_2_32: u64 = INV_SHIFT_32; + /// `2^-16` (as `2^16 · 2^-32`) + pub const INV_2_16: u64 = mul(1 << 16, INV_2_32); + /// `2^-64` + pub const INV_2_64: u64 = mul(INV_2_32, INV_2_32); + /// `2^-48` + pub const INV_2_48: u64 = mul(INV_2_16, INV_2_32); + pub const NEG_INV_2_32: u64 = neg(INV_2_32); + pub const NEG_INV_2_64: u64 = neg(INV_2_64); + pub const NEG_SEVEN_INV_2_64: u64 = neg(mul(7, INV_2_64)); + pub const EIGHT_INV_2_64: u64 = mul(8, INV_2_64); + /// `8 / 2^64 = -2^35` in the field: `2^-64 = -2^32`, since `2^96 = -1`. + pub const EIGHT_INV_2_64_SIGNED: i64 = -(1 << 35); +} + +/// `count_borrows[1]`, the borrow out of `count - step`, as a linear form. +/// +/// ```text +/// borrow_0 = (count_decr_lo + step - count_0) / 2^32 +/// borrow_1 = (count_decr_hi + borrow_0 - count_1) / 2^32 +/// ``` +/// +/// expanded over the four `count_decr` halfwords, the two `count` words and `single`. +/// `8 / 2^64` is `-2^35` in the field, which is how a term that looks like it could +/// not fit an `i64` constant fits one comfortably. On a padding row it cancels +/// against `-count_0 / 2^64 = 2^35` exactly, which is what the `count = 8` padding +/// is for. +fn count_borrow_1_terms() -> Vec { + use borrow_consts::*; + vec![ + LinearTerm::ColumnUnsigned { + coefficient: INV_2_32, + column: cols::COUNT_DECR_0 + 2, + }, + LinearTerm::ColumnUnsigned { + coefficient: INV_2_16, + column: cols::COUNT_DECR_0 + 3, + }, + LinearTerm::ColumnUnsigned { + coefficient: NEG_INV_2_32, + column: cols::COUNT_1, + }, + LinearTerm::ColumnUnsigned { + coefficient: INV_2_64, + column: cols::COUNT_DECR_0, + }, + LinearTerm::ColumnUnsigned { + coefficient: INV_2_48, + column: cols::COUNT_DECR_0 + 1, + }, + LinearTerm::ColumnUnsigned { + coefficient: NEG_INV_2_64, + column: cols::COUNT_0, + }, + LinearTerm::ColumnUnsigned { + coefficient: NEG_SEVEN_INV_2_64, + column: cols::SINGLE, + }, + LinearTerm::Constant(EIGHT_INV_2_64_SIGNED), + ] +} + +/// `mu - count_borrows[1]`, the multiplicity of the two memory accesses. +fn mu_minus_borrow() -> Multiplicity { + let mut terms = vec![LinearTerm::Column { + coefficient: 1, + column: cols::MU, + }]; + terms.extend(negate(count_borrow_1_terms())); + Multiplicity::Linear(terms) +} + +fn negate(terms: Vec) -> Vec { + const P: u128 = 0xFFFF_FFFF_0000_0001; + terms + .into_iter() + .map(|term| match term { + LinearTerm::Column { + coefficient, + column, + } => LinearTerm::Column { + coefficient: -coefficient, + column, + }, + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => LinearTerm::ColumnUnsigned { + coefficient: (P - coefficient as u128) as u64, + column, + }, + LinearTerm::Constant(c) => LinearTerm::Constant(-c), + }) + .collect() +} + +/// A MEMW register access (`is_register = 1`, width 2) on the 24-element tuple: +/// `[old[8], is_register, address[2], value[8], timestamp[2], w2, w4, w8]`. +/// +/// `address` is a `BusValue` rather than a constant because `copy:c:read_dst` +/// addresses `x10` or `x254` depending on `is_write`. +fn memw_register(address: BusValue, old: [BusValue; 2], value: [BusValue; 2]) -> Vec { + let pad6 = || (0..6).map(|_| BusValue::constant(0)); + let mut tuple = Vec::with_capacity(24); + tuple.extend(old); + tuple.extend(pad6()); + tuple.push(BusValue::constant(1)); // is_register + tuple.push(address); + tuple.push(BusValue::constant(0)); // address high limb + tuple.extend(value); + tuple.extend(pad6()); + tuple.push(BusValue::Packed { + start_column: cols::TIMESTAMP_0, + packing: Packing::Direct, + }); + tuple.push(BusValue::Packed { + start_column: cols::TIMESTAMP_1, + packing: Packing::Direct, + }); + tuple.push(BusValue::constant(1)); // w2 + tuple.push(BusValue::constant(0)); // w4 + tuple.push(BusValue::constant(0)); // w8 + tuple +} + +fn direct(column: usize) -> BusValue { + BusValue::Packed { + start_column: column, + packing: Packing::Direct, + } +} + +/// The two 32-bit words of a `DWordHL` quadruple, as the `DWordWL` the bus wants. +fn word2l_pair(start_column: usize) -> [BusValue; 2] { + [ + BusValue::Packed { + start_column, + packing: Packing::Word2L, + }, + BusValue::Packed { + start_column: start_column + 2, + packing: Packing::Word2L, + }, + ] +} + +/// `T + offset + coefficient · is_set`, the timestamp-order customisation. +fn timestamp_with_order(offset: i64, is_set_coefficient: i64) -> BusValue { + BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::TIMESTAMP_0, + }, + LinearTerm::Column { + coefficient: is_set_coefficient, + column: cols::IS_SET, + }, + LinearTerm::Constant(offset), + ]) +} + +fn value_columns() -> Vec { + cols::VALUE.iter().map(|&column| direct(column)).collect() +} + +fn halfword(column: usize, multiplicity: Multiplicity) -> BusInteraction { + BusInteraction::sender(BusId::IsHalfword, multiplicity, vec![direct(column)]) +} + +/// The COPY bus interactions. +pub fn bus_interactions() -> Vec { + let mu_minus_end = Multiplicity::Diff(cols::MU, cols::END); + let mu_minus_first = Multiplicity::Diff(cols::MU, cols::FIRST); + let w8 = || { + BusValue::linear(vec![ + LinearTerm::Constant(1), + LinearTerm::Column { + coefficient: -1, + column: cols::SINGLE, + }, + ]) + }; + let reg = |index: u8| BusValue::constant(2 * index as u64); + + let mut interactions = vec![ + // 1. Receive the ECALL. The syscall number is a linear function of the two + // selectors, so the decoded functionality is pinned to the ecall the guest + // actually made: `64` for `write`, `-30` for memcpy/memmove, `-31` for + // memset, and nothing else. + BusInteraction::receiver( + BusId::Ecall, + Multiplicity::Column(cols::FIRST), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::linear(vec![ + LinearTerm::Constant(MEMCPY_LO32), + LinearTerm::Column { + coefficient: MEMSET_LO32 - MEMCPY_LO32, + column: cols::IS_SET, + }, + LinearTerm::Column { + coefficient: WRITE_LO32 - MEMCPY_LO32, + column: cols::IS_WRITE, + }, + ]), + BusValue::linear(vec![ + LinearTerm::Constant(MEMCPY_HI32), + LinearTerm::Column { + coefficient: MEMSET_HI32 - MEMCPY_HI32, + column: cols::IS_SET, + }, + LinearTerm::Column { + coefficient: WRITE_HI32 - MEMCPY_HI32, + column: cols::IS_WRITE, + }, + ]), + ], + ), + // 2. Read `src` from x11. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register( + reg(11), + [direct(cols::SRC_0), direct(cols::SRC_1)], + [direct(cols::SRC_0), direct(cols::SRC_1)], + ), + ), + // 3. Read `count` from x12. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register( + reg(12), + [direct(cols::COUNT_0), direct(cols::COUNT_1)], + [direct(cols::COUNT_0), direct(cols::COUNT_1)], + ), + ), + // 4. Read `dst` from x10 (the `mem*` destination) or x254 (the running commit + // index), and write `dst_res` back: the `void*` those calls return, and the + // advanced index a `write` needs. One interaction, selected by `is_write`. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST), + memw_register( + BusValue::linear(vec![ + LinearTerm::Constant(2 * DST_REGISTER_BASE as i64), + LinearTerm::Column { + coefficient: 2 * DST_REGISTER_WRITE_OFFSET, + column: cols::IS_WRITE, + }, + ]), + [direct(cols::DST_0), direct(cols::DST_1)], + word2l_pair(cols::DST_RES_0), + ), + ), + // 5. On the first row of a `write`, x10 holds the file descriptor and must be + // 1 (only stdout is supported); the same access returns `count`, the number + // of bytes written, because this chip always writes all of them. + BusInteraction::sender( + BusId::Memw, + Multiplicity::Column(cols::FIRST_WRITE), + memw_register( + reg(10), + [BusValue::constant(1), BusValue::constant(0)], + [direct(cols::COUNT_0), direct(cols::COUNT_1)], + ), + ), + // 6. Chain forward. The selectors ride inside the tuple, so a chain cannot + // change functionality half way through it. + BusInteraction::sender( + BusId::CopyNext, + mu_minus_end.clone(), + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::SRC_INCR_0, + packing: Packing::DWordHL, + }, + BusValue::Packed { + start_column: cols::DST_INCR_0, + packing: Packing::DWordHL, + }, + BusValue::Packed { + start_column: cols::COUNT_DECR_0, + packing: Packing::DWordHL, + }, + direct(cols::IS_SET), + direct(cols::IS_WRITE), + ], + ), + // 7. Chain backward. + BusInteraction::receiver( + BusId::CopyNext, + mu_minus_first, + vec![ + direct(cols::TIMESTAMP_0), + direct(cols::TIMESTAMP_1), + BusValue::Packed { + start_column: cols::SRC_0, + packing: Packing::DWordWL, + }, + BusValue::Packed { + start_column: cols::DST_0, + packing: Packing::DWordWL, + }, + BusValue::Packed { + start_column: cols::COUNT_0, + packing: Packing::DWordWL, + }, + direct(cols::IS_SET), + direct(cols::IS_WRITE), + ], + ), + ]; + + // 8-19. `IS_WHH` on the three chained dwords, as four `IS_HALF` each. + for start in [cols::SRC_INCR_0, cols::DST_INCR_0, cols::COUNT_DECR_0] { + for k in 0..4 { + interactions.push(halfword(start + k, Multiplicity::Column(cols::MU))); + } + } + // 20-23. `IS_WHH[dst_res]`, which only the `ADD` of interaction 4 needs, so it is + // gated on `is_write` exactly as the spec gates it. On a non-write row `dst_res` + // is pinned equal to `dst` instead. + for k in 0..4 { + interactions.push(halfword( + cols::DST_RES_0 + k, + Multiplicity::Column(cols::IS_WRITE), + )); + } + + interactions.extend([ + // 24. Read the source at `T + is_set`. + BusInteraction::sender(BusId::Memw, mu_minus_borrow(), { + let mut values = value_columns(); + let mut tuple = Vec::with_capacity(24); + tuple.extend(values.iter().cloned()); + tuple.push(BusValue::constant(0)); // is_register + tuple.push(direct(cols::SRC_0)); + tuple.push(direct(cols::SRC_1)); + tuple.append(&mut values); + tuple.push(timestamp_with_order(0, 1)); + tuple.push(direct(cols::TIMESTAMP_1)); + tuple.push(BusValue::constant(0)); // w2 + tuple.push(BusValue::constant(0)); // w4 + tuple.push(w8()); + tuple + }), + // 25. Write the destination at `T + 1 - is_set`, RAM domain only. The spec + // sends this one interaction with domain `2 · is_write`; this prover has + // no domain-2 endpoint on the memory bus yet, so a `write` row takes the + // COMMIT bus below instead and this send is gated off for it. The gate is + // `mu - count_borrows[1] - mu_com`, which is the same product expanded. + BusInteraction::sender( + BusId::Memw, + { + let Multiplicity::Linear(mut terms) = mu_minus_borrow() else { + unreachable!("mu_minus_borrow is Linear") + }; + terms.push(LinearTerm::Column { + coefficient: -1, + column: cols::MU_COM, + }); + Multiplicity::Linear(terms) + }, + { + let mut tuple = Vec::with_capacity(16); + tuple.push(BusValue::constant(0)); // is_register + tuple.push(direct(cols::DST_0)); + tuple.push(direct(cols::DST_1)); + tuple.extend(value_columns()); + tuple.push(timestamp_with_order(1, -1)); + tuple.push(direct(cols::TIMESTAMP_1)); + tuple.push(BusValue::constant(0)); // w2 + tuple.push(BusValue::constant(0)); // w4 + tuple.push(w8()); + tuple + }, + ), + ]); + + // 26-33. Write the destination in the COMMIT domain, one `(index, value)` pair per + // byte. A row still carries eight bytes, but it sends them as eight separate pairs + // at `dst`, `dst + 1`, ..., rather than as one eight-lane tuple. + // + // The arity is what matters here, not the row width. The verifier rebuilds this bus + // from `public_output` alone, and it does not know where one `write` ECALL ended and + // the next began — it sees only the concatenation. With one tuple per row the + // verifier would have to reproduce the prover's row schedule exactly, which it + // cannot: the schedule restarts at every ECALL, so a guest committing 4 bytes and + // then 4 more sends eight one-byte rows where the verifier, chunking the eight + // bytes it sees, would expect a single eight-byte row. An honest proof would be + // rejected. Addressing every byte by its own global index removes the grouping, and + // with it anything for the two sides to disagree about. + // + // Lane 0 is sent whenever the row copies (`mu_com`); lanes 1..7 only on an + // eight-byte row (`mu_com_wide`), so a one-byte row does not send seven spurious + // `(index, 0)` pairs. + let commit_pair = |lane: usize| { + vec![ + BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::DST_0, + }, + LinearTerm::Constant(lane as i64), + ]), + direct(cols::VALUE[lane]), + ] + }; + interactions.push(BusInteraction::sender( + BusId::Commit, + Multiplicity::Column(cols::MU_COM), + commit_pair(0), + )); + for lane in 1..8 { + interactions.push(BusInteraction::sender( + BusId::Commit, + Multiplicity::Column(cols::MU_COM_WIDE), + commit_pair(lane), + )); + } + + interactions +} + +/// The COPY constraints. +#[derive(Clone, Copy)] +pub struct CopyConstraints; + +impl ConstraintSet for CopyConstraints { + fn eval>(&self, b: &mut B) { + // `bits`: copy:c:range_first / range_end / range_single / range_mu, and + // `flags`: copy:c:range_is_set / range_is_write. + emit_is_bit(b, 0, cols::FIRST, None); + emit_is_bit(b, 1, cols::END, None); + emit_is_bit(b, 2, cols::SINGLE, None); + emit_is_bit(b, 3, cols::MU, None); + emit_is_bit(b, 4, cols::IS_SET, None); + emit_is_bit(b, 5, cols::IS_WRITE, None); + + let one = b.one(); + let first = b.main(0, cols::FIRST); + let end = b.main(0, cols::END); + let mu = b.main(0, cols::MU); + let single = b.main(0, cols::SINGLE); + let is_set = b.main(0, cols::IS_SET); + let is_write = b.main(0, cols::IS_WRITE); + + // copy:c:first_or_end_implies_mu. An active row is implied by first or end, + // which keeps the chain multiplicities `mu - first` and `mu - end` binary. + b.emit_base( + 6, + (first.clone() + end.clone()) * (one.clone() - mu.clone()), + ); + // copy:c:one_hot, copy:c:functionality_implies_mu, copy:c:first_write. The + // functionality is one-hot and only set on active rows. + b.emit_base(7, is_set.clone() * is_write.clone()); + b.emit_base( + 8, + (is_set.clone() + is_write.clone()) * (one.clone() - mu.clone()), + ); + b.emit_base( + 9, + b.main(0, cols::FIRST_WRITE) - first.clone() * is_write.clone(), + ); + + let step = AddOperand::linear( + &[ + AddLinearTerm::Constant(8), + AddLinearTerm::Column { + coefficient: -7, + column: cols::SINGLE, + }, + ], + &[], + ); + + // copy:c:src_incr and copy:c:dst_incr, plain `ADD`. The carry out of the high + // limb is free here, exactly as the spec's `ADD` leaves it; the two + // constraints at the end of `eval` are what forbid it. Nothing else does: + // `count` decrements independently of the two positions, so a wrapping chain + // terminates perfectly well. + emit_add_pair( + b, + 10, + &[], + &AddOperand::dword(cols::SRC_0), + &step, + &AddOperand::from_dword_hl(cols::SRC_INCR_0), + ); + emit_add_pair( + b, + 12, + &[], + &AddOperand::dword(cols::DST_0), + &step, + &AddOperand::from_dword_hl(cols::DST_INCR_0), + ); + // copy:c:count_borrows: `count_decr + step = count`, the `SUB` the spec inlines. + // The two carries are `count_borrows`, the borrows + // out of `count - step`; `count_borrows[1]` is what `restrict_end` reads. + emit_add_pair( + b, + 14, + &[], + &AddOperand::from_dword_hl(cols::COUNT_DECR_0), + &step, + &AddOperand::dword(cols::COUNT_0), + ); + + // copy:c:dst_res_is_write and copy:c:dst_res_non_write. + emit_add_pair( + b, + 16, + &[cols::IS_WRITE], + &AddOperand::dword(cols::DST_0), + &AddOperand::dword(cols::COUNT_0), + &AddOperand::from_dword_hl(cols::DST_RES_0), + ); + let dst_res_lo = + b.main(0, cols::DST_RES_0) + b.const_base(1 << 16) * b.main(0, cols::DST_RES_0 + 1); + let dst_res_hi = + b.main(0, cols::DST_RES_0 + 2) + b.const_base(1 << 16) * b.main(0, cols::DST_RES_0 + 3); + b.emit_base( + 18, + (one.clone() - is_write.clone()) * (dst_res_lo - b.main(0, cols::DST_0)), + ); + b.emit_base( + 19, + (one.clone() - is_write) * (dst_res_hi - b.main(0, cols::DST_1)), + ); + + // copy:c:set_gap -- memset's operand contract: `dst = src + 8`. + // + // This is what pins `value` on an `is_set` row. The inverted order puts the + // write at `T` and the read at `T+1`, so a row's read observes writes this + // call already made -- that is the propagation. It pins the copied value only + // while the read resolves to a *different* address, already written, with the + // recursion bottoming out in memory the call never wrote. `dst == src` is the + // one degenerate case: read and write address the same cell at adjacent + // timestamps, the memory argument closes on `value == value`, and all eight + // lanes become free field elements -- unconstrained RAM, chosen by the prover. + // Nothing else touches them (the lane constraints below only *zero* lanes 1..7 + // on narrow rows, and no `AreBytes` reaches them), so this is the only thing + // standing between the chip and an arbitrary memory write. + // + // `is_set` alone is the correct gate. `step` advances `src` and `dst` together, + // so `dst - src` is invariant along a chain and the relation holds on every row + // of it; padding rows leave `is_set = 0`. + // + // Pinning the exact gap rather than merely `src != dst` also settles the + // direction: `dst < src` is sound but propagates the wrong way, so the AIR + // would otherwise admit traces the executor's forward byte walk never produces. + // + // What these do NOT give on their own is the gap over the integers: getting + // from "gap of 8 in F" to "gap of 8 in Z" needs both limbs canonical, and COPY + // range-checks none of `SRC_0/SRC_1/DST_0/DST_1` -- the spec lists those as + // assumptions. Canonicality comes from the far end of the `Memory` bus instead: + // PAGE builds `address_lo` as `page_base_lo + OFFSET` from a preprocessed + // offset, and in continuations L2G must chain back to a GLOBAL_MEMORY genesis + // token of the same shape, so a non-canonical limb pair has no receiver. Worth + // knowing before adding another `Memw` producer or a non-PAGE `Memory` + // endpoint -- either would weaken this to the field statement. + // + // There is no executor backstop for the limb boundary any more: `set_gap` is + // one 64-bit `ADD`, carries and all, so a range crossing 2^32 is provable and + // the guard that used to refuse it is gone. The PAGE argument above carries + // this alone. + emit_add_pair( + b, + 20, + &[cols::IS_SET], + &AddOperand::dword(cols::SRC_0), + &AddOperand::constant(DMA_MEMSET_GAP as i64), + &AddOperand::dword(cols::DST_0), + ); + + // copy:c:single_lanes: unused lanes are zero on one-byte rows. + for (i, &column) in cols::VALUE.iter().enumerate().skip(1) { + b.emit_base(21 + i, single.clone() * b.main(0, column)); + } + + // `restrict_end`: `end <=> count == step or (count == 0 and first)`, i.e. + // copy:c:borrow_implies_first, borrow_implies_count_is_zero, + // end_implies_count_is_step_or_zc_upper / _lower and borrow_implies_end. + // + // The second state is the zero-length call, and `count_borrows[1]` -- the + // borrow out of `count - step` -- is its proxy: a borrow can only happen when + // `count < step <= 8`, and the two implications below pin that to `count == 0` + // on a `first` row. The reverse of `end <= count == step` needs no constraint: + // not setting `end` there produces a successor row with `count == 0` and + // `first == 0`, which `borrow_implies_first` rejects. + let borrow_1 = { + use borrow_consts::*; + let decr = |k: usize| b.main(0, cols::COUNT_DECR_0 + k); + b.const_base(INV_2_32) * decr(2) + + b.const_base(INV_2_16) * decr(3) + + b.const_base(NEG_INV_2_32) * b.main(0, cols::COUNT_1) + + b.const_base(INV_2_64) * decr(0) + + b.const_base(INV_2_48) * decr(1) + + b.const_base(NEG_INV_2_64) * b.main(0, cols::COUNT_0) + + b.const_base(NEG_SEVEN_INV_2_64) * single.clone() + + b.const_base(EIGHT_INV_2_64) + }; + let not_borrow = one.clone() - borrow_1.clone(); + b.emit_base(29, borrow_1.clone() * (one.clone() - first)); + b.emit_base(30, borrow_1.clone() * b.main(0, cols::COUNT_0)); + b.emit_base(31, borrow_1.clone() * b.main(0, cols::COUNT_1)); + b.emit_base( + 32, + end.clone() * not_borrow.clone() * b.main(0, cols::COUNT_1), + ); + let step_expr = b.const_base(8) - b.const_base(7) * single.clone(); + b.emit_base( + 33, + end.clone() * not_borrow * (b.main(0, cols::COUNT_0) - step_expr), + ); + b.emit_base(34, borrow_1.clone() * (one.clone() - end)); + + // The two COMMIT-bus gates. `mu - count_borrows[1]` is the copy multiplicity; + // `mu_com` restricts it to `write` rows and `mu_com_wide` to wide ones. + b.emit_base( + 35, + b.main(0, cols::MU_COM) - (mu.clone() - borrow_1.clone()) * b.main(0, cols::IS_WRITE), + ); + let mu_com = b.main(0, cols::MU_COM); + b.emit_base(36, b.main(0, cols::MU_COM_WIDE) - mu_com * (one - single)); + + // Nothing a row computes may wrap the address space. All three `ADD`s in this + // chip leave the carry out of the high limb free -- that is the spec's `ADD` + // template, and the `ADDNW` that used to carry this is gone from the spec + // entirely. Unconstrained, a chain walks off the top of the address space and + // resumes at 0: every row still reads and writes PAGE-backed cells at + // consistent timestamps, so the memory argument balances and no byte value is + // forged, but the verifier accepts a copy the executor refuses outright + // (`src.checked_add(n)` / `dst.checked_add(n)` for the positions, + // `src.checked_add(DMA_MEMSET_GAP) != Some(dst)` for the gap, all in + // `execution.rs`). An acceptance set wider than the VM's is the one thing a + // proof of execution may not have. + // + // The gate is `mu - count_borrows[1]`, not `mu`: the borrow is set exactly on + // the zero-length row, where no byte moves and `src_incr`/`dst_incr` go + // nowhere -- `copy:c:send_next_chunk` carries `mu - end` and that row is + // `end`. Gating on plain `mu` would reject a zero-length call whose pointer + // sits at the very top of memory, which the executor accepts. On every row + // that moves a byte the two gates agree, including the last one, which is + // what keeps the parity with `checked_add`: a copy may not finish on `2^64` + // either. Padding rows carry `mu = 0` and are unaffected. + // + // None of the three is in the spec; see the divergence note at the top. + let active = mu - borrow_1; + let src_carry_hi = add_pair_carry_hi( + b, + &AddOperand::dword(cols::SRC_0), + &step, + &AddOperand::from_dword_hl(cols::SRC_INCR_0), + ); + b.emit_base(37, active.clone() * src_carry_hi); + let dst_carry_hi = add_pair_carry_hi( + b, + &AddOperand::dword(cols::DST_0), + &step, + &AddOperand::from_dword_hl(cols::DST_INCR_0), + ); + b.emit_base(38, active * dst_carry_hi); + // The gap of `copy:c:set_gap` may not wrap either, and neither constraint + // above catches it: one-byte rows never push `src` or `dst` past `2^64`, so a + // `memset` with `src = 2^64 - 4`, `dst = 4` walks a legal chain over an + // illegal operand pair. `is_set` is the right gate -- it is what turns the + // `ADD` itself on. + let gap_carry_hi = add_pair_carry_hi( + b, + &AddOperand::dword(cols::SRC_0), + &AddOperand::constant(DMA_MEMSET_GAP as i64), + &AddOperand::dword(cols::DST_0), + ); + b.emit_base(39, is_set * gap_carry_hi); + } + + /// `restrict_end` multiplies `end`, `1 - count_borrows[1]` and a `count` limb, + /// and the two conditional `ADD`s multiply their gate by a carry bit. Both are + /// degree 3; everything else in this chip is degree 2. + fn max_degree(&self) -> usize { + 3 + } +} + +#[cfg(test)] +mod shape_tests { + /// Pins the committed width of the table. + /// + /// **The spec says 34 and this says 41, and the gap is three deliberate + /// representation choices:** + /// + /// - `timestamp` is a `Word` in the spec and a `DWordWL` here (+1). The high limb + /// is provably zero (the CPU sends `constant(0)` in the `Ecall` tuple and + /// `COPY_NEXT` propagates it), so it carries no information. + /// - `src_incr`, `dst_incr`, `count_decr` and `dst_res` are `DWordWHH` + /// (`Half, Half, Word`) in the spec and `DWordHL` (four `Half`s) here (+4). + /// The spec range-checks them with `IS_WHH`, which decomposes into the `IS_WH` + /// chip; this prover has no such chip, and four `IS_HALF` lookups prove exactly + /// the same bound with the primitive it does have. + /// - `mu_com` and `mu_com_wide` (+2) gate the COMMIT bus. The spec has no such + /// bus: it writes committed bytes through `MEMW` in domain `2 · is_write` and + /// lets the verifier initialise and finalise that domain in the memory + /// argument. Moving to that is a change to the memory subsystem, not to this + /// chip. + #[test] + fn the_committed_shape_is_pinned() { + assert_eq!(super::cols::NUM_COLUMNS, 41, "COPY columns (spec: 34 + 7)"); + + let n = super::bus_interactions().len(); + println!( + "COPY: {} columns, {} bus interactions, aux {} -> weight {}", + super::cols::NUM_COLUMNS, + n, + n.div_ceil(2), + super::cols::NUM_COLUMNS + 3 * n.div_ceil(2) + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn op(functionality: Functionality, dst: u64, count: u64, width: u8) -> CopyOperation { + CopyOperation { + timestamp: 100, + src: 0x1000, + dst, + count, + width, + first: false, + end: false, + functionality, + value: [1, 2, 3, 4, 5, 6, 7, 8], + } + } + + #[test] + fn timestamp_order_is_inverted_only_for_memset() { + let copy = op(Functionality::Copy, 0x2000, 64, 8); + assert_eq!(copy.read_timestamp(), 100); + assert_eq!(copy.write_timestamp(), 101); + + let write = op(Functionality::Write, 0, 64, 8); + assert_eq!(write.read_timestamp(), 100); + assert_eq!(write.write_timestamp(), 101); + + // memset writes first, so a row's read observes the previous row's write and + // the seeded bytes propagate across the range. + let set = op(Functionality::Set, 0x2008, 64, 8); + assert_eq!(set.write_timestamp(), 100); + assert_eq!(set.read_timestamp(), 101); + } + + #[test] + fn dst_res_advances_only_for_write() { + assert_eq!(op(Functionality::Copy, 0x2000, 64, 8).dst_res(), 0x2000); + assert_eq!(op(Functionality::Set, 0x2008, 64, 8).dst_res(), 0x2008); + assert_eq!(op(Functionality::Write, 40, 64, 8).dst_res(), 104); + } + + #[test] + fn gate_columns_follow_the_functionality() { + let rows = [ + op(Functionality::Copy, 0x2000, 64, 8), + op(Functionality::Write, 0, 64, 8), + op(Functionality::Set, 0x2008, 64, 8), + ]; + let trace = generate_copy_trace(&rows); + let table = &trace.main_table; + let get = |row: usize, column: usize| *table.get(row, column); + + // The RAM write rides `mu - count_borrows[1] - mu_com` and has no column, so + // `mu_com` is what says which domain a row writes to. + assert_eq!(get(0, cols::MU_COM), FE::zero()); + assert_eq!(get(1, cols::MU_COM), FE::one()); + assert_eq!(get(1, cols::MU_COM_WIDE), FE::one()); + assert_eq!(get(2, cols::MU_COM), FE::zero()); + assert_eq!(get(2, cols::IS_SET), FE::one()); + } + + #[test] + fn a_one_byte_write_row_closes_the_wide_lanes() { + // Otherwise it would send seven spurious `(index, 0)` pairs on the COMMIT bus + // and corrupt the public-output fingerprint. + let rows = [op(Functionality::Write, 40, 3, 1)]; + let trace = generate_copy_trace(&rows); + assert_eq!(*trace.main_table.get(0, cols::MU_COM), FE::one()); + assert_eq!(*trace.main_table.get(0, cols::MU_COM_WIDE), FE::zero()); + } + + #[test] + fn the_schedule_is_wide_until_the_tail_whatever_the_alignment() { + use super::super::trace_builder::copy_row_width_for_test as w; + // Alignment no longer enters into it: matched residues, mismatched residues + // and both-aligned all take eight bytes while eight remain. + for (src, dst) in [(0x1005u64, 0x2005u64), (0x1002, 0x2005), (0x1000, 0x2000)] { + assert_eq!(w(src, dst, 0, 24, false), 8, "src {src:#x} dst {dst:#x}"); + assert_eq!(w(src, dst, 8, 16, false), 8, "src {src:#x} dst {dst:#x}"); + } + // A short remainder is one byte a row, which is the only case that narrows. + assert_eq!(w(0x1000, 0x2000, 16, 5, false), 1); + assert_eq!(w(0x1005, 0x2005, 0, 7, false), 1); + // `write` is unchanged. + assert_eq!(w(0x1002, 0x2005, 0, 24, true), 8); + } +} diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index fc4c2f976..1f4b33562 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -193,6 +193,12 @@ pub struct CpuOperation { /// addresses (x10/x11/x12) are recovered from the register state in the trace /// builder, exactly like ECSM. pub ecall_hint: bool, + + /// Whether this ECALL is a DMA memcpy. Operands are recovered from x10/x11/x12. + pub ecall_dma_memcpy: bool, + + /// Whether this ECALL is a DMA memset. Operands are recovered from x10/x11/x12. + pub ecall_dma_memset: bool, } impl CpuOperation { @@ -242,6 +248,10 @@ impl CpuOperation { f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; let ecall_hint = f.ecall && log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; + let ecall_dma_memcpy = f.ecall + && log.src1_val == executor::vm::instruction::execution::DMA_MEMCPY_SYSCALL_NUMBER; + let ecall_dma_memset = f.ecall + && log.src1_val == executor::vm::instruction::execution::DMA_MEMSET_SYSCALL_NUMBER; // Word instructions are fully handled by CPU32; the main CPU row is a // delegate that only advances the PC and sends the CPU32 lookup. We still @@ -361,6 +371,8 @@ impl CpuOperation { keccak_state_addr, ecall_ecsm, ecall_hint, + ecall_dma_memcpy, + ecall_dma_memset, } } @@ -964,7 +976,7 @@ pub fn bus_interactions() -> Vec { } // ------------------------------------------------------------------------- - // ECALL: system-call bus (HALT/COMMIT/KECCAK receive). mult = ECALL. + // ECALL: system-call bus (HALT/COPY/KECCAK/ECSM/HINT receive). mult = ECALL. // ECALL[timestamp, rv1]. // ------------------------------------------------------------------------- interactions.push(BusInteraction::sender( diff --git a/prover/src/tables/hint.rs b/prover/src/tables/hint.rs index cb1dab9f3..aff37a4d8 100644 --- a/prover/src/tables/hint.rs +++ b/prover/src/tables/hint.rs @@ -1,6 +1,7 @@ //! HINT table — receiver for the non-constraining `hint` ecall. //! -//! The `hint` ecall (syscall `u64::MAX - 30`) lets the executor hand the guest a +//! The `hint` ecall (syscall `u64::MAX - 31`, i.e. `-32`) lets the executor hand the +//! guest a //! value that is expensive to compute but cheap to verify (modular inverse, sqrt, //! …); the guest verifies it with ordinary constrained instructions. Unlike a //! normal `STORE`, the ecall writes the 32-byte output to guest memory *directly* @@ -32,7 +33,7 @@ //! sound and avoids the mixed-timestamp bookkeeping of a partial-buffer read. //! //! `mu` is constrained to a bit (`IS_BIT`, the table's only algebraic constraint) — -//! the same guard every other multiplicity-column table carries (ECSM/ECDAS/COMMIT/ +//! the same guard every other multiplicity-column table carries (ECSM/ECDAS/COPY/ //! STORE/MEMW_R). The `Ecall` bus alone does not establish it: its tuple carries the //! timestamp, a free column, so the LogUp identity pins only the *sum* of `mu` over //! the rows sharing a `(ts, syscall)` tuple to the CPU's send — it does not rule out diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index 7b84cbd48..1d47f0d6e 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -164,7 +164,7 @@ pub fn bus_interactions() -> Vec { // 1. ECALL receiver (shared bus, per spec keccak:c:output) // Payload: [ts_lo, ts_hi, syscall_lo32, syscall_hi32] in DWordWL [lo, hi] - // ordering, matching the CPU ECALL sender shared with HALT/COMMIT. + // ordering, matching the CPU ECALL sender shared with HALT/COPY/ECSM/HINT. interactions.push(BusInteraction::receiver( BusId::Ecall, Multiplicity::Column(cols::MU), diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index f1a899f56..63bcb5d84 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -24,7 +24,7 @@ pub mod types; pub mod bitwise; pub mod branch; pub mod bytewise; -pub mod commit; +pub mod copy; pub mod cpu; pub mod cpu32; pub mod decode; diff --git a/prover/src/tables/register.rs b/prover/src/tables/register.rs index 34bda3e74..7df3877d7 100644 --- a/prover/src/tables/register.rs +++ b/prover/src/tables/register.rs @@ -42,14 +42,14 @@ use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable}; /// x0-x31 (32 GPRs), x254 (synthetic commit index), x255 (PC register). pub const NUM_REGISTERS: usize = 34; -/// Most register accesses are 64-bit = 2 Words of 32 bits each. -/// The COMMIT spec adds a synthetic single-word x254 entry at address 508. +/// Every register access is 64-bit = 2 Words of 32 bits each, x254 included: the +/// COPY chip reads it through the same `REG` template as x10, so the two share one +/// interaction and therefore one access width. pub const WORDS_PER_REGISTER: usize = 2; /// Total number of register Word addresses. -/// x0-x31 use addresses 0..63, x254 uses address 508, x255 uses addresses 510..511. -/// -1 because x254 is single-word (1 address instead of 2). -pub const NUM_REGISTER_ADDRESSES: usize = NUM_REGISTERS * WORDS_PER_REGISTER - 1; +/// x0-x31 use addresses 0..63, x254 uses 508..509, x255 uses addresses 510..511. +pub const NUM_REGISTER_ADDRESSES: usize = NUM_REGISTERS * WORDS_PER_REGISTER; /// Number of preprocessed columns (OFFSET, INIT) for the monolithic prover. /// OFFSET encodes the Word address, INIT holds the initial value. @@ -112,30 +112,34 @@ pub type FinalRegisterStateMap = HashMap; /// Returns the Word addresses for all register table rows. /// -/// x0-x31 use addresses 0..63, x254 uses address 508, x255 uses 510..511. +/// x0-x31 use addresses 0..63, x254 uses 508..509, x255 uses 510..511. pub(crate) fn register_word_address_list() -> [u64; NUM_REGISTER_ADDRESSES] { let mut addrs = [0u64; NUM_REGISTER_ADDRESSES]; // x0-x31: addresses 0..63 for (i, addr) in addrs.iter_mut().enumerate().take(64) { *addr = i as u64; } - // x254: synthetic commit index (single-word) + // x254: synthetic commit index, addresses 508, 509 addrs[64] = 508; + addrs[65] = 509; // x255: addresses 510, 511 - addrs[65] = 510; - addrs[66] = 511; + addrs[66] = 510; + addrs[67] = 511; addrs } // Positions of the non-general-purpose registers within a register-init vector // (indexed in `register_word_address_list` order). x0-x31 occupy positions 0..63 // (position `i` is word address `i`), so register `r`'s two words are at `2r`, `2r+1`. -/// Position of x254 (synthetic commit index, word address 508). +/// Position of x254 (synthetic commit index) low word (word address 508). pub(crate) const X254_INDEX: usize = 64; +/// Position of x254 high word (word address 509). +#[cfg(test)] +pub(crate) const X254_HI_INDEX: usize = 65; /// Position of x255 (PC) low word (word address 510). -pub(crate) const PC_LO_INDEX: usize = 65; +pub(crate) const PC_LO_INDEX: usize = 66; /// Position of x255 (PC) high word (word address 511). -pub(crate) const PC_HI_INDEX: usize = 66; +pub(crate) const PC_HI_INDEX: usize = 67; /// Compute the initial value for a register Word address. /// @@ -144,7 +148,7 @@ pub(crate) const PC_HI_INDEX: usize = 66; /// and supply their own boundary register snapshot instead. /// /// - SP (x2) words at offset 4,5 hold STACK_TOP -/// - x254 at offset 508 is the synthetic commit index, initialized to 0 +/// - x254 at offsets 508,509 is the synthetic commit index, initialized to 0 /// - PC (x255) words at offset 510,511 hold entry_point /// - All others are 0 fn init_value_for_address(word_addr: u64, entry_point: u64) -> u32 { @@ -191,10 +195,11 @@ pub(crate) fn register_init_from_snapshot(registers: &Registers, pc: u64) -> Vec init[base + 1] = (value >> 32) as u32; } // x254 synthetic commit index, hardcoded to 0 in this test-only helper, so it - // is only correct for an epoch with no preceding COMMIT. The production path + // is only correct for an epoch with no preceding `write`. The production path // carries x254 across epochs via the previous epoch's bound FINI vector, not // this snapshot helper. init[X254_INDEX] = 0; + init[X254_HI_INDEX] = 0; init[PC_LO_INDEX] = (pc & 0xFFFF_FFFF) as u32; init[PC_HI_INDEX] = (pc >> 32) as u32; init @@ -204,7 +209,7 @@ pub(crate) fn register_init_from_snapshot(registers: &Registers, pc: u64) -> Vec /// /// Creates a table with NUM_REGISTER_ADDRESSES rows. /// Each row represents one Word address in register space. -/// x0-x31 at addresses 0..63, x254 at 508, x255 (PC) at 510..511. +/// x0-x31 at addresses 0..63, x254 at 508..509, x255 (PC) at 510..511. /// /// ## Arguments /// @@ -297,7 +302,7 @@ pub fn fini_from_final_state(final_state: &FinalRegisterStateMap, init: &[u32]) /// Computes the Merkle root commitment over the LDE of REGISTER precomputed columns. /// /// Program-dependent: x255 (PC) init = entry_point. -/// OFFSET encodes the Word address (0..63 for x0-x31, 508 for x254, 510-511 for x255). +/// OFFSET encodes the Word address (0..63 for x0-x31, 508-509 for x254, 510-511 for x255). /// INIT holds the initial value (SP=STACK_TOP, PC=entry_point, rest=0). pub fn compute_precomputed_commitment(options: &ProofOptions, init: &[u32]) -> Commitment { let num_rows = NUM_REGISTER_ADDRESSES.next_power_of_two(); @@ -473,7 +478,7 @@ pub fn register_base_address(reg_idx: u8) -> u64 { /// Compute the Word addresses used by a register. pub fn register_word_addresses(reg_idx: u8) -> Vec { match reg_idx { - 254 => vec![508], + 254 => vec![508, 509], 255 => vec![510, 511], _ => { let base = 2 * reg_idx as u64; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index c3b695a80..fcda6d941 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -42,7 +42,7 @@ use stark::trace::TraceTable; use super::bitwise::{self, BitwiseOperation, BitwiseOperationType}; use super::branch::{self, BranchOperation}; use super::bytewise; -use super::commit::{self, CommitOperation}; +use super::copy; use super::cpu::{self, CpuOperation}; use super::cpu32; use super::decode; @@ -264,12 +264,19 @@ impl RegisterState { ); } - // Synthetic x254 commit index at address 508 (single-word per spec). + // Synthetic x254 commit index at addresses 508..509. COPY reads it through + // the same `REG` template as x10, so it is a two-word register like any + // other; the index itself never exceeds 32 bits, so the high word is zero. { let (value, timestamp) = self.index_register; + let base_addr = register::register_base_address(254); + map.insert(base_addr, FinalRegisterWordState { timestamp, value }); map.insert( - register::register_base_address(254), - FinalRegisterWordState { timestamp, value }, + base_addr + 1, + FinalRegisterWordState { + timestamp, + value: 0, + }, ); } @@ -532,44 +539,46 @@ fn build_reg_fallback( /// /// MEMW and LOAD collection requires sequential processing with state tracking. /// -/// Returns: (memw_buckets, load_ops, lt_ops, shift_ops, bitwise_ops, commit_ops, +/// Returns: (memw_buckets, load_ops, lt_ops, shift_ops, bitwise_ops, /// keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops) #[allow(clippy::type_complexity)] fn collect_ops_from_cpu( cpu_ops: &[CpuOperation], memory_state: &mut MemoryState, register_state: &mut RegisterState, -) -> ( - MemwBuckets, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, - Vec, -) { +) -> Result< + ( + MemwBuckets, + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + ), + Error, +> { let mut memw = MemwBuckets::with_register_capacity(cpu_ops.len() * 3); let mut load_ops = Vec::with_capacity(cpu_ops.len() / 8 + 1); let mut lt_ops = Vec::with_capacity(cpu_ops.len() / 10 + 1); let mut shift_ops = Vec::with_capacity(cpu_ops.len() / 10 + 1); let mut bitwise_ops = Vec::with_capacity(cpu_ops.len() * 4); - let mut commit_ops = Vec::new(); let mut keccak_ops = Vec::new(); let mut cpu32_ops = Vec::new(); let mut ecsm_ops = Vec::new(); let mut ecdas_ops = Vec::new(); + let mut copy_ops: Vec = Vec::new(); let mut hint_ops = Vec::new(); // Seed from the carried x254 (0 for a monolithic run or the first epoch) so a // continuation epoch indexes its commits globally, matching the x254 the // register binding transports across epochs. Resetting to 0 here would drift - // from x254 and break the COMMIT chip's Memw token (see the drift assert below). + // from x254 and break the COPY chip's Memw token (see the drift assert below). let start_commit_index = register_state.read_index().0; let mut current_commit_index = start_commit_index; - let mut commit_ecall_count = 0u32; for op in cpu_ops { // Word (`*W`) instructions delegate to the CPU32 table (built in program @@ -595,25 +604,42 @@ fn collect_ops_from_cpu( // Collect register operations (M1, M3, M5) collect_register_ops_from_cpu(op, register_state, &mut memw); - // Collect COMMIT ECALL memory operations (register reads/writes + byte reads) + // The `write` syscall: the COPY chip receives the ECALL, reads the operand + // registers, advances x254 and moves the bytes into the COMMIT domain. if op.ecall_commit { - commit_ops.extend(expand_commit_operations_for_ecall( - op, + let index = u64::from(current_commit_index); + memw.extend_ops(collect_copy_register_ops( + copy::Functionality::Write, + op.timestamp, + op.commit_buf_addr, + index, + op.commit_count, + register_state, + )?); + let (write_memw, rows) = collect_copy_ops( + copy::Functionality::Write, + op.timestamp, + op.commit_buf_addr, + index, + op.commit_count, memory_state, - current_commit_index as u64, - )); - let reg_commit_ops = collect_commit_memw_ops(op, register_state, memory_state); - memw.extend_ops(reg_commit_ops); - let count = u32::try_from(op.commit_count).expect("commit_count exceeds u32 range"); + ); + memw.extend_ops(write_memw); + copy_ops.extend(rows); + let count = u32::try_from(op.commit_count).map_err(|_| { + Error::Prover(format!( + "commit_count {} exceeds u32 range", + op.commit_count + )) + })?; current_commit_index = current_commit_index .checked_add(count) - .expect("commit index exceeds u32 range"); + .ok_or_else(|| Error::Prover("commit index exceeds u32 range".to_string()))?; debug_assert_eq!( current_commit_index, register_state.read_index().0, "commit index drift: current_commit_index and register_state.index_register must stay in sync" ); - commit_ecall_count += 1; } // Collect KeccakPermute ECALL operations @@ -657,6 +683,64 @@ fn collect_ops_from_cpu( ecdas_ops.extend(ecdas_rows); } + // DMA memcpy: authenticate x10/x11/x12, snapshot all source bytes at + // T, then write all destination bytes at T+1. + if op.ecall_dma_memcpy { + let dst = register_state.read(10).0; + let src = register_state.read(11).0; + let count = register_state.read(12).0; + memw.extend_ops(collect_copy_register_ops( + copy::Functionality::Copy, + op.timestamp, + src, + dst, + count, + register_state, + )?); + let (mm_memw, rows) = collect_copy_ops( + copy::Functionality::Copy, + op.timestamp, + src, + dst, + count, + memory_state, + ); + memw.extend_ops(mm_memw); + copy_ops.extend(rows); + } + + // DMA memset: authenticate x10/x11/x12, then run the copy primitive with the + // read/write order inverted — write at T, read at T+1. There IS a source + // phase, and the self-overlap is the point: the stub seeds eight bytes and + // calls with `dst = src + 8`, so each row's read observes the write eight + // bytes back and the seed propagates. That is what `copy:c:set_gap` pins. + if op.ecall_dma_memset { + // memset is a copy call whose only distinguishing feature is the + // inverted timestamp order; the stub already seeded the first eight + // bytes and passed dst = seed_end, src = seed_start, count = n - 8. + let dst = register_state.read(10).0; + let src = register_state.read(11).0; + let count = register_state.read(12).0; + memw.extend_ops(collect_copy_register_ops( + copy::Functionality::Set, + op.timestamp, + src, + dst, + count, + register_state, + )?); + let (memset_memw, rows) = collect_copy_ops( + copy::Functionality::Set, + op.timestamp, + src, + dst, + count, + memory_state, + ); + memw.extend_ops(memset_memw); + copy_ops.extend(rows); + } + // Collect Hint ecall operations (the 32-byte output write). if op.ecall_hint { let (hint_memw, hint_op) = collect_hint_ops(op, memory_state, register_state); @@ -700,27 +784,32 @@ fn collect_ops_from_cpu( bitwise_ops.extend(op.collect_bitwise_ops()); } - // Each ecall generates count+1 operations (count real rows + 1 end row). - // Count only this epoch's rows, so subtract the carried start index. + // There is no COMMIT table any more: COPY receives the `write` ECALL itself. The + // committed length is checked against the rows that actually move the bytes, and + // a zero-length `write` contributes a single row that moves none. debug_assert_eq!( - commit_ops.len(), - (current_commit_index - start_commit_index) as usize + commit_ecall_count as usize, - "commit_ops count should match accumulated commit index plus end rows" + copy_ops + .iter() + .filter(|op| op.functionality == copy::Functionality::Write && op.count != 0) + .map(|op| op.width as u64) + .sum::(), + u64::from(current_commit_index - start_commit_index), + "COPY should move exactly the committed byte count" ); - ( + Ok(( memw, load_ops, lt_ops, shift_ops, bitwise_ops, - commit_ops, keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops, + copy_ops, hint_ops, - ) + )) } /// Collects a LOAD operation and corresponding MEMW read from CpuOperation. @@ -959,6 +1048,266 @@ fn collect_ecsm_ops( (memw_ops, ecsm_op, ecdas_ops) } +/// The register accesses one copy ecall makes, all at the raw ECALL timestamp. +/// +/// These mirror `copy:c:read_src`, `copy:c:read_count`, `copy:c:read_dst` and +/// `copy:c:read_fd` one for one. `x11` and `x12` are plain reads; `x10 + 244·is_write` +/// is a read-modify-write that returns `dst_res` (the `void*` the `mem*` calls return, +/// or the advanced commit index a `write` needs); and a `write` additionally reads the +/// file descriptor out of `x10` and returns `count` there. +fn collect_copy_register_ops( + functionality: copy::Functionality, + timestamp: u64, + src: u64, + dst: u64, + count: u64, + register_state: &mut RegisterState, +) -> Result, Error> { + let is_write = functionality == copy::Functionality::Write; + let mut ops = Vec::with_capacity(4); + + let read_back = |reg: u8, value: u64, register_state: &mut RegisterState| { + let packed = pack_register_value(value); + let (_old_value, old_ts) = register_state.read(reg); + let op = MemwOperation::new(true, 2 * reg as u64, packed, timestamp, 2, true) + .with_old(packed, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]); + register_state.write(reg, value, timestamp); + op + }; + + ops.push(read_back(11, src, register_state)); + ops.push(read_back(12, count, register_state)); + + if is_write { + // `dst` is the running commit index in x254, and `dst_res = dst + count`. + let (old_index, old_ts) = register_state.read_index(); + // Checked in release, not `debug_assert`: a mismatch here produces a trace + // that is merely unprovable, surfacing as an opaque Memw bus imbalance. An + // error names the cause instead, and does it without taking the process + // down -- these are the prover's own invariants, and a caller proving one + // run should not lose the others to a panic. + if u64::from(old_index) != dst { + return Err(Error::Prover(format!( + "the commit index in x254 ({old_index}) must be the `write` \ + destination ({dst})" + ))); + } + let new_index = u32::try_from(dst + count).map_err(|_| { + Error::Prover(format!( + "commit index {} exceeds u32 range; the COMMIT bus carries it in one \ + 32-bit limb", + dst + count + )) + })?; + ops.push( + MemwOperation::new( + true, + register::register_base_address(254), + pack_register_value(u64::from(new_index)), + timestamp, + 2, + true, + ) + .with_old( + pack_register_value(u64::from(old_index)), + [old_ts, old_ts, 0, 0, 0, 0, 0, 0], + ), + ); + register_state.write_index(new_index, timestamp); + + // x10 carries the file descriptor in and the byte count out. Only stdout is + // supported, so the AIR pins the old value to 1. + let (fd, fd_ts) = register_state.read(10); + if fd != 1 { + return Err(Error::Prover(format!( + "ECALL write: x10 (fd) must be 1, got {fd}" + ))); + } + ops.push( + MemwOperation::new(true, 2 * 10, pack_register_value(count), timestamp, 2, true) + .with_old(pack_register_value(1), [fd_ts, fd_ts, 0, 0, 0, 0, 0, 0]), + ); + register_state.write(10, count, timestamp); + } else { + // `dst_res == dst`: the `mem*` calls return their destination unchanged. + ops.push(read_back(10, dst, register_state)); + } + + Ok(ops) +} + +/// Replays one ecall through the unified copy primitive. +/// +/// Register operands are read at `T` by [`collect_copy_register_ops`]. For a copy, +/// every source chunk is read at `T` before any destination chunk is written at +/// `T+1`, which is the executor's snapshot semantics even when the regions overlap; +/// a memset inverts that order so each row's read observes the previous row's write. +/// Chunks are eight bytes while at least eight remain, then one byte per tail row — +/// width is a per-row choice the AIR permits at any count, and this is simply the +/// schedule the builder takes. +/// +/// `Write` moves its bytes into the COMMIT domain, so it emits no MEMW write at all. +/// +/// There is no terminal row: the chain stops on the row that moves the last bytes, +/// where `count == step`. A zero-length call still produces one row — the degenerate +/// `(count, first) = (0, 1)` state — which moves nothing. +fn collect_copy_ops( + functionality: copy::Functionality, + timestamp: u64, + src: u64, + dst: u64, + count: u64, + memory_state: &mut MemoryState, +) -> (Vec, Vec) { + let mut memw_ops = Vec::new(); + let mut rows: Vec = Vec::new(); + let inverted = functionality == copy::Functionality::Set; + let is_write = functionality == copy::Functionality::Write; + let read_ts = timestamp + u64::from(inverted); + let write_ts = timestamp + 1 - u64::from(inverted); + + let mut offset = 0u64; + let mut remaining = count; + let mut first = true; + // Snapshot order needs every read to land before any write, so the writes of a + // non-inverted call are held back to a second pass. + let mut deferred_writes = Vec::new(); + + while remaining != 0 { + let width = executor::vm::instruction::execution::copy_row_width( + src, dst, offset, remaining, is_write, + ); + let source_addr = src.wrapping_add(offset); + let destination_addr = dst.wrapping_add(offset); + let (value, old_timestamps) = memory_state.read_bytes(source_addr, width as usize); + let bytes = value.map(|byte| byte as u8); + let dword = u64::from_le_bytes(bytes); + + if inverted { + // Write first, at the earlier timestamp, so this row's read sees the + // previous row's write. + let (old_values, old_dst_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + memw_ops.push( + MemwOperation::new(false, destination_addr, value, write_ts, width, false) + .with_old(old_values, old_dst_timestamps), + ); + memory_state.write_bytes(destination_addr, dword, width as usize, write_ts); + memw_ops.push( + MemwOperation::new(false, source_addr, value, read_ts, width, true) + .with_old(value, old_timestamps), + ); + memory_state.write_bytes(source_addr, dword, width as usize, read_ts); + } else { + memw_ops.push( + MemwOperation::new(false, source_addr, value, read_ts, width, true) + .with_old(value, old_timestamps), + ); + memory_state.write_bytes(source_addr, dword, width as usize, read_ts); + if !is_write { + deferred_writes.push((destination_addr, width, value, dword)); + } + } + + rows.push(copy::CopyOperation { + timestamp, + src: source_addr, + dst: destination_addr, + count: remaining, + width, + first, + end: false, + functionality, + value: bytes, + }); + + first = false; + offset += u64::from(width); + remaining -= u64::from(width); + } + + for (destination_addr, width, value, dword) in deferred_writes { + let (old_values, old_timestamps) = + memory_state.read_bytes(destination_addr, width as usize); + memw_ops.push( + MemwOperation::new(false, destination_addr, value, write_ts, width, false) + .with_old(old_values, old_timestamps), + ); + memory_state.write_bytes(destination_addr, dword, width as usize, write_ts); + } + + match rows.last_mut() { + Some(last) => last.end = true, + // A zero-length call: one row, first and last, moving nothing. `count - step` + // borrows out, which is the AIR's proxy for exactly this state. + None => rows.push(copy::CopyOperation { + timestamp, + src, + dst, + count: 0, + width: 1, + first: true, + end: true, + functionality, + value: [0; 8], + }), + } + + (memw_ops, rows) +} + +/// Sizing-pass replay of one COPY-driven ecall. +/// +/// Deliberately delegates to [`collect_copy_ops`] rather than re-deriving the +/// schedule: the two used to be separate implementations pinned together by an +/// assertion, and the schedule is now a function of `dst` as well as `count`, which +/// is exactly the kind of thing that drifts. The cost is one allocation per ecall. +#[cfg(feature = "disk-spill")] +fn replay_copy_for_sizing( + functionality: copy::Functionality, + timestamp: u64, + src: u64, + dst: u64, + count: u64, + memory_state: &mut MemoryState, + mut visit_memw: impl FnMut(&MemwOperation), +) -> u64 { + let (memw_ops, rows) = + collect_copy_ops(functionality, timestamp, src, dst, count, memory_state); + for op in &memw_ops { + visit_memw(op); + } + rows.len() as u64 +} + +/// Test hook for the schedule, so the COPY unit tests can pin it. +#[cfg(test)] +pub fn copy_row_width_for_test( + src: u64, + dst: u64, + offset: u64, + remaining: u64, + is_write: bool, +) -> u8 { + executor::vm::instruction::execution::copy_row_width(src, dst, offset, remaining, is_write) +} + +/// Test hook for the row schedule the production builder actually emits. +/// +/// The COPY unit tests build their rows with a local helper, which is fast but means +/// nothing in that file would notice the real builder drifting away from it. This +/// runs `collect_copy_ops` against a fresh memory image and hands back the rows. +#[cfg(test)] +pub fn copy_ops_for_test( + functionality: copy::Functionality, + src: u64, + dst: u64, + count: u64, +) -> Vec { + let mut memory_state = MemoryState::new(); + collect_copy_ops(functionality, 100, src, dst, count, &mut memory_state).1 +} + /// Collects the memory operations for a `Hint` ecall. /// /// The `hint` ecall writes a 32-byte value (a modular inverse / sqrt) to guest @@ -1267,113 +1616,6 @@ fn cpu32_chip_op( } } -/// Collects MEMW operations for a COMMIT ECALL from CpuOperation. -/// -/// All operations use the raw ECALL timestamp (no offsets). Per the spec, -/// independent accesses at different addresses can share a timestamp. -/// -/// Operations: -/// - Read+write x10 at ts: asserts fd=1 (old), writes count (new) -/// - Read x11 at ts: reads buf_addr -/// - Read x12 at ts: reads count -/// - Read+write x254 at ts: updates the global commit index -/// - Read bytes at ts: reads committed bytes from memory -/// -/// Note: x17 (syscall number) is read by CPU's M1 interaction (read_register1=true, rs1=17). -/// -/// Returns: Vec of MEMW operations -fn collect_commit_memw_ops( - op: &CpuOperation, - register_state: &mut RegisterState, - memory_state: &mut MemoryState, -) -> Vec { - let ts = op.timestamp; - let buf_addr = op.commit_buf_addr; - let count = op.commit_count; - - let mut memw_ops = Vec::with_capacity(5 + count as usize); - - // Combined read+write x10 at ts: old=fd=1, new=count - // This atomically asserts x10 held fd=1 and writes count as return value. - // Uses is_read=true so MEMW activates the CO24 receiver (24 elements with old[]), - // matching the COMMIT chip's CO24 bus send format. - { - let old_value = pack_register_value(1); // fd = 1 - let new_value = pack_register_value(count); - let reg_addr = 2 * 10u64; // x10 → addr 20 - let (old_val, old_ts) = register_state.read(10); - debug_assert_eq!( - old_val, 1, - "ECALL commit: x10 (fd) must be 1, got {old_val}" - ); - let old_timestamps = [old_ts, old_ts, 0, 0, 0, 0, 0, 0]; - let memw_op = MemwOperation::new(true, reg_addr, new_value, ts, 2, true) - .with_old(old_value, old_timestamps); - memw_ops.push(memw_op); - register_state.write(10, count, ts); - } - - // Read x11 (buf_addr) at ts - { - let reg_value = pack_register_value(buf_addr); - let reg_addr = 2 * 11u64; // x11 → addr 22 - let (_old_val, old_ts) = register_state.read(11); - let old_timestamps = [old_ts, old_ts, 0, 0, 0, 0, 0, 0]; - let memw_op = MemwOperation::new(true, reg_addr, reg_value, ts, 2, true) - .with_old(reg_value, old_timestamps); - memw_ops.push(memw_op); - register_state.write(11, buf_addr, ts); - } - - // Read x12 (count) at ts - { - let reg_value = pack_register_value(count); - let reg_addr = 2 * 12u64; // x12 → addr 24 - let (_old_val, old_ts) = register_state.read(12); - let old_timestamps = [old_ts, old_ts, 0, 0, 0, 0, 0, 0]; - let memw_op = MemwOperation::new(true, reg_addr, reg_value, ts, 2, true) - .with_old(reg_value, old_timestamps); - memw_ops.push(memw_op); - register_state.write(12, count, ts); - } - - // Read+write x254 (global commit index) at ts - { - let (old_index, old_ts) = register_state.read_index(); - let new_index = old_index - .checked_add(u32::try_from(count).expect("commit_count exceeds u32 range")) - .expect("commit index exceeds u32 range"); - let old_value = [old_index, 0, 0, 0, 0, 0, 0, 0]; - let new_value = [new_index, 0, 0, 0, 0, 0, 0, 0]; - let old_timestamps = [old_ts, 0, 0, 0, 0, 0, 0, 0]; - let memw_op = MemwOperation::new( - true, - register::register_base_address(254), - new_value, - ts, - 1, - true, - ) - .with_old(old_value, old_timestamps); - memw_ops.push(memw_op); - register_state.write_index(new_index, ts); - } - - // Memory byte reads at ts - for i in 0..count { - let addr = buf_addr.wrapping_add(i); - let (byte_val, old_ts) = memory_state.read_byte(addr); - let value = [byte_val as u32, 0, 0, 0, 0, 0, 0, 0]; - let old_timestamps = [old_ts, 0, 0, 0, 0, 0, 0, 0]; - let memw_op = - MemwOperation::new(false, addr, value, ts, 1, true).with_old(value, old_timestamps); - memw_ops.push(memw_op); - memory_state.write_byte(addr, byte_val, ts); - } - - memw_ops -} - /// Collects HALT finalization MEMW operations for all 33 registers. /// /// Per spec (halt.toml): at timestamp 2^64-1, HALT finalizes the GP registers: @@ -1642,7 +1884,7 @@ fn collect_bitwise_from_memw_aligned(ops: &[MemwOperation]) -> Vec old_timestamp[0] (lower limb ordering) /// 5. timestamp[0] - old_timestamp[0] <= 0x10000 (delta fits in IS_HALF range [1, 2^16]) /// -/// Width-1 register ops (e.g. COMMIT x254) stay in MEMW, which has +/// Width-1 register ops stay in MEMW, which has /// dynamic write flags. MEMW_R hardcodes write2=1. pub(crate) fn is_register_op(op: &MemwOperation) -> bool { if !op.is_register || op.width != 2 { @@ -2165,7 +2407,7 @@ pub(crate) fn epoch_touched_cells( let mut memory_state = MemoryState::from_image(initial_image); let mut register_state = RegisterState::from_init(register_init); - let _ = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); + let _ = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state)?; Ok(touched_cells_from_memory_state(&memory_state)) } @@ -2242,95 +2484,33 @@ fn collect_bitwise_from_page( } // ============================================================================= -// COMMIT Operation Expansion -// ============================================================================= - -/// Expand one Commit ECALL into its per-byte COMMIT rows using the memory state -/// at the moment the ECALL executes. -fn expand_commit_operations_for_ecall( - ecall: &CpuOperation, - memory_state: &MemoryState, - start_index: u64, -) -> Vec { - let mut ops = Vec::new(); - - let timestamp = ecall.timestamp; - let buf_addr = ecall.commit_buf_addr; - let count = ecall.commit_count; - - for i in 0..=count { - let remaining = count - i; - let is_end = remaining == 0; - let value = if !is_end { - let (byte_val, _ts) = memory_state.read_byte(buf_addr.wrapping_add(i)); - byte_val - } else { - 0 - }; - ops.push(CommitOperation { - timestamp, - index: start_index.wrapping_add(i), - address: buf_addr.wrapping_add(i), - count: remaining, - first: i == 0, - end: is_end, - value, - }); - } - - ops -} - -/// Collect bitwise lookups from COMMIT operations. +/// BITWISE lookups sent by the COPY table: twelve `IS_HALF` for the three chained +/// dwords on every row, plus four more for `dst_res` on a `write` row. /// -/// The COMMIT table sends: -/// - IsHalfword for count_decr components (4 per real row, mult = mu) -/// - IsHalfword for address_incr halfwords (4 per real row, mult = mu) -/// - Zero for end detection (1 per real row, mult = mu) -/// -/// Note: AreBytes for value is intentionally omitted per spec. -fn collect_bitwise_from_commit(commit_ops: &[CommitOperation]) -> Vec { - let mut lookups = Vec::new(); - - for op in commit_ops { - // IsHalfword for count_decr components (4 halfwords, mult = mu) - let count_decr = if op.count == 0 { - u64::MAX - } else { - op.count - 1 - }; - for shift in [0, 16, 32, 48] { - let half = ((count_decr >> shift) & 0xFFFF) as u16; - lookups.push(BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (half & 0xFF) as u8, - ((half >> 8) & 0xFF) as u8, - )); - } +/// The `ZERO` end detection is gone with the lookup it fed: `end` is now pinned by +/// the `restrict_end` constraint group against the borrow out of `count - step`. +fn collect_bitwise_from_copy(ops: &[copy::CopyOperation]) -> Vec { + let mut lookups = Vec::with_capacity(ops.len() * 16); + for op in ops { + let width = u64::from(op.width); + let count_decr = op.count.wrapping_sub(width); + let src_incr = op.src.wrapping_add(width); + let dst_incr = op.dst.wrapping_add(width); - // IsHalfword for address_incr halfwords (4 halfwords, mult = mu) - // All real rows send these, matching the spec's unconditional mult = mu. - let address_incr = op.address.wrapping_add(1); - for shift in [0, 16, 32, 48] { - let half = ((address_incr >> shift) & 0xFFFF) as u16; - lookups.push(BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (half & 0xFF) as u8, - ((half >> 8) & 0xFF) as u8, - )); + for value in [src_incr, dst_incr, count_decr] { + for shift in [0, 16, 32, 48] { + let half = ((value >> shift) & 0xFFFF) as u16; + lookups.push(is_half_op(half)); + } } - // Zero bus for end detection (mult = mu) - // Input: (65535 - cd_0) + (65535 - cd_1) + (65535 - cd_2) + (65535 - cd_3) - // When count_decr = 0xFFFF_FFFF_FFFF_FFFF (count=0), sum = 0 → end=1 - let cd_0 = (count_decr & 0xFFFF) as u32; - let cd_1 = ((count_decr >> 16) & 0xFFFF) as u32; - let cd_2 = ((count_decr >> 32) & 0xFFFF) as u32; - let cd_3 = ((count_decr >> 48) & 0xFFFF) as u32; - let zero_input = (65535 - cd_0) + (65535 - cd_1) + (65535 - cd_2) + (65535 - cd_3); - lookups.push(BitwiseOperation::zero(zero_input)); + if op.functionality == copy::Functionality::Write { + for shift in [0, 16, 32, 48] { + let half = ((op.dst_res() >> shift) & 0xFFFF) as u16; + lookups.push(is_half_op(half)); + } + } } - lookups } @@ -2860,10 +3040,6 @@ pub struct Traces { /// HALT single-row table for program termination pub halt: TraceTable, - /// COMMIT table for write syscall (byte-by-byte commit with recursive bus). - /// Empty when the run commits no bytes. - pub commits: Vec>, - /// KECCAK core table (one row per keccak permutation call). Empty when the /// run makes no keccak call. pub keccaks: Vec>, @@ -2885,6 +3061,14 @@ pub struct Traces { /// makes no hint ecall. pub hints: Vec>, + /// Unified COPY table: one streaming copy primitive for memcpy/memmove, + /// memset and the commit byte loop, selected by decoded functionality columns. + /// Always present: COPY receives the `write` ECALL, so any run that produces + /// output reaches it. A run that neither commits nor copies still ships an + /// all-padding COPY sub-proof — it could be made optional like the other + /// accelerators, which is tracked separately. + pub copy: TraceTable, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -2917,7 +3101,6 @@ struct CollectedOps { branch_ops: Vec, mul_ops: Vec<(MulOperation, bool)>, dvrm_ops: Vec<(DvrmOperation, bool)>, - commit_ops: Vec, keccak_ops: Vec, // Auxiliary ALU / memory / CPU32 dispatch chips (driven by the CPU ALU/MEMORY dispatch). eq_ops: Vec, @@ -2927,6 +3110,8 @@ struct CollectedOps { // EC scalar-multiplication accelerator chips. ecsm_ops: Vec, ecdas_ops: Vec, + // Unified COPY rows: memcpy/memmove, memset and the `write` byte loop. + copy_ops: Vec, // Non-constraining hint ecall. hint_ops: Vec, } @@ -3046,11 +3231,11 @@ fn collect_all_ops( mut lt_ops: Vec, mut shift_ops: Vec, mut bitwise_ops: Vec, - commit_ops: Vec, keccak_ops: Vec, cpu32_ops: Vec, ecsm_ops: Vec, ecdas_ops: Vec, + copy_ops: Vec, hint_ops: Vec, register_state: &mut RegisterState, is_final: bool, @@ -3186,7 +3371,6 @@ fn collect_all_ops( branch_ops, mul_ops, dvrm_ops, - commit_ops, keccak_ops, eq_ops, bytewise_ops, @@ -3195,6 +3379,7 @@ fn collect_all_ops( ecsm_ops, ecdas_ops, hint_ops, + copy_ops, } } @@ -3230,7 +3415,6 @@ fn build_traces( branch_ops, mul_ops, dvrm_ops, - commit_ops, keccak_ops, eq_ops, bytewise_ops, @@ -3238,6 +3422,7 @@ fn build_traces( cpu32_ops, ecsm_ops, ecdas_ops, + copy_ops, hint_ops, } = ops; @@ -3246,6 +3431,11 @@ fn build_traces( // ===================================================================== lt_ops.extend(collect_lt_from_memw(&memw_ops)); lt_ops.extend(collect_lt_from_memw_aligned(&memw_aligned_ops)); + // COPY sends no ALU lookups any more. The `count < 8` check on wide rows and the + // per-ecall `count <= 256` bound are both gone: a chain that overshoots can no + // longer reach a legal `end`, so the termination argument subsumes them. The + // executor still refuses an ecall above 256 bytes, which is where the guest ABI + // needs that bound. // HINT range-checks: selector < 3 and both address low limbs < 2^32 - 31 (matching // the executor's HintUnknownSelector / HintAddressOverflow rejections). Three LT ops // per hint call; the HINT table sends the matching ALU LT interactions. @@ -3263,11 +3453,20 @@ fn build_traces( #[cfg(feature = "instruments")] let __sp = stark::instruments::span("p4_bitwise_collect"); - let public_output_bytes: Vec = commit_ops - .iter() - .filter(|op| !op.end) - .map(|op| op.value) - .collect(); + // The committed bytes flow through the COPY chip, so the public output is read + // off its `write` rows. A zero-length `write` contributes a row that moves no + // bytes; `count != 0` is exactly the `mu - count_borrows[1]` gate the AIR uses. + let public_output_bytes: Vec = { + let mut rows: Vec<©::CopyOperation> = copy_ops + .iter() + .filter(|op| op.functionality == copy::Functionality::Write && op.count != 0) + .collect(); + // `dst` is the COMMIT-domain address, i.e. the running global byte index. + rows.sort_by_key(|op| op.dst); + rows.iter() + .flat_map(|op| op.value[..op.width as usize].iter().copied()) + .collect() + }; // CPU padding rows send ARE_BYTES with all-zero values. // Add corresponding ops so the bitwise table multiplicities balance. @@ -3285,7 +3484,7 @@ fn build_traces( // chunk size used to split them into instances so multiplicities match the per-instance // sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G - // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; + // table owns). COPY sends IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; // HINT sends ARE_BYTES for its 32 output cells. // We never concatenate the lookups into one giant `Vec` (~140 M ops / // ~560 MB at 10-tx whose only consumer is the multiplicity count). Each collector bumps @@ -3321,7 +3520,7 @@ fn build_traces( } }), Box::new(|h| h.add_ops(&collect_bitwise_from_memw_aligned(&memw_aligned_ops))), - Box::new(|h| h.add_ops(&collect_bitwise_from_commit(&commit_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_copy(©_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), @@ -3579,14 +3778,6 @@ fn build_traces( decode::update_multiplicities(&mut decode, decode_pc_to_row, &decode_lookups); decode }; - let gen_commits = || { - generate_optional( - &commit_ops, - commit::generate_commit_trace, - #[cfg(feature = "disk-spill")] - storage_mode, - ) - }; let gen_keccaks = || { generate_optional( &keccak_ops, @@ -3654,19 +3845,21 @@ fn build_traces( storage_mode, ) }; + // COPY is not optional: it receives the `write` ECALL directly. + let gen_copy = || copy::generate_copy_trace(©_ops); let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); let (mut loads_slot, mut lts_slot, mut shifts_slot, mut muls_slot) = (None, None, None, None); let (mut dvrms_slot, mut branches_slot, mut bitwise_slot, mut decode_slot) = (None, None, None, None); - let (mut commits_slot, mut keccaks_slot, mut keccak_rnds_slot, mut keccak_rc_slot) = - (None, None, None, None); + let (mut keccaks_slot, mut keccak_rnds_slot, mut keccak_rc_slot) = (None, None, None); let (mut pages_slot, mut register_slot, mut halt_slot) = (None, None, None); let (mut eqs_slot, mut bytewises_slot, mut stores_slot, mut cpu32s_slot) = (None, None, None, None); let (mut ecsms_slot, mut ecdases_slot) = (None, None); let mut hints_slot = None; + let mut copy_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3699,7 +3892,6 @@ fn build_traces( spawn_into!(keccaks_slot, gen_keccaks); spawn_into!(keccak_rnds_slot, gen_keccak_rnds); spawn_into!(keccak_rc_slot, gen_keccak_rc); - spawn_into!(commits_slot, gen_commits); spawn_into!(register_slot, gen_register); spawn_into!(halt_slot, gen_halt); spawn_into!(eqs_slot, gen_eqs); @@ -3709,6 +3901,7 @@ fn build_traces( spawn_into!(ecsms_slot, gen_ecsms); spawn_into!(ecdases_slot, gen_ecdases); spawn_into!(hints_slot, gen_hints); + spawn_into!(copy_slot, gen_copy); }); } else { cpus_slot = Some(gen_cpus()); @@ -3723,7 +3916,6 @@ fn build_traces( branches_slot = Some(gen_branches()); bitwise_slot = Some(gen_bitwise()); decode_slot = Some(gen_decode()); - commits_slot = Some(gen_commits()); keccaks_slot = Some(gen_keccaks()); keccak_rnds_slot = Some(gen_keccak_rnds()); keccak_rc_slot = Some(gen_keccak_rc()); @@ -3737,6 +3929,7 @@ fn build_traces( ecsms_slot = Some(gen_ecsms()); ecdases_slot = Some(gen_ecdases()); hints_slot = Some(gen_hints()); + copy_slot = Some(gen_copy()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3758,7 +3951,6 @@ fn build_traces( let mut bitwise = bitwise_slot.expect(PHASE5_RAN); #[allow(unused_mut)] let mut decode = decode_slot.expect(PHASE5_RAN); - let commits = commits_slot.expect(PHASE5_RAN)?; let keccaks = keccaks_slot.expect(PHASE5_RAN)?; let keccak_rnds = keccak_rnds_slot.expect(PHASE5_RAN)?; let keccak_rc_trace = keccak_rc_slot.expect(PHASE5_RAN); @@ -3771,6 +3963,8 @@ fn build_traces( let ecsms = ecsms_slot.expect(PHASE5_RAN)?; let ecdases = ecdases_slot.expect(PHASE5_RAN)?; let hints = hints_slot.expect(PHASE5_RAN)?; + #[allow(unused_mut)] + let mut copy_trace = copy_slot.expect(PHASE5_RAN); // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3792,6 +3986,12 @@ fn build_traces( .main_table .spill_to_disk() .map_err(|e| Error::Prover(format!("disk-spill halt: {e}")))?; + // COPY joined this list when it became a fixed table: it carries one row per + // eight committed bytes, so it is not small on an output-heavy run. + copy_trace + .main_table + .spill_to_disk() + .map_err(|e| Error::Prover(format!("disk-spill copy: {e}")))?; for page in &mut pages { page.main_table .spill_to_disk() @@ -3828,13 +4028,13 @@ fn build_traces( public_output_bytes, branches, halt: halt_trace, - commits, keccaks, keccak_rnds, keccak_rc: keccak_rc_trace, ecsms, ecdases, hints, + copy: copy_trace, memw_registers, local_to_global, touched_memory_cells, @@ -3882,7 +4082,7 @@ pub struct TableLengths { pub mul_padded_rows: u64, pub dvrm_padded_rows: u64, pub branch_padded_rows: u64, - pub commit_padded_rows: u64, + pub copy_padded_rows: u64, pub decode_rows: u64, pub unique_page_count: u64, pub cycle_count: u64, @@ -3921,7 +4121,11 @@ pub fn count_table_lengths( let mut mul_count = 0usize; let mut dvrm_count = 0usize; let mut branch_count = 0usize; - let mut commit_count = 0usize; + let mut copy_count = 0usize; + // Zero, not the carried x254 the generation walk seeds from: `count_table_lengths` + // takes no register-init vector, so it is only usable for a whole run or a first + // epoch anyway. It is sound today because no row count depends on `dst` — wire a + // `register_init` through before that stops being true. let mut current_commit_index = 0u32; let partition_memw = |op: &MemwOperation, @@ -3989,28 +4193,71 @@ pub fn count_table_lengths( ); } - // ECALL Commit - if cpu_op.ecall_commit { - // Match `expand_commit_operations_for_ecall`'s `0..=count` loop - // without building the op vector. - commit_count += (cpu_op.commit_count as usize) - .checked_add(1) - .ok_or_else(|| Error::Execution("commit_count overflows usize".into()))?; - let reg_commit_ops = - collect_commit_memw_ops(&cpu_op, &mut register_state, &mut memory_state); - for memw_op in ®_commit_ops { + // Every copy ecall — `write`, `memcpy`/`memmove` and `memset` — goes through + // the same chip, so it is sized through the same path. + if cpu_op.ecall_commit || cpu_op.ecall_dma_memcpy || cpu_op.ecall_dma_memset { + let (functionality, src, dst, count) = if cpu_op.ecall_commit { + ( + copy::Functionality::Write, + cpu_op.commit_buf_addr, + u64::from(current_commit_index), + cpu_op.commit_count, + ) + } else { + let functionality = if cpu_op.ecall_dma_memset { + copy::Functionality::Set + } else { + copy::Functionality::Copy + }; + ( + functionality, + register_state.read(11).0, + register_state.read(10).0, + register_state.read(12).0, + ) + }; + + for reg_op in collect_copy_register_ops( + functionality, + cpu_op.timestamp, + src, + dst, + count, + &mut register_state, + )? { partition_memw( - memw_op, + ®_op, &mut memw_by_width, &mut memw_aligned_count, &mut memw_register_count, ); } - let count = u32::try_from(cpu_op.commit_count) - .map_err(|_| Error::Execution("commit_count exceeds u32 range".into()))?; - current_commit_index = current_commit_index - .checked_add(count) - .ok_or_else(|| Error::Execution("commit index exceeds u32 range".into()))?; + + let rows = replay_copy_for_sizing( + functionality, + cpu_op.timestamp, + src, + dst, + count, + &mut memory_state, + |memw_op| { + partition_memw( + memw_op, + &mut memw_by_width, + &mut memw_aligned_count, + &mut memw_register_count, + ); + }, + ); + copy_count += rows as usize; + + if cpu_op.ecall_commit { + let delta = u32::try_from(count) + .map_err(|_| Error::Execution("commit_count exceeds u32 range".into()))?; + current_commit_index = current_commit_index + .checked_add(delta) + .ok_or_else(|| Error::Execution("commit index exceeds u32 range".into()))?; + } } if cpu_op.ecall_hint { @@ -4090,14 +4337,7 @@ pub fn count_table_lengths( mul_padded_rows: padded_chunked_rows_optional(mul_count, max_rows.mul), dvrm_padded_rows: padded_chunked_rows_optional(dvrm_count, max_rows.dvrm), branch_padded_rows: padded_chunked_rows_optional(branch_count, max_rows.branch), - commit_padded_rows: if commit_count == 0 { - 0 - } else { - commit_count - .checked_next_power_of_two() - .unwrap_or(usize::MAX) - .max(4) as u64 - }, + copy_padded_rows: copy_count.next_power_of_two().max(4) as u64, decode_rows, unique_page_count, cycle_count, @@ -4164,6 +4404,11 @@ impl Traces { tables.extend(self.keccak_rnds.iter_mut()); tables.extend(self.ecsms.iter_mut()); tables.extend(self.ecdases.iter_mut()); + // COPY qualifies on the same criterion as ECSM/ECDAS: of the tables left + // out, it is the only one whose height scales with the workload rather than + // being one row per event. Nothing mutates it after the build, so unlike + // BITWISE it cannot go stale on the device. + tables.push(&mut self.copy); let bytes_of = |t: &TraceTable| { t.num_rows() * t.num_main_columns * 8 @@ -4195,7 +4440,6 @@ impl Traces { use super::bitwise::cols::NUM_COLUMNS as BITWISE_COLS; use super::branch::cols::NUM_COLUMNS as BRANCH_COLS; use super::bytewise::cols::NUM_COLUMNS as BYTEWISE_COLS; - use super::commit::cols::NUM_COLUMNS as COMMIT_COLS; use super::cpu::cols::NUM_COLUMNS as CPU_COLS; use super::cpu32::cols::NUM_COLUMNS as CPU32_COLS; use super::decode::NUM_PRECOMPUTED_COLS as DECODE_PRECOMPUTED; @@ -4238,13 +4482,13 @@ impl Traces { register, branches, halt, - commits, keccaks, keccak_rnds, keccak_rc, ecsms, ecdases, hints, + copy, memw_registers, eqs, bytewises, @@ -4287,9 +4531,6 @@ impl Traces { total += (t.num_rows() * BRANCH_COLS) as u64; } total += (halt.num_rows() * HALT_COLS) as u64; - for t in commits { - total += (t.num_rows() * COMMIT_COLS) as u64; - } total += (register.num_rows() * (REGISTER_COLS - REGISTER_PREPROCESSED)) as u64; for t in pages { total += (t.num_rows() * (PAGE_COLS - PAGE_PREPROCESSED)) as u64; @@ -4325,6 +4566,7 @@ impl Traces { for t in hints { total += (t.num_rows() * HINT_COLS) as u64; } + total += (copy.num_rows() * super::copy::cols::NUM_COLUMNS) as u64; total } @@ -4352,7 +4594,6 @@ impl Traces { let n_dvrm = aux_cols(super::dvrm::bus_interactions().len()); let n_branch = aux_cols(super::branch::bus_interactions().len()); let n_halt = aux_cols(super::halt::bus_interactions().len()); - let n_commit = aux_cols(super::commit::bus_interactions().len()); let n_register = aux_cols(super::register::bus_interactions().len()); // page::bus_interactions count is constant regardless of page_base. let n_page = aux_cols(super::page::bus_interactions(0).len()); @@ -4366,6 +4607,7 @@ impl Traces { let n_cpu32 = aux_cols(super::cpu32::bus_interactions().len()); let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); + let n_copy = aux_cols(super::copy::bus_interactions().len()); let n_hint = aux_cols(super::hint::bus_interactions().len()); let Traces { @@ -4383,13 +4625,13 @@ impl Traces { register, branches, halt, - commits, keccaks, keccak_rnds, keccak_rc, ecsms, ecdases, hints, + copy, memw_registers, eqs, bytewises, @@ -4432,9 +4674,6 @@ impl Traces { total += (t.num_rows() * n_branch) as u64; } total += (halt.num_rows() * n_halt) as u64; - for t in commits { - total += (t.num_rows() * n_commit) as u64; - } total += (register.num_rows() * n_register) as u64; for t in pages { total += (t.num_rows() * n_page) as u64; @@ -4470,6 +4709,7 @@ impl Traces { for t in hints { total += (t.num_rows() * n_hint) as u64; } + total += (copy.num_rows() * n_copy) as u64; total } @@ -4495,7 +4735,6 @@ impl Traces { ecsm: self.ecsms.len(), ecdas: self.ecdases.len(), hint: self.hints.len(), - commit: self.commits.len(), } } @@ -4824,13 +5063,13 @@ impl Traces { lt_ops, shift_ops, bitwise_ops, - commit_ops, keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops, hint_ops, - ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); + copy_ops, + ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state)?; #[cfg(feature = "instruments")] drop(__sp); @@ -4843,12 +5082,12 @@ impl Traces { lt_ops, shift_ops, bitwise_ops, - commit_ops, keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops, hint_ops, + copy_ops, &mut register_state, is_final, ); @@ -4937,13 +5176,13 @@ impl Traces { lt_ops, shift_ops, bitwise_ops, - commit_ops, keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops, hint_ops, - ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state); + copy_ops, + ) = collect_ops_from_cpu(&cpu_ops, &mut memory_state, &mut register_state)?; let ops = collect_all_ops( cpu_ops, @@ -4952,12 +5191,12 @@ impl Traces { lt_ops, shift_ops, bitwise_ops, - commit_ops, keccak_ops, cpu32_ops, ecsm_ops, ecdas_ops, hint_ops, + copy_ops, &mut register_state, true, ); diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index fab4aabff..24d4e9b70 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -307,11 +307,11 @@ pub enum BusId { // ========================================================================= /// Instruction decode lookup Decode = 18, - /// System call handling (CPU → HALT/COMMIT for all ECALLs) + /// System call handling (CPU → HALT/COPY/KECCAK/ECSM/HINT for all ECALLs) Ecall = 19, - /// COMMIT self-referencing recursive bus (row N → row N+1) - CommitNextByte = 20, - /// COMMIT output bus: verifier computes the receiver contribution externally + // ID 20 is reserved for the removed CommitNextByte bus: COMMIT's per-byte + // recursion moved to COPY, which chains over [`BusId::CopyNext`]. + /// Commit output bus (sent by COPY's commit rows): verifier computes the receiver contribution externally /// from `VmProof.public_output` using the shared LogUp challenges Commit = 21, /// Keccak core ↔ round chip: (timestamp, round, state[200 bytes]) @@ -353,12 +353,32 @@ pub enum BusId { /// and sends Bit[ts, idx_k] for the MSB (mult = μ). Bit = 30, + // ========================================================================= + // Copy accelerator + // ========================================================================= + // IDs 29 and 32 are unused. The copy primitive chains over a single + // [`BusId::CopyNext`] and carries the functionality selectors inside the tuple, + // rather than giving each of its three operations a bus of its own. + // ========================================================================= // Continuations // ========================================================================= /// Cross-epoch memory bus: the local-to-global table's per-cell init/fini /// boundary claims, matched across epochs by the final aggregation LogUp. GlobalMemory = 31, + + // ========================================================================= + // Unified copy primitive + // ========================================================================= + /// COPY self-referential streaming bus. A row sends + /// `(timestamp, src_incr, dst_incr, count_decr, is_set, is_write)` to the next + /// row and receives `(timestamp, src, dst, count, is_set, is_write)` from the + /// previous one. The functionality selectors travel inside the tuple, so a chain + /// cannot change operation half way through it — a guarantee that separating the + /// operations by bus id would otherwise have to carry structurally. + CopyNext = 33, + // ID 34 is unused. COPY receives the `write` ECALL and reads its operand + // registers itself, so nothing defers the commit to a second chip. } impl BusId { @@ -377,8 +397,8 @@ impl BusId { BusId::Branch => "Branch", BusId::Decode => "Decode", BusId::Ecall => "Ecall", - BusId::CommitNextByte => "CommitNextByte", BusId::Commit => "Commit", + BusId::CopyNext => "CopyNext", BusId::Keccak => "Keccak", BusId::KeccakRc => "KeccakRc", BusId::ByteAlu => "ByteAlu", @@ -409,7 +429,6 @@ impl TryFrom for BusId { 17 => Ok(BusId::Branch), 18 => Ok(BusId::Decode), 19 => Ok(BusId::Ecall), - 20 => Ok(BusId::CommitNextByte), 21 => Ok(BusId::Commit), 22 => Ok(BusId::Keccak), 23 => Ok(BusId::KeccakRc), @@ -420,6 +439,7 @@ impl TryFrom for BusId { 28 => Ok(BusId::Ecdas), 30 => Ok(BusId::Bit), 31 => Ok(BusId::GlobalMemory), + 33 => Ok(BusId::CopyNext), other => Err(other), } } diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index 5b4206356..4d5026df4 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -45,9 +45,6 @@ use crate::tables::branch::{ use crate::tables::bytewise::{ bus_interactions as bytewise_bus_interactions, cols as bytewise_cols, }; -use crate::tables::commit::{ - CommitConstraints, bus_interactions as commit_bus_interactions, cols as commit_cols, -}; use crate::tables::cpu::{ CpuOperation, bus_interactions as cpu_bus_interactions, cols as cpu_cols, }; @@ -915,15 +912,18 @@ pub fn create_hint_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { +/// Create the unified COPY AIR: one streaming copy primitive for memcpy/memmove, +/// memset and the `write` syscall, selected by the decoded functionality columns. +pub fn create_copy_air( + proof_options: &ProofOptions, +) -> ConcreteVmAir { build_air( - commit_cols::NUM_COLUMNS, - commit_bus_interactions(), + crate::tables::copy::cols::NUM_COLUMNS, + crate::tables::copy::bus_interactions(), proof_options, 1, - CommitConstraints, - "COMMIT", + crate::tables::copy::CopyConstraints, + "COPY", ) } diff --git a/prover/src/tests/commit_tests.rs b/prover/src/tests/commit_tests.rs deleted file mode 100644 index fdaf4d2cd..000000000 --- a/prover/src/tests/commit_tests.rs +++ /dev/null @@ -1,447 +0,0 @@ -//! Tests for the COMMIT (ECALL) table. -//! -//! Covers trace generation, constraint formula verification, and edge cases. - -use crate::constraints::templates::INV_SHIFT_32; -use crate::tables::commit::{CommitOperation, cols, generate_commit_trace}; -use crate::tables::types::FE; - -// ========================================================================= -// Helper: build a commit row -// ========================================================================= - -fn op( - timestamp: u64, - index: u64, - address: u64, - count: u64, - first: bool, - end: bool, - value: u8, -) -> CommitOperation { - CommitOperation { - timestamp, - index, - address, - count, - first, - end, - value, - } -} - -// ========================================================================= -// Trace generation tests -// ========================================================================= - -#[test] -fn test_commit_single_byte() { - // count=1: first row (first=1, count=1, value=0x41) + end row (end=1, count=0) - let ops = vec![ - op(100, 0, 0x1000, 1, true, false, 0x41), - op(100, 1, 0x1001, 0, false, true, 0), - ]; - let trace = generate_commit_trace(&ops); - - // Row 0: first=1, end=0, count=1, value=0x41, mu=1 - let r0 = trace.main_table.get_row(0); - assert_eq!(r0[cols::FIRST], FE::one()); - assert_eq!(r0[cols::END], FE::zero()); - assert_eq!(r0[cols::COUNT_0], FE::one()); - assert_eq!(r0[cols::COUNT_1], FE::zero()); - assert_eq!(r0[cols::VALUE], FE::from(0x41u64)); - assert_eq!(r0[cols::MU], FE::one()); - assert_eq!(r0[cols::TIMESTAMP_0], FE::from(100u64)); - assert_eq!(r0[cols::INDEX], FE::zero()); - - // Row 0: address = 0x1000 - assert_eq!(r0[cols::ADDRESS_0], FE::from(0x1000u64)); - assert_eq!(r0[cols::ADDRESS_1], FE::zero()); - - // Row 0: address_incr = 0x1001 - assert_eq!(r0[cols::ADDRESS_INCR_0], FE::from(0x1001u64)); - assert_eq!(r0[cols::ADDRESS_INCR_1], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_2], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_3], FE::zero()); - - // Row 0: count_decr = 0 (count=1 → count-1=0) - assert_eq!(r0[cols::COUNT_DECR_0], FE::zero()); - assert_eq!(r0[cols::COUNT_DECR_1], FE::zero()); - assert_eq!(r0[cols::COUNT_DECR_2], FE::zero()); - assert_eq!(r0[cols::COUNT_DECR_3], FE::zero()); - - // Row 1: first=0, end=1, count=0, value=0, mu=1 - let r1 = trace.main_table.get_row(1); - assert_eq!(r1[cols::FIRST], FE::zero()); - assert_eq!(r1[cols::END], FE::one()); - assert_eq!(r1[cols::COUNT_0], FE::zero()); - assert_eq!(r1[cols::VALUE], FE::zero()); - assert_eq!(r1[cols::MU], FE::one()); - assert_eq!(r1[cols::INDEX], FE::one()); - - // Row 1: count_decr = all 0xFFFF (count=0 → underflow) - assert_eq!(r1[cols::COUNT_DECR_0], FE::from(0xFFFFu64)); - assert_eq!(r1[cols::COUNT_DECR_1], FE::from(0xFFFFu64)); - assert_eq!(r1[cols::COUNT_DECR_2], FE::from(0xFFFFu64)); - assert_eq!(r1[cols::COUNT_DECR_3], FE::from(0xFFFFu64)); -} - -#[test] -fn test_commit_multi_byte() { - // count=3: 3 data rows + 1 end row = 4 rows - let ops = vec![ - op(200, 10, 0x2000, 3, true, false, b'H'), - op(200, 11, 0x2001, 2, false, false, b'i'), - op(200, 12, 0x2002, 1, false, false, b'!'), - op(200, 13, 0x2003, 0, false, true, 0), - ]; - let trace = generate_commit_trace(&ops); - - // Row 0: first=1 - let r0 = trace.main_table.get_row(0); - assert_eq!(r0[cols::FIRST], FE::one()); - assert_eq!(r0[cols::END], FE::zero()); - assert_eq!(r0[cols::COUNT_0], FE::from(3u64)); - assert_eq!(r0[cols::VALUE], FE::from(b'H' as u64)); - assert_eq!(r0[cols::INDEX], FE::from(10u64)); - - // Row 1: middle row, count decrement 3→2 - let r1 = trace.main_table.get_row(1); - assert_eq!(r1[cols::FIRST], FE::zero()); - assert_eq!(r1[cols::END], FE::zero()); - assert_eq!(r1[cols::COUNT_0], FE::from(2u64)); - assert_eq!(r1[cols::VALUE], FE::from(b'i' as u64)); - assert_eq!(r1[cols::INDEX], FE::from(11u64)); - - // Row 2: middle row, count decrement 2→1 - let r2 = trace.main_table.get_row(2); - assert_eq!(r2[cols::COUNT_0], FE::from(1u64)); - assert_eq!(r2[cols::VALUE], FE::from(b'!' as u64)); - assert_eq!(r2[cols::INDEX], FE::from(12u64)); - - // Row 3: end row - let r3 = trace.main_table.get_row(3); - assert_eq!(r3[cols::FIRST], FE::zero()); - assert_eq!(r3[cols::END], FE::one()); - assert_eq!(r3[cols::COUNT_0], FE::zero()); - assert_eq!(r3[cols::INDEX], FE::from(13u64)); - - // All rows share timestamp and mu=1 - for row in 0..4 { - let r = trace.main_table.get_row(row); - assert_eq!(r[cols::TIMESTAMP_0], FE::from(200u64)); - assert_eq!(r[cols::MU], FE::one()); - } - - // Address chain: 0x2000, 0x2001, 0x2002, 0x2003 - for (row, addr) in (0x2000u64..=0x2003).enumerate() { - let r = trace.main_table.get_row(row); - assert_eq!(r[cols::ADDRESS_0], FE::from(addr)); - } -} - -#[test] -fn test_commit_zero_count() { - // count=0: single row with first=1 AND end=1 - let ops = vec![op(50, 7, 0x3000, 0, true, true, 0)]; - let trace = generate_commit_trace(&ops); - let r0 = trace.main_table.get_row(0); - - assert_eq!(r0[cols::FIRST], FE::one()); - assert_eq!(r0[cols::END], FE::one()); - assert_eq!(r0[cols::COUNT_0], FE::zero()); - assert_eq!(r0[cols::MU], FE::one()); - assert_eq!(r0[cols::INDEX], FE::from(7u64)); - - // count_decr = all 0xFFFF when count=0 - assert_eq!(r0[cols::COUNT_DECR_0], FE::from(0xFFFFu64)); - assert_eq!(r0[cols::COUNT_DECR_1], FE::from(0xFFFFu64)); - assert_eq!(r0[cols::COUNT_DECR_2], FE::from(0xFFFFu64)); - assert_eq!(r0[cols::COUNT_DECR_3], FE::from(0xFFFFu64)); -} - -#[test] -fn test_commit_trace_padding() { - // 1 real row → padded to 4 (minimum power of 2) - let ops = vec![op(10, 0, 0x100, 0, true, true, 0)]; - let trace = generate_commit_trace(&ops); - assert_eq!(trace.num_rows(), 4); - - // Padding rows (1..4): mu=0, count=1, address_incr_0=1 - for row in 1..4 { - let r = trace.main_table.get_row(row); - assert_eq!(r[cols::MU], FE::zero()); - assert_eq!(r[cols::COUNT_0], FE::one()); - assert_eq!(r[cols::ADDRESS_INCR_0], FE::one()); - assert_eq!(r[cols::FIRST], FE::zero()); - assert_eq!(r[cols::END], FE::zero()); - assert_eq!(r[cols::VALUE], FE::zero()); - assert_eq!(r[cols::ADDRESS_0], FE::zero()); - assert_eq!(r[cols::TIMESTAMP_0], FE::zero()); - assert_eq!(r[cols::INDEX], FE::zero()); - } -} - -#[test] -fn test_commit_trace_dimensions() { - // 5 rows → next power of 2 = 8 - let ops: Vec<_> = (0..5) - .map(|i| op(300, i, 0x4000 + i, 5 - i, i == 0, i == 4, (0x60 + i) as u8)) - .collect(); - let trace = generate_commit_trace(&ops); - - assert_eq!(trace.num_rows(), 8); - assert_eq!(cols::NUM_COLUMNS, 19); -} - -// ========================================================================= -// Constraint formula tests (field arithmetic) -// ========================================================================= - -#[test] -fn test_is_bit_constraints() { - // x * (1 - x) = 0 for x in {0, 1} - for x_val in [FE::zero(), FE::one()] { - let result = x_val * (FE::one() - x_val); - assert_eq!(result, FE::zero()); - } - // x=2 should fail - let x = FE::from(2u64); - assert_ne!(x * (FE::one() - x), FE::zero()); -} - -#[test] -fn test_first_or_end_implies_mu() { - // (first + end) * (1 - mu) = 0 - // Valid combos: (0,0,0), (0,0,1), (1,0,1), (0,1,1), (1,1,1) - let valid = [ - (0u64, 0u64, 0u64), - (0, 0, 1), - (1, 0, 1), - (0, 1, 1), - (1, 1, 1), - ]; - for (f, e, m) in valid { - let first = FE::from(f); - let end = FE::from(e); - let mu = FE::from(m); - let result = (first + end) * (FE::one() - mu); - assert_eq!( - result, - FE::zero(), - "Should pass for first={f}, end={e}, mu={m}" - ); - } - - // Invalid: first=1, mu=0 - let result = (FE::one() + FE::zero()) * (FE::one() - FE::zero()); - assert_ne!(result, FE::zero()); - - // Invalid: end=1, mu=0 - let result = (FE::zero() + FE::one()) * (FE::one() - FE::zero()); - assert_ne!(result, FE::zero()); -} - -#[test] -fn test_add_constraint_address() { - // address + 1 = address_incr - // carry_0 = (addr_lo + 1 - incr_lo) * 2^(-32) - let inv_2_32 = FE::from(INV_SHIFT_32); - - // Case 1: no carry. address=0x1000, address+1=0x1001 - let addr_lo = FE::from(0x1000u64); - let incr_lo = FE::from(0x1001u64); - let carry_0 = (addr_lo + FE::one() - incr_lo) * inv_2_32; - assert_eq!(carry_0, FE::zero()); - assert_eq!(carry_0 * (FE::one() - carry_0), FE::zero()); - - // carry_1 = (addr_hi + carry_0 - incr_hi) * 2^(-32) - let carry_1 = (FE::zero() + carry_0 - FE::zero()) * inv_2_32; - assert_eq!(carry_1, FE::zero()); - - // Case 2: carry at 32-bit boundary. address=0x0000_0000_FFFF_FFFF - // address+1 = 0x0000_0001_0000_0000 - // DWordHL halfwords: [0x0000, 0x0000, 0x0001, 0x0000] - // incr_lo = h[0] + 2^16*h[1] = 0 - // incr_hi = h[2] + 2^16*h[3] = 1 - let addr_lo_2 = FE::from(0xFFFF_FFFFu64); - let incr_lo_2 = FE::zero(); - let incr_hi_2 = FE::one(); - let carry_0_2 = (addr_lo_2 + FE::one() - incr_lo_2) * inv_2_32; - assert_eq!(carry_0_2, FE::one()); - let carry_1_2 = (FE::zero() + carry_0_2 - incr_hi_2) * inv_2_32; - assert_eq!(carry_1_2, FE::zero()); -} - -#[test] -fn test_sub_constraint_count() { - // SUB via reversed ADD: count_decr + 1 = count - // carry_0 = (count_decr_lo + 1 - count_lo) * 2^(-32) - let inv_2_32 = FE::from(INV_SHIFT_32); - - // Case 1: count=3, count_decr=2 - let cd_lo = FE::from(2u64); - let count_lo = FE::from(3u64); - let carry_0 = (cd_lo + FE::one() - count_lo) * inv_2_32; - assert_eq!(carry_0, FE::zero()); - - // Case 2: count=0, count_decr=0xFFFF_FFFF_FFFF_FFFF - // count_decr_lo = 0xFFFF + 0xFFFF*2^16 = 0xFFFF_FFFF - let cd_lo_0 = FE::from(0xFFFF_FFFFu64); - let cd_hi_0 = FE::from(0xFFFF_FFFFu64); - // carry_0 = (0xFFFF_FFFF + 1 - 0) * 2^(-32) = 1 - let carry_0_0 = (cd_lo_0 + FE::one() - FE::zero()) * inv_2_32; - assert_eq!(carry_0_0, FE::one()); - // carry_1 = (0xFFFF_FFFF + 1 - 0) * 2^(-32) = 1 - let carry_1_0 = (cd_hi_0 + carry_0_0 - FE::zero()) * inv_2_32; - assert_eq!(carry_1_0, FE::one()); - // Both carries are valid bits - assert_eq!(carry_1_0 * (FE::one() - carry_1_0), FE::zero()); -} - -#[test] -fn test_padding_satisfies_constraints() { - // Padding row: first=0, end=0, mu=0, count=1, address=0, address_incr=[1,0,0,0] - // count_decr=[0,0,0,0] (count=1 -> count-1=0) - let inv_2_32 = FE::from(INV_SHIFT_32); - let one = FE::one(); - let zero = FE::zero(); - - // C0-2: IS_BIT for first=0, end=0, mu=0 - assert_eq!(zero * (one - zero), zero); - - // C3: (first + end) * (1 - mu) = (0+0)*(1-0) = 0 - assert_eq!((zero + zero) * (one - zero), zero); - - // C4-5: address + 1 = address_incr - // addr_lo=0, incr_lo=1 -> carry_0 = (0+1-1)*inv = 0 - let carry_0 = (zero + one - one) * inv_2_32; - assert_eq!(carry_0, zero); - assert_eq!(carry_0 * (one - carry_0), zero); - let carry_1 = (zero + carry_0 - zero) * inv_2_32; - assert_eq!(carry_1, zero); - assert_eq!(carry_1 * (one - carry_1), zero); - - // C6-7: count_decr + 1 = count - // cd_lo=0, count_lo=1 -> carry_0 = (0+1-1)*inv = 0 - let carry_0_sub = (zero + one - one) * inv_2_32; - assert_eq!(carry_0_sub, zero); - assert_eq!(carry_0_sub * (one - carry_0_sub), zero); - let carry_1_sub = (zero + carry_0_sub - zero) * inv_2_32; - assert_eq!(carry_1_sub, zero); - assert_eq!(carry_1_sub * (one - carry_1_sub), zero); -} - -// ========================================================================= -// Edge case tests -// ========================================================================= - -#[test] -fn test_count_decr_at_zero() { - // count=0 -> count_decr halfwords all 0xFFFF - let ops = vec![op(1, 0, 0, 0, true, true, 0)]; - let trace = generate_commit_trace(&ops); - let r0 = trace.main_table.get_row(0); - - for col in [ - cols::COUNT_DECR_0, - cols::COUNT_DECR_1, - cols::COUNT_DECR_2, - cols::COUNT_DECR_3, - ] { - assert_eq!(r0[col], FE::from(0xFFFFu64)); - } -} - -#[test] -fn test_address_incr_overflow() { - // address = 0xFFFF_FFFF_FFFF_FFFF -> address+1 wraps to 0 - let ops = vec![op(1, 0, u64::MAX, 1, true, false, 0xFF)]; - let trace = generate_commit_trace(&ops); - let r0 = trace.main_table.get_row(0); - - // address = [0xFFFF_FFFF, 0xFFFF_FFFF] - assert_eq!(r0[cols::ADDRESS_0], FE::from(0xFFFF_FFFFu64)); - assert_eq!(r0[cols::ADDRESS_1], FE::from(0xFFFF_FFFFu64)); - - // address_incr = 0 (all halfwords zero) - assert_eq!(r0[cols::ADDRESS_INCR_0], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_1], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_2], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_3], FE::zero()); - - // Verify ADD constraint holds for the wrapped case - let inv_2_32 = FE::from(INV_SHIFT_32); - let addr_lo = FE::from(0xFFFF_FFFFu64); - let addr_hi = FE::from(0xFFFF_FFFFu64); - // carry_0 = (0xFFFF_FFFF + 1 - 0) * inv = 1 - let carry_0 = (addr_lo + FE::one() - FE::zero()) * inv_2_32; - assert_eq!(carry_0, FE::one()); - // carry_1 = (0xFFFF_FFFF + 1 - 0) * inv = 1 - let carry_1 = (addr_hi + carry_0 - FE::zero()) * inv_2_32; - assert_eq!(carry_1, FE::one()); - assert_eq!(carry_0 * (FE::one() - carry_0), FE::zero()); - assert_eq!(carry_1 * (FE::one() - carry_1), FE::zero()); -} - -#[test] -fn test_large_timestamp() { - // Timestamp with both hi and lo words populated - let ts: u64 = 0x0000_0001_0000_0064; // hi=1, lo=100 - let ops = vec![op(ts, 0, 0x5000, 1, true, false, 0xAB)]; - let trace = generate_commit_trace(&ops); - let r0 = trace.main_table.get_row(0); - - assert_eq!(r0[cols::TIMESTAMP_0], FE::from(ts & 0xFFFF_FFFF)); - assert_eq!(r0[cols::TIMESTAMP_1], FE::from(ts >> 32)); -} - -#[test] -fn test_minimum_table_size() { - // Empty ops -> still 4 rows (minimum) - let trace = generate_commit_trace(&[]); - assert_eq!(trace.num_rows(), 4); - - // All padding rows - for row in 0..4 { - let r = trace.main_table.get_row(row); - assert_eq!(r[cols::MU], FE::zero()); - assert_eq!(r[cols::COUNT_0], FE::one()); - } -} - -#[test] -fn test_address_incr_halfword_carry() { - // address = 0xFFFF -> address+1 = 0x10000 - // Tests carry propagation across halfwords within the low 32-bit word - let ops = vec![op(1, 0, 0xFFFF, 1, true, false, 0)]; - let trace = generate_commit_trace(&ops); - let r0 = trace.main_table.get_row(0); - - // address_incr = 0x10000: h[0]=0x0000, h[1]=0x0001, h[2]=0, h[3]=0 - assert_eq!(r0[cols::ADDRESS_INCR_0], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_1], FE::one()); - assert_eq!(r0[cols::ADDRESS_INCR_2], FE::zero()); - assert_eq!(r0[cols::ADDRESS_INCR_3], FE::zero()); -} - -#[test] -fn test_bus_interactions_count() { - use crate::tables::commit::bus_interactions; - let interactions = bus_interactions(); - assert_eq!(interactions.len(), 18); -} - -#[test] -fn test_constraints_count_and_indices() { - use crate::tables::commit::CommitConstraints; - use stark::constraints::builder::ConstraintSet; - let meta = CommitConstraints.meta(); - assert_eq!(meta.len(), 8); - // Dense, idx-ordered. - for (i, m) in meta.iter().enumerate() { - assert_eq!(m.constraint_idx, i); - } - // All constraints are degree 2 (unconditional). - assert_eq!(CommitConstraints.max_degree(), 2); -} diff --git a/prover/src/tests/compute_commit_bus_offset_tests.rs b/prover/src/tests/compute_commit_bus_offset_tests.rs index ca6aab272..6b1d2eda5 100644 --- a/prover/src/tests/compute_commit_bus_offset_tests.rs +++ b/prover/src/tests/compute_commit_bus_offset_tests.rs @@ -6,6 +6,8 @@ use math::field::element::FieldElement; +use executor::vm::instruction::execution::copy_row_width; + use crate::compute_commit_bus_offset; use crate::tables::types::{BusId, GoldilocksExtension}; @@ -122,3 +124,90 @@ fn test_zero_fingerprint_in_middle_returns_none() { None, ); } + +/// The COMMIT tuples the COPY chip actually sends, walking the production row +/// schedule one commit ECALL at a time. +/// +/// This is the prover side, not a second copy of the verifier: the widths come from +/// `copy_row_width`, the same function the trace builder and the sizing pass use, +/// and the tuple shape is the chip's — one `(global index, byte)` pair per byte. +/// `commits` is the per-ECALL split of the public output, which is exactly the thing +/// the verifier never learns. +fn prover_offset( + commits: &[&[u8]], + start_index: u64, + z: &FieldElement, + alpha: &FieldElement, +) -> Option> { + let bus_id = FieldElement::::from(BusId::Commit as u64); + let alpha_sq = alpha * alpha; + let mut total = FieldElement::::zero(); + let mut index = start_index; + + for bytes in commits { + let base = index; + let mut offset = 0u64; + let mut remaining = bytes.len() as u64; + while remaining != 0 { + let width = u64::from(copy_row_width(0, base, offset, remaining, true)); + for lane in 0..width { + let byte = bytes[(offset + lane) as usize]; + let lc = bus_id + + (FieldElement::::from(base + offset + lane) * alpha) + + (FieldElement::::from(byte as u64) * alpha_sq); + total += (z - lc).inv().ok()?; + } + offset += width; + remaining -= width; + } + index += bytes.len() as u64; + } + + Some(total) +} + +/// The verifier rebuilds the COMMIT bus from the concatenated `public_output` and +/// never learns where one commit ECALL ended and the next began. So the prover's +/// tuples must not depend on that split. +/// +/// This is the regression test for the eight-lane tuple: with one tuple per row, +/// `[&[..4], &[..4]]` sent eight one-byte tuples while the verifier, chunking the +/// eight bytes it sees, expected a single eight-byte one — an honest proof rejected. +#[test] +fn test_prover_tuples_are_independent_of_the_ecall_split() { + let z = FieldElement::::from(9_876_543_211u64); + let alpha = FieldElement::::from(1_357u64); + + let splits: &[&[&[u8]]] = &[ + // One ECALL, sub-eight, exact eight, and a wide body with a tail. + &[&[1, 2, 3, 4]], + &[&[1, 2, 3, 4, 5, 6, 7, 8]], + &[&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]], + // Several ECALLs. Only the last may be a multiple of eight without the + // schedule and the verifier's chunking drifting apart. + &[&[1, 2, 3, 4], &[5, 6, 7, 8]], + &[&[1, 2, 3], &[4, 5, 6, 7, 8, 9, 10, 11, 12]], + &[&[1, 2, 3, 4, 5, 6, 7, 8], &[9], &[10, 11, 12, 13, 14]], + &[&[1], &[2], &[3], &[4], &[5], &[6], &[7], &[8], &[9]], + ]; + + // The last start index is the highest one a run can actually reach: the + // executor caps the committed output at `MAX_PUBLIC_OUTPUT_TOTAL_SIZE`, so the + // running commit index never leaves the low megabyte. A value near `2^32` would + // exercise arithmetic no chip ever sees. + let top = executor::vm::memory::MAX_PUBLIC_OUTPUT_TOTAL_SIZE - 16; + for (case, commits) in splits.iter().enumerate() { + for &start_index in &[0u64, 1, 7, 8, top] { + let concatenated: Vec = commits.concat(); + let prover = prover_offset(commits, start_index, &z, &alpha) + .expect("no fingerprint collision on the prover side"); + let verifier = compute_commit_bus_offset(&concatenated, start_index, &z, &alpha) + .expect("no fingerprint collision on the verifier side"); + assert_eq!( + prover, verifier, + "case {case} at start_index {start_index}: the COMMIT bus does not \ + balance, so an honest proof would be rejected" + ); + } + } +} diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a29a7cb49..040748daf 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -157,6 +157,7 @@ fn all_table_programs_lower_and_match_folders() { let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); check_air_device(&create_cpu_air(&opts), "CPU"); + check_air_device(&create_copy_air(&opts), "copy"); check_air_device(&create_bitwise_air(&opts), "BITWISE"); check_air_device(&create_lt_air(&opts), "LT"); check_air_device(&create_shift_air(&opts), "SHIFT"); @@ -173,7 +174,6 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_dvrm_air(&opts), "DVRM"); check_air_device(&create_branch_air(&opts), "BRANCH"); check_air_device(&create_halt_air(&opts), "HALT"); - check_air_device(&create_commit_air(&opts), "COMMIT"); check_air_device(&create_page_air(&opts, 0x1000), "PAGE"); check_air_device(&create_register_air(&opts), "REGISTER"); check_air_device(&create_keccak_air(&opts), "KECCAK"); diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index e227da53d..c6ae628d9 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -155,6 +155,7 @@ fn all_table_programs_match_folders() { let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); check_air(&create_cpu_air(&opts), "CPU"); + check_air(&create_copy_air(&opts), "copy"); check_air(&create_bitwise_air(&opts), "BITWISE"); check_air(&create_lt_air(&opts), "LT"); check_air(&create_shift_air(&opts), "SHIFT"); @@ -171,7 +172,6 @@ fn all_table_programs_match_folders() { check_air(&create_dvrm_air(&opts), "DVRM"); check_air(&create_branch_air(&opts), "BRANCH"); check_air(&create_halt_air(&opts), "HALT"); - check_air(&create_commit_air(&opts), "COMMIT"); check_air(&create_page_air(&opts, 0x1000), "PAGE"); check_air(&create_register_air(&opts), "REGISTER"); check_air(&create_keccak_air(&opts), "KECCAK"); diff --git a/prover/src/tests/constraint_set_tests_b.rs b/prover/src/tests/constraint_set_tests_b.rs index a7f68ecfd..6a19dbb25 100644 --- a/prover/src/tests/constraint_set_tests_b.rs +++ b/prover/src/tests/constraint_set_tests_b.rs @@ -227,17 +227,13 @@ mod branch { } } -// ============================================================================= -// commit.rs -// ============================================================================= - -mod commit { +mod copy { use super::*; - use crate::tables::commit::{CommitConstraints, cols}; + use crate::tables::copy::{CopyConstraints, cols}; #[test] - fn commit_constraint_set_folder_capture_agree() { - check_table("commit", &CommitConstraints, cols::NUM_COLUMNS); + fn copy_constraint_set_folder_capture_agree() { + check_table("copy", &CopyConstraints, cols::NUM_COLUMNS); } } diff --git a/prover/src/tests/copy_tests.rs b/prover/src/tests/copy_tests.rs new file mode 100644 index 000000000..eb21024da --- /dev/null +++ b/prover/src/tests/copy_tests.rs @@ -0,0 +1,1140 @@ +use crate::tables::copy::{CopyOperation, Functionality, cols, generate_copy_trace}; +use crate::tables::types::{FE, VmTable}; +use crate::test_utils::{busless_air, validate_busless}; + +fn air() -> crate::test_utils::VmAir { + busless_air(cols::NUM_COLUMNS, crate::tables::copy::CopyConstraints) +} + +/// One row with an explicitly chosen width, so a test can state `single` and `count` +/// independently. +fn row_of_width( + functionality: Functionality, + count: u64, + width: u8, + first: bool, + end: bool, + src: u64, + dst: u64, +) -> CopyOperation { + CopyOperation { + width, + functionality, + timestamp: 100, + src, + dst, + count, + first, + end, + value: if width == 1 { + [0xAB, 0, 0, 0, 0, 0, 0, 0] + } else { + [0xAB; 8] + }, + } +} + +/// A whole honest chain, on the schedule the trace builder takes: eight bytes while +/// eight remain, then one byte a row. `end` lands on the row that moves the last +/// bytes — there is no terminal row. +fn chain(functionality: Functionality, src: u64, dst: u64, count: u64) -> Vec { + let mut rows = Vec::new(); + let mut offset = 0u64; + let mut remaining = count; + let mut first = true; + while remaining != 0 { + let width = if remaining < 8 { 1 } else { 8 }; + rows.push(row_of_width( + functionality, + remaining, + width, + first, + false, + src + offset, + dst + offset, + )); + first = false; + offset += u64::from(width); + remaining -= u64::from(width); + } + match rows.last_mut() { + Some(last) => last.end = true, + None => rows.push(CopyOperation { + width: 1, + functionality, + timestamp: 100, + src, + dst, + count: 0, + first: true, + end: true, + value: [0; 8], + }), + } + rows +} + +/// A memset chain. The contract the AIR pins is `dst = src + 8`, so build it that +/// way by default and let the tests below break it deliberately. +fn set_chain(src: u64, dst: u64, count: u64) -> Vec { + chain(Functionality::Set, src, dst, count) +} + +#[test] +fn copy_trace_uses_eight_byte_rows_then_a_byte_tail() { + let trace = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 10)); + + let wide = trace.main_table.get_row(0); + assert_eq!(wide[cols::SINGLE], FE::zero()); + assert_eq!(wide[cols::SRC_INCR_0], FE::from(0x1008u64)); + assert_eq!(wide[cols::COUNT_DECR_0], FE::from(2u64)); + assert_eq!(wide[cols::END], FE::zero()); + + let tail = trace.main_table.get_row(1); + assert_eq!(tail[cols::SINGLE], FE::one()); + assert_eq!(tail[cols::COUNT_0], FE::from(2u64)); + assert_eq!(tail[cols::COUNT_DECR_0], FE::one()); + assert_eq!(tail[cols::END], FE::zero()); + + // The chain stops on the row that moves the last byte: `count == step == 1`. + let last = trace.main_table.get_row(2); + assert_eq!(last[cols::COUNT_0], FE::one()); + assert_eq!(last[cols::COUNT_DECR_0], FE::zero()); + assert_eq!(last[cols::END], FE::one()); + + assert_eq!(trace.main_table.height, 4, "three rows pad to four"); + assert_eq!(trace.main_table.get_row(3)[cols::MU], FE::zero()); +} + +#[test] +fn an_empty_copy_call_is_a_single_first_and_end_row() { + let rows = chain(Functionality::Copy, 0x1000, 0x2000, 0); + assert_eq!(rows.len(), 1); + let trace = generate_copy_trace(&rows); + let first = trace.main_table.get_row(0); + assert_eq!(first[cols::FIRST], FE::one()); + assert_eq!(first[cols::END], FE::one()); + assert_eq!(first[cols::MU], FE::one()); + // `count - step` borrows out, which is the AIR's proxy for this state. + for k in 0..4 { + assert_eq!(first[cols::COUNT_DECR_0 + k], FE::from(0xFFFFu64)); + } + assert!(validate_busless(&air(), &trace)); +} + +#[test] +fn copy_constraints_accept_valid_rows_and_reject_nonzero_tail_lanes() { + let mut trace = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 2)); + assert!(validate_busless(&air(), &trace)); + + trace.main_table.set(0, cols::VALUE[1], FE::one()); + assert!( + !validate_busless(&air(), &trace), + "a one-byte row must not smuggle additional copied lanes" + ); +} + +/// `restrict_end`, which replaced the `ZERO` lookup over `count_decr`. +/// +/// `end` must mean `count == step`, or the degenerate `(count, first) = (0, 1)`. +/// Setting it early and clearing it late are both rejected, and the zero-length +/// state is only available on a `first` row. +#[test] +fn copy_constraints_pin_end_to_the_last_chunk() { + let air = air(); + + // Early: claim `end` on a row that still has bytes left. + let mut early = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 16)); + assert!(validate_busless(&air, &early), "the baseline must be clean"); + early.main_table.set(0, cols::END, FE::one()); + assert!( + !validate_busless(&air, &early), + "`end` on a row with count != step must be rejected" + ); + + // Late: clear `end` on the row that moves the last bytes. Nothing busless + // rejects that on its own — the row simply chains on — so the AIR instead + // catches the successor, which has `count == 0` without `first`. + let mut late = generate_copy_trace(&[ + row_of_width(Functionality::Copy, 8, 8, true, false, 0x1000, 0x2000), + row_of_width(Functionality::Copy, 0, 8, false, false, 0x1008, 0x2008), + ]); + assert!( + !validate_busless(&air, &late), + "a `count == 0` row that is not `first` must be rejected" + ); + // ...and marking that successor `end` does not save it either. + late.main_table.set(1, cols::END, FE::one()); + assert!( + !validate_busless(&air, &late), + "the zero-count state is only legal on a `first` row" + ); +} + +#[test] +fn copy_padding_row_cannot_claim_first_or_end() { + // `(first + end) * (1 - mu) = 0` is the sole guard that a padding row (mu = 0) + // cannot masquerade as the first or last row of a copy — bitness alone accepts + // first = 1 or end = 1, so nothing else rejects it. A padding row claiming + // `first` would forge an ECALL receive; claiming `end` would close a chain. + let air = air(); + let base = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 2)); + assert_eq!(base.main_table.get_row(3)[cols::MU], FE::zero()); + assert!(validate_busless(&air, &base)); + + let mut forge_first = base.clone(); + forge_first.main_table.set(3, cols::FIRST, FE::one()); + assert!( + !validate_busless(&air, &forge_first), + "a padding row (mu = 0) must not claim to be a copy's first row" + ); + + let mut forge_end = base; + forge_end.main_table.set(3, cols::END, FE::one()); + assert!( + !validate_busless(&air, &forge_end), + "a padding row (mu = 0) must not claim to close a copy" + ); +} + +/// The memset operand contract, in the direction that matters. +/// +/// `dst == src` is the degenerate case: read and write address the same cell at +/// adjacent timestamps, so the memory argument closes on `value == value` and every +/// value lane becomes a free field element. `copy:c:set_gap` is the only thing that +/// rejects it, so it is tested in both directions, and a `Copy` row is tested to +/// confirm the gate is `is_set` and does not leak onto the copy path (a memcpy with +/// `dst == src` is harmless -- its read pins `value` to live memory). +#[test] +fn copy_constraints_pin_the_memset_gap() { + let air = air(); + + let honest = generate_copy_trace(&set_chain(0x1000, 0x1008, 16)); + assert!( + validate_busless(&air, &honest), + "a memset chain with dst = src + 8 must be accepted" + ); + + // The forgery: dst == src leaves `value` unconstrained. + let degenerate = generate_copy_trace(&set_chain(0x1000, 0x1000, 16)); + assert!( + !validate_busless(&air, °enerate), + "dst == src must be rejected: it makes every value lane a free field element" + ); + + // Wrong gap, and the wrong direction, are both out of contract too. + for (src, dst, why) in [ + (0x1000u64, 0x1004u64, "a gap under one row width"), + (0x1000, 0x1020, "a gap over one row width"), + ( + 0x1008, + 0x1000, + "dst below src, which propagates the wrong way", + ), + ] { + let trace = generate_copy_trace(&set_chain(src, dst, 16)); + assert!( + !validate_busless(&air, &trace), + "{why} must be rejected (src {src:#x}, dst {dst:#x})" + ); + } + + // The high limb is pinned as well, so the gap cannot be forged across limbs. + let straddle = generate_copy_trace(&set_chain(0x1000, 0x1_0000_1008, 16)); + assert!( + !validate_busless(&air, &straddle), + "a gap of 8 in the low limb but not the high one must be rejected" + ); + + // ...but a genuine gap of 8 that carries out of the low limb is accepted. This is + // what the single 64-bit `ADD` buys over the limb-wise pair it replaced: the + // executor used to refuse these ranges because the old constraints could not + // express the carry, and that guard is gone with them. + let crossing = generate_copy_trace(&set_chain(0xFFFF_FFFC, 0x1_0000_0004, 16)); + assert!( + validate_busless(&air, &crossing), + "a gap of 8 carrying into the high limb must be accepted" + ); + + // And the gate really is `is_set`. A memcpy with dst == src is harmless: its + // read pins `value` to live memory, which is exactly why the pin is gated. + let copy_aliased = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x1000, 8)); + assert!( + validate_busless(&air, ©_aliased), + "the memset gap pin must not fire on Copy rows, even with dst == src" + ); +} + +/// A one-byte row is legal at any count. +/// +/// The `count < 8` ALU lookup is gone: a wide row that overshoots can no longer +/// reach a legal `end`, so nothing has to check the width directly. What is left is +/// the freedom in the other direction, which the builder does not exercise and which +/// re-tightening the rule would silently remove. +#[test] +fn a_narrow_row_is_legal_at_any_count() { + let prologue = generate_copy_trace(&[ + row_of_width(Functionality::Copy, 16, 1, true, false, 0x1001, 0x2001), + row_of_width(Functionality::Copy, 15, 1, false, false, 0x1002, 0x2002), + row_of_width(Functionality::Copy, 14, 8, false, false, 0x1003, 0x2003), + row_of_width(Functionality::Copy, 6, 1, false, false, 0x100B, 0x200B), + row_of_width(Functionality::Copy, 5, 1, false, false, 0x100C, 0x200C), + row_of_width(Functionality::Copy, 4, 1, false, false, 0x100D, 0x200D), + row_of_width(Functionality::Copy, 3, 1, false, false, 0x100E, 0x200E), + row_of_width(Functionality::Copy, 2, 1, false, false, 0x100F, 0x200F), + row_of_width(Functionality::Copy, 1, 1, false, true, 0x1010, 0x2010), + ]); + assert!( + validate_busless(&air(), &prologue), + "a one-byte row at count >= 8 must be legal, whether or not the builder emits one" + ); +} + +/// `dst_res` and the register it is written back to. +/// +/// One `REG` interaction serves two jobs: the `void*` the `mem*` calls return, and +/// the commit index a `write` advances. The column has to follow the selector, and +/// so does the register address. +#[test] +fn copy_constraints_pin_dst_res_to_the_functionality() { + let air = air(); + + // Non-write: `dst_res == dst`, unconditionally. + let mut copy = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 8)); + assert!(validate_busless(&air, ©)); + copy.main_table.set(0, cols::DST_RES_0, FE::from(0x2001u64)); + assert!( + !validate_busless(&air, ©), + "a memcpy row must return its destination unchanged" + ); + + // Write: `dst_res == dst + count`. + let mut write = generate_copy_trace(&chain(Functionality::Write, 0x3000, 40, 8)); + assert!(validate_busless(&air, &write)); + assert_eq!( + *write.main_table.get(0, cols::DST_RES_0), + FE::from(48u64), + "dst_res advances the commit index by count" + ); + write.main_table.set(0, cols::DST_RES_0, FE::from(40u64)); + assert!( + !validate_busless(&air, &write), + "a write row must advance the commit index" + ); +} + +/// The `write` functionality at constraint level: the one-hot rule, the +/// no-selector-on-padding rule, and the COMMIT-bus wide gate. +#[test] +fn copy_constraints_gate_the_write_functionality() { + let air = air(); + + // Baseline: an honest `write` chain. `dst` is the global byte index, so it + // starts at 0 and the memset gap pin must not fire — a write is not `is_set`. + let honest = generate_copy_trace(&chain(Functionality::Write, 0x3000, 0, 12)); + assert!( + validate_busless(&air, &honest), + "an honest write chain must be accepted" + ); + + // One-hot (`is_set * is_write = 0`): a row cannot claim two functionalities. + // Setting `is_set` on a write row would buy the inverted timestamp order on a + // chain the `write` ecall authorised. + // + // This case has to be built on a chain whose addresses already satisfy the + // memset gap pin, or that rejects it first and the assertion passes for the + // wrong reason. + let gap_clean = generate_copy_trace(&[row_of_width( + Functionality::Write, + 8, + 8, + true, + true, + 0x3000, + 0x3008, + )]); + assert!( + validate_busless(&air, &gap_clean), + "the gap-clean write baseline must itself be accepted" + ); + let mut one_hot = gap_clean.clone(); + one_hot.main_table.set_fe(0, cols::IS_SET, FE::one()); + assert!( + !validate_busless(&air, &one_hot), + "is_set and is_write must not both be set (the one-hot constraint)" + ); + + // `mu_com_wide = mu_com * (1 - single)`: widen a one-byte write row. It is what + // stops it broadcasting seven spurious `(index, 0)` pairs onto the COMMIT bus, + // which the verifier rebuilds from `public_output` — so a forgery here corrupts + // the output fingerprint rather than merely wasting a row. + let mut trace = honest.clone(); + assert_eq!(*trace.main_table.get(1, cols::SINGLE), FE::one()); + trace.main_table.set_fe(1, cols::MU_COM_WIDE, FE::one()); + assert!( + !validate_busless(&air, &trace), + "a one-byte write row must not claim the wide lanes (mu_com_wide)" + ); + + // `(is_set + is_write) * (1 - mu) = 0`: no selector on a padding row. + let mut trace = honest.clone(); + let padding = trace.main_table.height - 1; + assert_eq!( + trace.main_table.get_row(padding)[cols::MU], + FE::zero(), + "the last row is expected to be padding" + ); + trace.main_table.set_fe(padding, cols::IS_WRITE, FE::one()); + assert!( + !validate_busless(&air, &trace), + "a padding row must not carry a functionality selector" + ); + + // `first_write = first * is_write`. + let mut trace = honest; + trace.main_table.set_fe(1, cols::FIRST_WRITE, FE::one()); + assert!( + !validate_busless(&air, &trace), + "first_write must follow first * is_write" + ); +} + +/// The ECALL number is decoded out of the two selectors rather than received on +/// three separate interactions, so the arithmetic has to produce exactly `64`, +/// `-30` and `-31` and nothing else. +#[test] +fn the_ecall_tuple_decodes_the_three_syscall_numbers() { + use crate::tables::copy::bus_interactions; + use crate::tables::types::BusId; + use executor::vm::instruction::execution::{ + DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER, + }; + use stark::lookup::{BusValue, LinearTerm}; + + let ecall = bus_interactions() + .into_iter() + .find(|i| i.bus_id == BusId::Ecall as u64) + .expect("the ECALL receive"); + assert!(!ecall.is_sender, "COPY receives the ECALL"); + + let eval = |value: &BusValue, is_set: u64, is_write: u64| -> FE { + match value { + BusValue::Linear(terms) => terms.iter().fold(FE::zero(), |acc, t| match t { + LinearTerm::Column { + coefficient, + column, + } => { + let v = if *column == cols::IS_SET { + is_set + } else if *column == cols::IS_WRITE { + is_write + } else { + panic!("unexpected column {column} in the ECALL tuple") + }; + let c = FE::from(coefficient.unsigned_abs()); + if *coefficient < 0 { + acc - c * FE::from(v) + } else { + acc + c * FE::from(v) + } + } + LinearTerm::ColumnUnsigned { .. } => panic!("unexpected unsigned column"), + LinearTerm::Constant(c) => { + if *c < 0 { + acc - FE::from(c.unsigned_abs()) + } else { + acc + FE::from(c.unsigned_abs()) + } + } + }), + other => panic!("expected a written-out linear form, got {other:?}"), + } + }; + + for (is_set, is_write, number) in [ + (0, 0, DMA_MEMCPY_SYSCALL_NUMBER), + (1, 0, DMA_MEMSET_SYSCALL_NUMBER), + (0, 1, 64), + ] { + assert_eq!( + eval(&ecall.values[2], is_set, is_write), + FE::from(number & 0xFFFF_FFFF), + "low limb for (is_set {is_set}, is_write {is_write})" + ); + assert_eq!( + eval(&ecall.values[3], is_set, is_write), + FE::from(number >> 32), + "high limb for (is_set {is_set}, is_write {is_write})" + ); + } +} + +/// `copy:c:read_dst` addresses `x10 + 244·is_write`, so one interaction covers both +/// the `mem*` destination register and the commit index in `x254`. +#[test] +fn the_destination_register_follows_is_write() { + use crate::tables::copy::bus_interactions; + use crate::tables::types::BusId; + use stark::lookup::{BusValue, LinearTerm, Multiplicity}; + + let reads: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| { + i.bus_id == BusId::Memw as u64 + && matches!(i.multiplicity, Multiplicity::Column(c) if c == cols::FIRST) + }) + .collect(); + assert_eq!(reads.len(), 3, "x11, x12 and the selected destination"); + + // The address sits at index 9: eight `old` lanes then `is_register`. + let selected = reads + .iter() + .find(|i| matches!(&i.values[9], BusValue::Linear(terms) if terms.len() == 2)) + .expect("one of the three reads addresses a selected register"); + match &selected.values[9] { + BusValue::Linear(terms) => assert!( + matches!( + terms.as_slice(), + [ + LinearTerm::Constant(20), + LinearTerm::Column { + coefficient: 488, + column + } + ] if *column == cols::IS_WRITE + ), + "the address must be 2·10 + 2·244·is_write, got {terms:?}" + ), + other => panic!("expected a linear address, got {other:?}"), + } +} + +/// Pins the shape of the COMMIT-domain sends: eight `(index, value)` pairs, one per +/// byte, indexed off `dst` — not one eight-lane tuple per row. +/// +/// The verifier rebuilds this bus from `public_output` alone and never learns where +/// one `write` ECALL ended and the next began. Per-byte pairs are what make its +/// rebuild independent of the prover's row schedule, which restarts at every ECALL. +#[test] +fn copy_commit_sends_one_pair_per_byte() { + use crate::tables::copy::bus_interactions; + use crate::tables::types::BusId; + use stark::lookup::{BusValue, LinearTerm, Multiplicity, Packing}; + + let commit_sends: Vec<_> = bus_interactions() + .into_iter() + .filter(|interaction| interaction.bus_id == BusId::Commit as u64) + .collect(); + + assert_eq!(commit_sends.len(), 8, "one COMMIT send per byte lane"); + + for (lane, interaction) in commit_sends.iter().enumerate() { + assert!(interaction.is_sender, "lane {lane} must send"); + + let expected_multiplicity = if lane == 0 { + cols::MU_COM + } else { + cols::MU_COM_WIDE + }; + assert!( + matches!(interaction.multiplicity, Multiplicity::Column(column) if column == expected_multiplicity), + "lane {lane} has the wrong multiplicity" + ); + + assert_eq!( + interaction.values.len(), + 2, + "lane {lane} is an (index, value) pair" + ); + + match &interaction.values[0] { + BusValue::Linear(terms) => { + assert!( + matches!( + terms.as_slice(), + [ + LinearTerm::Column { coefficient: 1, column }, + LinearTerm::Constant(offset), + ] if *column == cols::DST_0 && *offset == lane as i64 + ), + "lane {lane} must be indexed at dst + {lane}" + ); + } + _ => panic!("lane {lane}'s index must be a linear combination"), + } + + assert!( + matches!( + interaction.values[1], + BusValue::Packed { + start_column, + packing: Packing::Direct, + } if start_column == cols::VALUE[lane] + ), + "lane {lane} must carry value[{lane}]" + ); + } +} + +/// The chain bus carries `position + step` forward and `position` back. +/// +/// If the two sides ever disagree nothing errors — the bus simply stops balancing, +/// on some fixture, far from here. The send reads the `_incr`/`_decr` quadruples and +/// the receive reads the plain `DWordWL` columns. +#[test] +fn the_chain_bus_sends_the_incremented_position() { + use crate::tables::copy::bus_interactions; + use crate::tables::types::BusId; + use stark::lookup::{BusValue, Multiplicity, Packing}; + + let interactions = bus_interactions(); + let send = interactions + .iter() + .find(|i| i.bus_id == BusId::CopyNext as u64 && i.is_sender) + .expect("the forward chain sender"); + assert!( + matches!(send.multiplicity, Multiplicity::Diff(a, b) if a == cols::MU && b == cols::END) + ); + for (slot, start) in [ + (2, cols::SRC_INCR_0), + (3, cols::DST_INCR_0), + (4, cols::COUNT_DECR_0), + ] { + assert!( + matches!(send.values[slot], BusValue::Packed { start_column, packing: Packing::DWordHL } + if start_column == start), + "chain slot {slot} must send the incremented quadruple at {start}" + ); + } + + let receive = interactions + .iter() + .find(|i| i.bus_id == BusId::CopyNext as u64 && !i.is_sender) + .expect("the backward chain receiver"); + assert!( + matches!(receive.multiplicity, Multiplicity::Diff(a, b) if a == cols::MU && b == cols::FIRST) + ); + for (slot, start) in [(2, cols::SRC_0), (3, cols::DST_0), (4, cols::COUNT_0)] { + assert!( + matches!(receive.values[slot], BusValue::Packed { start_column, packing: Packing::DWordWL } + if start_column == start), + "chain slot {slot} must receive the plain position at {start}" + ); + } +} + +#[test] +fn copy_bus_interactions_count() { + use crate::tables::copy::bus_interactions; + // 1 ECALL receive, 4 register accesses, 2 chain tuples, 16 IS_HALF range checks + // (four each for `src_incr`, `dst_incr`, `count_decr` and `dst_res`), the two + // memory accesses, and eight COMMIT-domain `(index, value)` pairs. + assert_eq!(bus_interactions().len(), 1 + 4 + 2 + 16 + 2 + 8); +} + +#[test] +fn copy_constraints_count_and_indices() { + use crate::tables::copy::CopyConstraints; + use stark::constraints::builder::ConstraintSet; + let meta = CopyConstraints.meta(); + assert_eq!(meta.len(), 40); + // Dense, idx-ordered. + for (i, m) in meta.iter().enumerate() { + assert_eq!(m.constraint_idx, i); + } + // `restrict_end` and the two conditional ADDs are degree 3; nothing exceeds it. + assert_eq!(CopyConstraints.max_degree(), 3); +} + +/// The chain lengths around the width boundary, where `end` has to land on a row +/// whose `count` equals its own `step` rather than on a row after the last byte. +#[test] +fn copy_chains_of_every_shape_validate() { + let air = air(); + for count in [0u64, 1, 2, 7, 8, 9, 15, 16, 17, 64, 257] { + for (src, dst) in [(0x1000u64, 0x2000u64), (0x1003, 0x2007), (0x1007, 0x1000)] { + let rows = chain(Functionality::Copy, src, dst, count); + let trace = generate_copy_trace(&rows); + assert!( + validate_busless(&air, &trace), + "count {count}, src {src:#x}, dst {dst:#x}" + ); + // The last row closes the chain and nothing else does. + let closers = rows.iter().filter(|r| r.end).count(); + assert_eq!(closers, 1, "exactly one closing row at count {count}"); + assert!(rows.last().expect("at least one row").end); + assert_eq!(rows.iter().filter(|r| r.first).count(), 1); + // No count above 256 is special any more: the per-ecall bound is gone. + assert_eq!( + rows.iter().map(|r| u64::from(r.width)).sum::(), + count.max(1), + "the chain must move exactly `count` bytes (the zero-length chain \ + carries one row that moves none)" + ); + } + } +} + +/// The other half of the width rule, and the constraint that replaced a lookup. +/// +/// A wide row with fewer than eight bytes left used to be rejected by an ALU `LT` +/// lookup pinning `count >= 8`. That lookup is gone, and `copy:c:borrow_implies_count_is_zero` +/// is what replaced it: `count < step` forces the borrow out of `count - step`, and +/// the borrow demands `count == 0`. Without it a prover serves a three-byte `memcpy` +/// with one eight-byte row and writes five bytes past the range the ECALL authorised. +#[test] +fn a_wide_row_needs_eight_bytes_remaining() { + let air = air(); + + for count in 1..8u64 { + // `first` and `end` are set so that every *other* `restrict_end` constraint + // is satisfied or gated off; only the borrow implications can reject. + let trace = generate_copy_trace(&[row_of_width( + Functionality::Copy, + count, + 8, + true, + true, + 0x1000, + 0x2000, + )]); + assert!( + !validate_busless(&air, &trace), + "a wide row with only {count} bytes left must be rejected" + ); + } + + // The boundary is exact: at eight bytes the same row is legal. + let legal = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 8, + 8, + true, + true, + 0x1000, + 0x2000, + )]); + assert!( + validate_busless(&air, &legal), + "a wide row with exactly eight bytes left must be accepted" + ); +} + +/// A chain may not walk off the top of the address space. +/// +/// `src_incr`/`dst_incr` come out of a plain `ADD`, whose high carry the spec leaves +/// a free bit, so without the two constraints at the end of `eval` a chain can pass +/// `2^64` and resume at `0`. Nothing else in the chip objects: `count` decrements +/// independently of the two positions, so such a chain still terminates, and every +/// row still reads and writes PAGE-backed cells. The executor refuses the operand +/// range outright (`src.checked_add(n)`), so this is the acceptance-set gap those +/// constraints close. +/// +/// The gate stops short of the zero-length row, which moves nothing and sends its +/// increments nowhere; rejecting it there would refuse a call the executor accepts. +#[test] +fn a_chain_may_not_wrap_the_address_space() { + let air = air(); + + // `src + 8` lands exactly on `2^64`. + let wrapping_src = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 16, + 8, + true, + false, + u64::MAX - 7, + 0x2000, + )]); + assert!( + !validate_busless(&air, &wrapping_src), + "a row whose source walks past 2^64 must be rejected" + ); + + let wrapping_dst = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 16, + 8, + true, + false, + 0x1000, + u64::MAX - 7, + )]); + assert!( + !validate_busless(&air, &wrapping_dst), + "a row whose destination walks past 2^64 must be rejected" + ); + + // The gate reaches the last row of a real chain: the executor rejects a copy + // that ends on `2^64` too, because `checked_add` returns `None` there. + let wrapping_end = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 8, + 8, + true, + true, + u64::MAX - 7, + 0x2000, + )]); + assert!( + !validate_busless(&air, &wrapping_end), + "the last row of a chain may not wrap either" + ); + + // One byte below the boundary the same row is legal, so it is the wraparound + // being rejected and not the magnitude of the address. + let legal = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 16, + 8, + true, + false, + u64::MAX - 15, + 0x2000, + )]); + assert!( + validate_busless(&air, &legal), + "a chain ending exactly at the top of the address space must be accepted" + ); + + // ... and the zero-length call is exempt, because it moves no byte and its + // increments reach no successor. The executor accepts it -- `checked_add(0)` + // never overflows -- so gating on plain `mu` here would refuse an honest call. + let empty_at_the_top = generate_copy_trace(&chain(Functionality::Copy, u64::MAX, 0x2000, 0)); + assert!( + validate_busless(&air, &empty_at_the_top), + "a zero-length copy with a pointer at u64::MAX must be accepted" + ); +} + +/// The `memset` gap may not wrap either. +/// +/// `copy:c:set_gap` is a plain `ADD` too, and the two constraints above do not reach +/// it: one-byte rows never push `src` or `dst` past `2^64`, so a short `memset` with +/// `src = 2^64 - 4` and `dst = 4` walks an entirely legal chain over an operand pair +/// the executor rejects (`DmaMemsetBadGap`). +#[test] +fn the_memset_gap_may_not_wrap_the_address_space() { + let air = air(); + let wrapping_src = u64::MAX - 3; + let wrapped_dst = 4u64; + + // Zero-length, and then every length a one-byte row can carry without pushing + // `src` itself over the top. + for count in 0..=3u64 { + let trace = + generate_copy_trace(&chain(Functionality::Set, wrapping_src, wrapped_dst, count)); + assert!( + !validate_busless(&air, &trace), + "a memset of {count} bytes whose gap wraps 2^64 must be rejected" + ); + } + + // The same shape with a gap that does not wrap is accepted, so it is the + // wraparound being rejected and not the memset path itself. + for count in 0..=3u64 { + let trace = generate_copy_trace(&chain(Functionality::Set, 0x1000, 0x1008, count)); + assert!( + validate_busless(&air, &trace), + "an honest memset of {count} bytes must be accepted" + ); + } +} + +/// `end` must match `count` in BOTH limbs. +/// +/// `copy:c:end_implies_count_is_step_or_zc_upper` is the only constraint that looks +/// at the high limb. Every other test in this file keeps `count` under 2^32, so it +/// is identically satisfied there and a regression would go unnoticed. Broken, a +/// prover could terminate a chain of 2^32 + 8 bytes after its first eight while the +/// register read still authorised the full count. +#[test] +fn end_requires_the_high_limb_of_count_to_be_zero() { + let trace = generate_copy_trace(&[row_of_width( + Functionality::Copy, + 0x1_0000_0008, + 8, + true, + true, + 0x1000, + 0x2000, + )]); + assert!( + !validate_busless(&air(), &trace), + "the low limb matching `step` is not enough to close a chain" + ); +} + +/// The zero-length state must close the chain, not chain on. +/// +/// `copy:c:borrow_implies_end` is the only thing that says so. Without it the +/// degenerate row forwards `count_decr = 2^64 - 1` on `COPY_NEXT` and the chain +/// walks off into a count no honest sequence produces. +#[test] +fn a_borrowing_row_must_close_the_chain() { + let mut trace = generate_copy_trace(&chain(Functionality::Copy, 0x1000, 0x2000, 0)); + assert!( + validate_busless(&air(), &trace), + "the baseline must be clean" + ); + trace.main_table.set(0, cols::END, FE::zero()); + assert!( + !validate_busless(&air(), &trace), + "a zero-length row must set `end`" + ); +} + +/// `mu_com` is the gate that routes a row's bytes to the COMMIT bus *and* keeps them +/// off the RAM write, which rides `mu - count_borrows[1] - mu_com`. +/// +/// Its sibling `mu_com_wide` had a negative test and this did not. Clearing it on an +/// honest `write` row drops the eight `(index, value)` pairs that feed +/// `public_output` and simultaneously turns the RAM write back on — at `dst`, which +/// on a write row is the small-integer commit index rather than an address. +#[test] +fn a_write_row_cannot_disown_the_commit_bus() { + let mut trace = generate_copy_trace(&chain(Functionality::Write, 0x3000, 0, 12)); + assert!( + validate_busless(&air(), &trace), + "the baseline must be clean" + ); + trace.main_table.set(0, cols::MU_COM, FE::zero()); + trace.main_table.set(0, cols::MU_COM_WIDE, FE::zero()); + assert!( + !validate_busless(&air(), &trace), + "a copying write row must carry mu_com" + ); +} + +/// A zero-length `write`: reachable from any guest, and the one shape that combines +/// `is_write` with the degenerate row. +/// +/// `syscalls::commit` passes `slice.len()` through with no guard, so `commit(b"")` +/// produces a row with `first = end = 1`, `count = 0`, a borrow, and therefore no +/// memory access and no COMMIT pair. If the `count != 0` gate in `generate_copy_trace` +/// or in `public_output_bytes` broke, it would emit eight spurious `(index, 0)` pairs +/// and the verifier's rebuild from `public_output` would disagree. +#[test] +fn a_zero_length_write_moves_nothing_and_commits_nothing() { + let rows = chain(Functionality::Write, 0x3000, 40, 0); + assert_eq!(rows.len(), 1); + let trace = generate_copy_trace(&rows); + assert!(validate_busless(&air(), &trace)); + + let row = trace.main_table.get_row(0); + assert_eq!(row[cols::FIRST], FE::one()); + assert_eq!(row[cols::END], FE::one()); + assert_eq!(row[cols::IS_WRITE], FE::one()); + assert_eq!(row[cols::FIRST_WRITE], FE::one()); + assert_eq!( + row[cols::MU_COM], + FE::zero(), + "an empty commit must send no COMMIT pair" + ); + assert_eq!(row[cols::MU_COM_WIDE], FE::zero()); + // `dst_res == dst`: the commit index does not advance. + assert_eq!(row[cols::DST_RES_0], FE::from(40u64)); +} + +/// `copy:c:read_fd` pins the OLD value of `x10` to the constant 1. +/// +/// That constant is the AIR's entire "only stdout is supported" check. Nothing +/// asserted its shape: `the_destination_register_follows_is_write` filters on +/// `Multiplicity::Column(FIRST)` and so excludes this interaction, and changing the +/// 1 to anything else would pass every unit test and surface only as a MEMW +/// imbalance in a full prove run. +#[test] +fn the_file_descriptor_is_pinned_to_stdout() { + use crate::tables::copy::bus_interactions; + use crate::tables::types::BusId; + use stark::lookup::{BusValue, LinearTerm, Multiplicity, Packing}; + + let fd_reads: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| { + i.bus_id == BusId::Memw as u64 + && matches!(i.multiplicity, Multiplicity::Column(c) if c == cols::FIRST_WRITE) + }) + .collect(); + assert_eq!(fd_reads.len(), 1, "exactly one access rides first_write"); + let fd = &fd_reads[0]; + + // Tuple layout: eight `old` lanes, `is_register`, two address limbs, eight + // `value` lanes. The old value is the descriptor; the value written back is + // `count`, the return value of `write(2)`. + assert!( + matches!(&fd.values[0], BusValue::Linear(terms) + if matches!(terms.as_slice(), [LinearTerm::Constant(1)])), + "the old value of x10 must be pinned to the stdout descriptor" + ); + assert!( + matches!(&fd.values[9], BusValue::Linear(terms) + if matches!(terms.as_slice(), [LinearTerm::Constant(20)])), + "it must address x10 (word address 20), not the selected destination" + ); + assert!( + matches!(fd.values[11], BusValue::Packed { start_column, packing: Packing::Direct } + if start_column == cols::COUNT_0), + "it must return `count` in x10" + ); +} + +/// A full-width timestamp survives the limb split. +/// +/// Ported from the deleted `commit_tests`. Every other test in this file uses +/// `timestamp: 100`, so `TIMESTAMP_1` is zero throughout and a regression in the +/// split would be invisible — which matters because the high limb is what the +/// `COPY_NEXT` tuple and both memory accesses carry unchanged. +#[test] +fn a_full_width_timestamp_survives_the_limb_split() { + let trace = generate_copy_trace(&[CopyOperation { + timestamp: u64::MAX, + src: 0x1000, + dst: 0x2000, + count: 8, + width: 8, + first: true, + end: true, + functionality: Functionality::Copy, + value: [0xAB; 8], + }]); + let row = trace.main_table.get_row(0); + assert_eq!(row[cols::TIMESTAMP_0], FE::from(0xFFFF_FFFFu64)); + assert_eq!(row[cols::TIMESTAMP_1], FE::from(0xFFFF_FFFFu64)); + assert!(validate_busless(&air(), &trace)); +} + +/// Two ecalls are two independent chains. +/// +/// Ported from the deleted `commit_tests`. Each chain starts its own `first` row and +/// neither receives from the other; the commit index continues across them, which is +/// what makes the verifier's `start_index + i` enumeration line up with the per-byte +/// `(index, value)` pairs regardless of where one ECALL ended and the next began. +#[test] +fn several_ecalls_are_several_chains_and_nothing_links_them() { + let mut rows = chain(Functionality::Write, 0x2000, 0, 3); + rows.extend(chain(Functionality::Write, 0x3000, 3, 9)); + for row in rows.iter_mut().skip(3) { + row.timestamp = 200; + } + let trace = generate_copy_trace(&rows); + assert!(validate_busless(&air(), &trace)); + + // 3 one-byte rows, then one wide row and one one-byte row. + assert_eq!(rows.len(), 5); + assert_eq!(rows.iter().filter(|r| r.first).count(), 2); + assert_eq!(rows.iter().filter(|r| r.end).count(), 2); + assert_eq!(*trace.main_table.get(0, cols::FIRST), FE::one()); + assert_eq!(*trace.main_table.get(3, cols::FIRST), FE::one()); + assert_eq!( + *trace.main_table.get(3, cols::TIMESTAMP_0), + FE::from(200u64) + ); + // The index carries over: the second chain starts where the first stopped. + assert_eq!(*trace.main_table.get(3, cols::DST_0), FE::from(3u64)); +} + +/// Padding rows carry exactly the padding the spec declares, and validate on their own. +/// +/// Replaces the deleted `commit_tests::padding_rows_are_all_zero` — COPY's padding is +/// *not* all zero, because `count = src_incr = dst_incr = 8` is what closes the three +/// unconditional `ADD` relations and keeps both borrows at zero with `step = 8`. An +/// all-padding trace is also what every proof now ships when the run neither commits +/// nor copies, since COPY is in `FIXED_TABLE_COUNT`, and nothing covered it. +#[test] +fn padding_rows_match_the_declared_padding() { + let trace = generate_copy_trace(&[]); + assert_eq!( + trace.main_table.height, 4, + "an empty table still pads to four" + ); + assert!( + validate_busless(&air(), &trace), + "an all-padding COPY trace must satisfy every constraint" + ); + + for row_idx in 0..4 { + let row = trace.main_table.get_row(row_idx); + assert_eq!(row[cols::COUNT_0], FE::from(8u64), "row {row_idx} count"); + assert_eq!( + row[cols::SRC_INCR_0], + FE::from(8u64), + "row {row_idx} src_incr" + ); + assert_eq!( + row[cols::DST_INCR_0], + FE::from(8u64), + "row {row_idx} dst_incr" + ); + assert_eq!(row[cols::MU], FE::zero(), "row {row_idx} mu"); + // Everything else is zero, which is what keeps every multiplicity at zero. + for column in [ + cols::FIRST, + cols::END, + cols::SINGLE, + cols::IS_SET, + cols::IS_WRITE, + cols::FIRST_WRITE, + cols::MU_COM, + cols::MU_COM_WIDE, + cols::COUNT_1, + cols::COUNT_DECR_0, + ] { + assert_eq!(row[column], FE::zero(), "row {row_idx} column {column}"); + } + } +} + +/// The local `chain()` helper must agree with the production builder. +/// +/// Every other test in this file constructs its rows with `chain()`, so nothing here +/// would notice `collect_copy_ops` drifting away from it — the tests would keep +/// passing while describing a schedule the prover no longer emits. This pins the two +/// together across the shapes that matter: the width boundary, a tail, a mixed +/// chain, and the zero-length call. +#[test] +fn the_test_helper_matches_the_production_builder() { + use crate::tables::trace_builder::copy_ops_for_test; + + for functionality in [ + Functionality::Copy, + Functionality::Set, + Functionality::Write, + ] { + // memset is only defined for `dst = src + 8`; `write` addresses the commit + // index rather than RAM. + let (src, dst) = match functionality { + Functionality::Set => (0x1000, 0x1008), + Functionality::Write => (0x1000, 0), + Functionality::Copy => (0x1000, 0x2000), + }; + + for count in [0u64, 1, 7, 8, 9, 16, 23, 64] { + let built = copy_ops_for_test(functionality, src, dst, count); + let helper = chain(functionality, src, dst, count); + + assert_eq!( + built.len(), + helper.len(), + "{functionality:?} count {count}: row count" + ); + for (i, (b, h)) in built.iter().zip(&helper).enumerate() { + assert_eq!( + b.width, h.width, + "{functionality:?} count {count} row {i}: width" + ); + assert_eq!( + b.count, h.count, + "{functionality:?} count {count} row {i}: count" + ); + assert_eq!( + b.first, h.first, + "{functionality:?} count {count} row {i}: first" + ); + assert_eq!(b.end, h.end, "{functionality:?} count {count} row {i}: end"); + assert_eq!(b.src, h.src, "{functionality:?} count {count} row {i}: src"); + assert_eq!(b.dst, h.dst, "{functionality:?} count {count} row {i}: dst"); + } + } + } +} diff --git a/prover/src/tests/count_table_lengths_drift_tests.rs b/prover/src/tests/count_table_lengths_drift_tests.rs index b2ebddfff..f8c4630a2 100644 --- a/prover/src/tests/count_table_lengths_drift_tests.rs +++ b/prover/src/tests/count_table_lengths_drift_tests.rs @@ -5,6 +5,7 @@ use crate::tables::trace_builder::{Traces, count_table_lengths}; use crate::test_utils::run_asm_elf; use executor::elf::Elf; use executor::vm::execution::Executor; +use executor::vm::instruction::decoding::Instruction; use executor::vm::logs::Log; fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { @@ -47,9 +48,8 @@ fn assert_count_table_lengths_matches(elf: &Elf, logs: &[Log]) { "shift" ); assert_eq!( - predicted.commit_padded_rows, - sum_heights(&traces.commits), - "commit" + predicted.copy_padded_rows, traces.copy.main_table.height as u64, + "copy" ); assert_eq!( predicted.decode_rows, traces.decode.main_table.height as u64, @@ -100,6 +100,50 @@ fn count_table_lengths_matches_traces() { assert_count_table_lengths_matches(&elf, &logs); } +/// Runs one Rust DMA guest and asserts the sizing pass matches the built traces. +/// Sizing replays each ecall through `replay_copy_for_sizing`, which delegates to +/// the same `collect_copy_ops` trace generation uses; the fixtures cover a single +/// chunk plus the multi-chunk and overlapping cases of `dma_memcpy_cases`, and the +/// same schedule driven through memset. +fn assert_dma_fixture_counts(elf_name: &str, syscall_number: u64) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join(format!("executor/program_artifacts/rust/{elf_name}"))) + .unwrap_or_else(|_| panic!("{elf_name} not found — build its make target")); + let elf = Elf::load(&elf_bytes).expect("valid DMA guest ELF"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("DMA guest execution"); + + assert!( + result.logs.iter().any(|log| { + log.src1_val == syscall_number + && matches!( + result.instructions.get(&log.current_pc), + Some(Instruction::EcallEbreak) + ) + }), + "fixture must contain a DMA ecall" + ); + assert_count_table_lengths_matches(&elf, &result.logs); +} + +#[test] +fn count_table_lengths_matches_nonempty_dma_trace() { + use executor::vm::instruction::execution::{ + DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER, + }; + + assert_dma_fixture_counts("dma_memcpy_min.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memcpy_cases.elf", DMA_MEMCPY_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_min.elf", DMA_MEMSET_SYSCALL_NUMBER); + assert_dma_fixture_counts("dma_memset_cases.elf", DMA_MEMSET_SYSCALL_NUMBER); +} + /// The `hint` ecall routes three register reads (`a0`/`a1`/`a2`) and four output /// writes through the memory argument, plus two LT range-checks (selector, in_addr). /// `count_table_lengths` must replay all of that exactly, or `memw_register` (an diff --git a/prover/src/tests/ir_stats_dump.rs b/prover/src/tests/ir_stats_dump.rs index a269127d9..99767b3b8 100644 --- a/prover/src/tests/ir_stats_dump.rs +++ b/prover/src/tests/ir_stats_dump.rs @@ -122,7 +122,6 @@ fn dump_ir_stats() { stats(&create_dvrm_air(&opts), "DVRM"); stats(&create_branch_air(&opts), "BRANCH"); stats(&create_halt_air(&opts), "HALT"); - stats(&create_commit_air(&opts), "COMMIT"); stats(&create_page_air(&opts, 0x1000), "PAGE"); stats(&create_register_air(&opts), "REGISTER"); stats(&create_keccak_air(&opts), "KECCAK"); diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 73ff6ee45..cd746aa5a 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -11,8 +11,6 @@ pub mod branch_constraints_tests; #[cfg(test)] pub mod bytewise_tests; #[cfg(test)] -pub mod commit_tests; -#[cfg(test)] pub mod compute_commit_bus_offset_tests; #[cfg(test)] pub mod constraint_emit_tests; @@ -26,6 +24,8 @@ pub mod constraint_set_tests_a; pub mod constraint_set_tests_b; #[cfg(test)] pub mod constraints_tests; +#[cfg(test)] +pub mod copy_tests; #[cfg(all(test, feature = "disk-spill"))] pub mod count_table_lengths_drift_tests; #[cfg(test)] diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index 29d224627..dd680f9e7 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -90,6 +90,7 @@ fn all_table_windows_match_captured_ir() { let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 valid"); assert_ood_window_matches_ir(&create_cpu_air(&opts), true, "CPU"); + assert_ood_window_matches_ir(&create_copy_air(&opts), true, "copy"); assert_ood_window_matches_ir(&create_bitwise_air(&opts), true, "BITWISE"); assert_ood_window_matches_ir(&create_lt_air(&opts), true, "LT"); assert_ood_window_matches_ir(&create_shift_air(&opts), true, "SHIFT"); @@ -106,7 +107,6 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_dvrm_air(&opts), true, "DVRM"); assert_ood_window_matches_ir(&create_branch_air(&opts), true, "BRANCH"); assert_ood_window_matches_ir(&create_halt_air(&opts), true, "HALT"); - assert_ood_window_matches_ir(&create_commit_air(&opts), true, "COMMIT"); assert_ood_window_matches_ir(&create_page_air(&opts, 0x1000), true, "PAGE"); assert_ood_window_matches_ir(&create_register_air(&opts), true, "REGISTER"); assert_ood_window_matches_ir(&create_keccak_air(&opts), true, "KECCAK"); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 2707e67af..42e63e831 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1303,6 +1303,548 @@ fn test_prove_ecsm_rust_guest() { ); } +#[test] +fn test_prove_dma_memcpy_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memcpy_min.elf")) + .expect("dma_memcpy_min.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memcpy guest should verify" + ); + assert_eq!( + proof.public_output, + b"DMA copies eight-byte rows and a short tail" + ); +} + +/// Positive control for the fixture the memset forgery tests tamper with. Those +/// tests assert that verification FAILS, so without this they would also pass if +/// the untampered trace never verified in the first place. +#[test] +fn test_prove_dma_memset_min_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, [0x3Cu8; 43]); +} + +/// End-to-end memset: the guest exercises every row-schedule boundary (empty, +/// sub-tail, exact widths, the per-ecall cap, multi-chunk, a wide fill truncated +/// to its low byte, and an unaligned page-crossing destination), so a passing +/// proof covers the memset rows, their bus balance, and the operand-gap pin +/// together. +#[test] +fn test_prove_dma_memset_cases_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_cases.elf")) + .expect("dma_memset_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memset guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memset-ok"); +} + +/// memmove rides the memcpy ecall unchanged. The interesting case is a forward +/// overlap longer than one 256-byte chunk: the stub must walk chunks backwards, +/// or an earlier chunk clobbers source bytes a later one still needs. +#[test] +fn test_prove_dma_memmove_cases_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memmove_cases.elf")) + .expect("dma_memmove_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA memmove guest should verify" + ); + assert_eq!(proof.public_output, b"dma-memmove-ok"); +} + +#[test] +fn test_prove_dma_memcpy_cases_rust_guest() { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memcpy_cases.elf")) + .expect("dma_memcpy_cases.elf not found — build its make target"); + + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "DMA differential cases guest should verify" + ); + assert_eq!(proof.public_output, b"dma-cases-ok"); +} + +#[test] +fn test_prove_dma_memcpy_forged_value_rejected() { + use crate::tables::copy::cols as dma_cols; + + let (elf, mut traces) = dma_memcpy_fixture(); + let forged_row = dma_row_matching(&traces, |_, end, _tail| !end); + let original = *traces.copy.main_table.get(forged_row, dma_cols::VALUE[0]); + traces.copy.main_table.set( + forged_row, + dma_cols::VALUE[0], + original + FieldElement::::one(), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "changing the structurally shared copied byte must unbalance MEMW", + ); +} + +#[test] +fn test_prove_dma_memcpy_forged_intermediate_source_rejected() { + use crate::tables::copy::cols as dma_cols; + + let (elf, mut traces) = dma_memcpy_fixture(); + let forged_row = dma_row_matching(&traces, |first, end, _tail| !first && !end); + + // Shift both the current source and its locally-consistent successor. The + // row's ADD remains valid, but the predecessor's CopyNext tuple and the + // source-memory read no longer match. + let src_lo = *traces.copy.main_table.get(forged_row, dma_cols::SRC_0); + let src_incr_lo = *traces.copy.main_table.get(forged_row, dma_cols::SRC_INCR_0); + traces.copy.main_table.set( + forged_row, + dma_cols::SRC_0, + src_lo + FieldElement::from(8u64), + ); + traces.copy.main_table.set( + forged_row, + dma_cols::SRC_INCR_0, + src_incr_lo + FieldElement::from(8u64), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate source row must remain chained to its predecessor", + ); +} + +#[test] +fn test_prove_dma_memcpy_forged_early_end_rejected() { + use crate::tables::copy::cols as dma_cols; + + let (elf, mut traces) = dma_memcpy_fixture(); + let forged_row = dma_row_matching(&traces, |_first, end, _tail| !end); + traces + .copy + .main_table + .set(forged_row, dma_cols::END, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "END must be equivalent to count == step"); +} + +#[test] +fn test_prove_dma_memcpy_forged_wide_tail_rejected() { + use crate::tables::copy::cols as dma_cols; + + let (elf, mut traces) = dma_memcpy_fixture(); + let forged_row = dma_row_matching(&traces, |_first, end, tail| !end && !tail); + traces + .copy + .main_table + .set(forged_row, dma_cols::SINGLE, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "a row's width must match its step"); +} + +#[test] +fn test_prove_dma_memset_forged_intermediate_fill_rejected() { + use crate::tables::copy::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + // A wide row matters here: on a one-byte row the unused lanes must stay zero, so + // shifting both lanes there would trip the `single * value[i] = 0` set locally and + // the test would prove something else. + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); + // Shift both lanes so the row stays internally consistent locally; only the chain + // token and the MEMW write disagree. + for column in [dma_set_cols::VALUE[0], dma_set_cols::VALUE[1]] { + let original = *traces.copy.main_table.get(forged_row, column); + traces.copy.main_table.set( + forged_row, + column, + original + FieldElement::::one(), + ); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must keep the fill byte its predecessor sent", + ); +} + +#[test] +fn test_prove_dma_memset_forged_early_end_rejected() { + use crate::tables::copy::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, _tail| !end); + traces + .copy + .main_table + .set(forged_row, dma_set_cols::END, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "END must be equivalent to count == step"); +} + +/// Flipping `single` rewrites `step` from 8 to 1, so the row's own address and +/// count arithmetic stop holding. It is rejected locally by the ADD carries; there +/// is no ALU LT pinning the width any more, because a chain that takes the wrong +/// width can no longer reach a legal `end`. +#[test] +fn test_prove_dma_memset_forged_wide_tail_rejected() { + use crate::tables::copy::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, tail| !end && !tail); + traces + .copy + .main_table + .set(forged_row, dma_set_cols::SINGLE, FieldElement::one()); + + assert_dma_forgery_rejected(&elf, &mut traces, "a row's width must match its step"); +} + +/// Soundness: the timestamp order is what makes a memset a memset. Clearing `IS_SET` +/// on a chain turns the row back into an ordinary snapshot copy, which reads at `T` +/// instead of `T+1` — so the read no longer observes the previous row's write and the +/// MEMW tuples stop matching the memory the executor produced. This is the one piece of +/// the unified chip with no ancestor in either of the tables it replaces. +#[test] +fn test_prove_dma_memset_forged_order_bit_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |_first, end, _tail| !end); + traces + .copy + .main_table + .set(forged_row, mm_cols::IS_SET, FieldElement::zero()); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "clearing the order bit must break the propagation the fill depends on", + ); +} + +#[test] +fn test_prove_dma_memset_forged_intermediate_destination_rejected() { + use crate::tables::copy::cols as dma_set_cols; + + let (elf, mut traces) = dma_memset_fixture(); + let forged_row = dma_set_row_matching(&traces, |first, end, tail| !first && !end && !tail); + + // Shift both the current destination and its locally-consistent successor. + // The row's ADD stays valid; the predecessor's CopyNext tuple and the + // memory write no longer match. + for column in [dma_set_cols::DST_0, dma_set_cols::DST_INCR_0] { + let original = *traces.copy.main_table.get(forged_row, column); + traces + .copy + .main_table + .set(forged_row, column, original + FieldElement::from(8u64)); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "an intermediate row must stay chained to its predecessor's address", + ); +} + +fn dma_memset_fixture() -> (Elf, Traces) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memset_min.elf")) + .expect("dma_memset_min.elf not found — build its make target"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("execution"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +fn dma_set_row_matching(traces: &Traces, predicate: impl Fn(bool, bool, bool) -> bool) -> usize { + use crate::tables::copy::cols as mm_cols; + + let one = FieldElement::::one(); + (0..traces.copy.num_rows()) + .find(|&row| { + let get = |column| *traces.copy.main_table.get(row, column) == one; + get(mm_cols::MU) + && get(mm_cols::IS_SET) + && predicate(get(mm_cols::FIRST), get(mm_cols::END), get(mm_cols::SINGLE)) + }) + .expect("guest must contain the requested real memset row") +} + +fn dma_memcpy_fixture() -> (Elf, Traces) { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + let elf_bytes = + std::fs::read(workspace_root.join("executor/program_artifacts/rust/dma_memcpy_min.elf")) + .expect("dma_memcpy_min.elf not found — build its make target"); + let elf = Elf::load(&elf_bytes).expect("ELF load"); + let result = Executor::new(&elf, vec![]) + .expect("executor") + .run() + .expect("execution"); + let traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +fn dma_row_matching(traces: &Traces, predicate: impl Fn(bool, bool, bool) -> bool) -> usize { + use crate::tables::copy::cols as mm_cols; + + let one = FieldElement::::one(); + (0..traces.copy.num_rows()) + .find(|&row| { + let get = |column| *traces.copy.main_table.get(row, column) == one; + // memcpy rows only: memset and `write` have their own fixtures above. + let is_copy = !get(mm_cols::IS_SET) && !get(mm_cols::IS_WRITE); + get(mm_cols::MU) + && is_copy + && predicate(get(mm_cols::FIRST), get(mm_cols::END), get(mm_cols::SINGLE)) + }) + .expect("guest must contain the requested real COPY copy row") +} + +fn assert_dma_forgery_rejected(elf: &Elf, traces: &mut Traces, reason: &str) { + assert!(!prove_and_verify_vm_minimal(elf, traces), "{reason}"); +} + +/// A guest that commits four bytes, and the COPY rows that carry them. +/// +/// `test_commit_4` is the smallest fixture that drives the `write` functionality end +/// to end: ECALL 64, the fd check on x10, the x254 commit-index update, and the +/// COMMIT-domain sends that the verifier rebuilds from `public_output`. +fn commit_fixture() -> (Elf, Traces) { + let (elf, logs, _instructions) = crate::test_utils::run_asm_elf("test_commit_4"); + let traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + (elf, traces) +} + +/// The first `write` row of the chain. +fn commit_row(traces: &Traces) -> usize { + use crate::tables::copy::cols as mm_cols; + + let one = FieldElement::::one(); + (0..traces.copy.num_rows()) + .find(|&row| { + let get = |column| *traces.copy.main_table.get(row, column) == one; + get(mm_cols::MU) && get(mm_cols::IS_WRITE) && get(mm_cols::MU_COM) + }) + .expect("the guest must contain a real COPY write row") +} + +/// Soundness: the committed bytes are what the verifier rebuilds `public_output` +/// from, so a forged `value` lane has to break the bus. +/// +/// This is the `write` counterpart of `test_prove_dma_memcpy_forged_value_rejected`. +/// It did not exist: memcpy had four proof-level forgery tests and memset five, +/// while the one functionality that determines the proof's public output had none. +#[test] +fn test_prove_commit_forged_value_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + let original = *traces.copy.main_table.get(forged_row, mm_cols::VALUE[0]); + traces.copy.main_table.set( + forged_row, + mm_cols::VALUE[0], + original + FieldElement::::one(), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a committed byte must match both the memory it was read from and the \ + public output the verifier rebuilds", + ); +} + +/// The commit index is the COMMIT bus address. Shifting it moves every +/// `(index, value)` pair this row sends away from where the verifier puts its +/// receivers, while the row stays locally consistent — the analogue of +/// `test_prove_dma_memset_forged_intermediate_destination_rejected`. +#[test] +fn test_prove_commit_forged_index_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + for column in [mm_cols::DST_0, mm_cols::DST_INCR_0] { + let original = *traces.copy.main_table.get(forged_row, column); + traces + .copy + .main_table + .set(forged_row, column, original + FieldElement::from(8u64)); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "the commit index must stay where x254 and the verifier agree it is", + ); +} + +/// The high word of the commit index. +/// +/// `copy:c:read_dst` writes the full `DWordWL` of `dst_res` back to x254, but the +/// `Commit` bus carries the index as `dst[0]` alone and the trace builder pins x254's +/// high word to zero rather than a constraint doing it. That makes the high half the +/// one place where the register file and the COMMIT bus could drift apart, so it is +/// worth pinning that a forged high word is caught — by the REGISTER side, since +/// nothing in COPY looks at it. +#[test] +fn test_prove_commit_forged_index_high_word_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + assert_eq!( + *traces + .copy + .main_table + .get(forged_row, mm_cols::DST_RES_0 + 2), + FieldElement::::zero(), + "the honest commit index must fit the low word" + ); + traces.copy.main_table.set( + forged_row, + mm_cols::DST_RES_0 + 2, + FieldElement::::one(), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a forged high word of the commit index must not reach x254 unnoticed", + ); +} + +/// `first_write` is the gate on the file-descriptor check: clear it and the row no +/// longer proves that x10 held 1 on the way in, nor that it returns `count`. It is +/// the only thing standing between the chip and a `write` to an arbitrary descriptor. +#[test] +fn test_prove_commit_forged_first_write_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + assert_eq!( + *traces.copy.main_table.get(forged_row, mm_cols::FIRST_WRITE), + FieldElement::::one(), + "the fixture's first write row must carry first_write" + ); + traces + .copy + .main_table + .set(forged_row, mm_cols::FIRST_WRITE, FieldElement::zero()); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a write sequence cannot skip the file-descriptor check", + ); +} + +/// `dst_res` is what the chip writes back to x254, so it is the link between the +/// COMMIT bus indices and the register file the verifier checks. Forging it on the +/// first row breaks that link while the row's own copy stays consistent. +#[test] +fn test_prove_commit_forged_dst_res_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + let original = *traces.copy.main_table.get(forged_row, mm_cols::DST_RES_0); + traces.copy.main_table.set( + forged_row, + mm_cols::DST_RES_0, + original + FieldElement::from(8u64), + ); + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "the commit index written back to x254 must be dst + count", + ); +} + +/// `mu_com` routes the row's bytes to the COMMIT bus and keeps them off the RAM +/// write. Clearing it drops the pairs that feed `public_output` and re-enables the +/// RAM write at the commit index. Constraint 35 rejects it locally; this pins that +/// the proof-level consequence follows too. +#[test] +fn test_prove_commit_forged_mu_com_rejected() { + use crate::tables::copy::cols as mm_cols; + + let (elf, mut traces) = commit_fixture(); + let forged_row = commit_row(&traces); + for column in [mm_cols::MU_COM, mm_cols::MU_COM_WIDE] { + traces + .copy + .main_table + .set(forged_row, column, FieldElement::zero()); + } + + assert_dma_forgery_rejected( + &elf, + &mut traces, + "a write row cannot disown the COMMIT bus", + ); +} /// End-to-end prove→verify for the non-constraining `Hint` ecall: the minimal Rust /// guest does one `hint` call (secp256k1 base-field inverse of 3) and commits the result. /// This exercises the whole HINT table bus surface (Ecall receive, the x10/x11/x12 @@ -2844,7 +3386,6 @@ fn test_verify_rejects_zero_table_counts() { ecsm: 0, ecdas: 0, hint: 0, - commit: 0, }, ..vm_proof }; @@ -2935,7 +3476,6 @@ fn test_crafted_zero_count_proof_must_not_verify() { ecsm: 0, ecdas: 0, hint: 0, - commit: 0, }; let airs = VmAirs::new( &elf, @@ -3152,7 +3692,7 @@ fn test_prove_private_input_different_values() { /// End-to-end: EF zkVM IO interface — demo guest reads its private input via /// `read_input` and emits it back through TWO `write_output` calls. The -/// COMMIT AIR's running `x254` index concatenates them; the resulting proof's +/// running `x254` index of COPY's commit rows concatenates them; the resulting proof's /// `public_output` must equal the original input. #[test] fn test_prove_ef_io_demo_concatenates() { @@ -3163,7 +3703,13 @@ fn test_prove_ef_io_demo_concatenates() { let elf_bytes = std::fs::read(workspace_root.join("executor/program_artifacts/rust/ef_io_demo.elf")) .expect("ef_io_demo.elf not found — run `make compile-programs-rust`"); - let input: &[u8] = b"hello world!"; + // 25 bytes, so `ef_io_demo`'s `buf_size / 2` split gives commits of 12 and 13. + // Both exceed eight, so each ecall emits a WIDE commit row, and the second is + // based at global index 12 -- not 8-aligned. That is the configuration where a + // prover-side row schedule and the verifier's `public_output` rebuild would drift + // apart if the COMMIT bus were grouped per row rather than per byte. The old + // input, `b"hello world!"`, split 6 + 6 and so produced only one-byte commit rows. + let input: &[u8] = b"hello world, and hello ef"; let proof = crate::prove_with_inputs(&elf_bytes, input).expect("prove should succeed"); assert!( crate::verify(&proof, &elf_bytes).expect("verify should not error"), diff --git a/prover/src/tests/register_tests.rs b/prover/src/tests/register_tests.rs index 66dcd2662..45109d845 100644 --- a/prover/src/tests/register_tests.rs +++ b/prover/src/tests/register_tests.rs @@ -21,7 +21,7 @@ fn test_generate_register_trace_empty() { let final_state = FinalRegisterStateMap::new(); let trace = generate_register_trace(&final_state, ®ister_init_from_entry_point(entry_point)); - // Should have power-of-2 rows >= 67 (x0-x31, x254, x255) + // Should have power-of-2 rows >= 68 (x0-x31, x254, x255) assert!(trace.num_rows() >= NUM_REGISTER_ADDRESSES); assert!(trace.num_rows().is_power_of_two()); @@ -32,24 +32,25 @@ fn test_generate_register_trace_empty() { assert_eq!(*trace.main_table.get(0, cols::FINI), FE::zero()); assert_eq!(*trace.main_table.get(0, cols::TIMESTAMP_LO), FE::from(1u64)); - // Check x254 row (row 64 = addr 508) + // Check x254 rows (row 64 = addr 508, row 65 = addr 509) assert_eq!(*trace.main_table.get(64, cols::OFFSET), FE::from(508u64)); assert_eq!(*trace.main_table.get(64, cols::INIT), FE::zero()); assert_eq!(*trace.main_table.get(64, cols::FINI), FE::zero()); + assert_eq!(*trace.main_table.get(65, cols::OFFSET), FE::from(509u64)); - // Check x255 rows (row 65 = addr 510, row 66 = addr 511) - assert_eq!(*trace.main_table.get(65, cols::OFFSET), FE::from(510u64)); + // Check x255 rows (row 66 = addr 510, row 67 = addr 511) + assert_eq!(*trace.main_table.get(66, cols::OFFSET), FE::from(510u64)); assert_eq!( - *trace.main_table.get(65, cols::INIT), + *trace.main_table.get(66, cols::INIT), FE::from(entry_point & 0xFFFF_FFFF) ); assert_eq!( - *trace.main_table.get(65, cols::FINI), + *trace.main_table.get(66, cols::FINI), FE::from(entry_point & 0xFFFF_FFFF) ); // fini=init when never accessed - assert_eq!(*trace.main_table.get(66, cols::OFFSET), FE::from(511u64)); + assert_eq!(*trace.main_table.get(67, cols::OFFSET), FE::from(511u64)); assert_eq!( - *trace.main_table.get(66, cols::INIT), + *trace.main_table.get(67, cols::INIT), FE::from(entry_point >> 32) ); } @@ -102,10 +103,10 @@ fn test_fini_from_trace_reads_every_register() { // One entry per real register Word address, in register_word_address_list order // (positions 0..63 are addresses 0..63, so addr a is at index a for a < 64). assert_eq!(fini.len(), NUM_REGISTER_ADDRESSES); - // Accessed register reflects its written value; PC (x255, addr 510 = index 65) + // Accessed register reflects its written value; PC (x255, addr 510 = index 66) // reflects entry point; x0 (addr 0 = index 0) ends at 0. assert_eq!(fini[10], 0x42); - assert_eq!(fini[65], 0x1000); + assert_eq!(fini[66], 0x1000); assert_eq!(fini[0], 0); } diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 8fef3d190..f9b38fa64 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -99,7 +99,6 @@ fn every_table_participates_in_the_bus() { ecsm: 1, ecdas: 1, hint: 1, - commit: 1, }; let airs = VmAirs::new( &elf, @@ -157,7 +156,6 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { ecsm, ecdas, hint, - commit, } = counts; [ ("CPU", cpu), @@ -179,7 +177,6 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { ("ECSM", ecsm), ("ECDAS", ecdas), ("HINT", hint), - ("COMMIT", commit), ] .into_iter() .map(|(name, _count)| name) @@ -190,8 +187,8 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { /// but a chip whose bus footprint cancelled against itself would contribute /// zero however many rows it carried, and dropping *that* would move the sum by /// nothing. The chained chips are the candidates — ECDAS receives an -/// accumulator state and sends the updated one back on the same bus, COMMIT -/// telescopes on `CommitNextByte`, KECCAK_RND chains rounds — and each is +/// accumulator state and sends the updated one back on the same bus, COPY +/// telescopes on `CopyNext`, KECCAK_RND chains rounds — and each is /// supposed to be anchored by a term that does not telescope away with it. /// /// The anchor is structural, but it is *not* the range checks. Whether a term @@ -204,7 +201,7 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { /// What anchors a chip is a term on a bus whose *other* participants all have /// constrained multiplicities, because then nobody is free to absorb it. The /// dispatch buses are those: ECDAS receives and re-sends on `BusId::Ecdas`, -/// COMMIT receives on `Ecall` and telescopes on `CommitNextByte`, KECCAK_RND +/// COPY receives on `Ecall` and telescopes on `CopyNext`, KECCAK_RND /// chains rounds on `BusId::Keccak`, and in each case the counterparty is a /// chip whose own constraints fix what it may put there. Drop the chip and that /// term has no receiver and no witness anywhere that can be lowered to hide it. @@ -376,10 +373,10 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { #[test] fn no_optional_table_uses_a_constant_multiplicity() { use crate::tables::{ - branch, bytewise, commit, cpu32, dvrm, ecdas, ecsm, eq, hint, keccak, keccak_rnd, load, lt, - memw, memw_aligned, mul, shift, store, + branch, bytewise, cpu32, dvrm, ecdas, ecsm, eq, hint, keccak, keccak_rnd, load, lt, memw, + memw_aligned, mul, shift, store, }; - let optional: [(&str, Vec); 18] = [ + let optional: [(&str, Vec); 17] = [ ("LT", lt::bus_interactions()), ("MEMW", memw::bus_interactions()), ("MEMW_A", memw_aligned::bus_interactions()), @@ -397,7 +394,6 @@ fn no_optional_table_uses_a_constant_multiplicity() { ("ECSM", ecsm::bus_interactions()), ("ECDAS", ecdas::bus_interactions()), ("HINT", hint::bus_interactions()), - ("COMMIT", commit::bus_interactions()), ]; for (name, interactions) in optional { assert!( @@ -539,7 +535,7 @@ fn validate_still_requires_cpu_and_the_register_file() { } /// The counts ride in the proof, so they are the prover's to choose, and the -/// sub-proof cross-check constrains only their *sum*. The six accelerators are +/// sub-proof cross-check constrains only their *sum*. The five accelerators are /// not chunked — `generate_optional` emits one table or none — so any count /// above 1 describes a table set no prover can build, and the sum alone does not /// catch it: lower another count by the same amount and the total still matches. @@ -565,13 +561,12 @@ fn an_accelerator_count_above_one_is_rejected() { // Named setters rather than a match with a catch-all: a wrong field would // otherwise pass the assertion below while testing the wrong count. type SetCount = fn(&mut TableCounts); - let inflate: [(&str, SetCount); 6] = [ + let inflate: [(&str, SetCount); 5] = [ ("keccak", |c| c.keccak = 2), ("keccak_rnd", |c| c.keccak_rnd = 2), ("ecsm", |c| c.ecsm = 2), ("ecdas", |c| c.ecdas = 2), ("hint", |c| c.hint = 2), - ("commit", |c| c.commit = 2), ]; for (name, set_to_two) in inflate { let mut counts = honest.clone(); @@ -582,7 +577,7 @@ fn an_accelerator_count_above_one_is_rejected() { ); } - // The chunked chips keep taking any count: this is a cap on the six, not a + // The chunked chips keep taking any count: this is a cap on the five, not a // cap on chunking. let mut many_mul = honest.clone(); many_mul.mul = 7; @@ -620,9 +615,9 @@ fn counts_that_wrap_have_no_total() { /// The accelerators are the ones a run most often never reaches, and each cost a /// four-row sub-proof regardless. A program with no keccak, no EC and no hint -/// ecall now carries none of the six. +/// ecall now carries none of the five. #[test] -fn a_run_without_accelerators_omits_all_six() { +fn a_run_without_accelerators_omits_all_five() { let (elf, logs, _instructions) = run_asm_elf("xori"); let mut traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); @@ -634,7 +629,6 @@ fn a_run_without_accelerators_omits_all_six() { ("ecsm", counts.ecsm), ("ecdas", counts.ecdas), ("hint", counts.hint), - ("commit", counts.commit), ] { assert_eq!( count, 0, @@ -695,19 +689,21 @@ fn omitting_a_chip_dispatched_by_another_chip_fails_the_bus_balance() { assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "KECCAK_RND"); } -/// COMMIT is the one newly-optional table whose bus target is not zero: the -/// verifier computes it from the public output the prover supplies -/// (`compute_expected_commit_bus_balance_view`), and nothing requires -/// `commit > 0` when that output is non-empty. Before this change the COMMIT -/// sub-proof was structurally mandatory; now its presence is a number the -/// prover picks, and the balance is the only thing standing behind it. +/// The committed output is the one bus target that is not zero: the verifier +/// computes it from the `public_output` the prover supplies +/// (`compute_expected_commit_bus_balance_view`). +/// +/// There is no COMMIT table to omit any more — the `write` syscall is served by +/// COPY, which is mandatory — so the forgery left is to keep the rows that send +/// the committed bytes and drop the output they claim. That moves the verifier's +/// target while the sum stays put, and the two sides have to disagree. #[test] -fn omitting_the_commit_table_fails_the_bus_balance() { +fn dropping_the_committed_output_fails_the_bus_balance() { let (elf, logs, _instructions) = run_asm_elf("test_commit_4"); let mut traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); assert!( - !traces.commits.is_empty() && !traces.public_output_bytes.is_empty(), + !traces.public_output_bytes.is_empty(), "test_commit_4 must commit output for this to be a forgery" ); @@ -716,29 +712,12 @@ fn omitting_the_commit_table_fails_the_bus_balance() { "the honest proof must verify first, or the negative below proves nothing" ); - traces.commits.clear(); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "COMMIT"); -} - -/// The variant the test above does not cover: a prover who drops COMMIT can -/// also drop the output it committed, and that moves the verifier's *target* -/// rather than the sum — the balance is recomputed for an empty output. The -/// CPU's commit ecalls are still in the trace with no receiver, so the two -/// sides still have to disagree. -#[test] -fn omitting_the_commit_table_and_its_output_fails_the_bus_balance() { - let (elf, logs, _instructions) = run_asm_elf("test_commit_4"); - let mut traces = - Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); let honest_target = weigh_the_bus(&elf, &mut traces, false).target; - - traces.commits.clear(); traces.public_output_bytes.clear(); let outcome = weigh_the_bus(&elf, &mut traces, true); assert_ne!( outcome.target, honest_target, - "clearing the public output must move the target, or this test is the \ - previous one over again" + "clearing the public output must move the target" ); - assert_only_the_bus_rejected(&outcome, "COMMIT together with its output"); + assert_only_the_bus_rejected(&outcome, "the committed output"); } diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 065d66ee7..68dc20023 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -28,7 +28,6 @@ fn sample_counts() -> TableCounts { ecsm: 1, ecdas: 1, hint: 1, - commit: 1, } } @@ -97,7 +96,6 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { ecsm, ecdas, hint, - commit, } = counts; vec![ ("cpu", cpu), @@ -119,7 +117,6 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { ("ecsm", ecsm), ("ecdas", ecdas), ("hint", hint), - ("commit", commit), ] } @@ -136,7 +133,7 @@ fn state_depends_on_every_table_count() { .into_iter() .map(|(name, _)| name) .collect(); - assert_eq!(names.len(), 20, "every count must be probed"); + assert_eq!(names.len(), 19, "every count must be probed"); for name in names { let mut counts = sample_counts(); diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 4446fb446..7f60a6bc1 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -263,7 +263,6 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_dvrm_air(&opts), "DVRM"); check_air(&create_branch_air(&opts), "BRANCH"); check_air(&create_halt_air(&opts), "HALT"); - check_air(&create_commit_air(&opts), "COMMIT"); check_air(&create_page_air(&opts, 0x1000), "PAGE"); check_air(&create_register_air(&opts), "REGISTER"); check_air(&create_keccak_air(&opts), "KECCAK"); @@ -271,5 +270,6 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_keccak_rc_air(&opts), "KECCAK_RC"); check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); + check_air(&create_copy_air(&opts), "copy"); check_air(&create_hint_air(&opts), "HINT"); } diff --git a/syscalls/src/allocator.rs b/syscalls/src/allocator.rs index 920d7e1c3..417dcee2b 100644 --- a/syscalls/src/allocator.rs +++ b/syscalls/src/allocator.rs @@ -1,6 +1,17 @@ use riscv as _; const MAX_MEMORY_SIZE: usize = 0xC000_0000; + +/// Kept for the accelerated `memset`, though its soundness no longer depends on it. +/// +/// COPY pins `dst = src + 8` with one 64-bit `ADD`, so a range crossing the 2^32 limb +/// boundary is perfectly provable and neither the chip nor the executor refuses it +/// any more. What this ceiling still buys is that every non-stack address stays in +/// one limb, which keeps the gap's integer reading available without leaning on the +/// PAGE canonicality argument; a stack object additionally satisfies +/// `buf + n <= STACK_TOP`, whose low limb is `0xFFFF_FFF0`, leaving 15 bytes of +/// headroom for the 8-byte gap. +const _: () = assert!(MAX_MEMORY_SIZE < 1 << 32); const WORD_SIZE: usize = 4; // Guest global allocator, selectable at build time. The default was chosen on measured A/Bs diff --git a/syscalls/src/ef_io.rs b/syscalls/src/ef_io.rs index dabf7818d..dc3f79950 100644 --- a/syscalls/src/ef_io.rs +++ b/syscalls/src/ef_io.rs @@ -59,7 +59,9 @@ pub unsafe extern "C" fn write_output(output: *const u8, size: usize) { unsafe { asm!( "ecall", - in("a0") 1usize, // fd = 1 (stdout) — required by the COMMIT chip + // fd = 1 (stdout) in, byte count out — the COPY chip proves both halves, + // so `a0` must be declared clobbered rather than preserved. + inout("a0") 1usize => _, in("a1") output, in("a2") size, in("a7") SyscallNumbers::Commit as usize, diff --git a/syscalls/src/entrypoint.rs b/syscalls/src/entrypoint.rs index 2e4f89a3b..c08c542d1 100644 --- a/syscalls/src/entrypoint.rs +++ b/syscalls/src/entrypoint.rs @@ -1,4 +1,11 @@ -use crate::{allocator::init_allocator, syscalls::sys_halt}; +use core::arch::global_asm; + +use crate::{ + allocator::init_allocator, + syscalls::{ + DMA_MEMCPY_MAX_BYTES, DMA_MEMCPY_SYSCALL_NUMBER, DMA_MEMSET_SYSCALL_NUMBER, sys_halt, + }, +}; /// # Safety /// @@ -14,3 +21,217 @@ pub unsafe extern "C" fn _start() -> ! { sys_halt(); } } + +// --------------------------------------------------------------------------- +// DMA memcpy symbol override +// +// `memcpy` is defined next to `_start` on purpose, and not in `syscalls.rs`. +// `compiler_builtins` defines `memcpy` weakly, and a linker extracts an archive +// member only to satisfy an undefined symbol — a weak definition already +// satisfies it, so a strong definition sitting in a member nothing else pulls in +// is silently dropped, with no duplicate-symbol diagnostic. The object defining +// `_start` is always extracted, so co-locating the symbol makes it win +// resolution without `--whole-archive` or any guest link flag. This is the +// "always-linked runtime" mechanism the accelerated-memory-operations standard +// requires vendors to pick and document; see `docs/general_flow.md`. +// +// This placement is insurance, not a repair for an observed failure: in +// `syscalls.rs` the symbol also won resolution, and not by luck — `_start` calls +// `sys_halt` from that module and it is not `#[inline]`, so every guest carries +// an undefined reference that forces the object out of the archive, whatever the +// guest itself names. What the move buys is not depending on that: neither on +// `_start` continuing to call into `syscalls.rs`, nor on rustc's codegen-unit +// merging keeping the two modules together. Only same-module items are +// guaranteed to share an object (partitioning places them together and merging +// never splits), so co-locating with `_start` — the one symbol the linker is +// obliged to resolve — makes the guarantee local. +// `test_dma_memcpy_compiler_emitted_copies` is what detects a regression: a guest +// that falls back still produces correct output, only its ecall count drops. +// +// A Rust `#[no_mangle] fn memcpy` did not reliably override compiler-builtins in +// optimized guests: the final ELF still jumped to compiler_builtins' +// implementation. LLVM still inlines statically-sized tiny copies. Remaining +// out-of-line copies are split into bounded DMA ecalls so a single guest +// instruction cannot create an unbounded continuation trace. +// +// `.p2align 2` is load-bearing: a bare `.section` gives sh_addralign = 1, so the +// linker is free to place `memcpy` at an address that is not a multiple of 4 and +// the VM, which fetches one 4-byte instruction per pc, could not decode it. +// --------------------------------------------------------------------------- + +global_asm!( + r#" + .section .text.memcpy,"ax",@progbits + .p2align 2 + .globl memcpy + .type memcpy,@function +memcpy: + mv t0, a0 + mv t1, a2 + beqz t1, .Ldma_memcpy_done +.Ldma_memcpy_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memcpy_call + mv a2, t1 +.Ldma_memcpy_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + add a1, a1, a2 + bnez t1, .Ldma_memcpy_loop +.Ldma_memcpy_done: + mv a0, t0 + ret + .size memcpy, .-memcpy +"#, + syscall = const DMA_MEMCPY_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + +// --------------------------------------------------------------------------- +// DMA memmove symbol override +// +// Here rather than in `syscalls.rs` for the same reason as `memcpy` above: +// `compiler_builtins` defines `memmove` weakly too, so the strong definition has +// to sit in the object the linker is obliged to extract. +// +// Reuses the memcpy ecall unchanged — no new table, no new syscall. Each ecall +// already snapshots its whole source range before writing (all reads at T, +// all writes at T+1), so a single chunk has memmove semantics for free. +// +// Chunking is what breaks it: copying [0,256) -> [4,260) clobbers source bytes +// that a later forward chunk still needs. So when the destination starts inside +// the source range (src < dst < src+n) the chunks are walked from the END +// backwards; every chunk then reads bytes no earlier chunk has written yet. +// Otherwise (disjoint, or dst below src) forward chunking is already safe. +// --------------------------------------------------------------------------- + +global_asm!( + r#" + .section .text.memmove,"ax",@progbits + .p2align 2 + .globl memmove + .type memmove,@function +memmove: + mv t0, a0 + beqz a2, .Ldma_memmove_done + bgeu a1, a0, .Ldma_memmove_fwd // src >= dst: forward is safe + add t2, a1, a2 + bgeu a0, t2, .Ldma_memmove_fwd // dst >= src+n: disjoint + // Overlapping with dst inside [src, src+n): walk chunks from the end. + add a0, a0, a2 + add a1, a1, a2 + mv t1, a2 +.Ldma_memmove_back_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_back_call + mv a2, t1 +.Ldma_memmove_back_call: + sub a0, a0, a2 + sub a1, a1, a2 + li a7, {syscall} + ecall + sub t1, t1, a2 + bnez t1, .Ldma_memmove_back_loop + j .Ldma_memmove_done +.Ldma_memmove_fwd: + mv t1, a2 +.Ldma_memmove_fwd_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memmove_fwd_call + mv a2, t1 +.Ldma_memmove_fwd_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + add a1, a1, a2 + bnez t1, .Ldma_memmove_fwd_loop +.Ldma_memmove_done: + mv a0, t0 + ret + .size memmove, .-memmove +"#, + syscall = const DMA_MEMCPY_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); + +// --------------------------------------------------------------------------- +// DMA memset symbol override +// +// Here rather than in `syscalls.rs` for the same reason as `memcpy` above. +// +// memset is expressed as a *propagating* memmove, so it needs no accelerator of its +// own: the stub seeds the first eight bytes with an ordinary store and then calls the +// copy accelerator with `dst = seed_end`, `src = seed_start`. The chip runs that call +// with the read/write timestamp order inverted — it writes at T and reads at T+1 — +// so every step observes the previous step's write and the seed propagates across the +// range. The ecall number is what selects the order; the guest never chooses it. +// +// `a1` therefore carries a source address here, not the fill byte, and it is only ever +// read by the `sb`s that lay down the seed. +// +// No range check for the 2^32 limb boundary here, and none is needed: the accelerator +// pins `dst = src + 8` with a single 64-bit `ADD`, carries and all, so a range that +// crosses the boundary is as provable as any other. `sb` writes the low byte of its +// source, so C's `(unsigned char)c` truncation comes for free and needs no masking of +// its own -- a wide or negative `int` fill lands as the right byte either way. +// +// Fills shorter than sixteen bytes take a plain store loop: they cannot amortise the +// seed, and below eight bytes there is nothing left to propagate. +// --------------------------------------------------------------------------- + +global_asm!( + r#" + .section .text.memset,"ax",@progbits + .p2align 2 + .globl memset + .type memset,@function +memset: + mv t0, a0 + beqz a2, .Ldma_memset_done + li t2, 16 + bltu a2, t2, .Ldma_memset_bytewise + // Seed the first eight bytes one at a time. A doubleword store would be shorter + // but would assume an alignment `dst` does not have: a byte array on the stack is + // 1-aligned, and seeding it with `sd` is silently wrong there. + sb a1, 0(a0) + sb a1, 1(a0) + sb a1, 2(a0) + sb a1, 3(a0) + sb a1, 4(a0) + sb a1, 5(a0) + sb a1, 6(a0) + sb a1, 7(a0) + mv t1, a2 + addi t1, t1, -8 + mv a1, a0 + addi a0, a0, 8 +.Ldma_memset_loop: + li a2, {max_bytes} + bgeu t1, a2, .Ldma_memset_call + mv a2, t1 +.Ldma_memset_call: + li a7, {syscall} + ecall + sub t1, t1, a2 + add a0, a0, a2 + add a1, a1, a2 + bnez t1, .Ldma_memset_loop + j .Ldma_memset_done +.Ldma_memset_bytewise: + mv t1, a2 +.Ldma_memset_byte_loop: + sb a1, 0(a0) + addi a0, a0, 1 + addi t1, t1, -1 + bnez t1, .Ldma_memset_byte_loop +.Ldma_memset_done: + mv a0, t0 + ret + .size memset, .-memset +"#, + syscall = const DMA_MEMSET_SYSCALL_NUMBER, + max_bytes = const DMA_MEMCPY_MAX_BYTES, +); diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 5228455ea..032861803 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -33,10 +33,23 @@ const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; -/// Syscall number for the non-constraining Hint ecall. -/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). +/// Copy-accelerator syscall number, serving `memcpy` and `memmove` (-30 as usize). +/// Must match the executor. #[cfg(target_arch = "riscv64")] -const HINT_SYSCALL_NUMBER: usize = usize::MAX - 30; +pub(crate) const DMA_MEMCPY_SYSCALL_NUMBER: usize = usize::MAX - 29; +/// Maximum bytes sent in one DMA ecall. Larger `memcpy` calls are split by the +/// strong assembly stub so continuation table height remains bounded by cycles. +#[cfg(target_arch = "riscv64")] +pub(crate) const DMA_MEMCPY_MAX_BYTES: usize = 256; + +/// `memset` syscall number (-31 as usize). Must match the executor. +#[cfg(target_arch = "riscv64")] +pub(crate) const DMA_MEMSET_SYSCALL_NUMBER: usize = usize::MAX - 30; + +/// Syscall number for the non-constraining Hint ecall (-32 as usize). +/// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 31). +#[cfg(target_arch = "riscv64")] +const HINT_SYSCALL_NUMBER: usize = usize::MAX - 31; /// Hint selectors passed in `a0` (must match the executor's `HINT_*`). pub const HINT_FIELD_INV: usize = 0; @@ -85,7 +98,11 @@ pub fn commit(slice: &[u8]) { unsafe { asm!( "ecall", - in("a0") 1usize, + // `a0` carries the file descriptor in and the byte count out, so it must + // be declared clobbered: with a plain `in` the compiler is entitled to + // assume it still holds 1 afterwards and reuse it, which would read a + // value the COPY chip proves is `count`. + inout("a0") 1usize => _, in("a1") slice.as_ptr(), in("a2") slice.len(), in("a7") SyscallNumbers::Commit as usize,