Skip to content

Commit 5d5d71d

Browse files
authored
Merge pull request #332 from AdaWorldAPI/claude/u64x8-transpose8
simd: U64x8::transpose8 — the 8×8 u64 cross-lane transpose, all six backends
2 parents 90cc821 + 873835d commit 5d5d71d

10 files changed

Lines changed: 252 additions & 0 deletions

File tree

‎.claude/blackboard.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 2026-09-25 — U64x8::transpose8: the one physical routing step argon2 needs
2+
3+
`U64x8::transpose8([U64x8; 8]) -> [U64x8; 8]`, `out[i]` lane `j` == `rows[j]` lane `i`, on all six realizations. AVX-512: 8 unpack + 16 `vshufi64x2` (24). AVX2: four 4×4 blocks, `unpack` + `vperm2i128`. NEON / wasm: 32 `vtrn1q`/`vtrn2q` or `i64x2.shuffle` on 2×2 blocks. Scalar / nightly: the index map.
4+
5+
**Why a transpose and not a lane rotation (folding doctrine: move the city only when physics forces it).** A teleport count over argon2's `Block::compress` (a word teleports when its lane changes between stages, 8 lanes, register choice free): the vertical layout (lane = permutation) costs **0** inside each pass, because BLAKE2b's diagonal `G(v0,v5,v10,v15)` is register renaming, and 112 at each of load, row→column and store (336). The horizontal "reference" layout costs 480 (96 per pass just for diagonalization); a greedy per-stage optimum 432. Row→column is the irreducible rendezvous: a column `G` reads rows 0,2,4,6, which sit in four different lanes. Load and store are only canonicalization: storing argon2 blocks transposed carries the map across calls and leaves 224. Teleports are not the only cost — control steps and backend (AVX-512 vs AVX2 shuffle cost) count too; measure per tier.
6+
7+
**Evidence:** `simd::tests::u64x8_transpose8_matches_the_index_map` (64 distinct words, fixture asserted non-symmetric, involution) passes native AVX-512 and pinned v3; `neon-parity.sh` (qemu) and `wasm-parity.sh` (node) pass with new check `0x310`. **Disable runs, all red:** AVX-512 stage-3 chunk select `0x88→0xDD`; AVX2 `vperm2i128 0x20→0x31`; NEON `vtrn2q→vtrn1q` (rc 784); wasm `<1,3>→<0,2>` (rc 784). Full `cargo test --lib` native: 2483 passed. clippy `-D warnings` clean native and v3. Not run: nightly-simd arm.
8+
9+
**Next:** argon2 fork `compress_simd` — load, row pass, `transpose8` ×2, column pass, `transpose8` ×2, XOR, store, all in registers; then the transposed block storage layout (changes `Block::as_ref()` contents — needs a decision).
10+
111
## 2026-09-24 (2) — U64x8::mul_lo32: the widening lo32×lo32→u64 multiply (argon2 BlaMka)
212

313
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.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,16 @@ mod checks {
380380
return Err(0x30F);
381381
}
382382
}
383+
// transpose8: out[i] lane j == rows[j] lane i, on 64 distinct words.
384+
let rows: [[u64; 8]; 8] = core::array::from_fn(|r| core::array::from_fn(|c| (r * 100 + c) as u64));
385+
let t = U64x8::transpose8(rows.map(U64x8::from_array)).map(|v| v.to_array());
386+
for i in 0..8 {
387+
for j in 0..8 {
388+
if t[i][j] != rows[j][i] {
389+
return Err(0x310);
390+
}
391+
}
392+
}
383393
Ok(())
384394
}
385395

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,16 @@ fn check_u64x8_algebra() -> Result<(), u32> {
361361
return Err(0x30F);
362362
}
363363
}
364+
// transpose8: out[i] lane j == rows[j] lane i, on 64 distinct words.
365+
let rows: [[u64; 8]; 8] = core::array::from_fn(|r| core::array::from_fn(|c| (r * 100 + c) as u64));
366+
let t = U64x8::transpose8(rows.map(U64x8::from_array)).map(|v| v.to_array());
367+
for i in 0..8 {
368+
for j in 0..8 {
369+
if t[i][j] != rows[j][i] {
370+
return Err(0x310);
371+
}
372+
}
373+
}
364374
Ok(())
365375
}
366376

‎src/simd.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,33 @@ mod tests {
10441044
}
10451045
}
10461046

