Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/blackboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -3827,3 +3827,25 @@ back into clippy::all), fmt clean. G5 asm identity: generic == hand-written on x
negative control differs. G6 2% timing band not resolvable here (identical code varies up to 17%);
recorded as NOT MET, G5 is the evidence. Results in plan §11. Next: phase (b) SIMD primitives in
src/simd_*.rs under W1a.

## U16x32 facade gaps filled on every backend (2026-10-29, branch claude/u16x32-facade-gaps)

Trigger: turbovec's AVX2 FastScan kernel is being rewritten against `ndarray::simd` at 64-byte width
(U8x64 -> U16x32 -> F32x16). U16x32 had a different method set per backend and no u16 -> f32 path.

Added on AVX-512, AVX2, AVX (simd_avx.rs, 128-bit quarters), scalar and nightly: `zero`, `simd_min/max`,
`saturating_add/sub`, `cmpeq_mask`/`cmpgt_mask` (u32), `reduce_min/max`, `to_f32x16_lo/hi`; on AVX-512 the bit
operators, `Not`, `SubAssign`, `Default`; on nightly the widening/shift/mullo/pack methods. `reduce_sum` returns
`u16` (wrapping) everywhere; AVX-512 returned `u32`.

Bugs found by the new parity test (`simd::u16x32_facade_parity`), both pre-existing on AVX-512 and now fixed:
`shl`/`shr` returned 0 for every count except 1, 2, 4, 8 (including 0); `pack_saturate_u8` used `packus_epi16`,
which saturates as signed and interleaves its inputs per 128-bit lane.

Witnessed: parity 4/4 on AVX-512 (native), AVX2 (config-v3), AVX (config-avx, qemu SandyBridge), scalar (config-v2,
qemu Nehalem) and nightly-simd. Lib suite 2653 passed. clippy -D warnings clean on default/v3/v2; the AVX arm's
clippy errors are all pre-existing (same set on master). Disable run: AVX2 cmpgt as `>=` -> parity red.

Trap recorded: `cargo --config <file> clippy` DROPS the config (clippy is an external subcommand); the v3 lint pass
reported clean in 0.05s while linting the native build. Put `--config` after the subcommand:
`cargo clippy --config .cargo/config-v3.toml`. `cargo --config <file> test` does apply it.
119 changes: 119 additions & 0 deletions src/simd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2194,3 +2194,122 @@ mod tests {
}
}
}

/// Parity of the `U16x32` surface against a plain-array reference, run on
/// whichever backend this build selected (AVX-512, AVX2, AVX, scalar, NEON,
/// wasm or nightly). The corpus mixes a fixed-seed generator with the edge
/// values a u16 lane can hit: 0, 1, 255, 256, 0x7FFF, 0x8000, 0xFFFF.
#[cfg(test)]
mod u16x32_facade_parity {
use super::{F32x16, U16x32, U8x64};

const EDGES: [u16; 7] = [0, 1, 255, 256, 0x7FFF, 0x8000, 0xFFFF];

fn corpus() -> Vec<[u16; 32]> {
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(state >> 48) as u16
};
let mut out: Vec<[u16; 32]> = (0..64).map(|_| core::array::from_fn(|_| next())).collect();
for &e in &EDGES {
out.push([e; 32]);
}
out.push(core::array::from_fn(|i| EDGES[i % EDGES.len()]));
out.push(core::array::from_fn(|i| EDGES[(i * 3 + 1) % EDGES.len()]));
out
}

fn v(a: [u16; 32]) -> U16x32 {
U16x32::from_array(a)
}

