Skip to content

ML-DSA on x86-64: RejNTTPoly four at a time, in verification (verify −28% instructions with AVX2) - #534

Merged
alex merged 18 commits into
mainfrom
claude/fervent-einstein-ukl7t7-rej4
Oct 2, 2026
Merged

alex merged 18 commits into
mainfrom
claude/fervent-einstein-ukl7t7-rej4

Conversation

@alex

@alex alex commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Based on main, now that #491 (AVX2 polynomial arithmetic) has merged. It implements vg_mldsa_rej_ntt_poly4, whose spec and contract were reviewed in #492. #535, #536, #537 and #544 are stacked on it.

What

vg_mldsa_rej_ntt_poly4 runs RejNTTPoly on four 34-byte seeds and writes four polynomials (Impl/MlDsa/X86_64/Sample/RejNtt4.lean). There are two implementations:

  • _avx2. It runs the four SHAKE128 instances at once, one in each 64-bit element of the ymm registers, using the absorb and squeeze code of vg_mlkem_sample_ntt4_avx2. It squeezes three blocks of each instance twice, then runs vg_mldsa_rej_ntt_poly's loop over the same 1008 bytes of each seed's output.
  • Baseline. It calls vg_mldsa_rej_ntt_poly on each seed, saving its caller's callee-saved registers in scratch.

Both are proven against rejNTT4Contract with 24 bytes of stack. They are a new function of the MlDsaArith backend, so the generic callers get the _avx2 variant with nothing listed by hand.

Verification. Each row of  is now sampled four entries at a time:

  • the seeds come from SB4, four copies of ρ with the row's indices;
  • the first call covers entries 0–3;
  • for ℓ = 7, a second call covers entries 3–6, so entry 3 is sampled again;
  • for ℓ = 5, the last entry is sampled alone.

The rej4 working space is the 8 KiB after the last row of Â, which the scratch size already allows. Verification's stack grows from 24 to 32 bytes, because the baseline callee calls three deep.

Proofs (untrusted)

  • Rej4Top, Rej4Sq, Rej4Parse: correctness of the AVX2 variant.
  • Rej4Scalar: correctness of the baseline (four calls).
  • Rej4CT: constant time; the result depends only on the seeds.
  • Rej4Verified: Verified against the shared contract.
  • Verify/StageA, CallSample, CTSample, Flag: the new sampling of each row, and its leakage.
  • The single-poly samplers' proofs only gain the scratch frame facts the four-way callers need.

There are no TCB or Spec changes.

Performance