1047+
/// `U64x8::transpose8` against its definition, `out[i][j] == rows[j][i]`.
1048+
///
1049+
/// Every one of the 64 input words is distinct (row·100 + lane), so a
1050+
/// transpose that drops, duplicates or misroutes any word fails, and the
1051+
/// fixture is asserted not to be symmetric, so an identity "transpose"
1052+
/// fails too. Transposing twice must return the input.
1053+
#[test]
1054+
fn u64x8_transpose8_matches_the_index_map() {
1055+
use super::U64x8;
1056+
1057+
let rows_arr: [[u64; 8]; 8] = core::array::from_fn(|r| core::array::from_fn(|c| (r * 100 + c) as u64));
1058+
assert!(
1059+
(0..8).any(|r| (0..8).any(|c| rows_arr[r][c] != rows_arr[c][r])),
1060+
"fixture is symmetric, so identity would pass"
1061+
);
1062+
let rows = rows_arr.map(U64x8::from_array);
1063+
1064+
let out = U64x8::transpose8(rows).map(|v| v.to_array());
1065+
for i in 0..8 {
1066+
for j in 0..8 {
1067+
assert_eq!(out[i][j], rows_arr[j][i], "out[{i}] lane {j}");
1068+
}
1069+
}
1070+
let back = U64x8::transpose8(U64x8::transpose8(rows)).map(|v| v.to_array());
1071+
assert_eq!(back, rows_arr, "transpose is not an involution");
1072+
}
1073+
10471074
/// The BLAKE3 shuffle surface on `U32x16`, checked against the REAL x86
10481075
/// intrinsics it reproduces — applied to each 256-bit half.
10491076
///

‎src/simd_avx2.rs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,59 @@ impl U64x8 {
16881688
// values only.
16891689
unsafe { Self::from_avx2_halves(_mm256_mul_epu32(a_lo, b_lo), _mm256_mul_epu32(a_hi, b_hi)) }
16901690
}
1691+
1692+
/// 8×8 transpose of `u64` words across eight registers:
1693+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
1694+
///
1695+
/// This is a *physical* cross-lane move, for the case where a lane-wise
1696+
/// consumer genuinely needs the other orientation (argon2's row pass →
1697+
/// column pass: a column `G` reads words that live in eight different
1698+
/// lanes). Where a consumer can read the other orientation by index
1699+
/// instead, prefer that; this is the materialization step, not the model.
1700+
///
1701+
/// Four 4×4 `unpack` + `vperm2i128` blocks, 8 shuffles each.
1702+
#[inline(always)]
1703+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
1704+
// Four 4×4 blocks. Block (bi, bj) is input rows 4bi.. , 256-bit half bj;
1705+
// it lands transposed in output rows 4bj.. , half bi. The off-diagonal
1706+
// blocks swapping places is only a choice of destination, not a move.
1707+
let halves = rows.map(|v| v.avx2_halves());
1708+
// Every (row, half) slot is overwritten below; `halves` only seeds the array.
1709+
let mut out = halves;
1710+
for bi in 0..2 {
1711+
for bj in 0..2 {
1712+
let h = |k: usize| {
1713+
if bj == 0 {
1714+
halves[4 * bi + k].0
1715+
} else {
1716+
halves[4 * bi + k].1
1717+
}
1718+
};
1719+
// SAFETY: as `avx2_halves` — AVX2 present on any host this arm
1720+
// runs on; register ops only.
1721+
let c = unsafe {
1722+
let t0 = _mm256_unpacklo_epi64(h(0), h(1));
1723+
let t1 = _mm256_unpackhi_epi64(h(0), h(1));
1724+
let t2 = _mm256_unpacklo_epi64(h(2), h(3));
1725+
let t3 = _mm256_unpackhi_epi64(h(2), h(3));
1726+
[
1727+
_mm256_permute2x128_si256::<0x20>(t0, t2),
1728+
_mm256_permute2x128_si256::<0x20>(t1, t3),
1729+
_mm256_permute2x128_si256::<0x31>(t0, t2),
1730+
_mm256_permute2x128_si256::<0x31>(t1, t3),
1731+
]
1732+
};
1733+
for k in 0..4 {
1734+
if bi == 0 {
1735+
out[4 * bj + k].0 = c[k];
1736+
} else {
1737+
out[4 * bj + k].1 = c[k];
1738+
}
1739+
}
1740+
}
1741+
}
1742+
out.map(|(lo, hi)| Self::from_avx2_halves(lo, hi))
1743+
}
16911744
}
16921745

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

‎src/simd_avx512.rs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,59 @@ impl U64x8 {
18051805
Self(unsafe { _mm512_mul_epu32(self.0, rhs.0) })
18061806
}
18071807

