Skip to content

Commit 90cc821

Browse files
authored
Merge pull request #331 from AdaWorldAPI/claude/u64x8-mul-lo32
simd: U64x8::mul_lo32 — widening lo32×lo32→u64 on every backend (argon2 BlaMka)
2 parents a1ad0bc + 2cd4246 commit 90cc821

10 files changed

Lines changed: 167 additions & 0 deletions

File tree

‎.claude/blackboard.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## 2026-09-24 (2) — U64x8::mul_lo32: the widening lo32×lo32→u64 multiply (argon2 BlaMka)
2+
3+
Added on all six realizations: AVX-512 `_mm512_mul_epu32`; AVX2 `_mm256_mul_epu32` per half; NEON `vmovn_u64` + `vmull_u32`; wasm `i32x4_shuffle::<0,2,0,2>` + `u64x2_extmul_low_u32x4`; scalar reference loop; nightly masked `core::simd` multiply. Unblocks argon2's BlaMka (`a + b + 2·lo32(a)·lo32(b)`), which `ogar-encryption` → a2ui sessions and ogar-auth logins run; also the limb multiply radix-2²⁶ Poly1305 / curve25519 need.
4+
5+
Why intrinsics, not portable source, on AVX-512 and NEON: the LLVM probe (see the 2026-09-24 inventory entry) showed the portable form in BlaMka's add chain lowered to `VPMULLQ` on x86-64-v4 and stayed scalar `madd` on aarch64.
6+
7+
**Evidence:** `simd::tests::u64x8_mul_lo32_matches_scalar` (every operand lane has non-zero high bits and is asserted to differ under a full 64-bit multiply; 5 lanes exceed 32-bit products; commutativity; RFC 9106 fBlaMka) passes native (AVX-512) and pinned v3; `neon-parity.sh` (qemu) and `wasm-parity.sh` (node) pass with the new `0x30F` check. **Disable runs, all red:** AVX-512 → `_mm512_mullo_epi64` fails lane 0; NEON high-half `vshrn` → rc 783 (0x30F); wasm `<1,3,1,3>` shuffle → rc 783. Full `cargo test --lib` native: 2478 passed. clippy `-D warnings` clean native and v3. Not run: the nightly-simd arm (no nightly toolchain here; CI's nightly rows cover it).
8+
9+
**Trap found:** `cargo --config X clippy` does NOT apply `X` — `clippy` is an external subcommand and cargo does not forward `--config` given before it. Measured: rustc ran with `target-cpu=native` only. `cargo clippy --config X` works (`native` then `x86-64-v3`, last wins). `cargo --config X test` is fine (built-in subcommand). A "v3 clippy" run the first way silently re-checks the native tier.
10+
11+
**Next:** vendor `argon2` and give `Block::compress` a vertical `U64x8` lane (8 independent G's per register; rotates already exist); RFC 9106 vectors as the oracle.
12+
113
## 2026-09-24 — per-CPU SIMD inventory generated from LLVM's TableGen source
214

315
`tools/gen_llvm_inventory.py` reads llvm-project's `X86.td`, `AArch64Processors.td`, `AArch64Features.td` (plus the instruction/predicate `.td` files) at the `llvmorg-*` tag matching `rustc -vV` (22.1.8 under the pinned 1.98.1), resolves every processor's features, and writes `tools/llvm-inventory/inventory.json` + `.claude/knowledge/llvm-cpu-inventory.md`. Same pattern as `gen_ternlog_bodies.py`: committed output, generator as provenance; default mode exits 1 if the output is stale.

‎crates/neon-simd-parity/src/main.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,13 @@ mod checks {
373373
if !(a == U64x8::from_array(a_arr)) || a == b {
374374
return Err(0x30E);
375375
}
376+
// mul_lo32: lo32(a) x lo32(b) as an exact u64 (argon2's BlaMka multiply).
377+
let m = a.mul_lo32(b).to_array();
378+
for i in 0..8 {
379+
if m[i] != (a_arr[i] & 0xFFFF_FFFF) * (b_arr[i] & 0xFFFF_FFFF) {
380+
return Err(0x30F);
381+
}
382+
}
376383
Ok(())
377384
}
378385

‎crates/wasm-simd-parity/src/lib.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,13 @@ fn check_u64x8_algebra() -> Result<(), u32> {
354354
if !(a == U64x8::from_array(a_arr)) || a == b {
355355
return Err(0x30E);
356356
}
357+
// mul_lo32: lo32(a) x lo32(b) as an exact u64 (argon2's BlaMka multiply).
358+
let m = a.mul_lo32(b).to_array();
359+
for i in 0..8 {
360+
if m[i] != (a_arr[i] & 0xFFFF_FFFF) * (b_arr[i] & 0xFFFF_FFFF) {
361+
return Err(0x30F);
362+
}
363+
}
357364
Ok(())
358365
}
359366

‎src/simd.rs‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,58 @@ mod tests {
992992
}
993993
}
994994

995+
/// `U64x8::mul_lo32` against its scalar definition, on whichever tier this
996+
/// build compiled (AVX-512 `VPMULUDQ`, the AVX2 halves, or scalar).
997+
///
998+
/// Every operand lane carries non-zero HIGH 32 bits, and every lane is
999+
/// chosen so a full 64-bit multiply (`VPMULLQ`, or a plain `*`) gives a
1000+
/// DIFFERENT answer — asserted below, so the fixture cannot silently lose
1001+
/// that property. Five lanes also produce products wider than 32 bits, so
1002+
/// a multiply that truncates to 32 bits (`VPMULLD`) fails too.
1003+
///
1004+
/// The second half checks the consumer shape: argon2's BlaMka
1005+
/// `a + b + 2·lo32(a)·lo32(b)` built from `mul_lo32` against the RFC 9106
1006+
/// definition (`fBlaMka`) computed in scalar `u64` wrapping arithmetic.
1007+
#[test]
1008+
fn u64x8_mul_lo32_matches_scalar() {
1009+
use super::U64x8;
1010+
1011+
let a_arr: [u64; 8] = [
1012+
0xFFFF_FFFF_FFFF_FFFF, 0xDEAD_BEEF_0000_0000, 0x0000_0001_0000_0001, 0x1234_5678_9ABC_DEF0,
1013+
0x8000_0000_8000_0000, 0xFFFF_FFFF_0000_0001, 0x0123_4567_89AB_CDEF, 0xAAAA_AAAA_5555_5555,
1014+
];
1015+
let b_arr: [u64; 8] = [
1016+
0xFFFF_FFFF_FFFF_FFFF, 0xFFFF_FFFF_FFFF_FFFF, 0xFFFF_FFFF_FFFF_FFFF, 0x0FED_CBA9_8765_4321,
1017+
0x8000_0001_8000_0000, 0x7777_7777_C0DE_CAFE, 0xFEDC_BA98_7654_3210, 0x5555_5555_AAAA_AAAA,
1018+
];
1019+
let (a, b) = (U64x8::from_array(a_arr), U64x8::from_array(b_arr));
1020+
1021+
let got = a.mul_lo32(b).to_array();
1022+
for i in 0..8 {
1023+
let want = (a_arr[i] & 0xFFFF_FFFF) * (b_arr[i] & 0xFFFF_FFFF);
1024+
assert_eq!(got[i], want, "lane {i}: mul_lo32");
1025+
assert_ne!(
1026+
want,
1027+
a_arr[i].wrapping_mul(b_arr[i]),
1028+
"fixture lane {i} no longer distinguishes mul_lo32 from a full 64-bit multiply"
1029+
);
1030+
}
1031+
// mul_lo32 is commutative; a backend that read one operand's high
1032+
// half by mistake would break this before it broke `want`.
1033+
assert_eq!(b.mul_lo32(a).to_array(), got, "mul_lo32 is not commutative");
1034+
1035+
// BlaMka, RFC 9106 §3.5: fBlaMka(x, y) = x + y + 2 * trunc(x) * trunc(y).
1036+
let m = a.mul_lo32(b);
1037+
let blamka = (a + b + m + m).to_array();
1038+
for i in 0..8 {
1039+
let t = (a_arr[i] & 0xFFFF_FFFF) * (b_arr[i] & 0xFFFF_FFFF);
1040+
let want = a_arr[i]
1041+
.wrapping_add(b_arr[i])
1042+
.wrapping_add(t.wrapping_mul(2));
1043+
assert_eq!(blamka[i], want, "lane {i}: BlaMka");
1044+
}
1045+
}
1046+
9951047
/// The BLAKE3 shuffle surface on `U32x16`, checked against the REAL x86
9961048
/// intrinsics it reproduces — applied to each 256-bit half.
9971049
///

‎src/simd_avx2.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,6 +1674,20 @@ impl U64x8 {
16741674
let (lo, hi) = self.avx2_halves();
16751675
Self::from_avx2_halves(Self::rotl_half(lo, 64 - n), Self::rotl_half(hi, 64 - n))
16761676
}
1677+
1678+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the widening
1679+
/// 32×32→64 multiply (`VPMULUDQ`, one per 256-bit half). The high 32
1680+
/// bits of every input lane are ignored; the product cannot overflow.
1681+
/// argon2's BlaMka multiply; see the AVX-512 backend for the contract.
1682+
#[inline(always)]
1683+
pub fn mul_lo32(self, rhs: Self) -> Self {
1684+
let (a_lo, a_hi) = self.avx2_halves();
1685+
let (b_lo, b_hi) = rhs.avx2_halves();
1686+
// SAFETY: same obligation as `avx2_halves` — AVX2 is present on any
1687+
// host this arm runs on; `_mm256_mul_epu32` operates on register
1688+
// values only.
1689+
unsafe { Self::from_avx2_halves(_mm256_mul_epu32(a_lo, b_lo), _mm256_mul_epu32(a_hi, b_hi)) }
1690+
}
16771691
}
16781692

