Skip to content

Prover optimisation ideas (not merge) - #729

Draft
kevaundray wants to merge 12 commits into
leanEthereum:mainfrom
kevaundray:kw/prover-opt-ideas
Draft

kevaundray wants to merge 12 commits into
leanEthereum:mainfrom
kevaundray:kw/prover-opt-ideas

Conversation

@kevaundray

Copy link
Copy Markdown
Collaborator

This PR collects twelve prover-only optimisation ideas, one commit each, for review: the constraint sumcheck, the bus GKR and its leaves, the Keccak reduction's kernels, the NTT and WHIR commits, and witness generation (execute, hints, column fill). It is not meant to be merged as is; pick or discuss individual commits. Proof bytes do not change: the PROOFDIGEST of every benchmark below equals main's, and proof size and committed words are unchanged.

Results

This branch against current main (e5d75f858), per proving pass, ab.sh 3 rounds x 4 repeats (recursion also 5 x 4):

Benchmark Wall median, main -> PR Wall min, main -> PR CPU median, main -> PR Peak memory, main -> PR
aggregate --xmss 900 --log-inv-rate 1 1.704 -> 1.166 s 0.952 -> 0.843 s 9.80 -> 8.53 s (-13%) 3.21 -> 2.77 GiB
aggregate --sphincs 410 --log-inv-rate 1 2.178 -> 2.210 s 1.758 -> 1.386 s 16.96 -> 16.25 s (-4%) 4.21 -> 4.46 GiB
recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2, 3 x 4 0.738 -> 0.846 s 0.429 -> 0.569 s 2.80 -> 2.86 s 1.64 -> 1.53 GiB
same, 5 x 4 0.717 -> 0.615 s 0.356 -> 0.360 s 3.00 -> 2.82 s (-6%)

Proof size is unchanged (322.8, 331.8 and 233.4 KiB), as are the table sizes and TOTAL_COMMITTED (2^25.418, 2^26.326, 2^23.845). Peak memory is the CLI's peak memory with --repeat 2, the median of three or four runs each.

Per-stage process CPU, spancpu.sh median, main -> PR. xmss 900 in two separate runs (5 and 8 interleaved rounds), recursion in one (5 rounds):

Stage xmss 900, 5 rounds xmss 900, 8 rounds recursion, 5 rounds
Constraint sumcheck (Prove constraints minus Constraint evals) 0.428 -> 0.407 s 0.462 -> 0.421 s 0.215 -> 0.170 s
Prove bus > Bus GKR 1.055 -> 1.016 s 1.255 -> 0.996 s 0.485 -> 0.359 s
Prove bus > Bus leaves 0.402 -> 0.375 s 0.470 -> 0.436 s 0.166 -> 0.125 s
Keccak reduction > Layer > Skip 1.385 -> 1.142 s 1.364 -> 1.081 s 0.129 -> 0.114 s
Keccak reduction > Layer > Tables 0.596 -> 0.545 s 0.591 -> 0.536 s 0.050 -> 0.041 s
Keccak reduction > Layer > Tail 0.605 -> 0.596 s 0.647 -> 0.640 s 0.086 -> 0.074 s
Commit 1.350 -> 1.369 s 1.344 -> 1.316 s 0.798 -> 0.707 s
PCS open > Commit 0.799 -> 0.768 s 0.839 -> 0.802 s 0.419 -> 0.400 s
PCS open > Open 0.087 -> 0.091 s 0.091 -> 0.107 s 0.066 -> 0.067 s
Execute program 0.293 -> 0.269 s 0.217 -> 0.185 s 0.051 -> 0.038 s
Build witness > Fill columns 0.244 -> 0.218 s 0.261 -> 0.213 s 0.105 -> 0.071 s
Prove 9.284 -> 8.765 s 9.514 -> 8.735 s 3.077 -> 2.659 s

Host: Ryzen 9 7950X3D (16C/32T), shared with other jobs, load average 47 to 105 during the runs, so CPU is the steadier number and wall varies a lot. Every comparison alternates the two binaries round by round (ab.sh, spancpu.sh). The two recursion ab.sh runs disagree on wall; the per-stage CPU of recursion improves in every touched stage.

Ideas

Constraint sumcheck: expand each table's state once, fold with the next message (commit 210a7a93b)
  • Problem: the table sumcheck's kernels were the passes over the K columns: message_k (about 40% of the kernel samples) and join (about 43%, its loads and stores hottest, so bandwidth bound) each read every column; the folded rounds were only about 18%. Each later round was a message sweep plus a fold sweep.
  • Change: a table's first active round builds its unfolded state (lin, left_g, right_g) and its message in one pass over the K columns. Every fold then computes the next round's message from the blocks it just wrote, so a round is one pass. Below sixteen rows per half the old fold and message stay; message_k, join and their row loader are gone.
  • Effect: constraint sumcheck CPU -5% and -9% at xmss 900, -21% at recursion. On the older base the kernel samples fell 26%.
  • Files: crates/leanvm_core/src/constraints.rs.
  • Review notes: the round messages are the same polynomials. On the older base peak memory rose 3.111 -> 3.131 GiB; the per-commit bisection on this base shows no change from this commit.