1808+
/// 8×8 transpose of `u64` words across eight registers:
1809+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
1810+
///
1811+
/// This is a *physical* cross-lane move, for the case where a lane-wise
1812+
/// consumer genuinely needs the other orientation (argon2's row pass →
1813+
/// column pass: a column `G` reads words that live in eight different
1814+
/// lanes). Where a consumer can read the other orientation by index
1815+
/// instead, prefer that; this is the materialization step, not the model.
1816+
///
1817+
/// 24 shuffles: 8 unpacks, then two rounds of 8 `vshufi64x2`.
1818+
#[inline(always)]
1819+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
1820+
// SAFETY: `Self` is a native `__m512i` and this arm is compiled only
1821+
// under the avx512f dispatch; every intrinsic below is a register op.
1822+
unsafe {
1823+
let r = rows.map(|v| v.0);
1824+
// Stage 1: interleave lane pairs. t[2p] = (r[2p]_2k, r[2p+1]_2k) in
1825+
// 128-bit chunk k; t[2p+1] the same for the odd lanes.
1826+
let t = [
1827+
_mm512_unpacklo_epi64(r[0], r[1]),
1828+
_mm512_unpackhi_epi64(r[0], r[1]),
1829+
_mm512_unpacklo_epi64(r[2], r[3]),
1830+
_mm512_unpackhi_epi64(r[2], r[3]),
1831+
_mm512_unpacklo_epi64(r[4], r[5]),
1832+
_mm512_unpackhi_epi64(r[4], r[5]),
1833+
_mm512_unpacklo_epi64(r[6], r[7]),
1834+
_mm512_unpackhi_epi64(r[6], r[7]),
1835+
];
1836+
// Stage 2: gather 128-bit chunks {0,1} and {2,3} of row pairs.
1837+
let s = [
1838+
_mm512_shuffle_i64x2::<0x44>(t[0], t[2]),
1839+
_mm512_shuffle_i64x2::<0xEE>(t[0], t[2]),
1840+
_mm512_shuffle_i64x2::<0x44>(t[4], t[6]),
1841+
_mm512_shuffle_i64x2::<0xEE>(t[4], t[6]),
1842+
_mm512_shuffle_i64x2::<0x44>(t[1], t[3]),
1843+
_mm512_shuffle_i64x2::<0xEE>(t[1], t[3]),
1844+
_mm512_shuffle_i64x2::<0x44>(t[5], t[7]),
1845+
_mm512_shuffle_i64x2::<0xEE>(t[5], t[7]),
1846+
];
1847+
// Stage 3: pick chunk k of all four row pairs -> column 2k / 2k+1.
1848+
[
1849+
Self(_mm512_shuffle_i64x2::<0x88>(s[0], s[2])),
1850+
Self(_mm512_shuffle_i64x2::<0x88>(s[4], s[6])),
1851+
Self(_mm512_shuffle_i64x2::<0xDD>(s[0], s[2])),
1852+
Self(_mm512_shuffle_i64x2::<0xDD>(s[4], s[6])),
1853+
Self(_mm512_shuffle_i64x2::<0x88>(s[1], s[3])),
1854+
Self(_mm512_shuffle_i64x2::<0x88>(s[5], s[7])),
1855+
Self(_mm512_shuffle_i64x2::<0xDD>(s[1], s[3])),
1856+
Self(_mm512_shuffle_i64x2::<0xDD>(s[5], s[7])),
1857+
]
1858+
}
1859+
}
1860+
18081861
#[inline(always)]
18091862
pub fn splat(v: u64) -> Self {
18101863
Self(unsafe { _mm512_set1_epi64(v as i64) })

‎src/simd_neon.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2837,6 +2837,38 @@ impl U64x8 {
28372837
Self(core::array::from_fn(|p| unsafe { U64x2(vmull_u32(vmovn_u64(self.0[p].0), vmovn_u64(rhs.0[p].0))) }))
28382838
}
28392839

2840+
/// 8×8 transpose of `u64` words across eight registers:
2841+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
2842+
///
2843+
/// This is a *physical* cross-lane move, for the case where a lane-wise
2844+
/// consumer genuinely needs the other orientation (argon2's row pass →
2845+
/// column pass: a column `G` reads words that live in eight different
2846+
/// lanes). Where a consumer can read the other orientation by index
2847+
/// instead, prefer that; this is the materialization step, not the model.
2848+
///
2849+
/// 32 `vtrn1q`/`vtrn2q`, one per output pair.
2850+
#[inline(always)]
2851+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
2852+
// Each register is four `U64x2` pairs, so the 8×8 is a 4×4 grid of
2853+
// 2×2 blocks. Block (bi, bj) = pair bj of rows 2bi, 2bi+1; transposed
2854+
// with `vtrn1q`/`vtrn2q`, it becomes pair bi of rows 2bj, 2bj+1. Moving
2855+
// whole blocks is only a choice of destination.
2856+
core::array::from_fn(|i| {
2857+
let (bj, odd) = (i / 2, i % 2 == 1);
2858+
Self(core::array::from_fn(|bi| {
2859+
let (x, y) = (rows[2 * bi].0[bj].0, rows[2 * bi + 1].0[bj].0);
2860+
// SAFETY: NEON baseline; pure register ops on uint64x2_t.
2861+
U64x2(unsafe {
2862+
if odd {
2863+
vtrn2q_u64(x, y)
2864+
} else {
2865+
vtrn1q_u64(x, y)
2866+
}
2867+
})
2868+
}))
2869+
})
2870+
}
2871+
28402872
/// Lane-wise population count: `vcntq_u8` on the bytes, then the
28412873
/// `vpaddlq_u8 → vpaddlq_u16 → vpaddlq_u32` widening-add ladder back to
28422874
/// one count per u64 lane (0..=64).

