Skip to main content

lattice_embed/simd/
dot_product.rs

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