16791693
/// Lane-wise variable shifts for the mask family's word ops (the Morton hex

‎src/simd_avx512.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,27 @@ impl U64x8 {
17841784
Self(unsafe { _mm512_rorv_epi64(self.0, _mm512_set1_epi64(n as i64)) })
17851785
}
17861786

1787+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the widening
1788+
/// 32×32→64 multiply (`VPMULUDQ`). The high 32 bits of every input lane
1789+
/// are ignored; the product cannot overflow, since `(2³²−1)² < 2⁶⁴`.
1790+
///
1791+
/// This is the multiply in argon2's BlaMka G function,
1792+
/// `a + b + 2·lo32(a)·lo32(b)`, and the limb multiply of radix-2²⁶
1793+
/// Poly1305 and curve25519 field arithmetic.
1794+
///
1795+
/// Written as the intrinsic rather than portable source on this tier: in
1796+
/// BlaMka's add chain LLVM lowered the portable `(a as u32 as u64) *
1797+
/// (b as u32 as u64)` to `VPMULLQ` (the full 64-bit multiply) instead of
1798+
/// `VPMULUDQ` on x86-64-v4, although both operands are provably
1799+
/// zero-extended.
1800+
#[inline(always)]
1801+
pub fn mul_lo32(self, rhs: Self) -> Self {
1802+
// SAFETY: `Self` is a native `__m512i`; this arm is compiled only
1803+
// under the avx512f dispatch. `_mm512_mul_epu32` reads the low 32 bits
1804+
// of each 64-bit lane and writes the full 64-bit product.
1805+
Self(unsafe { _mm512_mul_epu32(self.0, rhs.0) })
1806+
}
1807+
17871808
#[inline(always)]
17881809
pub fn splat(v: u64) -> Self {
17891810
Self(unsafe { _mm512_set1_epi64(v as i64) })

‎src/simd_neon.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2824,6 +2824,19 @@ impl U64x8 {
28242824
self.rotate_left(64 - n)
28252825
}
28262826

2827+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the widening
2828+
/// 32×32→64 multiply. Per quad: `vmovn_u64` keeps the low 32 bits of each
2829+
/// lane, then `vmull_u32` (`UMULL`) widens the product back to 64 bits.
2830+
/// The high 32 bits of every input lane are ignored; the product cannot
2831+
/// overflow. argon2's BlaMka multiply — written explicitly because LLVM
2832+
/// left the portable form as scalar `madd` inside BlaMka's add chain on
2833+
/// aarch64.
2834+
#[inline(always)]
2835+
pub fn mul_lo32(self, rhs: Self) -> Self {
2836+
// SAFETY: NEON baseline; pure register ops on uint64x2_t / uint32x2_t.
2837+
Self(core::array::from_fn(|p| unsafe { U64x2(vmull_u32(vmovn_u64(self.0[p].0), vmovn_u64(rhs.0[p].0))) }))
2838+
}
2839+
28272840
/// Lane-wise population count: `vcntq_u8` on the bytes, then the
28282841
/// `vpaddlq_u8 → vpaddlq_u16 → vpaddlq_u32` widening-add ladder back to
28292842
/// one count per u64 lane (0..=64).

‎src/simd_nightly/u_word_types.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ impl U64x8 {
6262
Self::from_array(o)
6363
}
6464

65+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the widening
66+
/// 32×32→64 multiply (argon2's BlaMka). Masking both operands to their
67+
/// low 32 bits makes the 64-bit `core::simd` multiply exact: the product
68+
/// of two values below 2³² cannot overflow a `u64`.
69+
#[inline(always)]
70+
pub fn mul_lo32(self, rhs: Self) -> Self {
71+
let lo = u64x8::splat(0xFFFF_FFFF);
72+
Self((self.0 & lo) * (rhs.0 & lo))
73+
}
74+
6575
#[inline(always)]
6676
pub fn splat(v: u64) -> Self {
6777
Self(u64x8::splat(v))

‎src/simd_scalar.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,21 @@ impl U64x8 {
572572
}
573573
Self::from_array(o)
574574
}
575+
576+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the widening
577+
/// 32×32→64 multiply. The high 32 bits of every input lane are ignored;
578+
/// the product cannot overflow, since `(2³²−1)² < 2⁶⁴`. argon2's BlaMka
579+
/// multiply. This loop is the reference the native arms are tested
580+
/// against.
581+
#[inline(always)]
582+
pub fn mul_lo32(self, rhs: Self) -> Self {
583+
let (a, b) = (self.to_array(), rhs.to_array());
584+
let mut o = [0u64; 8];
585+
for i in 0..8 {
586+
o[i] = (a[i] as u32 as u64) * (b[i] as u32 as u64);
587+
}
588+
Self::from_array(o)
589+
}
575590
}
576591