Bus GKR: two rounds per pass with at least eight threads (commit ee76820d6)
  • Problem: the vertical kernels read the whole layer once per sumcheck round, and the bus GKR is one of the largest stages (about 1.1 s CPU at xmss 900), waiting on memory at full thread count.
  • Change: a port of upstream leanvm_core: prove the bus GKR two rounds per pass #557 onto main's batched triple GKR. Four rows sharing all but their two lowest index bits form a block; one pass sums a degree-four bivariate grid that gives round j's message and, after its challenge, round j+1's. The next pass folds each block at both challenges and sums the next pair's grid from the folded rows in registers. The identity tail and runs are still skipped, the three trees still run in one dispatch, and rows stay transposed between passes (Quads of F192x8, F192x8::load_blocks).
  • Effect: Bus GKR CPU -4% and -21% at xmss 900, -26% at recursion. On the older base the stage's wall median fell about 20%.
  • Files: crates/leanvm_core/src/gkr.rs, crates/primitives/src/field/x8.rs.
  • Review notes: AVX-512 only, and only with PAIRED_THREADS = 8 or more pool threads: the pairs multiply more, and at one thread they cost 15 to 20% more GKR samples, so fewer threads keep the vertical kernels. Peak resident memory moves with this commit and no other (per-commit bisection): xmss 900 3.21 -> 2.77 GiB, recursion 1.63 -> 1.56 GiB, but sphincs 410 4.20 -> 4.44 GiB. The fold keeps a spare quad buffer per tree and frees the layer's rows after the first pair; with jemalloc keeping freed pages mapped the peak depends on that buffer schedule, and the sphincs increase is not isolated further.
Bus leaves: a push and a pull block share their products (commit 137dfd69f)
  • Problem: an interaction's push and pull blocks over one table's columns flush the same coordinates at the same weights but for the count's slot, so their leaf formulas share all but a term or two, yet every block formed all its mixed products. At xmss 900 that was 155M mixed products per proof.
  • Change: build_leaves pairs each block of a shape with the block sharing the most terms (Family, at most two members). Per chunk of rows the shared products are summed once, unreduced; each member adds its own terms, reduces and adds its constant. A leaf is the same XOR of the same unreduced products. The pairing removes 64M of the 155M products.
  • Effect: Bus leaves CPU -7% in both xmss 900 runs, -25% at recursion.
  • Files: crates/leanvm_core/src/leaf.rs.
  • Review notes: pairing is greedy and quadratic in the blocks of one shape. KECCAK-instruction with custom output size #724's gated memory counts merge the products of one column pair into one term before pairing; terms still match by source and coefficient, so push and pull of a gated cell still pair.
Keccak skip sums: pivots and GFNI-weighted packs (commit 48cea407b)
  • Problem: the AVX-512 SkipBlock was bound by the carry-less multiplier port: it weighed every product of a row by its position's weight, 40 VPCLMULQDQ per mixed point and eight slices, 208k per block.
  • Change: a row's five products go by three pivots, each a factor its products share. A weight times an embedding is linear in the pack's bits, so per-position affine byte maps (SkipWeights, built once a layer) give the weighted packs already reduced with GF2P8AFFINEQB; a mixed point then costs 18 VPCLMULQDQ (94k per block plus 38k affine ops that overlap the multiplier).
  • Effect: Skip CPU -18% and -21% at xmss 900, -12% at recursion. On the older base SkipBlock::run alone was about 33% fewer cycles per block.
  • Files: crates/keccak_gkr/src/kernels.rs, crates/keccak_gkr/src/prover.rs.
  • Review notes: the sums are a polynomial congruent to the old ones modulo the minimal polynomial, so the evaluated values are equal. Only the AVX-512 + GFNI arm changes; the other arms keep the old kernels behind the same interface.
Keccak round values: without reducing the factor B (commit 2e33b7205)
  • Problem: Round::add_pair is CLMUL bound (carry-less products about 45% of its samples): it reduced B[j] for both rows of a pair (Karatsuba's six products plus Barrett's twelve per row), then took two dot products with T[j + 2].
  • Change: the x86 kernel (PowerField::round_dot) leaves B as six words and meets T[j + 2] with it as two halves of three: 72 carry-less products per entry where there were 96. B + B' is weights * (T1 + T1'), so the value at infinity needs no second B. The sums grow to nine words, congruent to the old ones.
  • Effect: Keccak Tables CPU (where add_pair runs) -9% in both xmss 900 runs, recursion 0.050 -> 0.041 s; Tail, which also calls it, is flat. On the older base add_pair's cycle samples fell about 19%.
  • Files: crates/keccak_gkr/src/power.rs, crates/keccak_gkr/src/prover.rs.
  • Review notes: x86 only; the other arms keep reducing B (round_dot_rows), which is also the new kernel's test oracle.
