Skip to content

test: implement the protocol batch kernel in Rust - #1242

Merged
bitwalker merged 12 commits into
nextfrom
i1158-batch-kernel
Aug 1, 2026
Merged

test: implement the protocol batch kernel in Rust#1242
bitwalker merged 12 commits into
nextfrom
i1158-batch-kernel

Conversation

@greenhat

@greenhat greenhat commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Close #1158

Port the Miden protocol batch kernel (0xMiden/protocol#2905, plus the expiration running-minimum of 0xMiden/protocol#3019) to Rust compiled with the Miden compiler, to exercise the compiler on a realistic kernel-sized program.

The fixture at tests/fixtures/batch-kernel mirrors the MASM kernel module-for-module.

@greenhat

greenhat commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Compiled size is 180253 bytes (stripped, compacted). The cycle count for the test is 163006.

@greenhat greenhat linked an issue Jul 6, 2026 that may be closed by this pull request
@greenhat
greenhat force-pushed the i1158-batch-kernel branch from 6d8dccd to 9a6abbc Compare July 7, 2026 10:54
@greenhat
greenhat changed the base branch from next to speedup-reload July 7, 2026 10:54
Base automatically changed from speedup-reload to next July 7, 2026 21:52
@greenhat
greenhat force-pushed the i1158-batch-kernel branch from f6b4007 to a8e17e2 Compare July 13, 2026 06:59
@greenhat

Copy link
Copy Markdown
Contributor Author

After the second optimization run (last 3 commits), the size is down to 101 KB.

For the following scenario (happy path):

// Scenario 1: two transactions, no intra-batch note relationships.
// - tx1 consumes one authenticated note (empty note id) and creates one note.
// - tx2 consumes one unauthenticated note whose note id is not created in this batch, and
// creates two notes.

The cycle count is at 30151.

/cc @bitwalker @bobbinth

@greenhat

Copy link
Copy Markdown
Contributor Author

@bitwalker @bobbinth I measured the cycle count in the two_tx_batch test in 0xMiden/protocol#2905 and got 4220 cycles. We get 30151 cycles. This puts compiler overhead at ~7x.

greenhat added 12 commits July 31, 2026 15:24
Port the Miden protocol batch kernel (0xMiden/protocol#2905, plus the expiration running-minimum of 0xMiden/protocol#3019) to Rust compiled with the Miden compiler, to exercise the compiler on a realistic kernel-sized program.

The fixture at tests/fixtures/batch-kernel mirrors the MASM kernel module-for-module: the prologue unhashes the layered advice data anchored at the public BATCH_ID, the note tracker determines intra-batch erasure and binds the host-provided sorted note lists to the verified per-transaction notes, and the epilogue enforces the tracking invariants and computes INPUT_NOTES_COMMITMENT. The smoke test plays the protocol's BatchKernel::prepare_inputs role: it derives batch ids, transaction ids and note commitments for mock transactions with the host hasher, feeds the layered advice map and the expiration advice stack, and checks the kernel outputs across four scenarios (a plain two-transaction batch, intra-batch note erasure, a tampered BATCH_ID pre-image, and a consume-before-create rejection).

Note-flag state is felt-typed and the sorted-list lookup uses a hand-rolled word comparison: integer discriminant matches currently lower to a br_table whose checked I32->U32 selector cast rejects legitimately wrapped selectors ("value does not fit in i32"), so the fixture stays in the felt domain where comparisons lower to opaque intrinsics.
Pin the compiled batch kernel's stripped MAST forest size and the VM cycles consumed by the representative two-transaction batch as expect-test values, so compiler changes that regress the kernel's code size or execution cost surface in this test the same way they do for the basic-wallet examples.

The cycle count is observed by unrolling the executor's run loop through the debug executor, which exposes the clock cycle counter; the resulting execution path is identical to Executor::execute, so the measurement matches what the VM reports in error messages and traces.
Profiling the batch kernel fixture showed word_lt costing ~150 VM cycles per call: its index loop compiles to bounds-checked dynamic indexing with memory-backed loop state, dwarfing the four felt comparisons it performs.

Rewrite the comparison as straight-line code over constant indices so the bounds checks constant-fold and the function inlines into its callers (the sortedness check and the binary-search lookups). Scenario-1 cycles drop from 163006 to 144057 (-11.6%).
Profiling showed the batch kernel spending most of its cycles on Rust collection bookkeeping rather than kernel work: re-decoding piped advice data into per-entry struct Vecs, per-entry flag-struct construction, allocation machinery (~1.2k cycles per Vec at opt-level=z), and an extra full copy of the input-note list for the epilogue hash.

Keep the Vec<Felt> buffers returned by the SDK pipe helpers as the batch state exactly as piped, mirroring the flat memory regions of the reference kernel's memory.masm: words are accessed as 4-felt array views into the buffers, the parallel flags live in flat felt arrays (stride 2 for input notes, 3 for output notes), and the whole single-call-tree of kernel helpers is inlined. The epilogue now consumes the batch state, so the no-erasure common case moves the sorted input-note buffer into hash_elements instead of copying it; only batches with erased notes collect the surviving entries first.

Scenario-1 cycles drop from 144057 to 94090 (-35%) and the stripped package from 190083 to 148319 bytes, using safe Rust and SDK APIs only.
LLVM at opt-level=z ignores the #[inline] hint on the binary-search lookup, the sortedness check, and the flat-buffer word accessors; as outlined procedures their state spills into memory-backed VM locals costing several hundred cycles per call.

Promote them to #[inline(always)]. Scenario-1 cycles drop from 94090 to 86894 (-8%); the stripped package grows to 160697 bytes from the duplicated inline bodies.
Rebasing onto the codegen change that loads and stores element-space pointers with bare memory ops shrinks the compiled batch kernel and its execution: the stripped package drops from 160697 to 127243 bytes and the two-transaction scenario from 86894 to 44986 VM cycles (-48%), putting the safe SDK-only kernel below the cycle count previously reachable only with raw-pointer workarounds.
pipe_words_to_memory and pipe_double_words_to_memory carried no #[inline] hint, so cross-crate callers paid an outlined call whose frame and argument marshaling cost several hundred VM cycles per invocation -- an order of magnitude more than some of the stdlib procedures they wrap.

Mark both #[inline], matching the other advice/memory helpers in the crate.
Allocation machinery costs ~200+ cycles per Vec under the bump allocator at opt-level=z, so the two parallel flag arrays are now one felt buffer: the input-note flags followed by the output-note flags at offset num_input_notes * INPUT_FLAGS_STRIDE, initialized with a single fill. The remaining kernel helpers that LLVM still declined to inline are promoted to #[inline(always)].

Together with inlining the SDK advice pipe wrappers this drops the two-transaction scenario from 44986 to 38740 VM cycles (-14%) and the stripped package from 127243 to 125650 bytes.
Only the happy-path scenario pinned its cycle count; the erasure scenario and the two rejection scenarios gave no visibility into their execution cost or abort point.

Rework the execute helper to return the trace and cycle count on success, or the execution error and the cycle at which the VM rejected the batch on failure, replacing the catch_unwind wrappers with a direct match on the error arm. Every scenario now pins its cycles: 38740 for the two-transaction batch, 34096 for the erasure batch, 1086 for a tampered BATCH_ID pre-image (rejected by the Layer 1 hash check in the prologue), and 16522 for consume-before-create (rejected by the ordering gate during note tracking), so the pins also document where each rejection fires.
Marking pipe_words_to_memory and pipe_double_words_to_memory #[inline] silenced the unconstrained-advice lint: the lint keys on the call boundary where an unconstrained advice value is passed as a constrained argument, and inlining the wrapper into the caller erases that boundary, so the flow goes completely unflagged (caught by the midenc::rust_source.rs lit test in CI).

Drop the inline hints and the changelog entry; a safety diagnostic outweighs the call overhead. The batch kernel pins move accordingly: 40857 cycles for the two-transaction scenario, 36249 for the erasure scenario, 1070 for the tampered pre-image rejection, 18850 for the consume-before-create rejection, and 125977 bytes for the stripped package.
Sequential expiration, sorting, and flag passes repeatedly rebuilt indexed addresses. Output flag access also recomputed the same region split for every operation.\n\nTraverse sequential data in stride-sized chunks and retain the precomputed output-flag offset. Refresh kernel size and cycle expectations for reduced instruction and stack traffic.
@greenhat
greenhat force-pushed the i1158-batch-kernel branch from 95f8ed3 to ce2c73c Compare July 31, 2026 13:27
@greenhat

Copy link
Copy Markdown
Contributor Author

Rebased and ready.

@bitwalker
bitwalker merged commit 6711d1b into next Aug 1, 2026
17 checks passed
@bitwalker
bitwalker deleted the i1158-batch-kernel branch August 1, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement batch kernel in Rust

2 participants