‎src/simd_nightly/u_word_types.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ impl U64x8 {
7272
Self((self.0 & lo) * (rhs.0 & lo))
7373
}
7474

75+
/// 8×8 transpose of `u64` words across eight registers:
76+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
77+
///
78+
/// This is a *physical* cross-lane move, for the case where a lane-wise
79+
/// consumer genuinely needs the other orientation (argon2's row pass →
80+
/// column pass: a column `G` reads words that live in eight different
81+
/// lanes). Where a consumer can read the other orientation by index
82+
/// instead, prefer that; this is the materialization step, not the model.
83+
#[inline(always)]
84+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
85+
let a = rows.map(|v| v.to_array());
86+
core::array::from_fn(|i| Self::from_array(core::array::from_fn(|j| a[j][i])))
87+
}
88+
7589
#[inline(always)]
7690
pub fn splat(v: u64) -> Self {
7791
Self(u64x8::splat(v))

‎src/simd_scalar.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,22 @@ impl U64x8 {
587587
}
588588
Self::from_array(o)
589589
}
590+
591+
/// 8×8 transpose of `u64` words across eight registers:
592+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
593+
///
594+
/// This is a *physical* cross-lane move, for the case where a lane-wise
595+
/// consumer genuinely needs the other orientation (argon2's row pass →
596+
/// column pass: a column `G` reads words that live in eight different
597+
/// lanes). Where a consumer can read the other orientation by index
598+
/// instead, prefer that; this is the materialization step, not the model.
599+
///
600+
/// The index map itself; the reference the native arms are tested against.
601+
#[inline(always)]
602+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
603+
let a = rows.map(|v| v.to_array());
604+
core::array::from_fn(|i| Self::from_array(core::array::from_fn(|j| a[j][i])))
605+
}
590606
}
591607

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

‎src/simd_wasm.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,33 @@ pub mod wasm32_simd {
18751875
}))
18761876
}
18771877

1878+
/// 8×8 transpose of `u64` words across eight registers:
1879+
/// `out[i]` lane `j` == `rows[j]` lane `i`.
1880+
///
1881+
/// This is a *physical* cross-lane move, for the case where a lane-wise
1882+
/// consumer genuinely needs the other orientation (argon2's row pass →
1883+
/// column pass: a column `G` reads words that live in eight different
1884+
/// lanes). Where a consumer can read the other orientation by index
1885+
/// instead, prefer that; this is the materialization step, not the model.
1886+
///
1887+
/// 32 `i64x2.shuffle`s, one per output pair.
1888+
#[inline(always)]
1889+
pub fn transpose8(rows: [Self; 8]) -> [Self; 8] {
1890+
// Four `U64x2` pairs per register: a 4×4 grid of 2×2 blocks. Block
1891+
// (bi, bj) = pair bj of rows 2bi, 2bi+1 -> pair bi of rows 2bj, 2bj+1.
1892+
core::array::from_fn(|i| {
1893+
let (bj, odd) = (i / 2, i % 2 == 1);
1894+
Self(core::array::from_fn(|bi| {
1895+
let (x, y) = (rows[2 * bi].0[bj].0, rows[2 * bi + 1].0[bj].0);
1896+
U64x2(if odd {
1897+
i64x2_shuffle::<1, 3>(x, y)
1898+
} else {
1899+
i64x2_shuffle::<0, 2>(x, y)
1900+
})
1901+
}))
1902+
})
1903+
}
1904+
18781905
/// Lane-wise population count: `i8x16_popcnt`, then the pairwise
18791906
/// widening adds up to 32-bit halves, then the two halves of each u64
18801907
/// summed (`u64x2_shr` 32 + masked add).

0 commit comments

Comments
 (0)