NTT short last chunk: skip the empty vector (commit ca23a5f5d)
  • Problem: the AVX-512 register kernels take two eight-lane vectors of a row at a time. The L0 base encode's rows of 56 words are three such chunks and one of a single vector, and the kernel ran both vectors of that last chunk, the second all zeros: 64 lanes of work for 56 in every sweep of the deep pass and of the openings' row rebuilds.
  • Change: the last chunk runs the kernel on the vectors that hold lanes only, each count an inlined call of its own. Rows of whole chunks are unchanged.
  • Effect: together with the next commit, Commit CPU at xmss 900 is within noise on this base (+1% and -2%), recursion -11%. On the older base Commit fell 1.456 -> 1.322 s and the kernel alone went from 0.31 to 0.37 ns per butterfly to 0.26.
  • Files: crates/pcs/src/ntt/kernels.rs.
  • Review notes: AVX-512 only. A per-commit span run (4 rounds) did not separate this commit's effect from the host's noise at xmss 900.
NTT depth first: run the layers depth first past L1 (commit e5f837c28)
  • Problem: run_layers swept each fused group of layers over the whole buffer before the next. A deep task is an L2 budget of rows or more (1.5 MiB at the level-2 encode), so each sweep streamed it through L2 and spilled to L3.
  • Change: a buffer larger than L1 (4096 words) runs depth first: after a sweep, each block of the sweep's last layer runs its remaining layers while it is in cache. Same butterflies and twiddles in another order. With live (the openings' pruned rebuilds) the order stays breadth first.
  • Effect: shares the Commit numbers of the previous block. On the older base the kernel alone gained 7 to 13% per butterfly, but in the prover the deep pass waits on its DRAM reads and the stage moved within noise.
  • Files: crates/pcs/src/ntt/layers.rs.
  • Review notes: the weakest idea here: a clear kernel gain that the prover does not resolve at xmss 900.