Instructions for key generation, one signature and 20 verifications (callgrind; base is #491's head):

AVX2 baseline (VG_CPU_FEATURES=none)
ML-DSA-44 23.7M → 16.9M (−28%) +0.06%
ML-DSA-65 35.7M → 25.6M (−28%) +0.04%
ML-DSA-87 72.8M → 52.0M (−29%) 83.5M → 89.6M (+7.3%)

The baseline ML-DSA-87 slowdown is the duplicated entry 3 of each row: without AVX2 it costs a whole extra vg_mldsa_rej_ntt_poly call. #544, at the top of this stack, samples verification's  as one run of kℓ entries, as key generation and signing do. That removes the duplicate (baseline ML-DSA-87 −9.3% per verification) and saves another ~8% with AVX2.

Checks run

  • lake build and the emitter
  • check_lean_imports, check_lean_speed, check_vectors, check_arch_gates, check_variants, check_mcdt, algorithms_table
  • cargo fmt --check and cargo clippy --all-targets -D warnings
  • cargo test --release with Wycheproof, both on the default path and with VG_CPU_FEATURES=none --features cpu-features-env

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr

claude added 17 commits October 1, 2026 13:33
vg_mldsa_ntt and vg_mldsa_inv_ntt now compute on four coefficients at a
time in SSE2 registers, as ML-KEM's x86-64 NTT does: a Montgomery
multiplication with pmuludq (the even doublewords, then the odd ones moved
down by pshufd), conditional additions of q with psrad masks, and for the
layers with len 2 and 1 the coefficients of two or four blocks gathered with
punpck{l,h}qdq (and pshufd) and interleaved back. The zetas are a table in
Montgomery form that the prologue stores in scratch; the multiplications run
inside ML-KEM's withMxcsr, so Intel's MCDT mitigation holds.

Proofs: the lanes' arithmetic (VArith), the butterflies on registers
(VLanes), the loads and stores of four coefficients and the zetas (VMem),
the layers (VLay, VLay21), and the functions (Ntt, NttInv). Signing and
verification now check that their code loads MXCSR only to restore it
(ctlOk, through a compositional ctlC for verification) rather than never,
since their primitives now do.

ML-DSA-65 on this machine: sign 1.53 ms -> 0.81 ms, verify 281 us ->
181 us, keygen 276 us -> 248 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
vg_mldsa_multiply_ntt, vg_mldsa_multiply_add_ntt, vg_mldsa_add and
vg_mldsa_sub now compute on four coefficients at a time in SSE2 registers,
with the vector helpers of the NTT (Vec.lean).

The products are two Montgomery multiplications: f·g·2⁻³², then by
2⁶⁴ mod q, which is f·g in [0, 2q), reduced with vcsub (for
multiply_add, h is then added and the sum reduced). They use pmuludq, so
they run inside ML-KEM's withMxcsr. These functions have no working space
and use no stack, so MXCSR goes through the last 8 bytes of h, addressed
through r8 = h. The last four coefficients of h are loaded into xmm6
first. The loop stores the first 252 coefficients. The last four are
computed from registers before MXCSR is loaded back, and stored after it.
Their callers' proofs are unchanged: the functions still need no stack
and never write rsp.

add and sub use paddd/psubd and vcsub/vcadd.

Proofs: the lanes of a product (mul_lane, mulAdd_lane), the loop
(Mul.step, Mul.loop_ok), the last block (Mul.last), the function
(Mul.fn_ok), and withMxcsr through the end of a polynomial
(withMxcsrH_ok). AddSub is reproven the same way.

ML-DSA-65 instructions executed (callgrind, PR #464 -> this): sign
-19%, verify -11%, keygen -2%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
…er the arithmetic

Key generation, signing and verification on x86-64 now call the
polynomial arithmetic of an implementation given as a variant of the new
interface MlDsaArith (Variants/MlDsaArith/X86_64/). They are registered
in Generic/MlDsaArith/X86_64/ and emitted once for each implementation,
so a faster arithmetic (e.g. AVX2) reaches them without editing them. The
only variant is the existing SSE2 code (Sse2), and the generated code is
unchanged.

- Impl: Arith.Backend holds the six functions' code and a name suffix.
  Each Prims gets a sfx field, and the arithmetic calls are named
  "vg_mldsa_ntt" ++ sfx and so on. primsWith B swaps in B's arithmetic.
- Proof: ArithImpl is a backend with FnOk for each function: verified
  without stack, no rsp writes, depth <= 2, ctlOk, spSafe. prims_okWith
  builds each caller's PrimsOk from it.
- ctlOk and spSafe of signing and key generation are no longer evaluated
  on the whole code with a concrete backend. Same/same_tac
  (Proof/MlDsa/X86_64/Arith/Same.lean) shows that a check composing over
  the code's structure (ctlC, Code.allInstrs) gives the same result as
  on the code with every arithmetic function empty, which the kernel
  evaluates once per parameter set. Verification already did this by
  hand; its call lemmas now allow the names to differ.
- The registration files Artifacts/MlDsa{KeyGen,Sign,Verify}/X86_64.lean
  move to Generic/MlDsaArith/X86_64/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
…de/fervent-einstein-ukl7t7-generic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Add an AVX2 variant of MlDsaArith on x86-64: vg_mldsa_ntt_avx2,
vg_mldsa_inv_ntt_avx2, vg_mldsa_multiply_ntt_avx2,
vg_mldsa_multiply_add_ntt_avx2, vg_mldsa_add_avx2 and vg_mldsa_sub_avx2
compute on eight coefficients at a time, four in each 128-bit lane of an
AVX2 register. In each lane the VEX.256 form of the SSE2 code does what the
SSE2 code does to an xmm register, so the proofs lift the SSE2 lemmas to
each lane (ML-KEM's ylanes). The NTT layers with len >= 8 load eight
coefficients of each half of a block; len = 4 regroups two blocks with
vperm2i128; len = 2 and 1 run the SSE2 gatherings in each lane, with the
zetas of each lane arranged by vpshufd/vpblendd or vpermq.

Key generation, signing and verification are generic over MlDsaArith, so
the emitter generates their _avx2 instances; the Rust API selects them on
CPUs with AVX and AVX2 (mldsa_common::Backend).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
The contract of a function that samples four elements of the matrix A at
once, as ML-KEM's vg_mlkem_sample_ntt4 does: for each of the four 34-byte
seeds, RejNTTPoly (FIPS 204 Algorithm 30) of it, reduced, or 0 if the loop
does not finish within Appendix C's least bound for one of them. Its
scratch is that of vg_mldsa_rej_ntt_poly (256 u64s), so callers can pass
the same working space. No implementation yet: an x86-64 one, with four
SHAKE128 instances in AVX2 registers, follows in its own PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Four interleaved Keccak states, the second buffer of the 4-way permutation
and its table of round constants already take 2368 bytes, before the
squeezed output; ML-KEM's vg_mlkem_sample_ntt4 has 1024 u64s for the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
ML-DSA now chooses between its SSE2 and AVX2 polynomial arithmetic by CPU
feature, so test it end to end where the choice differs: under SDE's
Pentium 4 (the SSE2 code, and the line that chooses it) and Haswell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
vg_mldsa_rej_ntt_poly4 runs RejNTTPoly on four seeds. Its AVX2 variant
runs the four SHAKE128 instances at once in the 64-bit elements of ymm
registers, with the absorb and squeeze code of vg_mlkem_sample_ntt4_avx2,
and runs vg_mldsa_rej_ntt_poly's loop over the same 1008 bytes of each
seed's output (squeezed in two rounds of three blocks). The baseline calls
vg_mldsa_rej_ntt_poly on each seed. Both are proven against
rejNTT4Contract with 24 bytes of stack, and are a new function of the
MlDsaArith backend.

Verification now samples each row of  four entries at a time from SB4
(four copies of ρ with the row's indices): the entries from 0, then for
ℓ = 7 the last four (sampling entry 3 again), or for ℓ = 5 the last one
alone. Its rej4 working space is the 8 KiB after the last row of Â, which
the scratch size already allows. Its stack grows from 24 to 32 bytes, as
its baseline callee calls three deep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
…t-einstein-ukl7t7-rej4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Also fixes the conflict markers the previous merge left in ci.yml: the
x86-64 CPU-feature matrix runs both poly1305's and mldsa's tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
…t-einstein-ukl7t7-rej4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Base automatically changed from claude/fervent-einstein-ukl7t7-avx2 to main October 2, 2026 00:07
@alex

alex commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

merge conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr

alex commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Merged main in 8cc896c. The conflicts came from #491's squash: its files were add/add against this branch's copies, which already had #491's final content plus this PR's changes. So the only new content is #529's Rust changes, and the README tables are regenerated. #535, #536, #537 and #544 are updated on top.


Generated by Claude Code

@alex
alex enabled auto-merge October 2, 2026 00:24
@alex
alex added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 50c4434 Oct 2, 2026
35 checks passed
@alex
alex deleted the claude/fervent-einstein-ukl7t7-rej4 branch October 2, 2026 01:37
alex pushed a commit that referenced this pull request Oct 2, 2026
Main has #534's vg_mldsa_rej_ntt_poly4, which this branch already merged
with its own Backend fields (highBits, lowBits and rej4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants