perf(keccak256): absorb unaligned operands without staging allocations - #3070
Draft
Qumeric wants to merge 1 commit into
Draft
perf(keccak256): absorb unaligned operands without staging allocations#3070Qumeric wants to merge 1 commit into
Qumeric wants to merge 1 commit into
Conversation
Qumeric
marked this pull request as draft
July 25, 2026 07:22
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Qumeric
force-pushed
the
perf/keccak-xorin-unaligned
branch
from
July 25, 2026 08:39
71b32e5 to
0544fab
Compare
This comment has been minimized.
This comment has been minimized.
Qumeric
force-pushed
the
perf/keccak-xorin-unaligned
branch
from
July 29, 2026 07:40
0544fab to
97ef510
Compare
This comment has been minimized.
This comment has been minimized.
Qumeric
force-pushed
the
perf/keccak-xorin-unaligned
branch
from
July 29, 2026 07:50
97ef510 to
33ada7b
Compare
This comment has been minimized.
This comment has been minimized.
`native_xorin` fell back to allocating aligned copies of both the state slice and the input whenever either pointer or the length was not a multiple of 8, costing two allocations and up to three copies of the data per absorb. The allocations go through the guest bump allocator, so they are never reclaimed. Decompose the unaligned case instead: XOR the bytes below the buffer's next word boundary and past its last whole word in software (at most 14 bytes total), and absorb the whole aligned words in between with one XORIN. Only a misaligned input still needs staging, into a stack buffer sized for one rate block, and only for the instruction's part. Overlapping operands keep the instruction's semantics: XORIN reads both ranges before writing (see the executor in extensions/keccak256/circuit/src/xorin/execution.rs), so the input is snapshotted up front when the ranges intersect. Staging the snapshot at the buffer's misalignment preserves relative alignment, letting the recursive call reach the instruction without a second copy. The aligned path is unchanged and stays assertion-free; the rate bound that protects the fixed staging buffers is asserted on the cold path. The new alignment test program hashes every input misalignment against every chunking, pins the overlap semantics, and runs in the VM.
Qumeric
force-pushed
the
perf/keccak-xorin-unaligned
branch
from
July 29, 2026 08:30
33ada7b to
6e274c3
Compare
Contributor
Note: cells_used metrics omitted because CUDA tracegen does not expose unpadded trace heights. Commit: 6e274c3 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The XORIN instruction absorbs whole 8-byte words from 8-byte-aligned pointers.
native_xorinchecks for that and, when any of the three conditions fails, builds an aligned world to run the instruction in:That is two allocations and up to three copies of the data per absorb.
AlignedBufgoes through the guest bump allocator, where deallocation is a no-op, so every unaligned absorb also permanently grows the heap — which for a long-running guest feeds into peak memory and segment count.The fallback is not an edge case for the only in-tree caller.
Keccak256::update_ptrabsorbs straight from the caller's pointer and advancesidxby an arbitrary amount, so:min(len, RATE - idx)— a multiple of 8 only by luck;state[idx..], so once one absorb leavesidxunaligned, every later absorb in that hash is unaligned too;Hashing a 20-byte address, or any RLP-shaped buffer, takes the slow path.
Approach
The instruction can only cover aligned whole words, but the bytes it cannot cover are just an XOR into state memory the caller already owns — no allocation needed to apply them:
Only a misaligned input pointer genuinely needs staging, and only for the middle; that now uses a stack buffer instead of the heap. This is the same reasoning
finalize_ptralready relies on when it appliespad10*1with two byte-XORs on the state.Per call: at most 14 bytes are absorbed in software, nothing is allocated, and neither pointer is read or written outside
[0, len)— so the documented contract is unchanged and no caller needs to guarantee spare capacity. The all-aligned fast path is byte-for-byte the same; the new work sits behind#[cold] #[inline(never)], and codegen confirms it is reached by atailcall, so the fast path keeps its empty stack frame (6 instructions + XORIN on riscv64).Overlapping operands
The instruction's semantics for overlapping ranges are defined by its executor (
extensions/keccak256/circuit/src/xorin/execution.rs): it reads all state bytes, then all input bytes, then writes — a full snapshot before any write. A bare lead/bulk/tail would break that, because the software lead writes state bytes an overlapping input would later be read from. The fallback therefore detects overlap and snapshots the input into the stack buffer first, placed at the state pointer's misalignment so relative alignment is preserved and the recursion reaches the instruction without a second copy. The old fallback's full copies were incidental snapshots, so this also preserves its behaviour exactly; the test program pins the semantics in the VM.The rate bound
The old fallback allocated whatever size a call needed; the new stack buffers hold one rate block, so the cold path asserts
len <= KECCAK_RATE— a loud panic instead of a silent stack overrun for an out-of-contract caller. The check is deliberately not on the aligned path: an over-rate aligned absorb fails at proving time exactly as it did before this PR, and moving the assert off the hot entry measures out to 189,084 instructions on a mainnet block (see below).Coverage
The existing KAT vectors already exercise unaligned lengths and the
idxdrift that follows from them. What they do not vary is where the input sits relative to a word boundary, sokeccak_alignmenthashes the same inputs at all 8 misalignments and cross-checks one-shot against chunked absorbs, over lengths that cover every residue mod 8 and both sides of the rate boundary, plus a check that pins the overlap semantics. The program's assertions run inside the VM, so CI executes the real instruction path.Separately, I ran a differential harness over the extracted source against a byte-wise snapshot-semantics reference: all 8×8 (state offset, input offset) pairs × lengths 0–136 for disjoint operands, all 16×32 relative placements × lengths for overlapping ones, with canary regions confirming nothing outside
[0, len)is written and a case proving the over-rate panic fires — 78,913 cases, with the mock XORIN asserting 8-byte-aligned operands and a multiple-of-8 length on every call it receives.Results
Ethereum mainnet block 24001988 through the openvm-eth stateless client,
execute-metered, base = openvm530eb47on both sides of each comparison. Measured at33ada7b; the only change since is to the test example, so the guest library is identical.Stock client (no keccak specialization of its own — all MPT hashing goes through
native_xorin): base vs this PR:execute_metered_insnsmetered_main_cells_unpaddedmetered_memory_unpadded_bytesThe memory and segment reductions are the leaked staging allocations disappearing. With a client that already routes its MPT hashing around the wrapper (openvm-eth with its #668 sponge), the marginal is −0.241% — the revm-internal keccaks that remain on it.
Because fewer, fuller segments can shift per-AIR heights across power-of-two boundaries, segment count alone does not decide proving cost, so the same pair was also run under
prove-app(run): total padded cells 54.40B → 52.27B (−3.92%, with the padding component itself −9.20%) andapp_prove_time_ms388,915 → 389,729 (+0.21%, within single-run GPU variance). The proving side moves with the unpadded win rather than against it.OpenVM's own keccak benchmark hashes one 8-aligned, 8-multiple-length buffer, so it never leaves the fast path: its bit-identical cycle count across this PR is the no-regression check, and it cannot show the win by construction.