#[test]
fn elementwise_ops_match_reference() {
let c = corpus();
let mut checked = 0;
for a in &c {
for b in &c {
let (x, y) = (v(*a), v(*b));
let r = |f: fn(u16, u16) -> u16| -> [u16; 32] { core::array::from_fn(|i| f(a[i], b[i])) };
assert_eq!(x.simd_min(y).to_array(), r(u16::min), "simd_min {a:?} {b:?}");
assert_eq!(x.simd_max(y).to_array(), r(u16::max), "simd_max");
assert_eq!(x.saturating_add(y).to_array(), r(u16::saturating_add), "saturating_add");
assert_eq!(x.saturating_sub(y).to_array(), r(u16::saturating_sub), "saturating_sub");
assert_eq!(x.mullo(y).to_array(), r(u16::wrapping_mul), "mullo");
assert_eq!((x + y).to_array(), r(u16::wrapping_add), "add");
assert_eq!((x - y).to_array(), r(u16::wrapping_sub), "sub");
let eq = (0..32).fold(0u32, |m, i| m | (((a[i] == b[i]) as u32) << i));
let gt = (0..32).fold(0u32, |m, i| m | (((a[i] > b[i]) as u32) << i));
assert_eq!(x.cmpeq_mask(y), eq, "cmpeq_mask {a:?} {b:?}");
assert_eq!(x.cmpgt_mask(y), gt, "cmpgt_mask {a:?} {b:?}");
checked += 1;
}
}
assert!(checked > 5000, "corpus too small: {checked}");
}

#[test]
fn reductions_and_conversions_match_reference() {
for a in corpus() {
let x = v(a);
assert_eq!(x.reduce_min(), *a.iter().min().unwrap(), "reduce_min {a:?}");
assert_eq!(x.reduce_max(), *a.iter().max().unwrap(), "reduce_max {a:?}");
assert_eq!(x.reduce_sum(), a.iter().fold(0u16, |s, &l| s.wrapping_add(l)), "reduce_sum {a:?}");
let lo: [f32; 16] = core::array::from_fn(|i| a[i] as f32);
let hi: [f32; 16] = core::array::from_fn(|i| a[16 + i] as f32);
assert_eq!(x.to_f32x16_lo().to_array(), lo, "to_f32x16_lo {a:?}");
assert_eq!(x.to_f32x16_hi().to_array(), hi, "to_f32x16_hi {a:?}");
for s in [0u32, 1, 7, 8, 15, 16, 31] {
let shl: [u16; 32] = core::array::from_fn(|i| if s < 16 { a[i] << s } else { 0 });
let shr: [u16; 32] = core::array::from_fn(|i| if s < 16 { a[i] >> s } else { 0 });
assert_eq!(x.shl(s).to_array(), shl, "shl {s}");
assert_eq!(x.shr(s).to_array(), shr, "shr {s}");
}
}
assert_eq!(U16x32::zero().to_array(), [0u16; 32]);
let _: F32x16 = U16x32::zero().to_f32x16_lo();
}

#[test]
fn widening_and_narrowing_match_reference() {
let bytes: [u8; 64] = core::array::from_fn(|i| (i as u8).wrapping_mul(37).wrapping_add(200));
let b = U8x64::from_array(bytes);
let lo: [u16; 32] = core::array::from_fn(|i| bytes[i] as u16);
let hi: [u16; 32] = core::array::from_fn(|i| bytes[32 + i] as u16);
assert_eq!(U16x32::from_u8x64_lo(b).to_array(), lo);
assert_eq!(U16x32::from_u8x64_hi(b).to_array(), hi);
for a in corpus() {
let other: [u16; 32] = core::array::from_fn(|i| a[31 - i]);
let want: [u8; 64] = core::array::from_fn(|i| {
if i < 32 {
a[i].min(255) as u8
} else {
other[i - 32].min(255) as u8
}
});
assert_eq!(v(a).pack_saturate_u8(v(other)).to_array(), want, "pack_saturate_u8 {a:?}");
}
}

