Skip to content

perf(keccak256): absorb unaligned operands without staging allocations - #3070

Draft
Qumeric wants to merge 1 commit into
develop-v2.1.0from
perf/keccak-xorin-unaligned
Draft

perf(keccak256): absorb unaligned operands without staging allocations#3070
Qumeric wants to merge 1 commit into
develop-v2.1.0from
perf/keccak-xorin-unaligned

Conversation

@Qumeric

@Qumeric Qumeric commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Problem

The XORIN instruction absorbs whole 8-byte words from 8-byte-aligned pointers. native_xorin checks for that and, when any of the three conditions fails, builds an aligned world to run the instruction in:

aligned_input = AlignedBuf::uninit(adjusted_len, MIN_ALIGN);
ptr::copy_nonoverlapping(input, aligned_input.ptr, len);
// ...and the state slice too when the length was rounded up, then copy it back

That is two allocations and up to three copies of the data per absorb. AlignedBuf goes 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_ptr absorbs straight from the caller's pointer and advances idx by an arbitrary amount, so:

  • the length is min(len, RATE - idx) — a multiple of 8 only by luck;
  • the state pointer is state[idx..], so once one absorb leaves idx unaligned, every later absorb in that hash is unaligned too;
  • the input pointer is whatever the caller had.

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:

  1. XOR the bytes ahead of the state's next word boundary in software, which is what makes the pointer aligned rather than copying to fix it;
  2. run XORIN over the aligned middle;
  3. XOR the trailing partial word in software.

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_ptr already relies on when it applies pad10*1 with 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 a tail call, 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).

case before after
all aligned 1 instruction 1 instruction
unaligned length only 2 allocs + 3 copies 1 instruction + ≤7 software bytes
unaligned state pointer 2 allocs + 3 copies ≤7 software bytes + 1 instruction + ≤7 software bytes
unaligned input pointer 2 allocs + 3 copies 1 stack copy + 1 instruction (+ ≤14 software bytes)

Coverage

The existing KAT vectors already exercise unaligned lengths and the idx drift that follows from them. What they do not vary is where the input sits relative to a word boundary, so keccak_alignment hashes 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 = openvm 530eb47 on both sides of each comparison. Measured at 33ada7b; 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:

metric base this PR delta
execute_metered_insns 573,556,521 564,894,855 −8,661,666 (−1.510%)
metered_main_cells_unpadded 42,649,258,946 41,600,805,854 −2.458%
metered_memory_unpadded_bytes 705,216,713,049 690,546,303,240 −2.080%
app segments 63 61 −2

The 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%) and app_prove_time_ms 388,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.

@Qumeric
Qumeric marked this pull request as draft July 25, 2026 07:22
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@Qumeric
Qumeric force-pushed the perf/keccak-xorin-unaligned branch from 71b32e5 to 0544fab Compare July 25, 2026 08:39
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@Qumeric
Qumeric force-pushed the perf/keccak-xorin-unaligned branch from 97ef510 to 33ada7b Compare July 29, 2026 07:50
@github-actions

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
Qumeric force-pushed the perf/keccak-xorin-unaligned branch from 33ada7b to 6e274c3 Compare July 29, 2026 08:30
@github-actions

Copy link
Copy Markdown
Contributor
group app.proof_time_ms app.cycles leaf.proof_time_ms
fibonacci 458 4,000,051 241
keccak 7,257 14,365,133 1,522
sha2_bench 4,739 11,167,961 532
regex 651 4,090,656 217
ecrecover 229 112,210 184
pairing 304 592,827 186
kitchen_sink 2,636 1,979,971 463

Note: cells_used metrics omitted because CUDA tracegen does not expose unpadded trace heights.

Commit: 6e274c3

Benchmark Workflow

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.

1 participant