Skip to main content

lattice_embed/simd/
dot_product.rs

1//! SIMD float dot-product kernels, including a one-query/four-candidate path.
2//!
3//! The public operations return zero for dimensional mismatches.
4//!
5//! See docs/simd.md for dispatch and batch-kernel design.
6
7#[cfg(target_arch = "x86_64")]
8use std::arch::x86_64::*;
9
10#[cfg(target_arch = "aarch64")]
11use std::arch::aarch64::*;
12
13#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
14use std::arch::wasm32::*;
15
16use std::sync::OnceLock;
17
18use super::simd_config;
19
20/// SIMD kernel function pointer type for f32 dot product.
21pub type DotKernel = fn(&[f32], &[f32]) -> f32;
22
23static DOT_PRODUCT_KERNEL: OnceLock<DotKernel> = OnceLock::new();
24
25/// Resolve the best available f32 dot-product kernel once and return it.
26///
27/// Used by `batch_dot_product` to hoist SIMD dispatch out of batch loops.
28#[inline]
29pub fn resolved_dot_product_kernel() -> DotKernel {
30    *DOT_PRODUCT_KERNEL.get_or_init(resolve_dot_product_kernel)
31}
32
33fn resolve_dot_product_kernel() -> DotKernel {
34    let config = simd_config();
35
36    #[cfg(target_arch = "x86_64")]
37    {
38        if config.avx512f_enabled {
39            return dot_product_avx512_kernel;
40        }
41        if config.avx2_enabled && config.fma_enabled {
42            return dot_product_avx2_kernel;
43        }
44    }
45
46    #[cfg(target_arch = "aarch64")]
47    {
48        if config.neon_enabled {
49            return dot_product_neon_kernel;
50        }
51    }
52
53    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
54    {
55        if config.simd128_enabled() {
56            return dot_product_simd128_kernel;
57        }
58    }
59
60    dot_product_scalar
61}
62
63// ---------------------------------------------------------------------------
64// Batch-4 dot product kernel (query vs. 4 candidates simultaneously)
65// ---------------------------------------------------------------------------
66
67/// SIMD kernel type for batch-4 f32 dot product.
68///
69/// Signature: (query, c0, c1, c2, c3) → [dot(q,c0), dot(q,c1), dot(q,c2), dot(q,c3)].
70/// All slices must have equal length (enforced by `dot_product_batch4`).
71pub type DotBatch4Kernel = fn(&[f32], &[f32], &[f32], &[f32], &[f32]) -> [f32; 4];
72
73static DOT_PRODUCT_BATCH4_KERNEL: OnceLock<DotBatch4Kernel> = OnceLock::new();
74
75/// Resolve the best available batch-4 f32 dot-product kernel once and return it.
76///
77/// Used by HNSW expansion loop and `batch_dot_product` for same-query chunks.
78#[inline]
79pub fn resolved_dot_product_batch4_kernel() -> DotBatch4Kernel {
80    *DOT_PRODUCT_BATCH4_KERNEL.get_or_init(resolve_dot_product_batch4_kernel)
81}
82
83/// Compute dot product of one query against 4 candidates simultaneously.
84///
85/// Returns `[0.0; 4]` if any candidate length differs from the query length.
86#[inline]
87pub fn dot_product_batch4(
88    query: &[f32],
89    c0: &[f32],
90    c1: &[f32],
91    c2: &[f32],
92    c3: &[f32],
93) -> [f32; 4] {
94    if query.len() != c0.len()
95        || query.len() != c1.len()
96        || query.len() != c2.len()
97        || query.len() != c3.len()
98    {
99        debug_assert!(
100            false,
101            "dot_product_batch4: dimension mismatch (query={}, c0={}, c1={}, c2={}, c3={})",
102            query.len(),
103            c0.len(),
104            c1.len(),
105            c2.len(),
106            c3.len()
107        );
108        return [0.0; 4];
109    }
110    resolved_dot_product_batch4_kernel()(query, c0, c1, c2, c3)
111}
112
113fn resolve_dot_product_batch4_kernel() -> DotBatch4Kernel {
114    let config = simd_config();
115
116    #[cfg(target_arch = "x86_64")]
117    {
118        if config.avx2_enabled && config.fma_enabled {
119            return dot_product_batch4_avx2_kernel;
120        }
121    }
122
123    #[cfg(target_arch = "aarch64")]
124    {
125        if config.neon_enabled {
126            return dot_product_batch4_neon_kernel;
127        }
128    }
129
130    dot_product_batch4_scalar
131}
132
133/// Scalar batch-4 dot product fallback. Used when no SIMD is available.
134fn dot_product_batch4_scalar(
135    q: &[f32],
136    c0: &[f32],
137    c1: &[f32],
138    c2: &[f32],
139    c3: &[f32],
140) -> [f32; 4] {
141    let mut out = [0.0f32; 4];
142    for i in 0..q.len() {
143        let qi = q[i];
144        out[0] += qi * c0[i];
145        out[1] += qi * c1[i];
146        out[2] += qi * c2[i];
147        out[3] += qi * c3[i];
148    }
149    out
150}
151
152#[cfg(target_arch = "x86_64")]
153#[inline]
154fn dot_product_avx512_kernel(a: &[f32], b: &[f32]) -> f32 {
155    // SAFETY: only stored in DOT_PRODUCT_KERNEL when avx512f was detected at init time.
156    unsafe { dot_product_avx512_unrolled(a, b) }
157}
158
159#[cfg(target_arch = "x86_64")]
160#[inline]
161fn dot_product_avx2_kernel(a: &[f32], b: &[f32]) -> f32 {
162    // SAFETY: only stored in DOT_PRODUCT_KERNEL when avx2+fma were detected at init time.
163    if a.len() == 384 {
164        unsafe { dot_product_384_avx2(a, b) }
165    } else {
166        unsafe { dot_product_avx2_8acc(a, b) }
167    }
168}
169
170#[cfg(target_arch = "aarch64")]
171#[inline]
172fn dot_product_neon_kernel(a: &[f32], b: &[f32]) -> f32 {
173    // SAFETY: only stored in DOT_PRODUCT_KERNEL when neon was detected at init time (always true on aarch64).
174    unsafe { dot_product_neon_unrolled(a, b) }
175}
176
177#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
178#[inline]
179fn dot_product_simd128_kernel(a: &[f32], b: &[f32]) -> f32 {
180    // SAFETY: only stored in DOT_PRODUCT_KERNEL when compiled with the wasm32
181    // `simd128` target feature (the `#[cfg(target_feature = "simd128")]` gate
182    // above is compile-time, not runtime -- see `SimdConfig::simd128_enabled`).
183    unsafe { dot_product_simd128_unrolled(a, b) }
184}
185
186/// Computes the float dot product, returning `0.0` for a dimensional mismatch.
187///
188/// See [`docs/simd.md`](../../docs/simd.md#public-api-contracts) for ANN and normalization semantics.
189#[inline]
190pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
191    // Runtime length check to prevent UB in release builds
192    if a.len() != b.len() {
193        return 0.0;
194    }
195    debug_assert_eq!(a.len(), b.len());
196    resolved_dot_product_kernel()(a, b)
197}
198
199/// Scalar dot product implementation.
200#[inline]
201pub(crate) fn dot_product_scalar(a: &[f32], b: &[f32]) -> f32 {
202    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
203}
204
205/// Computes a four-accumulator dot product with AVX-512F.
206///
207/// # Safety
208/// Caller must provide AVX-512F and equal slices; chunked unaligned loads stay in bounds.
209/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for the shared kernel invariant.
210#[cfg(target_arch = "x86_64")]
211#[target_feature(enable = "avx512f")]
212unsafe fn dot_product_avx512_unrolled(a: &[f32], b: &[f32]) -> f32 {
213    const SIMD_WIDTH: usize = 16;
214    const UNROLL: usize = 4;
215    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL; // 64 floats per iteration
216
217    let n = a.len();
218    debug_assert_eq!(n, b.len());
219    let chunks = n / CHUNK_SIZE;
220
221    // 4 independent accumulators to break dependency chains
222    let mut sum0 = _mm512_setzero_ps();
223    let mut sum1 = _mm512_setzero_ps();
224    let mut sum2 = _mm512_setzero_ps();
225    let mut sum3 = _mm512_setzero_ps();
226
227    for i in 0..chunks {
228        let base = i * CHUNK_SIZE;
229
230        let a0 = _mm512_loadu_ps(a.as_ptr().add(base));
231        let b0 = _mm512_loadu_ps(b.as_ptr().add(base));
232        sum0 = _mm512_fmadd_ps(a0, b0, sum0);
233
234        let a1 = _mm512_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH));
235        let b1 = _mm512_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH));
236        sum1 = _mm512_fmadd_ps(a1, b1, sum1);
237
238        let a2 = _mm512_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 2));
239        let b2 = _mm512_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 2));
240        sum2 = _mm512_fmadd_ps(a2, b2, sum2);
241
242        let a3 = _mm512_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 3));
243        let b3 = _mm512_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 3));
244        sum3 = _mm512_fmadd_ps(a3, b3, sum3);
245    }
246
247    // Combine accumulators (dependencies are introduced only once at the end)
248    let sum01 = _mm512_add_ps(sum0, sum1);
249    let sum23 = _mm512_add_ps(sum2, sum3);
250    let sum_vec = _mm512_add_ps(sum01, sum23);
251
252    let main_sum = horizontal_sum_avx512(sum_vec);
253
254    let main_processed = chunks * CHUNK_SIZE;
255    let remaining = n - main_processed;
256    let remaining_chunks = remaining / SIMD_WIDTH;
257
258    let mut remainder_sum = _mm512_setzero_ps();
259    for i in 0..remaining_chunks {
260        let offset = main_processed + i * SIMD_WIDTH;
261        let a_vec = _mm512_loadu_ps(a.as_ptr().add(offset));
262        let b_vec = _mm512_loadu_ps(b.as_ptr().add(offset));
263        remainder_sum = _mm512_fmadd_ps(a_vec, b_vec, remainder_sum);
264    }
265
266    let mut total = main_sum + horizontal_sum_avx512(remainder_sum);
267
268    let scalar_start = main_processed + remaining_chunks * SIMD_WIDTH;
269    for i in scalar_start..n {
270        total += a[i] * b[i];
271    }
272
273    total
274}
275
276/// Horizontal sum of AVX-512 register (16 floats -> 1 float).
277///
278/// # Safety
279///
280/// Caller must ensure CPU supports AVX-512F (verified via `target_feature` gate).
281#[cfg(target_arch = "x86_64")]
282#[target_feature(enable = "avx512f")]
283#[inline]
284pub(crate) unsafe fn horizontal_sum_avx512(v: __m512) -> f32 {
285    _mm512_reduce_add_ps(v)
286}
287
288/// Computes an eight-accumulator dot product with AVX2 and FMA.
289///
290/// # Safety
291/// Caller must provide AVX2/FMA and equal slices; chunked unaligned loads stay in bounds.
292/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for the shared kernel invariant.
293#[cfg(target_arch = "x86_64")]
294#[target_feature(enable = "avx2", enable = "fma")]
295unsafe fn dot_product_avx2_8acc(a: &[f32], b: &[f32]) -> f32 {
296    const SIMD_WIDTH: usize = 8;
297    const UNROLL: usize = 8;
298    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL; // 64 floats per iteration
299    let n = a.len();
300    debug_assert_eq!(n, b.len());
301    let chunks = n / CHUNK_SIZE;
302
303    // 8 independent accumulators to break dependency chains
304    let mut sum0 = _mm256_setzero_ps();
305    let mut sum1 = _mm256_setzero_ps();
306    let mut sum2 = _mm256_setzero_ps();
307    let mut sum3 = _mm256_setzero_ps();
308    let mut sum4 = _mm256_setzero_ps();
309    let mut sum5 = _mm256_setzero_ps();
310    let mut sum6 = _mm256_setzero_ps();
311    let mut sum7 = _mm256_setzero_ps();
312
313    for i in 0..chunks {
314        let base = i * CHUNK_SIZE;
315
316        let a0 = _mm256_loadu_ps(a.as_ptr().add(base));
317        let b0 = _mm256_loadu_ps(b.as_ptr().add(base));
318        sum0 = _mm256_fmadd_ps(a0, b0, sum0);
319
320        let a1 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH));
321        let b1 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH));
322        sum1 = _mm256_fmadd_ps(a1, b1, sum1);
323
324        let a2 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 2));
325        let b2 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 2));
326        sum2 = _mm256_fmadd_ps(a2, b2, sum2);
327
328        let a3 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 3));
329        let b3 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 3));
330        sum3 = _mm256_fmadd_ps(a3, b3, sum3);
331
332        let a4 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 4));
333        let b4 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 4));
334        sum4 = _mm256_fmadd_ps(a4, b4, sum4);
335
336        let a5 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 5));
337        let b5 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 5));
338        sum5 = _mm256_fmadd_ps(a5, b5, sum5);
339
340        let a6 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 6));
341        let b6 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 6));
342        sum6 = _mm256_fmadd_ps(a6, b6, sum6);
343
344        let a7 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 7));
345        let b7 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 7));
346        sum7 = _mm256_fmadd_ps(a7, b7, sum7);
347    }
348
349    // Combine accumulators pairwise to reduce dependency chain depth
350    let sum01 = _mm256_add_ps(sum0, sum1);
351    let sum23 = _mm256_add_ps(sum2, sum3);
352    let sum45 = _mm256_add_ps(sum4, sum5);
353    let sum67 = _mm256_add_ps(sum6, sum7);
354    let sum0123 = _mm256_add_ps(sum01, sum23);
355    let sum4567 = _mm256_add_ps(sum45, sum67);
356    let sum_vec = _mm256_add_ps(sum0123, sum4567);
357
358    let sum = horizontal_sum_avx2(sum_vec);
359
360    let main_processed = chunks * CHUNK_SIZE;
361    let remaining = n - main_processed;
362    let remaining_chunks = remaining / SIMD_WIDTH;
363
364    let mut remainder_sum = _mm256_setzero_ps();
365    for i in 0..remaining_chunks {
366        let offset = main_processed + i * SIMD_WIDTH;
367        let a_vec = _mm256_loadu_ps(a.as_ptr().add(offset));
368        let b_vec = _mm256_loadu_ps(b.as_ptr().add(offset));
369        remainder_sum = _mm256_fmadd_ps(a_vec, b_vec, remainder_sum);
370    }
371
372    let mut total = sum + horizontal_sum_avx2(remainder_sum);
373
374    let scalar_start = main_processed + remaining_chunks * SIMD_WIDTH;
375    for i in scalar_start..n {
376        total += a[i] * b[i];
377    }
378
379    total
380}
381
382/// Computes the fixed-size 384-dimension AVX2/FMA dot product.
383///
384/// # Safety
385/// Caller must provide AVX2/FMA and two 384-element slices.
386/// See [`docs/simd.md`](../../docs/simd.md#dot-product) for the specialization rationale.
387#[cfg(target_arch = "x86_64")]
388#[target_feature(enable = "avx2", enable = "fma")]
389unsafe fn dot_product_384_avx2(a: &[f32], b: &[f32]) -> f32 {
390    const SIMD_WIDTH: usize = 8;
391    // 384 / 8 = 48 iterations, processed as 6 groups of 8 for accumulator reuse
392    const UNROLL: usize = 8;
393    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL; // 64 floats per iteration
394    const CHUNKS: usize = 384 / CHUNK_SIZE; // 6 full chunks
395    const TAIL_ITERS: usize = (384 - CHUNKS * CHUNK_SIZE) / SIMD_WIDTH; // 0 remainder
396
397    debug_assert_eq!(a.len(), 384);
398    debug_assert_eq!(b.len(), 384);
399    debug_assert_eq!(CHUNKS * CHUNK_SIZE + TAIL_ITERS * SIMD_WIDTH, 384);
400
401    // 8 independent accumulators
402    let mut sum0 = _mm256_setzero_ps();
403    let mut sum1 = _mm256_setzero_ps();
404    let mut sum2 = _mm256_setzero_ps();
405    let mut sum3 = _mm256_setzero_ps();
406    let mut sum4 = _mm256_setzero_ps();
407    let mut sum5 = _mm256_setzero_ps();
408    let mut sum6 = _mm256_setzero_ps();
409    let mut sum7 = _mm256_setzero_ps();
410
411    // 6 full chunks of 64 elements = 384 elements total
412    for i in 0..CHUNKS {
413        let base = i * CHUNK_SIZE;
414
415        let a0 = _mm256_loadu_ps(a.as_ptr().add(base));
416        let b0 = _mm256_loadu_ps(b.as_ptr().add(base));
417        sum0 = _mm256_fmadd_ps(a0, b0, sum0);
418
419        let a1 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH));
420        let b1 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH));
421        sum1 = _mm256_fmadd_ps(a1, b1, sum1);
422
423        let a2 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 2));
424        let b2 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 2));
425        sum2 = _mm256_fmadd_ps(a2, b2, sum2);
426
427        let a3 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 3));
428        let b3 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 3));
429        sum3 = _mm256_fmadd_ps(a3, b3, sum3);
430
431        let a4 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 4));
432        let b4 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 4));
433        sum4 = _mm256_fmadd_ps(a4, b4, sum4);
434
435        let a5 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 5));
436        let b5 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 5));
437        sum5 = _mm256_fmadd_ps(a5, b5, sum5);
438
439        let a6 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 6));
440        let b6 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 6));
441        sum6 = _mm256_fmadd_ps(a6, b6, sum6);
442
443        let a7 = _mm256_loadu_ps(a.as_ptr().add(base + SIMD_WIDTH * 7));
444        let b7 = _mm256_loadu_ps(b.as_ptr().add(base + SIMD_WIDTH * 7));
445        sum7 = _mm256_fmadd_ps(a7, b7, sum7);
446    }
447
448    // Combine accumulators pairwise
449    let sum01 = _mm256_add_ps(sum0, sum1);
450    let sum23 = _mm256_add_ps(sum2, sum3);
451    let sum45 = _mm256_add_ps(sum4, sum5);
452    let sum67 = _mm256_add_ps(sum6, sum7);
453    let sum0123 = _mm256_add_ps(sum01, sum23);
454    let sum4567 = _mm256_add_ps(sum45, sum67);
455    let sum_vec = _mm256_add_ps(sum0123, sum4567);
456
457    horizontal_sum_avx2(sum_vec)
458}
459
460/// Horizontal sum of AVX2 register (8 floats -> 1 float).
461///
462/// # Safety
463///
464/// Caller must ensure CPU supports AVX2 (verified via `target_feature` gate).
465#[cfg(target_arch = "x86_64")]
466#[target_feature(enable = "avx2")]
467#[inline]
468pub(crate) unsafe fn horizontal_sum_avx2(v: __m256) -> f32 {
469    // Sum high and low 128-bit lanes
470    let high = _mm256_extractf128_ps(v, 1);
471    let low = _mm256_castps256_ps128(v);
472    let sum128 = _mm_add_ps(high, low);
473
474    // Horizontal add within 128-bit
475    let shuf = _mm_movehdup_ps(sum128); // [1,1,3,3]
476    let sums = _mm_add_ps(sum128, shuf); // [0+1,1+1,2+3,3+3]
477    let shuf2 = _mm_movehl_ps(sums, sums); // [2+3,3+3,2+3,3+3]
478    let sums2 = _mm_add_ss(sums, shuf2); // [0+1+2+3,...]
479
480    _mm_cvtss_f32(sums2)
481}
482
483/// AVX2 batch-4 dot product kernel wrapper (routes to 384-specialized or general path).
484///
485/// # Safety
486///
487/// Only stored in `DOT_PRODUCT_BATCH4_KERNEL` when AVX2+FMA was detected at init time.
488#[cfg(target_arch = "x86_64")]
489#[inline]
490fn dot_product_batch4_avx2_kernel(
491    q: &[f32],
492    c0: &[f32],
493    c1: &[f32],
494    c2: &[f32],
495    c3: &[f32],
496) -> [f32; 4] {
497    if q.len() == 384 {
498        unsafe { dot_product_384_batch4_avx2(q, c0, c1, c2, c3) }
499    } else {
500        unsafe { dot_product_batch4_avx2(q, c0, c1, c2, c3) }
501    }
502}
503
504/// Computes one 384-dimension query against four candidates with AVX2/FMA.
505///
506/// # Safety
507/// Caller must provide AVX2/FMA and five 384-element slices.
508/// See [`docs/simd.md`](../../docs/simd.md#dot-product) for the batch-kernel layout.
509#[cfg(target_arch = "x86_64")]
510#[target_feature(enable = "avx2", enable = "fma")]
511unsafe fn dot_product_384_batch4_avx2(
512    q: &[f32],
513    c0: &[f32],
514    c1: &[f32],
515    c2: &[f32],
516    c3: &[f32],
517) -> [f32; 4] {
518    const W: usize = 8; // floats per AVX2 register
519    const CHUNK: usize = W * 2; // 16 floats per loop (2 query loads reused across 4 candidates)
520    const CHUNKS: usize = 384 / CHUNK; // 24 chunks, zero remainder
521
522    debug_assert_eq!(q.len(), 384);
523
524    let mut acc00 = _mm256_setzero_ps();
525    let mut acc01 = _mm256_setzero_ps();
526    let mut acc10 = _mm256_setzero_ps();
527    let mut acc11 = _mm256_setzero_ps();
528    let mut acc20 = _mm256_setzero_ps();
529    let mut acc21 = _mm256_setzero_ps();
530    let mut acc30 = _mm256_setzero_ps();
531    let mut acc31 = _mm256_setzero_ps();
532
533    for i in 0..CHUNKS {
534        let base = i * CHUNK;
535        let q0 = _mm256_loadu_ps(q.as_ptr().add(base));
536        let q1 = _mm256_loadu_ps(q.as_ptr().add(base + W));
537
538        acc00 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c0.as_ptr().add(base)), acc00);
539        acc01 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c0.as_ptr().add(base + W)), acc01);
540        acc10 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c1.as_ptr().add(base)), acc10);
541        acc11 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c1.as_ptr().add(base + W)), acc11);
542        acc20 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c2.as_ptr().add(base)), acc20);
543        acc21 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c2.as_ptr().add(base + W)), acc21);
544        acc30 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c3.as_ptr().add(base)), acc30);
545        acc31 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c3.as_ptr().add(base + W)), acc31);
546    }
547
548    [
549        horizontal_sum_avx2(_mm256_add_ps(acc00, acc01)),
550        horizontal_sum_avx2(_mm256_add_ps(acc10, acc11)),
551        horizontal_sum_avx2(_mm256_add_ps(acc20, acc21)),
552        horizontal_sum_avx2(_mm256_add_ps(acc30, acc31)),
553    ]
554}
555
556/// Computes one query against four candidates with AVX2/FMA.
557///
558/// # Safety
559/// Caller must provide AVX2/FMA and five equal-length slices; bounds are chunked.
560/// See [`docs/simd.md`](../../docs/simd.md#dot-product) for the reuse and accumulator strategy.
561#[cfg(target_arch = "x86_64")]
562#[target_feature(enable = "avx2", enable = "fma")]
563unsafe fn dot_product_batch4_avx2(
564    q: &[f32],
565    c0: &[f32],
566    c1: &[f32],
567    c2: &[f32],
568    c3: &[f32],
569) -> [f32; 4] {
570    const W: usize = 8;
571    const CHUNK: usize = W * 2; // 16 floats per loop
572
573    let n = q.len();
574    let chunks = n / CHUNK;
575
576    let mut acc00 = _mm256_setzero_ps();
577    let mut acc01 = _mm256_setzero_ps();
578    let mut acc10 = _mm256_setzero_ps();
579    let mut acc11 = _mm256_setzero_ps();
580    let mut acc20 = _mm256_setzero_ps();
581    let mut acc21 = _mm256_setzero_ps();
582    let mut acc30 = _mm256_setzero_ps();
583    let mut acc31 = _mm256_setzero_ps();
584
585    for i in 0..chunks {
586        let base = i * CHUNK;
587        let q0 = _mm256_loadu_ps(q.as_ptr().add(base));
588        let q1 = _mm256_loadu_ps(q.as_ptr().add(base + W));
589
590        acc00 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c0.as_ptr().add(base)), acc00);
591        acc01 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c0.as_ptr().add(base + W)), acc01);
592        acc10 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c1.as_ptr().add(base)), acc10);
593        acc11 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c1.as_ptr().add(base + W)), acc11);
594        acc20 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c2.as_ptr().add(base)), acc20);
595        acc21 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c2.as_ptr().add(base + W)), acc21);
596        acc30 = _mm256_fmadd_ps(q0, _mm256_loadu_ps(c3.as_ptr().add(base)), acc30);
597        acc31 = _mm256_fmadd_ps(q1, _mm256_loadu_ps(c3.as_ptr().add(base + W)), acc31);
598    }
599
600    let mut out = [
601        horizontal_sum_avx2(_mm256_add_ps(acc00, acc01)),
602        horizontal_sum_avx2(_mm256_add_ps(acc10, acc11)),
603        horizontal_sum_avx2(_mm256_add_ps(acc20, acc21)),
604        horizontal_sum_avx2(_mm256_add_ps(acc30, acc31)),
605    ];
606
607    let scalar_start = chunks * CHUNK;
608    for i in scalar_start..n {
609        let qi = q[i];
610        out[0] += qi * c0[i];
611        out[1] += qi * c1[i];
612        out[2] += qi * c2[i];
613        out[3] += qi * c3[i];
614    }
615
616    out
617}
618
619/// Computes a four-accumulator dot product with NEON.
620///
621/// # Safety
622/// Caller must run on aarch64 with equal slices; chunked loads stay in bounds.
623/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for the shared kernel invariant.
624#[cfg(target_arch = "aarch64")]
625#[inline]
626unsafe fn dot_product_neon_unrolled(a: &[f32], b: &[f32]) -> f32 {
627    const SIMD_WIDTH: usize = 4;
628    const UNROLL: usize = 4;
629    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL; // 16 floats per iteration
630    let n = a.len();
631    debug_assert_eq!(n, b.len());
632    let chunks = n / CHUNK_SIZE;
633
634    // 4 independent accumulators
635    let mut sum0 = vdupq_n_f32(0.0);
636    let mut sum1 = vdupq_n_f32(0.0);
637    let mut sum2 = vdupq_n_f32(0.0);
638    let mut sum3 = vdupq_n_f32(0.0);
639
640    for i in 0..chunks {
641        let base = i * CHUNK_SIZE;
642
643        let a0 = vld1q_f32(a.as_ptr().add(base));
644        let b0 = vld1q_f32(b.as_ptr().add(base));
645        sum0 = vfmaq_f32(sum0, a0, b0);
646
647        let a1 = vld1q_f32(a.as_ptr().add(base + SIMD_WIDTH));
648        let b1 = vld1q_f32(b.as_ptr().add(base + SIMD_WIDTH));
649        sum1 = vfmaq_f32(sum1, a1, b1);
650
651        let a2 = vld1q_f32(a.as_ptr().add(base + SIMD_WIDTH * 2));
652        let b2 = vld1q_f32(b.as_ptr().add(base + SIMD_WIDTH * 2));
653        sum2 = vfmaq_f32(sum2, a2, b2);
654
655        let a3 = vld1q_f32(a.as_ptr().add(base + SIMD_WIDTH * 3));
656        let b3 = vld1q_f32(b.as_ptr().add(base + SIMD_WIDTH * 3));
657        sum3 = vfmaq_f32(sum3, a3, b3);
658    }
659
660    // Combine accumulators
661    let sum01 = vaddq_f32(sum0, sum1);
662    let sum23 = vaddq_f32(sum2, sum3);
663    let sum_vec = vaddq_f32(sum01, sum23);
664
665    let mut sum = horizontal_sum_neon(sum_vec);
666
667    let main_processed = chunks * CHUNK_SIZE;
668    let remaining = n - main_processed;
669    let remaining_chunks = remaining / SIMD_WIDTH;
670
671    let mut remainder_sum = vdupq_n_f32(0.0);
672    for i in 0..remaining_chunks {
673        let offset = main_processed + i * SIMD_WIDTH;
674        let a_vec = vld1q_f32(a.as_ptr().add(offset));
675        let b_vec = vld1q_f32(b.as_ptr().add(offset));
676        remainder_sum = vfmaq_f32(remainder_sum, a_vec, b_vec);
677    }
678
679    sum += horizontal_sum_neon(remainder_sum);
680
681    let scalar_start = main_processed + remaining_chunks * SIMD_WIDTH;
682    for i in scalar_start..n {
683        sum += a[i] * b[i];
684    }
685
686    sum
687}
688
689/// Horizontal sum of NEON register (4 floats -> 1 float).
690///
691/// # Safety
692///
693/// Caller must ensure running on aarch64 (NEON is mandatory on this arch).
694#[cfg(target_arch = "aarch64")]
695#[inline]
696pub(crate) unsafe fn horizontal_sum_neon(v: float32x4_t) -> f32 {
697    vaddvq_f32(v)
698}
699
700/// Computes a four-accumulator dot product with wasm32 SIMD128.
701///
702/// # Safety
703/// This function requires compile-time SIMD128 and equal slices; bounds are chunked.
704/// See [`docs/simd.md`](../../docs/simd.md#kernel-safety-boundary) for wasm and reassociation semantics.
705#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
706#[inline]
707unsafe fn dot_product_simd128_unrolled(a: &[f32], b: &[f32]) -> f32 {
708    const SIMD_WIDTH: usize = 4;
709    const UNROLL: usize = 4;
710    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL; // 16 floats per iteration
711
712    let n = a.len();
713    debug_assert_eq!(n, b.len());
714    let chunks = n / CHUNK_SIZE;
715
716    // 4 independent accumulators to break dependency chains
717    let mut sum0 = f32x4_splat(0.0);
718    let mut sum1 = f32x4_splat(0.0);
719    let mut sum2 = f32x4_splat(0.0);
720    let mut sum3 = f32x4_splat(0.0);
721
722    for i in 0..chunks {
723        let base = i * CHUNK_SIZE;
724
725        let a0 = v128_load(a.as_ptr().add(base) as *const v128);
726        let b0 = v128_load(b.as_ptr().add(base) as *const v128);
727        sum0 = f32x4_add(sum0, f32x4_mul(a0, b0));
728
729        let a1 = v128_load(a.as_ptr().add(base + SIMD_WIDTH) as *const v128);
730        let b1 = v128_load(b.as_ptr().add(base + SIMD_WIDTH) as *const v128);
731        sum1 = f32x4_add(sum1, f32x4_mul(a1, b1));
732
733        let a2 = v128_load(a.as_ptr().add(base + SIMD_WIDTH * 2) as *const v128);
734        let b2 = v128_load(b.as_ptr().add(base + SIMD_WIDTH * 2) as *const v128);
735        sum2 = f32x4_add(sum2, f32x4_mul(a2, b2));
736
737        let a3 = v128_load(a.as_ptr().add(base + SIMD_WIDTH * 3) as *const v128);
738        let b3 = v128_load(b.as_ptr().add(base + SIMD_WIDTH * 3) as *const v128);
739        sum3 = f32x4_add(sum3, f32x4_mul(a3, b3));
740    }
741
742    // Combine accumulators (dependencies are introduced only once at the end)
743    let sum01 = f32x4_add(sum0, sum1);
744    let sum23 = f32x4_add(sum2, sum3);
745    let sum_vec = f32x4_add(sum01, sum23);
746
747    let mut total = horizontal_sum_simd128(sum_vec);
748
749    let main_processed = chunks * CHUNK_SIZE;
750    let remaining = n - main_processed;
751    let remaining_chunks = remaining / SIMD_WIDTH;
752
753    let mut remainder_sum = f32x4_splat(0.0);
754    for i in 0..remaining_chunks {
755        let offset = main_processed + i * SIMD_WIDTH;
756        let a_vec = v128_load(a.as_ptr().add(offset) as *const v128);
757        let b_vec = v128_load(b.as_ptr().add(offset) as *const v128);
758        remainder_sum = f32x4_add(remainder_sum, f32x4_mul(a_vec, b_vec));
759    }
760
761    total += horizontal_sum_simd128(remainder_sum);
762
763    let scalar_start = main_processed + remaining_chunks * SIMD_WIDTH;
764    for i in scalar_start..n {
765        total += a[i] * b[i];
766    }
767
768    total
769}
770
771/// Horizontal sum of a wasm32 SIMD128 register (4 floats -> 1 float).
772///
773/// # Safety
774///
775/// Caller must ensure the crate was compiled with the wasm32 `simd128` target
776/// feature (this function only exists under `#[cfg(target_feature =
777/// "simd128")]`).
778#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
779#[inline]
780pub(crate) unsafe fn horizontal_sum_simd128(v: v128) -> f32 {
781    f32x4_extract_lane::<0>(v)
782        + f32x4_extract_lane::<1>(v)
783        + f32x4_extract_lane::<2>(v)
784        + f32x4_extract_lane::<3>(v)
785}
786
787/// NEON batch-4 dot product kernel wrapper.
788///
789/// # Safety
790///
791/// Only stored in `DOT_PRODUCT_BATCH4_KERNEL` when NEON is detected (always on aarch64).
792#[cfg(target_arch = "aarch64")]
793#[inline]
794fn dot_product_batch4_neon_kernel(
795    q: &[f32],
796    c0: &[f32],
797    c1: &[f32],
798    c2: &[f32],
799    c3: &[f32],
800) -> [f32; 4] {
801    // SAFETY: only stored when NEON detected, which is mandatory on aarch64.
802    unsafe { dot_product_batch4_neon(q, c0, c1, c2, c3) }
803}
804
805/// Computes one query against four candidates with NEON.
806///
807/// # Safety
808/// Caller must run on aarch64 with five equal-length slices; bounds are chunked.
809/// See [`docs/simd.md`](../../docs/simd.md#dot-product) for the reuse and accumulator strategy.
810#[cfg(target_arch = "aarch64")]
811#[inline]
812unsafe fn dot_product_batch4_neon(
813    q: &[f32],
814    c0: &[f32],
815    c1: &[f32],
816    c2: &[f32],
817    c3: &[f32],
818) -> [f32; 4] {
819    const W: usize = 4; // floats per NEON register
820    const CHUNK: usize = W * 2; // 8 floats per loop (2 NEON loads from query)
821
822    let n = q.len();
823    let chunks = n / CHUNK;
824
825    let mut acc00 = vdupq_n_f32(0.0);
826    let mut acc01 = vdupq_n_f32(0.0);
827    let mut acc10 = vdupq_n_f32(0.0);
828    let mut acc11 = vdupq_n_f32(0.0);
829    let mut acc20 = vdupq_n_f32(0.0);
830    let mut acc21 = vdupq_n_f32(0.0);
831    let mut acc30 = vdupq_n_f32(0.0);
832    let mut acc31 = vdupq_n_f32(0.0);
833
834    for i in 0..chunks {
835        let base = i * CHUNK;
836        let q0 = vld1q_f32(q.as_ptr().add(base));
837        let q1 = vld1q_f32(q.as_ptr().add(base + W));
838
839        acc00 = vfmaq_f32(acc00, q0, vld1q_f32(c0.as_ptr().add(base)));
840        acc01 = vfmaq_f32(acc01, q1, vld1q_f32(c0.as_ptr().add(base + W)));
841        acc10 = vfmaq_f32(acc10, q0, vld1q_f32(c1.as_ptr().add(base)));
842        acc11 = vfmaq_f32(acc11, q1, vld1q_f32(c1.as_ptr().add(base + W)));
843        acc20 = vfmaq_f32(acc20, q0, vld1q_f32(c2.as_ptr().add(base)));
844        acc21 = vfmaq_f32(acc21, q1, vld1q_f32(c2.as_ptr().add(base + W)));
845        acc30 = vfmaq_f32(acc30, q0, vld1q_f32(c3.as_ptr().add(base)));
846        acc31 = vfmaq_f32(acc31, q1, vld1q_f32(c3.as_ptr().add(base + W)));
847    }
848
849    let mut out = [
850        vaddvq_f32(vaddq_f32(acc00, acc01)),
851        vaddvq_f32(vaddq_f32(acc10, acc11)),
852        vaddvq_f32(vaddq_f32(acc20, acc21)),
853        vaddvq_f32(vaddq_f32(acc30, acc31)),
854    ];
855
856    let scalar_start = chunks * CHUNK;
857    for i in scalar_start..n {
858        let qi = q[i];
859        out[0] += qi * c0[i];
860        out[1] += qi * c1[i];
861        out[2] += qi * c2[i];
862        out[3] += qi * c3[i];
863    }
864
865    out
866}
867
868/// Returns true only when all 4 pairs in a chunk share the same query pointer and all lengths match.
869#[inline]
870fn same_query_batch4(chunk: &[(&[f32], &[f32])]) -> bool {
871    debug_assert_eq!(chunk.len(), 4);
872    let q_ptr = chunk[0].0.as_ptr();
873    let q_len = chunk[0].0.len();
874    q_len == chunk[0].1.len()
875        && chunk
876            .iter()
877            .all(|(q, c)| q.as_ptr() == q_ptr && q.len() == q_len && c.len() == q_len)
878}
879
880/// **Unstable**: batched dot-product dispatch with a same-query fast path.
881pub fn batch_dot_product(pairs: &[(&[f32], &[f32])]) -> Vec<f32> {
882    let pair_kernel = resolved_dot_product_kernel();
883    let batch4_kernel = resolved_dot_product_batch4_kernel();
884    let mut out = Vec::with_capacity(pairs.len());
885
886    let mut chunks = pairs.chunks_exact(4);
887    for chunk in &mut chunks {
888        if same_query_batch4(chunk) {
889            let q = chunk[0].0;
890            let dots = batch4_kernel(q, chunk[0].1, chunk[1].1, chunk[2].1, chunk[3].1);
891            out.extend_from_slice(&dots);
892        } else {
893            for &(a, b) in chunk {
894                out.push(if a.len() == b.len() {
895                    pair_kernel(a, b)
896                } else {
897                    0.0
898                });
899            }
900        }
901    }
902    for &(a, b) in chunks.remainder() {
903        out.push(if a.len() == b.len() {
904            pair_kernel(a, b)
905        } else {
906            0.0
907        });
908    }
909    out
910}