577592
// I8/I16 SIMD types (scalar fallback)

‎src/simd_wasm.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1859,6 +1859,22 @@ pub mod wasm32_simd {
18591859
self.rotate_left(64 - n)
18601860
}
18611861

1862+
/// Lane-wise `lo32(self) × lo32(rhs)` as an exact `u64` — the
1863+
/// widening 32×32→64 multiply. Per `v128`: the low 32 bits of the two
1864+
/// u64 lanes are u32 lanes 0 and 2 (little-endian), gathered into
1865+
/// lanes 0 and 1 by `i32x4_shuffle::<0, 2, 0, 2>`, then
1866+
/// `u64x2_extmul_low_u32x4` widens their product. The high 32 bits
1867+
/// of every input lane are ignored; the product cannot overflow.
1868+
/// argon2's BlaMka multiply.
1869+
#[inline(always)]
1870+
pub fn mul_lo32(self, rhs: Self) -> Self {
1871+
Self(core::array::from_fn(|p| {
1872+
let a = i32x4_shuffle::<0, 2, 0, 2>(self.0[p].0, self.0[p].0);
1873+
let b = i32x4_shuffle::<0, 2, 0, 2>(rhs.0[p].0, rhs.0[p].0);
1874+
U64x2(u64x2_extmul_low_u32x4(a, b))
1875+
}))
1876+
}
1877+
18621878
/// Lane-wise population count: `i8x16_popcnt`, then the pairwise
18631879
/// widening adds up to 32-bit halves, then the two halves of each u64
18641880
/// summed (`u64x2_shr` 32 + masked add).

0 commit comments

Comments
 (0)