WHIR extension encode: keep only the message when every layer is deep (commit f35ec0b61)
  • Problem: WHIR levels 2 to 4 plan a deep pass only: each task builds its cosets from the message, hands them to the leaf hasher, then streamed them out as the finished codeword (200, 100 and 50 MiB at xmss 900 on the older base), although on x86 (REBUILD_OVER_STORE) the commit only needs the codeword before its deep layers, which for these plans is the message's replicas.
  • Change: the encode writes no buffer: it keeps the message itself as Codeword::BeforeDeep, and a query's row runs the deep layers of its sub-block from it, pruned to the queried rows, as rows read from any codeword kept before its deep layers already do.
  • Effect: PCS open > Commit CPU -4% in both xmss 900 runs (this stage also runs the NTT commits' kernels), recursion -5%; PCS open > Open rises (0.087 -> 0.091 s, 0.091 -> 0.107 s) since the queried rows are finished on demand. On the older base this stopped allocating 350 MiB of level 2 to 4 codewords at xmss 900.
  • Files: crates/pcs/src/ntt/deep.rs, crates/pcs/src/ntt/encode.rs, crates/pcs/src/ntt/openings.rs, crates/pcs/src/ntt/tests.rs.
  • Review notes: level 1, the L0 base encode and the Apple path (which keeps the whole codeword) are unchanged. The deferred-deep test gains a shape whose tasks span several replicas.
Execute access counts: advanced in the access itself (commit bbf8c4c33)
  • Problem: after an instruction's reads and stores, a second pass of bump_access_count went over the row's cells again, each touch re-checking the page cover; the interpreter's loop is cache-miss bound and single-threaded on the critical path.
  • Change: Mem::read and Mem::put_counted return the cell's access count and advance it in the same touch, in flush order, so aliased operands still pair each access with its own count. Cells are indexed without bounds checks past ensure. cells_of_state is specialized on the layout like state_of_cells. Since KECCAK-instruction with custom output size #724 a KECCAK row writes only the output cells its bytecode requests; put_counted runs on those, and a cell not written keeps count 0, as on main.
  • Effect: with the two hints commits, Execute program CPU -8% and -15% at xmss 900, -25% at recursion. On the older base this commit cut user instructions per execute 19% (778M -> 627M) without moving cycles in the loop itself.
  • Files: crates/leanvm_core/src/cpu/execute.rs, crates/leanvm_core/src/keccak.rs.
  • Review notes: the unchecked indexing relies on at calling ensure first (SAFETY comments). DEREF's store side keeps its separate bumps.
Hints index: g-power slots carry their keys behind per-slot tags (commit 76c2a226e)
  • Problem: GPow's reverse index and coarse index held only the exponent and confirmed a candidate against the forward table, a random read of a table as large as the address space per probe. On the older base GPow::resolve drew 23% of the execute loop's L2 misses.
  • Change: a slot holds (key, exponent), and a byte per slot holds eight tag bits of the keys homed there, so a lookup of an absent key (all but the last of up to 64 probes a resolve makes) reads one byte of an array a sixteenth the slots' size. Resolve inserts the exponent it found directly.
  • Effect: shares the Execute program numbers above. On the older base the L2-miss share fell 23% -> 12% and cycles in GPow, the index, hint streams and inverses 75M -> 56M per execute.
  • Files: crates/leanvm_core/src/cpu/hints.rs.
  • Review notes: soundness is unaffected: hints stay untrusted witness data; this changes only how the interpreter finds an exponent.
Fill columns: each table's columns in one pass over its rows (commit 46bba9b95)
  • Problem: every table filled its columns in four to six parallel passes over its trace rows (PC, FP, operands and words, counts, bytecode count), each re-reading the rows and re-decoding the instruction. JUMP's w = 1/c was a serial pass over all rows building three temporary arrays.
  • Change: FillCtx::cols takes the list of local columns it writes, so each table fills all its columns in one dispatch and one decode per row; KECCAK lists every column but the input lanes. JUMP's inversion is a Montgomery batch per line of eight rows inside the same pass. Since KECCAK-instruction with custom output size #724 the KECCAK row also carries its OUT_TWO and OUT_ALL flags and its output lanes are the input lanes permuted again; the one pass forms both per row, as main's separate passes did. Rows::iter is gone.
  • Effect: with the next commit, Fill columns CPU -11% and -18% at xmss 900, -32% at recursion. On the older base this commit alone cut Fill columns 12%.
  • Files: crates/leanvm_core/src/tables.rs, crates/leanvm_core/src/cpu/trace.rs.
  • Review notes: the column order inside each closure must match the cols list; a mismatch would show as a wrong witness, so the tests and digests are the check.
Strided g-powers: the interpreter keeps strides, the fill builds the table (commit 0fc4679d7)
  • Problem: the interpreter grew a table of every power g^0..g^k as far as the run addressed (about 4M entries, 32 MiB, plus the copies of its doublings), serially and on the critical path, and handed it to the fill.
  • Change: GPow keeps g^(64j) for the covered strides and g^0..g^63, so g^k is one product; the coarse index is built from the strides as before. Program::build makes the fill's table with primitives::field::g_powers across the pool. Execution::gpow is gone.
  • Effect: shares the Execute program and Fill columns numbers above. On the older base GPow::extend left the profile and cycles in GPow, hints and inverses fell 56M -> 47M per execute; the traced Execute program min went 142 -> 124 ms over the three execute commits.
  • Files: crates/leanvm_core/src/cpu/execute.rs, crates/leanvm_core/src/cpu/hints.rs, crates/leanvm_core/src/cpu/layout.rs.
  • Review notes: GPow::pow now asserts the exponent is covered, where the table index would have panicked; the fill's table is always built rather than borrowed from the run.

How to verify

cargo test --release -p leanvm_core -p keccak_gkr -p pcs -p primitives -p lean_compiler -p rec_aggregation
cargo test --release -p leanvm --test api

Both pass on this branch; cargo fmt --all --check and cargo clippy --release -p leanvm_core -p keccak_gkr -p pcs -p primitives --all-targets -- -D warnings are clean. Proofs were checked by digest: throwaway builds of main and of this branch with a measurement patch print a hash of the proof bytes, and they match on every benchmark: 7d91b8513bdd4b03 (aggregate xmss 900), 3c41e153f8b2ee37 (aggregate sphincs 410), aa4b2ebd7a6989b2 (recursion). Every benchmark run also verifies its proof.

Commits

  1. 210a7a93b constraints: expand each table's state once, fold with the next message
  2. ee76820d6 leanvm_core: prove the bus GKR two rounds per pass with enough threads
  3. 137dfd69f leanvm_core: form the products a push and a pull block share once
  4. 48cea407b keccak_gkr: skip sums by pivots and GFNI-weighted packs
  5. 2e33b7205 keccak_gkr: round values without reducing the factor B
  6. ca23a5f5d pcs: skip the empty vector of an AVX-512 sweep's short last chunk
  7. e5f837c28 pcs: run the NTT's layers depth first past L1
  8. f35ec0b61 pcs: keep only the message of an extension encode whose layers are all deep
  9. bbf8c4c33 execute: advance access counts in the access that reads or writes the cell
  10. 76c2a226e hints: g-power index slots carry their keys, behind per-slot tags
  11. 46bba9b95 tables: fill each table's columns in one pass over its rows
  12. 0fc4679d7 hints: forward g-powers as strides times a first stride; the fill builds the table

kevaundray and others added 12 commits November 9, 2026 17:11
A table's first active round now builds its unfolded state (lin, left_g,
right_g over all its rows) and its message in one pass over the K columns,
so the join no longer reads them a second time. Every fold then computes the
next round's message from the blocks it just wrote (each task folds block k
of all four quarters into block k of both next halves), so a round is one
pass over the state instead of a message sweep plus a fold sweep. Below
sixteen rows per half the old fold and message stay. message_k, join and
their row loader are gone. Proof bytes unchanged (PROOFDIGEST f090f5e4ee18ffad).

