ML-DSA on x86-64: polynomial arithmetic in AVX2 (sign −23%, verify −10% instructions) - #491
Merged
Merged
Conversation
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
…into claude/fervent-einstein-ukl7t7-mul
…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
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
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
This was referenced Oct 1, 2026
Base automatically changed from
claude/fervent-einstein-ukl7t7-generic
to
main
October 1, 2026 22:32
Member
Author
|
merge conflicts |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr
Member
Author
|
Merged main in 4563920. The conflicts were in Generated by Claude Code |
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
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.
Stacked on #476, which makes key generation, signing and verification generic over the arithmetic (and #476 is stacked on #467). The base here is #476's branch, so the diff shows only this change. I'll retarget it as those merge.
What
This adds an AVX2 variant of
MlDsaArithon 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_avx2andvg_mldsa_sub_avx2(Impl/MlDsa/X86_64/Arith/Avx2.lean). Each works on eight coefficients at a time, four in each 128-bit lane.toY) does what the SSE2 code does to anxmmregister.len ≥ 8. Each iteration loads eight coefficients of each half of a block.len = 4. Two blocks at a time, regrouped withvperm2i128.len = 2andlen = 1. The SSE2 gatherings run in each lane. The zetas for each lane are arranged withvpshufd/vpblendd(yzetaS), or withvpermq0xD8 (forward) or 0x27 +vpshufd0xB1 (inverse).withMxcsrsaves MXCSR through the last 8 bytes ofh, so there is no stack. The products are inside the MCDT prologue and epilogue (check_mcdtpasses).vzeroupperruns before every return.Because #476 made keygen, sign and verify generic, the emitter generates
vg_mldsa{44,65,87}_{keygen,sign,verify}_avx2with nothing listed by hand (Variants/MlDsaArith/X86_64/Avx2.lean). On the Rust side,mldsa_common::Backend(Scalar / AArch64 Sha3 / x86-64 Avx2) wraps the Keccak backend's choice. It selectsAvx2when the CPU has the generated_AVX2_FEATURES(avx,avx2). Every call site matches on it exhaustively.ci.yml's x86-64 CPU-feature matrix now runs themldsatests as well. Thep4pchip covers the SSE2 path, andhswand native cover AVX2. Without this, no CI job took the SSE2 branch ofBackend::select, and coverage failed on it.Proofs (untrusted)
YBase,YAddSub,YMul: lanes of 256-bit loads and stores; add/sub; products (the h-slot MXCSR design as in ML-DSA on x86-64: polynomial arithmetic in SSE2 (sign −19%, verify −11% instructions) #467, now withymmHikept bywithMxcsrH_ok).YLay: layers withlen ≥ 8(ystep,yblock_ok,ylay_ok) andlen = 4(ystep4,ylay4_ok), each layer coefficient by coefficient (layF_get). Also the zeta loads (yzeta1_ok,yzetaS_ok).YLay21: one generic iteration forlen= 2 and 1 (ystep21), the per-lane gather → butterfly → scatter (core2_ok,core1_ok),vpermq0x27 (ypermq27_ok), and the eight-zeta loads (yzeta8_ok,yzeta8R_ok).YNtt: prologue,yscale, andnttY_correct/nttInvY_correct. Constant time is proved bytaint_decide, thenVerifiedfollows.BackendAvx2:ArithImpl.avx2, withfeatures := ["avx", "avx2"].There are no changes to TCB or Spec. One small change to existing untrusted code:
withMxcsrH_oknow also says the body'symmHiis kept, andMul.leanis adjusted to match.Performance
Instructions in each verified function, ML-DSA-65 (callgrind,
sign_deterministic, 21 calls each). Both columns are on the same CPU: SSE2 isVG_CPU_FEATURES=bmi1,bmi2,adx,sha, AVX2 is the default.The rest of keygen is mostly Keccak and sampling, which this PR doesn't change.
docs/algorithms/ml-dsa-*.tomlnow say "SSE2 and AVX2 polynomial arithmetic", and the README tables are regenerated.Checks run
lake buildandlake env lean --run Emit.lean --checkcheck_lean_imports,check_lean_speed,check_vectors,check_arch_gates,check_variants,check_mcdt,algorithms_tablecargo fmt --checkcargo clippy --all-targets -D warningsfor x86_64, i686 and aarch64, each with and withoutcpu-features-envcargo test --releasewith Wycheproof, both on the default AVX2 path and withVG_CPU_FEATURES=none --features cpu-features-env: all pass🤖 Generated with Claude Code
https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr