Skip to content

keccak_gkr: input opening's pack fold by BitFold, not byte tables - #741

Open
nconsigny wants to merge 3 commits into
leanEthereum:mainfrom
nconsigny:pr/keccak-open-bitfold
Open

nconsigny wants to merge 3 commits into
leanEthereum:mainfrom
nconsigny:pr/keccak-open-bitfold

Conversation

@nconsigny

Copy link
Copy Markdown
Contributor

What and why

open::fold_packs (the input opening's pack fold) weighs every bit of a run of eight blocks by a fixed eq weight, and summed them with 64 F192 subset-sum table lookups per position and block. A run is therefore one 32-byte row per position and instance, which is what primitives::bit_fold::BitFold folds: a task now folds four positions' 64 rows with one fold_columns call (GFNI on x86, the AVX2 nibble shuffles without GFNI, the same byte tables elsewhere). One commit, crates/keccak_gkr only, +103/-26 (65 of the added lines are the test).

Measured

Ryzen 9 9955HX3D (Zen 5, 16C/32T), 16 threads, --repeat 3 --cooldown 1, main (e5d75f8) vs this branch run in turns (ABBA). Δ is the median of the paired differences, [min, max]. Openings is the Openings span of the traced pass.

workload pairs Openings Δ (ms) Prove Δ (ms)
aggregate --sphincs 500 --log-inv-rate 2 6 -4.3 [-6.6, -2.7], 6/6 faster -4.5 [-21.7, +30.3] (-0.4%), 3/6 faster
aggregate --sphincs 480 --log-inv-rate 2 6 -7.9 [-10.3, -4.8], 6/6 faster -2.3 [-27.3, +18.3] (-0.2%), 3/6 faster
aggregate --sphincs 500 --log-inv-rate 1 3 -3.5 [-4.0, -2.4], 3/3 +6.3 [-1.0, +12.0]
aggregate --sphincs 480 --log-inv-rate 1 3 -5.4 [-6.0, -5.1], 3/3 +18.3 [-29.7, +41.0]
hash --log-n 18 --log-inv-rate 2 6 -4.3 [-4.9, +0.1], 5/6 -1.8 [-31.0, +13.7]
aggregate --xmss 1400 --log-inv-rate 2 3 -6.2 [-6.4, -3.2], 3/3 +19.0 [+5.7, +35.0] as run; -7.0 [-26.0, +37.0] by each run's median pass

Openings falls from 11-15 ms to about 7 ms on every workload. The end-to-end Prove change is within the noise (per-pass Prove spreads about ±30 ms within a run): the rate-1 and leanXMSS rows have 3 pairs, and the leanXMSS as-run excess sits in spans this change does not touch (Keccak reduction, Skip, of one traced pass). One machine, one target-cpu=native (AVX-512 + GFNI) build; the AVX2 and portable arms were not timed. Single thread, pinned, 2^18 instances, depth 12 (indicative, shared box): fold_packs 112-131 ms to 37-48 ms.

Proof bytes and soundness

Prover-only: the fold computes the same field elements, so proofs are byte for byte identical. A recursion --n 1 --sphincs-per-leaf 16 --leaf-log-inv-rate 2 leaf proof (199,521 B) has the same sha256 as main's, and proof sizes were equal on every benchmarked workload. The verifier is untouched. No new unsafe; the weights are read with get(..).unwrap_or(ZERO), every other index is bounded by constants (block < 8, pack < 4, 16 * dz + 16 <= 64), and BitFold::new / fold_columns::<32> assert on constants that always hold.

Tests

fold_packs_equal_the_byte_tables: the previous per-position table fold stays in the test module as the oracle, and the new fold must equal it on six shapes (0 to 70,000 instances, depth 6 to 24), short runs and the padding block included. cargo test --release -p keccak_gkr passes with target-cpu=native (GFNI + AVX-512), haswell (AVX2, no GFNI) and x86-64 (byte tables); aarch64 was not built locally. clippy -D warnings, fmt --check and doc are clean.

Memory

One BitFold of 256 weights per call, which holds the same 32 byte tables as before (about 196 KB) plus its GFNI matrices, and a 2 KiB row buffer on each task's stack. Peak RSS is unchanged (every paired delta within 0.01 GiB).

The pack fold weighs each bit of a run of eight blocks by a fixed eq
weight, so a run is one 32-byte BitFold row per position and instance.
A task folds four side by side positions' 64 rows with one fold_columns
call in place of 64 F192 table lookups per position and block: GFNI on
x86 with GFNI, the AVX2 nibble shuffles on AVX2 without it, and
elsewhere (aarch64) the byte tables as before, a row at a time.

A task taking several side by side positions of a lane is also the
shape of leanEthereum#730 (cde0b65); here it is four, the positions whose sixteen
instances fill one 64-row fold.

Same field elements, so the proof is unchanged: the per-position table
fold stays as the test oracle (fold_packs_equal_the_byte_tables, six
shapes, short runs and padding blocks included).

1 thread, pinned, Zen 5 (indicative): fold_packs at 2^16 instances,
depth 12, new/old over alternated pairs in one process:
- AVX-512 + GFNI build: 0.33 (26.6 -> 8.8 ms)
- haswell build (AVX2 nibble shuffles): 0.76 to 0.77 (20.5 -> 15.7 ms)
- built without AVX2 (byte tables, PCLMUL on): 0.84 (21.7 -> 18.3 ms)

A run of fewer than eight blocks is still folded as 32 columns, so on
the builds without GFNI a small batch folds its packs slower than the
tables did: at 2^9 instances 0.50 -> 0.93 ms on the haswell build (the
fold_packs span of the hash_batch bench). From 4,096 instances the runs
are full.
fold_packs took the eight words of a position after chi from
state_words, an array::from_fn over a per-word closure that LLVM left
out of line: a call per position and block (409,600 at 2^17 instances
and depth 12) of about 210 scalar instructions, with 24 loads each
bounds-checked on its own and `along`'s divide by 5.

state_words now takes the words of the position and of the two along
its row as three slices of eight words, and applies chi and iota to
them whole. It is inlined into the fold_packs task: on AVX-512 three
64-byte loads, two of them folded into a vpandnq and a vpternlogq with
the iota mask broadcast; on AVX2 the same in two ymm halves; on aarch64
with SHA3, four bic and four eor3.

The words are the same, so the proof is unchanged.

1 thread, pinned, Zen 5 (indicative): fold_packs at 2^16 instances,
depth 12, new/old over 60 alternated pairs in one process:
- AVX-512 + GFNI build: 0.89 to 0.90 (9.0 -> 8.1 ms)
- built without AVX2 (byte tables, PCLMUL on): 0.94 (18.8 -> 17.6 ms)
- haswell build: 0.97 (15.2 -> 14.7 ms), and 1.02 in a second build of
  the same harness: no reliable gain there
spare_slices built a subset-sum table of the eq table for every eight
steps and looked each spare slot's transposed bytes up in it: at 2^17
instances and depth 12, 4.2M F192 additions and 14.7M lookups, over a
2^17-entry eq table.

The eq table is the product of one on the low eight instance variables
and one on the rest. So a chunk of 256 steps is one 64-row fold_columns
by a BitFold of the low table, row z holding bit z of the chunk's steps
as their transposed bytes already give it, and only the 64 sums meet
the chunk's entry of the high table. One BitFold serves the whole call
and the full eq table is not built.

Same field elements, so the proof is unchanged: the table version stays
as the test oracle (spare_slices_equal_the_byte_tables, six shapes, 7 to
53 spare slots).

1 thread, pinned, Zen 5 (indicative): spare_slices at 2^16 instances,
depth 12, its 14 spare slots filled with random words, new/old over
alternated pairs in one process:
- AVX-512 + GFNI build: 0.19 to 0.20 (6.7 -> 1.3 ms)
- haswell build (AVX2 nibble shuffles): 0.49 (7.5 -> 3.7 ms)
- built without AVX2 (byte tables, PCLMUL on): 0.71 (6.0 -> 4.3 ms)
With the spare slots all zero, as the hash_batch bench has them, the
haswell build goes 6.9 -> 3.2 ms and the byte-table build stays level
(5.9 -> 5.8 ms).
Without carry-less multiply (plain x86-64, aarch64 without +aes) the 64
products a chunk and slot are software and cost more than the additions
they replace (plain x86-64 at 2^11 instances: 1.2 -> 3.8 ms); no prover
is built that way. At 2^9 instances the fold's setup shows on the
haswell build: 0.06 -> 0.13 ms.
@nconsigny
nconsigny force-pushed the pr/keccak-open-bitfold branch from 4fe1c56 to 31f338b Compare November 11, 2026 18:13
@github-actions

Copy link
Copy Markdown

Benchmarks: base against this PR, on one runner

31f338b merged into its base, against the base dd759d2: each benchmark run in turns on one runner, base and PR 5 times each. A size is shown when it changed; a time or a peak memory when it moved the same way in every round and its median by at least 1% (the change is the median ratio, with the rounds' range).

Threads shows the named configuration; default means no thread count is encoded in the benchmark ID.

Keccak GKR

benchmark threads runner measure base this PR change
keccak-hash-batch-262144 default x86-64 Proving time 5.44 s 5.37 s -1.32% (-1.36% to -0.55%)
Every result

Keccak GKR

benchmark threads runner CPU measure base this PR change
keccak-hash-batch-262144 default arm64 Neoverse-N2 Proving time 3.85 s 3.81 s -0.71% (-3.13% to +0.19%)
keccak-hash-batch-262144 default x86-64 AMD EPYC 9V45 96-Core Processor Proving time 5.44 s 5.37 s -1.32% (-1.36% to -0.55%)

This branch has not been deployed

No deployments
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