Skip to content

ML-DSA on x86-64: polynomial arithmetic in AVX2 (sign −23%, verify −10% instructions) - #491

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

alex merged 11 commits into
mainfrom
claude/fervent-einstein-ukl7t7-avx2

Conversation

@alex

@alex alex commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

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 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 (Impl/MlDsa/X86_64/Arith/Avx2.lean). Each works on eight coefficients at a time, four in each 128-bit lane.

  • Per-lane reuse. In each lane, the VEX.256 form of the SSE2 code (toY) does what the SSE2 code does to an xmm register.
  • NTT layers with len ≥ 8. Each iteration loads eight coefficients of each half of a block.
  • len = 4. Two blocks at a time, regrouped with vperm2i128.
  • len = 2 and len = 1. The SSE2 gatherings run in each lane. The zetas for each lane are arranged with vpshufd/vpblendd (yzetaS), or with vpermq 0xD8 (forward) or 0x27 + vpshufd 0xB1 (inverse).
  • Multiplications. They keep the SSE2 code's MXCSR handling: withMxcsr saves MXCSR through the last 8 bytes of h, so there is no stack. The products are inside the MCDT prologue and epilogue (check_mcdt passes).
  • vzeroupper runs before every return.

Because #476 made keygen, sign and verify generic, the emitter generates vg_mldsa{44,65,87}_{keygen,sign,verify}_avx2 with 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 selects Avx2 when 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 the mldsa tests as well. The p4p chip covers the SSE2 path, and hsw and native cover AVX2. Without this, no CI job took the SSE2 branch of Backend::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 with ymmHi kept by withMxcsrH_ok).
  • YLay: layers with len ≥ 8 (ystep, yblock_ok, ylay_ok) and len = 4 (ystep4, ylay4_ok), each layer coefficient by coefficient (layF_get). Also the zeta loads (yzeta1_ok, yzetaS_ok).
  • YLay21: one generic iteration for len = 2 and 1 (ystep21), the per-lane gather → butterfly → scatter (core2_ok, core1_ok), vpermq 0x27 (ypermq27_ok), and the eight-zeta loads (yzeta8_ok, yzeta8R_ok).
  • YNtt: prologue, yscale, and nttY_correct / nttInvY_correct. Constant time is proved by taint_decide, then Verified follows.
  • BackendAvx2: ArithImpl.avx2, with features := ["avx", "avx2"].

There are no changes to TCB or Spec. One small change to existing untrusted code: withMxcsrH_ok now also says the body's ymmHi is kept, and Mul.lean is 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 is VG_CPU_FEATURES=bmi1,bmi2,adx,sha, AVX2 is the default.

SSE2 AVX2
keygen 39.0M 36.5M −6.4%
sign 107.4M 83.1M −22.6%
verify 32.5M 29.1M −10.3%

The rest of keygen is mostly Keccak and sampling, which this PR doesn't change. docs/algorithms/ml-dsa-*.toml now say "SSE2 and AVX2 polynomial arithmetic", and the README tables are regenerated.

Checks run

  • lake build and lake env lean --run Emit.lean --check
  • check_lean_imports, check_lean_speed, check_vectors, check_arch_gates, check_variants, check_mcdt, algorithms_table
  • cargo fmt --check
  • cargo clippy --all-targets -D warnings for x86_64, i686 and aarch64, each with and without cpu-features-env
  • cargo test --release with Wycheproof, both on the default AVX2 path and with VG_CPU_FEATURES=none --features cpu-features-env: all pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ddof3szoTi7HB8iCsM2MCr

claude added 8 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
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
@alex

alex commented Oct 1, 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 1, 2026

Copy link
Copy Markdown
Member Author

Merged main in 4563920. The conflicts were in Arith/Mxcsr.lean and Arith/Mul.lean (kept this PR's ymmHi additions) and in the ml-dsa-*.toml notes; the README tables are regenerated. lake build, the emitter (src/asm unchanged), all ci/check_*.py and cargo test with and without VG_CPU_FEATURES=none pass locally.


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
@alex
alex added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 42cd970 Oct 2, 2026
40 checks passed
@alex
alex deleted the claude/fervent-einstein-ukl7t7-avx2 branch October 2, 2026 00:07
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