Repository navigation
faster PCS - #650
Merged
Merged
faster PCS#650
Conversation
Benchmarks: base against this PR, on one runner8e7d480 merged into its base, against the base 8ba3bd9: 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; Keccak GKR
Every resultKeccak GKR
|
TomWambsgans
force-pushed
the
opt/1-pcs
branch
from
October 30, 2026 14:23
1e17cde to
6b143bc
Compare
Port of the PCS work on gattaca-com/leanVM's keccak-opt branch: the lane-major base encode that hashes leaves as each block finishes, the deferred-deep encode, the opening's Basis that precomputes what makes its sumcheck almost free, the flat opened rows, and the stack dropped after the lane rounds. It brings the primitives, parallel and fiat_shamir changes it is built on: the eight-lane F192x8 type, Plain and the safe casts, and the pool counting physical cores. Adapted for the M4 rather than taken as is: - Off AVX-512, F192x8's product split its lanes with `[0, 4].map`, which LLVM leaves out of line, so every product passed both operands through memory; the halves are now split by hand. Off AVX-512 the two deferred sums are told apart by use: Prod8 keeps eight lanes for the kernels that reduce each, Acc8 one sum for those that reduce the total, so no kernel folds lanes it then needs or reaches unreachable!(). - Their target_feature refactor of the aarch64 field helpers (gf2_64, gf2_64x3, neon) is reverted to main's #[inline(always)] form: a target_feature fn is only an inline hint, and those helpers were left out of line in execution and the bus leaves on the M4. - The commit rebuilds what an opening reads (the base codeword past 1 GiB, the last layers of a codeword), which wins on x86, where the stores cost more traffic than the products; on Apple silicon a store-only pass is as cheap as a read, so there it keeps the codeword whole. One constant, primitives::REBUILD_OVER_STORE, decides. - The cache-line assertion in Stream::copy_lines is x86-only, matching the non-temporal stores it guards, and the portable F192x8 is line-aligned, so a haswell build compiles and still streams. - flux-profiler, their benchmark notes and the clippy lints they allowed are dropped or fixed, as is the code no branch of the stack reaches. - primitives::filled_vec is an unsafe fn, its callers stating that the fill writes every element first; the pool's physical cores respect the process's CPU affinity on Linux; the AVX-512 NTT butterfly reduces through primitives' reduce_halves512 rather than a copy of it. Simpler than the port, each cut measured on the M4, h1 and h3, in interleaved pairs, and kept where it cost more than noise: - Dropped: the repeat map (a later branch supplies it, and takes its consumers), the coset encoder of small extension codewords (a memory saving only), the lazily split OOD eq basis and the sparse low table of the opening's Basis, the late write-back and the deep-pass prefetch with the hook they needed through every NTT sweep, the NEON lane-pair kernel, the tunings for pools of at most four threads, the flat Rows of an opening, and ring_switch's private byte-table fold (BitFold's portable fold serves it). One 8x8 transpose and one portable module of F192x8 remain where there were three and thirty-one cfgs. - Kept: the AVX-512 hash's own absorb and digest store (+1.0% on h1 and +3.0% on h3 without them), the L2 prefetch of the lane fold (+2.4% on h3), and the deferred-deep encode (+2.0% on h3's aggregate). aggregate --xmss 900 --log-inv-rate 1 / recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2, proving time and peak memory, main -> this branch: M4 Max 0.878 -> 0.879 s (+0.2%), 8.18 -> 6.99 GiB / 0.301 -> 0.316 s (+4.6%), 2.32 -> 2.37 GiB h1 Zen 4 1.587 -> 1.414 s (-10.9%), 8.15 -> 4.74 GiB / 0.524 -> 0.459 s (-12.4%), 2.31 -> 2.27 GiB h3 Zen 5 0.518 -> 0.423 s (-18.4%), 8.23 -> 4.88 GiB / 0.209 -> 0.179 s (-14.3%), 2.40 -> 2.32 GiB Interleaved runs, two to four per version, before the simplification. Against that port, the simplified branch measured, aggregate / recursion: M4 Max -1.1% / -0.4%, peak memory unchanged h1 Zen 4 -0.3% / +1.2%, 4.73 -> 4.99 GiB aggregate peak (no coset encoder) h3 Zen 5 -1.8% / -0.7%, 4.90 -> 5.07 GiB aggregate peak On the M4, the aggregate measured 0.980 s rebuilding as x86 does and 0.897 s keeping everything. On h1 built for haswell (AVX2, 128-bit CLMUL), storing as Apple silicon does measured 2.593 -> 2.448 s (-5.6%) on the xmss aggregate and 3.053 -> 2.911 s (-4.6%) on the sphincs one, at 4.66 -> 6.20 GiB and 5.19 -> 6.58 GiB of peak memory, recursion and hash unchanged; x86 keeps rebuilding. The pool now counts physical cores. On main's code SMT had won on h1, so it was re-measured on the full five-branch stack: proving time with every SMT thread against the physical default, aggregate / recursion, positive where SMT is slower: h1 Zen 4, 16 vs 8 workers: +4.1% / -0.8% h3 Zen 5, 64 vs 32 workers: +1.1% / +9.3% Co-Authored-By: alexX512 <alexey@gattaca.com> Co-Authored-By: vladimir-ea <85992906+vladimir-ea@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TomWambsgans
force-pushed
the
opt/1-pcs
branch
from
October 30, 2026 17:19
6b143bc to
b2fb096
Compare
- The window keeps its first row rather than its first coset, so the deep pass and a gathered pass place a partial buffer by rows. The coset offset underflowed in debug builds whenever one deep block spanned several cosets; release wrapped back to the right index because the offset was zero. - MerkleBuilder checks a block's shape before claiming it, so a caught panic never leaves a claimed block unwritten for finish to read. - fold_high_k asserts a table of whole pairs; an odd length left its last outputs unwritten. - The bounds checks of load_at, store_at and the simd loads and stores saturate rather than wrap, so a huge index fails the check. - BitFold asserts the byte width it was built for, SendPtr is Send and Sync only for Send elements, Chunks::get asserts its index, the drain casts its scratch to lines through cast_slice, and the x86 SAFETY comments name the avx512bw requirement that makes them hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The extension codeword keeps its deep plan as an Option, as the base one does, and both read their opened rows through one kept_rows. - RingWeight holds the claim's map and point only; the eq split and the bit folds that fill its values are built where the lift needs them (RingFold), and prove_prepare builds its eq split only when it folds the witness itself. - whir_induce: the residual evaluator loses its always-empty prefix, both inducers share induce_sumcheck_enforced_sum, and a branch for a block of one row, which a rate of at least 2 never reaches, is gone. - Layers::run always runs to the last layer, so it no longer takes it. - Stage owns the lane-major message it fills from. - The AVX-512 message kernels of F192x8 go through the type's own products, and the portable F192x8 and F64x8 share one even/odd split and one transpose. - PrunedMerklePaths::prune takes the tree as a node function (the CutTree form); the flat-tree form lives in the one test that needs it. - PaddingSpec, F192x8::from_coeffs, the four local PAR_THRESHOLDs and the pool's element-at-a-time map_collect go; map_collect is main's again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments that named code since removed (the NEON pair kernel, butterfly_zmm, functions "mirrored" from main), described behaviour the code no longer has (a dense ring-switch weight, two cosets at a time past the codeword threshold, SMT siblings sharing an L2, fuses_rows' condition, when Encoder::rows returns a plan), or got a fact wrong (sixteen products for eight, a q_flock ring, a core counted only when all its threads are allowed, load_at in every hash backend). The garbled FOLD_STAGE doc is rewritten and STREAM_FOLD_MIN no longer describes one machine's cache. REBUILD_OVER_STORE is "every target but Apple silicon" in its doc and in AGENTS.md, which also lists the pool's two new primitives, says the pool counts the physical cores the process may use, and narrows the NEON inlining rule to helpers that are #[inline(always)] already. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tidy docs - AdditiveNttF64 is the twiddle table itself; the encodes, the layer sweeps (run_layers) and the reads of kept rows (rows) are its methods. Encoder, Layers, Openings and the forwarding facade.rs go, and the gathered pass writes a group back itself, so drain.rs goes too. - Plan names its two deep sizes apart: log_sub is a sub-block (a block of layer deep_start), log_task a deep task (a run of them). reads_lanes and rebuilds_from_lanes replace the plan built twice by for_lanes, and the encode decides once whether the deep pass defers. - The bounds checks of load_at, store_at and the simd loads and stores compare without overflow and without saturating arithmetic. - The PCS throughput bench no longer times a witness copy as part of the open; the pool skips an allowed CPU that is offline rather than dropping its physical-core cap; the deferred-deep test asserts that some shape splits instead of which, so it passes on any pool; the projection test adds a shape whose claims sum eight blocks at a time. - Docs: d, o and L become log_d, outer_log and layer; stage and parameter names match the code; misplaced and doubled comments move or go; RingFold and Stream each have one impl block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…earer names - round_msg_lsb::<EVAL> replaces round_msg_lsb, round_msg_and_eval_lsb_ext and accumulate_msg: one chunked pass, with the inner product when asked. - The lane rounds' lift is basis::Terms itself, its per-block body an impl Fn, so the single-implementation LaneLift trait, its LiftBlock box and the dyn call per block go. - Sizes say what they count: the tables' n_terms, the committed lanes' lane_block, the lift's lift_len, and SumcheckProver::new takes the stack. The opening's basis builds its factors with point_factors and is built by initial_basis, apart from Terms::project. - Prod8::reduce_each goes: it was reduce_lanes().to_array(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Encoded (a buffer that may be empty plus an optional plan), Kept, L0Codeword, kept_rows and the Option<(AdditiveNttF64, Plan)> of both commitments said one thing four ways. An encode now returns a Codeword, Whole, BeforeDeep(words, plan) or Nothing(plan), and AdditiveNttF64::rows reads any of them, from the message when nothing was kept. ProverData and LigeroWitness each hold their ntt and Codeword, the extension codeword is written as words, and an empty buffer with no plan can no longer be built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ries its plan
- With the residual prefix gone, induce_sumcheck_evaluate_at_residual
evaluated sum_i w_i prod_j (1 + p_j (1 + W_j)) at boolean points only,
which is the dense induce basis itself. The last level now calls
induce_basis, the basis half of induce_sumcheck_poly, and the second
implementation goes.
- Every Codeword carries the encode's Plan (Nothing also its lane count),
so AdditiveNttF64::rows takes the codeword, the message for Nothing and
the positions. encode_interleaved_with allocates and returns a Codeword
as encode_lane_major does, so neither caller rebuilds one.
- Past KEEP_CODEWORD_MAX_BYTES, a codeword the opening cannot rebuild is
kept before its deep layers rather than whole: the same memory, without
the deep write-back. Its doc and the stack's say what is kept and when.
- Terms has one impl block and gives its lift's block length, which
InitialBasis and SumcheckProver no longer store; Rebuild and
finished_rows take the sub-block size from the plan.
- Wording left from the folded types ("the encoder", "Openings") and the
whir_ntt_ext module title follow the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocs build - The NEON hash lanes and the single-state AVX-512 Keccak load and store through load_at/store_at and the simd helpers, so no backend moves memory through a raw pointer of its own. - F192x8::lane and set_lane on AVX-512 index its qwords through cast_slice instead of pointer arithmetic, the portable F64x8 is repr(transparent) as its Plain impl assumes, and Chunks is Copy for any T, as SendPtr already is. - bit_fold's gfni module is private, and its register-to-byte map is one byte_of_reg rather than two closures. - The pool's physical-core cap is documented as Linux-only, here and in AGENTS.md, and a test that compared perf_cores with itself goes. - Public docs no longer link the private items the fold of the NTT's wrapper types made them reach, so rustdoc builds again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… redundant allows go Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane access, transposes, constants, the F64x8 arithmetic and its lift to F192x8 were reached only by this branch's own test; the bus branch uses them and brings them back. simd::to_words, the AVX-512 mul_words and the portable transpose helper go with them, and the test checks what remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GFNI fold reads its registers as initialized only if at least one stage wrote them, which a row of 8 to 128 bytes guarantees. That is now a const assert where the registers are read, and the per-call width checks are debug_asserts again: on h1 (Zen 4) the runtime asserts measured +0.5% on the xmss aggregate (0.0 +/- 0.1% for the write-back's cast_slice and the bounds checks), in three interleaved rounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by slicing On h1 (Zen 4), three interleaved rounds each, against the tip before: - Chunks::get is an unsafe fn whose contract is i < count(), which every caller's loop keeps; its release assert cost 0.6% on the xmss aggregate (-0.9/-0.2/-0.6 without it), so it is a debug_assert again. - load_at and store_at slice to element i, then compare the rest's size, so the check cannot overflow and stays linear in i; the division form cost 0.5% on recursion against the original (-0.9/-0.4/-0.3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Routing the two-word NEON loads and stores through load_at gave each a length check the fixed two-word slice does not need: against the branch before review, recursion measured +2.1% on the M4 (+1.1/+1.9/+1.2/+4.1) with load_at, and +0.2% (+0.2/-0.2/+0.3/+0.6) with main's slice-then-vld1q form, which keeps its bounds check and now states its SAFETY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TomWambsgans
force-pushed
the
opt/1-pcs
branch
from
October 30, 2026 20:28
b940157 to
2977c3f
Compare
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.
faster PCS
This PR rewrites the prover side of the PCS: how the witness is committed (Reed-Solomon encode plus Merkle tree) and how the WHIR opening runs. It ports the PCS work of gattaca-com/leanVM (
keccak-optbranch), simplified and tuned so that it also helps on Apple silicon, not only on AVX-512.This is a prover-side opti. No verifier change.
Results
aggregate --xmss 900 --log-inv-rate 1andrecursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2, proving time against main, and peak memory of the aggregate:x86 gets much faster and uses about 40% less memory. The M4 keeps its speed and loses 15% of its memory.
The key ideas
1. Encode and hash in one pass over memory
The prover is limited by memory bandwidth, not by arithmetic, so the goal is to touch the large buffers as few times as possible.
ntt/stage.rs).ntt/plan.rs). The far layers pair rows that are far apart, so they run as a few passes over the buffer, each on small groups of rows gathered into L2. The near layers only mix rows within small blocks, so they run as one last pass, a block at a time in L2.CutTree); the opening rehashes the few leaf groups it needs.2. Store the codeword, or recompute the rows the opening needs
The codeword is the largest buffer of the proof, often a gigabyte. After it is hashed, the opening only ever reads a few hundred of its rows.
Recomputing a row is cheap: the last NTT layers only mix rows inside one small block, so a final row depends only on its block as it was before those layers. Redoing a few hundred blocks is a tiny fraction of the encode.
So each machine chooses, through one constant,
primitives::REBUILD_OVER_STORE:Keeping less is also most of the memory saving on x86. One type,
ntt::Codeword(Whole,BeforeDeep,Nothing), carries what an encode kept, andAdditiveNttF64::rowsreads the opened rows from any of them.3. The opening's first rounds run on small tables
The opening starts with a sumcheck whose first rounds fold the witness's lanes together. Main built the full weight vector, as large as the whole witness, and ran those rounds over it.
This PR writes the weight as a short sum of terms instead: each claim is an
eqpattern over a few low bits times a table over the lanes, and the Keccak claims are folded throughBitFold(GFNI byte matrices on x86, lookup tables elsewhere). The first rounds then run on two tables of a few hundred values per lane (stack_open/basis.rs). After the last of them, one fused pass rebuilds the folded witness and the weight together, block by block, while each block is in cache (whir::fold_all_lanes).4. Wide field kernels that suit every target
F192x8holds eight extension-field elements. On AVX-512 they are three registers of planes, so a product is a handful of carry-less multiplies with one reduction. On NEON the halves are split by hand so that products stay in registers.Prod8,Acc8) and are reduced once per sum, not once per term.whir::fold_and_msg_lsb).keccak_fheld in five vectors (used wherever one state is hashed alone, such as the transcript and the tree's upper levels), and every AVX-512 memory move goes through one audited module (primitives::simd).5. Smaller memory and threading details
primitives::filled_vec,uninit_vec), and large x86 outputs that are not read back soon use streaming stores (primitives::stream).Where to look
crates/pcs/src/ntt.rscrates/pcs/src/ntt/encode.rstransform, the spinecrates/pcs/src/ntt/openings.rsCodewordcrates/pcs/src/whir.rscrates/pcs/src/stack_open/basis.rscrates/primitives/src/field/f192x8.rscrates/primitives/src/bit_fold.rsTesting
cargo testall,cargo clippyall,cargo docallon the M4.pcstests also pass withLEANVM_NUM_THREADS=1.Notes
🤖 Generated with Claude Code