Profile (perf, xmss 900 rate 1, 3 passes per run): the sumcheck kernels were
the K-column passes (message_k ~40%, join ~43%; loads and stores hottest in
join, so bandwidth bound), the folded rounds only ~18%. Kernel samples per
run, interleaved: base 1763/2337/2501/2291/2268/2171/2606 (median 2291,
~380 ms CPU per pass), new 1566/1700/1525/1990/1706 (median 1700, ~283 ms),
-26%. Prove constraints minus Constraint evals, tracing wall on the loaded
host, 10-11 runs each: base min 148 ms median 234 ms, new min 119 ms median
189 ms. Whole pass (ab.sh 4x4): base cpu min 11.72 med 11.99 s, new min
11.54 med 12.17 s, inside the noise (expected gain ~0.8% CPU). Peak memory
3.111 -> 3.131 GiB.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
Port of upstream leanEthereum#557 (d6f597e) onto main's batched triple GKR. The vertical kernels read a layer once per sumcheck round. The new `paired` kernels take the rounds in pairs: four rows sharing all but their two lowest index bits form a block, each child bilinear in those bits, and one pass sums eq(q) times the product of the four children over the blocks, a degree-four bivariate grid that gives round j's message and, once its challenge is drawn, round j+1's. The next pass folds every block into one row at both challenges and sums the next pair's grid from the folded rows while they are in registers. The round messages are the same polynomials, so the proof is unchanged.

Main's properties are kept: the three trees run in one dispatch, the implicit identity tail and the identity runs are skipped (an identity quad adds only to the grid's constant term, which neither message reads, and folds to an unwritten identity quad), and the rows stay transposed between passes, eight blocks per `Quad` of F192x8 lanes, regrouped with lane deinterleaves after each fold. `F192x8::load_blocks` transposes eight runs of sixteen elements for the first pass of a layer.

The pairs multiply more (the first pass of a layer costs about twice the old message pass) to read and write a layer less often, so they run only with at least PAIRED_THREADS = 8 pool threads (upstream measured even at 4 and a gain at 8); fewer threads keep the one-round vertical kernels.

xmss 900, log-inv-rate 1, PROOFDIGEST f090f5e4ee18ffad unchanged (default threads and LEANVM_NUM_THREADS=1). 7950X3D, loaded host, ab.sh 3 rounds x 4 repeats, CPU and wall per proving pass, lv-base -> this:

  load ~75:      cpu med 11.08 -> 10.99 s, min 10.76 -> 10.58; wall med 2.80 -> 2.82 s, min 2.06 -> 2.19
  load ~95:      cpu med 11.35 -> 11.58 s, min 10.67 -> 11.05; wall med 4.11 -> 3.91 s, min 3.20 -> 2.93
  load 230-580:  cpu med 12.45 -> 12.46 s, min 11.88 -> 11.84; wall med 6.54 -> 6.98 s, min 5.60 -> 5.52

Whole-pass CPU is unchanged within noise. Bus GKR stage wall (tracing, 16 threads, load ~75, 4 interleaved runs): 305/360/364/489 ms -> 267/287/288/474 ms, median about -20%; at load 160-600 it was 341-495 -> 305-492 ms. perf samples in leanvm_core::gkr over two passes at 16 threads: kernels 2525/2338 -> 2578/2884, all GKR symbols including the dispatcher's wait 3045/3188 -> 2966/3298, so the stage's CPU is about even.