#[test]
fn bitwise_ops_match_reference() {
let c = corpus();
for a in &c {
for b in c.iter().take(8) {
let (x, y) = (v(*a), v(*b));
let r = |f: fn(u16, u16) -> u16| -> [u16; 32] { core::array::from_fn(|i| f(a[i], b[i])) };
assert_eq!((x & y).to_array(), r(|p, q| p & q));
assert_eq!((x | y).to_array(), r(|p, q| p | q));
assert_eq!((x ^ y).to_array(), r(|p, q| p ^ q));
let mut z = x;
z -= y;
assert_eq!(z.to_array(), r(u16::wrapping_sub));
}
assert_eq!((!v(*a)).to_array(), a.map(|l| !l));
}
assert_eq!(U16x32::default().to_array(), [0u16; 32]);
}
}
154 changes: 153 additions & 1 deletion src/simd_avx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use core::ops::{
Add, AddAssign, BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Mul, Not, Shl, Shr, Sub, SubAssign,
};

use crate::simd_avx2::{I32x16, U16x16, U64x8, U8x32};
use crate::simd_avx2::{F32x16, I32x16, U16x16, U16x32, U64x8, U8x32};
use crate::simd_avx512::{F32x8, I16x16, I8x32};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -815,3 +815,155 @@ impl SubAssign for I16x16 {
*self = I16x16::sub(*self, rhs);
}
}

// ---------------------------------------------------------------------------
// U16x32 (64-byte array) — the AVX2-gated methods, as four 128-bit quarters
// ---------------------------------------------------------------------------