At one thread (pairs forced on, before the gate): GKR samples 1827/1867 -> 2114/2243 (+15-20%), whole-pass CPU med 8.19 -> 8.40 s (ab.sh 2 x 2), the regression upstream reported. With the gate a one-thread proof runs the previous kernels.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interaction's push and pull blocks over one table's columns flush the same coordinates at the same fingerprint weights but for one (the count's slot), so their leaf formulas share all but a term or two. build_leaves now pairs each block of a shape with the block sharing the most terms with it (`Family`): per chunk of rows the shared mixed products are summed once, unreduced, and each member adds its own terms, reduces and adds its constant. A leaf is the same XOR of the same unreduced products, so the proof is unchanged. At xmss 900 the pairing removes 64M of the 155M mixed products per proof.

xmss 900, log-inv-rate 1: PROOFDIGEST c7afa387f98cdb98 unchanged. 7950X3D, loaded host, spancpu.sh median span CPU, lv-head4 -> this:

  default threads, 4 rounds:      Bus leaves 0.500 -> 0.451 s, Bus GKR 1.139 -> 1.116 (untouched), Prove 8.92 -> 8.80
  LEANVM_NUM_THREADS=1, 2 rounds: Bus leaves 0.313 -> 0.253 s

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AVX-512 SkipBlock weighed every product of a row by its position's
weight: per mixed point and eight slices, two carry-less products for b * c
and six for the weight, five times a row (40 VPCLMULQDQ). A row's five
products now go by three pivots, each a factor its products share:
e2 (w0 e1 + w1 e3), e4 (w2 e3 + w3 e0), e1 (w4 e0). A weight times an
embedding is linear in the pack's bits, so per-position affine byte maps
(SkipWeights, built once a layer, 384 bytes a position, laid out so eight
positions' maps are one contiguous register) give the weighted packs already
reduced to three words; the extension is a sum of those too, and a mixed
point then costs three products per pivot (18 VPCLMULQDQ). A half block runs
at a time, the extension is formed a high point at a time from the 16 packs,
and each output's three word sums stay in registers over the pivots, so per
eight slices only 12 quantities of 16 registers are stored. The sums are a
polynomial congruent to the old ones modulo the minimal polynomial, so the
prover's evaluation and the proof bytes are unchanged (PROOFDIGEST
f090f5e4ee18ffad). The other arms keep the old kernels behind the same
SkipWeights/SkipBlock interface.

Profile (head): SkipBlock was CLMUL-port bound, 208k zmm VPCLMULQDQ per
block at ~2.2 cycles each (measured on this host); the new kernel is 94k
per block, plus 38k GF2P8AFFINEQB that overlap the multiplier.

SkipBlock::run alone, one thread, perf cycles per block (5 runs each, host
loaded): head 707-747k, new 463-555k, about -33%.
Per-kernel perf cycle samples (aggregate --xmss 900 rate 1 --repeat 2, two
runs each, interleaved): SkipBlock head 19.22G / 19.34G, new 14.66G /
16.00G plus SkipWeights 0.18G / 0.16G, about -20%. hash --log-n 17
--repeat 3: head 18.98G / 19.54G, new 15.87G / 15.86G.
Whole pass (ab.sh 3x4, load average 100-370): head cpu min 11.93 med 12.24
s, new min 11.95 med 12.34 s, inside the noise (expected about -2%).

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
Round::add_pair reduced B[j] = weights[j] * (kappa + T[j + 1]) +
kappa * weights[j + 2] for both rows of a pair (Karatsuba's six products
plus Barrett's twelve, per row) and then took the two dot products with
T[j + 2] (six each). The x86 kernel (PowerField::round_dot) now leaves B as
six words and meets T[j + 2] with them as two halves of three: per entry 72
carry-less products where 96 were. B + B' is weights * (T1 + T1'), the
constants cancelling, so the value at infinity needs no second B. The sums
grow to nine words (RoundSums to twelve after the eq weight), congruent to
the old ones modulo the minimal polynomial, so the evaluated round values
and the proof bytes are unchanged (PROOFDIGEST f090f5e4ee18ffad). The other
arms keep reducing B (round_dot_rows), which is also the kernel's test
oracle; the shifted rows of T are built in one pass.

Profile: add_pair is CLMUL bound (carry-less products are ~45% of its
samples).

Per-kernel perf cycle samples, hash --log-n 17 --repeat 3, three runs each
interleaved: Round::add_pair 4.34G / 4.41G / 4.45G -> 3.55G / 3.63G /
3.47G, about -19%. add_pair is about 1.8% of an aggregate --xmss 900
proof's cycles, so the whole-pass effect (about -0.35%) is below what
ab.sh resolves on this host.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
The AVX-512 register kernels take two eight-lane vectors of every row at a
time. A row of 56 words, the L0 base encode's 51 lanes padded to a vector,
is three such chunks and a last chunk of one vector, and the kernel ran both
vectors of that chunk, the second all zeros: every sweep of the deep pass
and of the openings' row rebuilds did 64 lanes of work for 56. The last
chunk now runs the kernel on the vectors that hold lanes only, each count
an inlined call of its own. Rows of whole chunks (the gathered pass's
64-word stage rows, the extension encodes' 48 words) are unchanged.

Byte-identical: PROOFDIGEST c7afa387f98cdb98 (aggregate --xmss 900
--log-inv-rate 1). Tests: pcs.

Measured on the loaded host against lv-head4, BENCH_SPAN_CPU process CPU
per span, 7 interleaved runs: Prove > Commit (base encode and its leaf
hashing) median 1.456 -> 1.322 s, min 1.369 -> 1.204 s; level commits
unchanged (0.785 -> 0.769 s). Kernel alone, one thread, 11 layers over
2^11 rows of 56 words: 0.31-0.37 -> 0.26 ns per butterfly, the rate of a
64-word row.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
run_layers swept each fused group of layers over the whole buffer before
the next. A deep task is an L2 budget of rows or more (2^11 rows of 56
words at L0, 2^12 rows of 48 words at the level-2 encode, 1.5 MiB), so each
of its sweeps streamed it through L2 and spilled to L3. Now a buffer larger
than L1 (4096 words) runs depth first: after a sweep, each block of the
sweep's last layer runs its remaining layers alone, while it is in cache.
Same butterflies and twiddles, in another order. With `live` (the
openings' pruned rebuilds) the order stays breadth first.

Byte-identical: PROOFDIGEST c7afa387f98cdb98. Tests: pcs.

Kernel alone, one thread, min of 20: 12 layers over 2^12 rows of 48 words
0.341-0.347 -> 0.309-0.310 ns per butterfly, 11 layers over 2^11 rows of
48 words 0.325-0.336 -> 0.303-0.308, 2^11 rows of 56 words 0.40 -> 0.35
(before the previous commit). In the prover the deep pass waits on its
DRAM reads, and the stage moves within noise (BENCH_SPAN_CPU, 7
interleaved runs, on top of the previous commit): Prove > Commit median
1.322 -> 1.294 s, min 1.204 -> 1.279 s; level commits 0.769 -> 0.772 s.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
…l deep

WHIR levels 2 to 4 (rates 1/128 to 1/8192, 2^12 down to 2^4 message rows of
16 F192 lanes) plan a deep pass only: each task builds its cosets in
scratch from the message, hands them to the leaf hasher, and then streamed
them out as the finished codeword, 200, 100 and 50 MiB. On x86
(REBUILD_OVER_STORE) the commit keeps a codeword before its deep layers,
and for these plans that is the message's replicas. So the encode now
writes no buffer: it keeps one replica, the message itself (1.5 MiB at
level 2), as Codeword::BeforeDeep, and a query's row runs the deep layers
of its sub-block from that replica, pruned to the queried rows, as rows
read from any codeword kept before its deep layers already do. Level 1
(one gathered pass, then the deep pass) and the L0 base encode are
unchanged, as is the Apple path that keeps the whole codeword.

Byte-identical: PROOFDIGEST c7afa387f98cdb98 (aggregate --xmss 900
--log-inv-rate 1), 729f46975ab551c1 (recursion --n 2 --xmss-per-leaf 900
--log-inv-rate 2). Tests: pcs; the deferred-deep test adds a shape whose
tasks span several replicas.

Measured on the loaded host against lv-head4 (this and the two commits
before), BENCH_SPAN_CPU process CPU per span, 7 interleaved runs:
Prove > PCS open > Commit (levels 1 to 4) median 0.785 -> 0.737 s, min
0.734 -> 0.654 s; Prove > PCS open > Open 0.078 -> 0.094 s (the rows of
levels 2 to 4 are now finished on demand); Prove > PCS open 1.290 -> 1.271
s; Prove > Commit 1.456 -> 1.344 s. Whole pass (ab.sh 4x4, load average
500 to 850): CPU min 8.45 -> 8.03 s, median 8.90 -> 8.89 s; wall min
1.451 -> 1.763 s, median 2.596 -> 2.288 s. The 350 MiB of level 2 to 4
codewords are no longer allocated. PCS throughput bench (PCS_LOG_N=26,
rate 1/2, 5 passes, two interleaved rounds) is noise-bound on this host:
commit 3029 / 325 ms -> 287 / 401 ms, open 7097 / 394 ms -> 387 / 486 ms.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
… cell

An instruction's read and store now return the cell's access count and
advance it in the same touch (Mem::read, Mem::put_counted), instead of a
second pass of bump_access_count over the row's cells after its reads and
stores. Bumps keep flush order, so aliased operands still pair each access
with its own count; DEREF's store side keeps its separate bumps. A cell is
indexed past its covering check without bounds checks (SAFETY: ensure grew
the pages past it). cells_of_state is specialized on the layout like
state_of_cells.

Byte-identical: PROOFDIGEST c7afa387f98cdb98 (aggregate xmss 900 rate 1),
729f46975ab551c1 (recursion n 2 xmss-per-leaf 900 rate 2); witness checksum
of q and the virtual columns unchanged.

Measured (solo execute, one thread, perf stat over 20 executes, xmss 900):
user instructions per execute 778M -> 627M (-19%). Cycles per execute in
execute_with itself (5 interleaved rounds, min) 420M -> 420M: the loop is
bound by cache misses on this host, so the instructions saved do not show as
time here.

Since leanEthereum#724 a KECCAK row writes only the output cells its bytecode requests: put_counted runs on those, and a cell it does not write keeps count 0, as on main.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
GPow's reverse index (announced exponents) and its coarse index (every 64th
exponent) held only the exponent and confirmed a candidate slot against the
forward table, a random read of a table as large as the address space for
every probe. A slot now holds (key, exponent), and a byte per slot holds
eight tag bits of the keys whose home slot it is, so a lookup of an absent
key, all but the last of the up to 64 probes a resolve makes, reads one byte
of an array a sixteenth the slots' size. Resolve inserts the exponent it
found directly.

Byte-identical: PROOFDIGEST c7afa387f98cdb98, recursion 729f46975ab551c1.

Measured (solo execute, one thread, xmss 900): GPow::resolve's share of the
execute loop's L2 misses 23% -> 12% (in-run sample shares, so the host's
load cancels). Cycles per execute in GPow, Index, hint streams and field
inverses (perf, 3 to 5 interleaved rounds, min) 75M -> 56M.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
Every table filled its columns in four to six parallel passes over its trace
rows (PC, FP, operands and words, counts, bytecode count), each re-reading
the rows and re-decoding the instruction; KECCAK took six. FillCtx::cols now
takes the list of local columns it writes, so each table fills all its
columns in one dispatch and one decode per row; KECCAK lists every column
but the input lanes, which are q_keccak's. JUMP's is-nonzero witness w = 1/c
is a Montgomery batch inversion per line of eight rows inside the same pass,
where it was a serial pass over all rows building three temporary arrays.
Rows::iter is gone (no user left).

Byte-identical: PROOFDIGEST c7afa387f98cdb98, recursion 729f46975ab551c1.

Measured against lv-head4 (spancpu.sh 5 rounds, median process CPU, xmss
900 rate 1): Fill columns 0.266 s (parent commit) -> 0.235 s (-12%).

Since leanEthereum#724 the KECCAK row also carries its OUT_TWO and OUT_ALL flags, and its output lanes are the input lanes permuted again (a row may not write its output cells); the one pass forms both per row, as main's separate passes did.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
…lds the table

The interpreter grew a table of every power g^0..g^k as far as the run
addressed (about 4M entries, 32 MiB, plus the copies of its doublings),
serially and on the critical path, and handed it to the fill. GPow now keeps
g^(64j) for the covered strides and g^0..g^63, so g^k is one product; the
coarse index is built from the strides as before. Program::build makes the
fill's table with primitives::field::g_powers, across the pool.
Execution::gpow is gone.

Byte-identical: PROOFDIGEST c7afa387f98cdb98, recursion 729f46975ab551c1;
cargo test --release -p leanvm_core -p lean_compiler pass.

Measured (solo execute, one thread, xmss 900, interleaved, min): cycles per
execute in GPow, hints and inverses 56M -> 47M; GPow::extend is gone from the
profile. Whole series against lv-head4, xmss 900 rate 1 (loaded host, load
average 80 to 800 during the runs):
- traced --repeat 1, 6 interleaved runs: Execute program min 142 -> 124 ms,
  median 204 -> 160 ms; Build witness min 40 -> 33 ms, median 54 -> 36 ms;
  Fill columns min 34 -> 25 ms, median 46 -> 29 ms.
- spancpu.sh 6 rounds (median process CPU): Fill columns 0.274 -> 0.245 s,
  Build witness 0.302 -> 0.296 s, Execute program 0.280 -> 0.301 s (the
  span's CPU includes the Keccak block workers; within noise), Prove 8.98 ->
  8.69 s.
- ab.sh 3x4, CPU per pass: min 8.37 -> 8.25 s, median 8.58 -> 8.70 s (no
  whole-pass CPU change beyond noise); wall min 1.195 -> 0.929 s, median
  1.383 -> 1.172 s.

Rebased onto e5d75f8 (after leanEthereum#724): the digests and measurements above were taken on the older base 8577d1e. On the new base the series leaves the proofs byte-identical: PROOFDIGEST 7d91b8513bdd4b03 (aggregate --xmss 900 --log-inv-rate 1), 3c41e153f8b2ee37 (aggregate --sphincs 410 --log-inv-rate 1), aa4b2ebd7a6989b2 (recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2).
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Benchmarks: base against this PR, on one runner

0fc4679 merged into its base, against the base e5d75f8: 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.62 s 5.54 s -1.26% (-2.16% to -0.59%)
Every result

Keccak GKR

benchmark threads runner CPU measure base this PR change
keccak-hash-batch-262144 default arm64 Neoverse-N2 Proving time 3.86 s 3.85 s -0.38% (-2.01% to -0.19%)
keccak-hash-batch-262144 default x86-64 INTEL(R) XEON(R) PLATINUM 8573C Proving time 5.62 s 5.54 s -1.26% (-2.16% to -0.59%)

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