impl U16x32 {
/// The four 128-bit quarters of the 64-byte array (lanes 0..8, 8..16,
/// 16..24, 24..32).
#[inline(always)]
fn quarters(self) -> [__m128i; 4] {
// SAFETY: SSE2 is compiled in (module docs). The array is 64 bytes, so
// four 16-byte unaligned loads are in bounds.
unsafe {
let p = self.0.as_ptr() as *const __m128i;
[_mm_loadu_si128(p), _mm_loadu_si128(p.add(1)), _mm_loadu_si128(p.add(2)), _mm_loadu_si128(p.add(3))]
}
}

#[inline(always)]
fn from_quarters(q: [__m128i; 4]) -> Self {
let mut o = [0u16; 32];
// SAFETY: four 16-byte stores into a 64-byte array.
unsafe {
let p = o.as_mut_ptr() as *mut __m128i;
_mm_storeu_si128(p, q[0]);
_mm_storeu_si128(p.add(1), q[1]);
_mm_storeu_si128(p.add(2), q[2]);
_mm_storeu_si128(p.add(3), q[3]);
}
Self(o)
}

#[inline(always)]
fn zip(self, other: Self, f: impl Fn(__m128i, __m128i) -> __m128i) -> Self {
let a = self.quarters();
let b = other.quarters();
Self::from_quarters([f(a[0], b[0]), f(a[1], b[1]), f(a[2], b[2]), f(a[3], b[3])])
}

/// One bit per lane for two adjacent quarters of compare results.
#[inline(always)]
fn bits16(lo: __m128i, hi: __m128i) -> u32 {
// SAFETY: SSE2 is compiled in (module docs); register-only ops.
unsafe { _mm_movemask_epi8(_mm_packs_epi16(lo, hi)) as u32 & 0xFFFF }
}

#[inline(always)]
fn mask(self, other: Self, f: impl Fn(__m128i, __m128i) -> __m128i) -> u32 {
let a = self.quarters();
let b = other.quarters();
Self::bits16(f(a[0], b[0]), f(a[1], b[1])) | (Self::bits16(f(a[2], b[2]), f(a[3], b[3])) << 16)
}

/// Per-lane unsigned minimum.
#[inline(always)]
pub fn simd_min(self, other: Self) -> Self {
// SAFETY: SSE4.1 is compiled in (module docs); register-only op.
self.zip(other, |a, b| unsafe { _mm_min_epu16(a, b) })
}

/// Per-lane unsigned maximum.
#[inline(always)]
pub fn simd_max(self, other: Self) -> Self {
// SAFETY: SSE4.1 is compiled in (module docs); register-only op.
self.zip(other, |a, b| unsafe { _mm_max_epu16(a, b) })
}

/// Per-lane addition clamped at `u16::MAX`.
#[inline(always)]
pub fn saturating_add(self, other: Self) -> Self {
// SAFETY: SSE2 is compiled in (module docs); register-only op.
self.zip(other, |a, b| unsafe { _mm_adds_epu16(a, b) })
}

/// Per-lane subtraction clamped at 0.
#[inline(always)]
pub fn saturating_sub(self, other: Self) -> Self {
// SAFETY: SSE2 is compiled in (module docs); register-only op.
self.zip(other, |a, b| unsafe { _mm_subs_epu16(a, b) })
}

/// Per-lane equality as a bitmask, lane `i` in bit `i`.
#[inline(always)]
pub fn cmpeq_mask(self, other: Self) -> u32 {
// SAFETY: SSE2 is compiled in (module docs); register-only op.
self.mask(other, |a, b| unsafe { _mm_cmpeq_epi16(a, b) })
}

/// Per-lane unsigned `self > other` as a bitmask, lane `i` in bit `i`.
#[inline(always)]
pub fn cmpgt_mask(self, other: Self) -> u32 {
// `a > b` unsigned is `max(a, b) == a` and `a != b`.
// SAFETY: SSE4.1 is compiled in (module docs); register-only ops.
self.mask(other, |a, b| unsafe {
_mm_andnot_si128(_mm_cmpeq_epi16(a, b), _mm_cmpeq_epi16(_mm_max_epu16(a, b), a))
})
}

/// Smallest lane.
#[inline(always)]
pub fn reduce_min(self) -> u16 {
let q = self.quarters();
// SAFETY: SSE4.1 is compiled in (module docs); register-only ops.
unsafe {
let m = _mm_min_epu16(_mm_min_epu16(q[0], q[1]), _mm_min_epu16(q[2], q[3]));
(_mm_cvtsi128_si32(_mm_minpos_epu16(m)) & 0xFFFF) as u16
}
}

/// Largest lane.
#[inline(always)]
pub fn reduce_max(self) -> u16 {
let q = self.quarters();
// max(x) == !min(!x).
// SAFETY: SSE4.1 is compiled in (module docs); register-only ops.
unsafe {
let m = _mm_max_epu16(_mm_max_epu16(q[0], q[1]), _mm_max_epu16(q[2], q[3]));
let inv = _mm_xor_si128(m, _mm_set1_epi32(-1));
!((_mm_cvtsi128_si32(_mm_minpos_epu16(inv)) & 0xFFFF) as u16)
}
}

/// Lanes 0..16 converted to `f32`.
#[inline(always)]
pub fn to_f32x16_lo(self) -> F32x16 {
let q = self.quarters();
Self::quarters_to_f32x16(q[0], q[1])
}

/// Lanes 16..32 converted to `f32`.
#[inline(always)]
pub fn to_f32x16_hi(self) -> F32x16 {
let q = self.quarters();
Self::quarters_to_f32x16(q[2], q[3])
}

#[inline(always)]
fn quarters_to_f32x16(a: __m128i, b: __m128i) -> F32x16 {
// SAFETY: SSE4.1 and AVX are compiled in (module docs); register-only
// ops. `cvtepu16_epi32` widens the low four lanes; the high four are
// brought down by a whole-register 8-byte shift, which moves no byte
// within a lane.
unsafe {
let f = |v: __m128i| -> __m256 {
let lo = _mm_cvtepi32_ps(_mm_cvtepu16_epi32(v));
let hi = _mm_cvtepi32_ps(_mm_cvtepu16_epi32(_mm_srli_si128::<8>(v)));
_mm256_set_m128(hi, lo)
};
F32x16(F32x8(f(a)), F32x8(f(b)))
}
}
}
Loading
Loading