Skip to main content

lance_linalg/distance/
l2.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! L2 (Euclidean) distance.
5//!
6
7use std::iter::Sum;
8use std::ops::AddAssign;
9use std::sync::Arc;
10
11use crate::{Error, Result};
12use arrow_array::{
13    Array, FixedSizeListArray, Float32Array,
14    cast::AsArray,
15    types::{Float16Type, Float32Type, Float64Type, Int8Type},
16};
17use arrow_schema::DataType;
18use half::{bf16, f16};
19use lance_arrow::{ArrowFloatType, FixedSizeListArrayExt, FloatArray};
20use lance_core::assume_eq;
21use lance_core::deepsize::DeepSizeOf;
22use lance_core::utils::cpu::SIMD_SUPPORT;
23// Named tiers are only matched on x86_64, or by the fp16 kernels on the other
24// architectures; without either, nothing below names a `SimdSupport` variant.
25#[cfg(any(feature = "fp16kernels", target_arch = "x86_64"))]
26use lance_core::utils::cpu::SimdSupport;
27use num_traits::{AsPrimitive, Num};
28
29#[cfg(all(
30    target_arch = "x86_64",
31    not(all(target_feature = "avx2", target_feature = "fma"))
32))]
33use crate::distance::BatchIter;
34
35/// Calculate the L2 distance between two vectors.
36///
37pub trait L2: Num {
38    /// Calculate the L2 distance between two vectors.
39    fn l2(x: &[Self], y: &[Self]) -> f32;
40
41    /// L2 distance from `x` to each `dimension`-sized vector in `y`.
42    ///
43    /// The default calls [`L2::l2`] per vector. `f32` overrides it so the SIMD
44    /// tier is chosen once for the whole batch instead of once per vector —
45    /// on a build whose baseline already implies AVX2, per-vector dispatch
46    /// costs more than the kernel it selects.
47    ///
48    /// Returns `impl Iterator` rather than a trait object: the k-means
49    /// assignment loop drives this one element at a time, so a
50    /// `Box<dyn Iterator>` would cost a virtual call per element and an
51    /// allocation per batch.
52    fn l2_batch<'a>(
53        x: &'a [Self],
54        y: &'a [Self],
55        dimension: usize,
56    ) -> impl Iterator<Item = f32> + 'a {
57        y.chunks_exact(dimension).map(move |v| Self::l2(x, v))
58    }
59}
60
61#[inline]
62pub fn l2<T: L2>(from: &[T], to: &[T]) -> f32 {
63    T::l2(from, to)
64}
65
66/// L2 distance between two f32 slices, dispatched to the widest SIMD backend
67/// available at runtime.
68///
69/// On x86_64 with AVX-512 this uses 16-wide f32 lanes; otherwise it falls back
70/// to [`l2`], which auto-vectorizes to the compiled target (AVX2 on the default
71/// `haswell` build). Lance ships an AVX2-baseline binary, so the generic
72/// [`l2`] never emits AVX-512 even on capable CPUs — this dispatcher recovers
73/// that throughput for callers in the hot path (e.g. the in-memory HNSW index).
74#[inline]
75pub fn l2_f32(x: &[f32], y: &[f32]) -> f32 {
76    #[cfg(target_arch = "x86_64")]
77    {
78        if matches!(*SIMD_SUPPORT, SimdSupport::Avx512 | SimdSupport::Avx512FP16) {
79            // SAFETY: guarded by the runtime AVX-512 detection above.
80            return unsafe { l2_f32_avx512(x, y) };
81        }
82    }
83    l2(x, y)
84}
85
86#[cfg(target_arch = "x86_64")]
87#[target_feature(enable = "avx512f")]
88unsafe fn l2_f32_avx512(x: &[f32], y: &[f32]) -> f32 {
89    use std::arch::x86_64::*;
90    debug_assert_eq!(x.len(), y.len());
91    let n = x.len();
92    let mut acc = _mm512_setzero_ps();
93    let mut i = 0usize;
94    while i + 16 <= n {
95        let a = _mm512_loadu_ps(x.as_ptr().add(i));
96        let b = _mm512_loadu_ps(y.as_ptr().add(i));
97        let diff = _mm512_sub_ps(a, b);
98        acc = _mm512_fmadd_ps(diff, diff, acc);
99        i += 16;
100    }
101    let mut sum = _mm512_reduce_add_ps(acc);
102    while i < n {
103        let diff = x[i] - y[i];
104        sum += diff * diff;
105        i += 1;
106    }
107    sum
108}
109
110/// Calculate L2 distance between two uint8 slices.
111#[inline]
112pub fn l2_distance_uint_scalar(key: &[u8], target: &[u8]) -> f32 {
113    key.iter()
114        .zip(target.iter())
115        .map(|(&x, &y)| (x.abs_diff(y) as u32).pow(2))
116        .sum::<u32>() as f32
117}
118
119/// Calculate the L2 distance between two vectors, using scalar operations.
120///
121/// It relies on LLVM for auto-vectorization and unrolling.
122///
123/// This is pub for test/benchmark only. use [l2] instead.
124#[inline]
125pub fn l2_scalar<
126    T: AsPrimitive<Output>,
127    Output: Num + Copy + Sum + AddAssign + 'static,
128    const LANES: usize,
129>(
130    from: &[T],
131    to: &[T],
132) -> Output {
133    let x_chunks = from.chunks_exact(LANES);
134    let y_chunks = to.chunks_exact(LANES);
135
136    let s = if !x_chunks.remainder().is_empty() {
137        x_chunks
138            .remainder()
139            .iter()
140            .zip(y_chunks.remainder())
141            .map(|(&x, &y)| {
142                let diff = x.as_() - y.as_();
143                diff * diff
144            })
145            .sum::<Output>()
146    } else {
147        Output::zero()
148    };
149
150    let mut sums = [Output::zero(); LANES];
151    for (x, y) in x_chunks.zip(y_chunks) {
152        for i in 0..LANES {
153            let diff = x[i].as_() - y[i].as_();
154            sums[i] += diff * diff;
155        }
156    }
157
158    s + sums.iter().copied().sum()
159}
160
161impl L2 for u8 {
162    #[inline]
163    fn l2(x: &[Self], y: &[Self]) -> f32 {
164        super::l2_u8::l2_u8(x, y) as f32
165    }
166}
167
168#[cfg(feature = "fp16kernels")]
169mod bf16_kernel {
170    use half::bf16;
171
172    // These are the `l2_bf16` function in bf16.c. Our build.rs script compiles
173    // a version of this file for each SIMD level with different suffixes.
174    unsafe extern "C" {
175        #[cfg(target_arch = "aarch64")]
176        pub fn l2_bf16_neon(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32;
177        #[cfg(all(kernel_support = "avx512_bf16", target_arch = "x86_64"))]
178        pub fn l2_bf16_avx512(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32;
179        #[cfg(target_arch = "x86_64")]
180        pub fn l2_bf16_avx2(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32;
181        #[cfg(target_arch = "loongarch64")]
182        pub fn l2_bf16_lsx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32;
183        #[cfg(target_arch = "loongarch64")]
184        pub fn l2_bf16_lasx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32;
185    }
186}
187
188impl L2 for bf16 {
189    #[inline]
190    fn l2(x: &[Self], y: &[Self]) -> f32 {
191        match *SIMD_SUPPORT {
192            #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))]
193            SimdSupport::Neon => unsafe {
194                bf16_kernel::l2_bf16_neon(x.as_ptr(), y.as_ptr(), x.len() as u32)
195            },
196            #[cfg(all(
197                feature = "fp16kernels",
198                kernel_support = "avx512_bf16",
199                target_arch = "x86_64"
200            ))]
201            SimdSupport::Avx512FP16 => unsafe {
202                bf16_kernel::l2_bf16_avx512(x.as_ptr(), y.as_ptr(), x.len() as u32)
203            },
204            #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))]
205            SimdSupport::Avx2 | SimdSupport::Avx512 => unsafe {
206                bf16_kernel::l2_bf16_avx2(x.as_ptr(), y.as_ptr(), x.len() as u32)
207            },
208            #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))]
209            SimdSupport::Lasx => unsafe {
210                bf16_kernel::l2_bf16_lasx(x.as_ptr(), y.as_ptr(), x.len() as u32)
211            },
212            #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))]
213            SimdSupport::Lsx => unsafe {
214                bf16_kernel::l2_bf16_lsx(x.as_ptr(), y.as_ptr(), x.len() as u32)
215            },
216            // SimdSupport::AvxFma and SimdSupport::Avx fall through here:
217            // the bf16 C kernels are compiled with `-march=haswell` minimum
218            // (AVX2), so they cannot run on AVX-only or AVX+FMA hosts.
219            _ => l2_scalar::<Self, f32, 16>(x, y),
220        }
221    }
222}
223
224#[cfg(feature = "fp16kernels")]
225mod kernel {
226    use super::*;
227
228    // These are the `l2_f16` function in f16.c. Our build.rs script compiles
229    // a version of this file for each SIMD level with different suffixes.
230    unsafe extern "C" {
231        #[cfg(target_arch = "aarch64")]
232        pub fn l2_f16_neon(ptr1: *const f16, ptr2: *const f16, len: u32) -> f32;
233        #[cfg(all(kernel_support = "avx512_f16", target_arch = "x86_64"))]
234        pub fn l2_f16_avx512(ptr1: *const f16, ptr2: *const f16, len: u32) -> f32;
235        #[cfg(target_arch = "x86_64")]
236        pub fn l2_f16_avx2(ptr1: *const f16, ptr2: *const f16, len: u32) -> f32;
237        #[cfg(target_arch = "loongarch64")]
238        pub fn l2_f16_lsx(ptr1: *const f16, ptr2: *const f16, len: u32) -> f32;
239        #[cfg(target_arch = "loongarch64")]
240        pub fn l2_f16_lasx(ptr1: *const f16, ptr2: *const f16, len: u32) -> f32;
241    }
242}
243
244impl L2 for f16 {
245    #[inline]
246    fn l2(x: &[Self], y: &[Self]) -> f32 {
247        match *SIMD_SUPPORT {
248            #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))]
249            SimdSupport::Neon => unsafe {
250                kernel::l2_f16_neon(x.as_ptr(), y.as_ptr(), x.len() as u32)
251            },
252            #[cfg(all(
253                feature = "fp16kernels",
254                kernel_support = "avx512_f16",
255                target_arch = "x86_64"
256            ))]
257            SimdSupport::Avx512FP16 => unsafe {
258                kernel::l2_f16_avx512(x.as_ptr(), y.as_ptr(), x.len() as u32)
259            },
260            #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))]
261            SimdSupport::Avx2 => unsafe {
262                kernel::l2_f16_avx2(x.as_ptr(), y.as_ptr(), x.len() as u32)
263            },
264            #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))]
265            SimdSupport::Lasx => unsafe {
266                kernel::l2_f16_lasx(x.as_ptr(), y.as_ptr(), x.len() as u32)
267            },
268            #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))]
269            SimdSupport::Lsx => unsafe {
270                kernel::l2_f16_lsx(x.as_ptr(), y.as_ptr(), x.len() as u32)
271            },
272            // SimdSupport::AvxFma and SimdSupport::Avx fall through here:
273            // the f16 C kernels are compiled with `-march=haswell` minimum
274            // (AVX2), so they cannot run on AVX-only or AVX+FMA hosts.
275            _ => l2_scalar::<Self, f32, 16>(x, y),
276        }
277    }
278}
279
280impl L2 for f32 {
281    #[inline]
282    fn l2(x: &[Self], y: &[Self]) -> f32 {
283        // Trait methods cannot carry `#[target_feature]` attributes, so the body
284        // lives in a free function that runtime-dispatches via `*SIMD_SUPPORT`
285        // to an AVX2 or AVX-512 inner kernel on capable hosts, or a portable
286        // scalar fallback.
287        l2_f32_dispatched(x, y)
288    }
289
290    fn l2_batch<'a>(
291        x: &'a [Self],
292        y: &'a [Self],
293        dimension: usize,
294    ) -> impl Iterator<Item = Self> + 'a {
295        // Exactly one arm compiles; see `Dot::dot_batch` for f32.
296        // See `Dot::dot_batch` for f32.
297        #[cfg(all(
298            target_arch = "x86_64",
299            target_feature = "avx2",
300            target_feature = "fma"
301        ))]
302        {
303            // `l2_scalar::<_, _, 16>` chunks the vector by 16 lanes. At or below
304            // that width the chunking degenerates to its scalar remainder loop
305            // and vectorizes nothing, so the explicit AVX kernel is worth ~40%.
306            // Above it the autovectorizer already does well and the 8-wide
307            // kernel can lose, so keep the exact kernel the pre-dispatch code
308            // used and stay non-regressing by construction.
309            //
310            // SAFETY: the build baseline enables avx2+fma, which imply avx+fma,
311            // so the kernel's `#[target_feature]` contract is met statically.
312            let narrow = dimension <= 16;
313            y.chunks_exact(dimension).map(move |v| {
314                if narrow {
315                    unsafe { x86::l2_f32_avx_fma(x, v) }
316                } else {
317                    l2_f32_scalar(x, v)
318                }
319            })
320        }
321        #[cfg(all(
322            target_arch = "x86_64",
323            not(all(target_feature = "avx2", target_feature = "fma"))
324        ))]
325        {
326            l2_batch_f32_runtime_dispatch(x, y, dimension)
327        }
328        #[cfg(not(target_arch = "x86_64"))]
329        {
330            y.chunks_exact(dimension).map(move |v| Self::l2(x, v))
331        }
332    }
333}
334
335/// Sub-AVX2 builds: pick a `#[target_feature]` kernel once for the batch.
336#[cfg(all(
337    target_arch = "x86_64",
338    not(all(target_feature = "avx2", target_feature = "fma"))
339))]
340#[inline]
341fn l2_batch_f32_runtime_dispatch<'a>(
342    x: &'a [f32],
343    y: &'a [f32],
344    dimension: usize,
345) -> impl Iterator<Item = f32> + 'a {
346    // SAFETY: each kernel is entered only under its matching runtime detection.
347    match *SIMD_SUPPORT {
348        SimdSupport::Avx512 | SimdSupport::Avx512FP16 => {
349            BatchIter::Eager(unsafe { x86::l2_batch_f32_avx512(x, y, dimension) }.into_iter())
350        }
351        SimdSupport::Avx2 | SimdSupport::AvxFma => {
352            BatchIter::Eager(unsafe { x86::l2_batch_f32_avx_fma(x, y, dimension) }.into_iter())
353        }
354        SimdSupport::Avx => {
355            BatchIter::Eager(unsafe { x86::l2_batch_f32_avx(x, y, dimension) }.into_iter())
356        }
357        _ => BatchIter::Lazy(y.chunks_exact(dimension).map(move |v| l2_f32_scalar(x, v))),
358    }
359}
360
361/// L2 distance for f32, runtime-dispatched via `SIMD_SUPPORT` on x86_64
362/// (AVX-512 / AVX2+FMA / AVX+FMA / AVX / scalar). Non-x86 uses the
363/// auto-vectorised scalar loop.
364#[inline]
365fn l2_f32_dispatched(x: &[f32], y: &[f32]) -> f32 {
366    #[cfg(target_arch = "x86_64")]
367    {
368        match *SIMD_SUPPORT {
369            SimdSupport::Avx512 | SimdSupport::Avx512FP16 => unsafe { x86::l2_f32_avx512(x, y) },
370            SimdSupport::Avx2 | SimdSupport::AvxFma => unsafe { x86::l2_f32_avx_fma(x, y) },
371            SimdSupport::Avx => unsafe { x86::l2_f32_avx(x, y) },
372            _ => l2_f32_scalar(x, y),
373        }
374    }
375    #[cfg(not(target_arch = "x86_64"))]
376    {
377        l2_f32_scalar(x, y)
378    }
379}
380
381/// Portable scalar L2 distance for f32. Used as the x86_64 fallback when no
382/// AVX2 is detected, and as the only path on non-x86 architectures. The
383/// `LANES = 16` chunking matches the explicit-SIMD inner kernels above.
384#[inline]
385fn l2_f32_scalar(x: &[f32], y: &[f32]) -> f32 {
386    // 16 = 512 (avx512) / 8 bits / 4 (sizeof(f32))
387    // See https://github.com/lance-format/lance/pull/2450.
388    l2_scalar::<f32, f32, 16>(x, y)
389}
390
391impl L2 for f64 {
392    #[inline]
393    fn l2(x: &[Self], y: &[Self]) -> f32 {
394        l2_f64_simd(x, y)
395    }
396}
397
398/// L2 distance for f64, runtime-dispatched via `SIMD_SUPPORT` on x86_64
399/// (AVX-512 / AVX2+FMA / AVX+FMA / AVX / scalar). Non-x86 uses the SIMD
400/// primitives in `crate::simd::f64`, unconditionally backed by NEON / LSX-LASX.
401#[inline]
402fn l2_f64_simd(x: &[f64], y: &[f64]) -> f32 {
403    #[cfg(target_arch = "x86_64")]
404    {
405        match *SIMD_SUPPORT {
406            SimdSupport::Avx512 | SimdSupport::Avx512FP16 => unsafe { x86::l2_f64_avx512(x, y) },
407            SimdSupport::Avx2 | SimdSupport::AvxFma => unsafe { x86::l2_f64_avx_fma(x, y) },
408            SimdSupport::Avx => unsafe { x86::l2_f64_avx(x, y) },
409            _ => l2_f64_scalar(x, y),
410        }
411    }
412    #[cfg(not(target_arch = "x86_64"))]
413    {
414        l2_f64_simd_other(x, y)
415    }
416}
417
418/// Portable scalar L2 distance for f64. Used as the x86_64 fallback when no
419/// AVX2 is detected, and exposed for cross-backend parity testing.
420#[cfg(target_arch = "x86_64")]
421#[inline]
422fn l2_f64_scalar(x: &[f64], y: &[f64]) -> f32 {
423    x.iter()
424        .zip(y.iter())
425        .map(|(&a, &b)| {
426            let diff = a - b;
427            diff * diff
428        })
429        .sum::<f64>() as f32
430}
431
432#[cfg(target_arch = "x86_64")]
433mod x86 {
434    use std::arch::x86_64::*;
435
436    use crate::simd::f64::{f64x4, f64x8};
437    use crate::simd::x86::hsum256_ps;
438    use crate::simd::{FloatSimd, SIMD};
439
440    /// L2 distance from `x` to every `dimension`-sized vector in `batch`, with
441    /// the AVX-512 tier entered once for the whole batch rather than once per
442    /// vector.
443    ///
444    /// # Safety
445    /// The host must support AVX-512F.
446    ///
447    /// Only compiled for builds whose baseline is below avx2+fma; at or above
448    /// that baseline `l2_batch` inlines the kernel directly and never runtime-
449    /// dispatches, so this wrapper would be dead code (see `l2_batch`).
450    #[cfg(not(all(target_feature = "avx2", target_feature = "fma")))]
451    #[target_feature(enable = "avx512f")]
452    pub(super) unsafe fn l2_batch_f32_avx512(
453        x: &[f32],
454        batch: &[f32],
455        dimension: usize,
456    ) -> Vec<f32> {
457        batch
458            .chunks_exact(dimension)
459            .map(|y| unsafe { l2_f32_avx512(x, y) })
460            .collect()
461    }
462
463    /// As [`l2_batch_f32_avx512`], for the AVX+FMA and AVX2 tiers.
464    ///
465    /// # Safety
466    /// The host must support AVX and FMA.
467    #[cfg(not(all(target_feature = "avx2", target_feature = "fma")))]
468    #[target_feature(enable = "avx,fma")]
469    pub(super) unsafe fn l2_batch_f32_avx_fma(
470        x: &[f32],
471        batch: &[f32],
472        dimension: usize,
473    ) -> Vec<f32> {
474        batch
475            .chunks_exact(dimension)
476            .map(|y| unsafe { l2_f32_avx_fma(x, y) })
477            .collect()
478    }
479
480    /// As [`l2_batch_f32_avx512`], for the AVX-without-FMA tier.
481    ///
482    /// # Safety
483    /// The host must support AVX.
484    #[cfg(not(all(target_feature = "avx2", target_feature = "fma")))]
485    #[target_feature(enable = "avx")]
486    pub(super) unsafe fn l2_batch_f32_avx(x: &[f32], batch: &[f32], dimension: usize) -> Vec<f32> {
487        batch
488            .chunks_exact(dimension)
489            .map(|y| unsafe { l2_f32_avx(x, y) })
490            .collect()
491    }
492
493    /// AVX-512 path for f64: 8-wide `__m512d` with `vsubpd` + `vfmadd231pd` per iteration.
494    #[target_feature(enable = "avx512f")]
495    pub unsafe fn l2_f64_avx512(x: &[f64], y: &[f64]) -> f32 {
496        let dim = x.len();
497        let unrolled_len = dim / 8 * 8;
498
499        let mut acc = _mm512_setzero_pd();
500        for i in (0..unrolled_len).step_by(8) {
501            let a = _mm512_loadu_pd(x.as_ptr().add(i));
502            let b = _mm512_loadu_pd(y.as_ptr().add(i));
503            let diff = _mm512_sub_pd(a, b);
504            acc = _mm512_fmadd_pd(diff, diff, acc);
505        }
506
507        let tail: f64 = x[unrolled_len..]
508            .iter()
509            .zip(y[unrolled_len..].iter())
510            .map(|(&a, &b)| {
511                let diff = a - b;
512                diff * diff
513            })
514            .sum();
515
516        (_mm512_reduce_add_pd(acc) + tail) as f32
517    }
518
519    /// AVX + FMA path for f64. Covers both AvxFma and AVX2 dispatch (body uses no AVX2-specific intrinsics).
520    #[target_feature(enable = "avx,fma")]
521    pub unsafe fn l2_f64_avx_fma(x: &[f64], y: &[f64]) -> f32 {
522        let dim = x.len();
523        let unrolled_len = dim / 8 * 8;
524
525        let mut acc8 = f64x8::zeros();
526        for i in (0..unrolled_len).step_by(8) {
527            let a = f64x8::load_unaligned(x.as_ptr().add(i));
528            let b = f64x8::load_unaligned(y.as_ptr().add(i));
529            let diff = a - b;
530            acc8.multiply_add(diff, diff);
531        }
532
533        let aligned_len = dim / 4 * 4;
534        let mut acc4 = f64x4::zeros();
535        for i in (unrolled_len..aligned_len).step_by(4) {
536            let a = f64x4::load_unaligned(x.as_ptr().add(i));
537            let b = f64x4::load_unaligned(y.as_ptr().add(i));
538            let diff = a - b;
539            acc4.multiply_add(diff, diff);
540        }
541
542        let tail: f64 = x[aligned_len..]
543            .iter()
544            .zip(y[aligned_len..].iter())
545            .map(|(&a, &b)| {
546                let diff = a - b;
547                diff * diff
548            })
549            .sum();
550
551        (acc8.reduce_sum() + acc4.reduce_sum() + tail) as f32
552    }
553
554    /// AVX-only path for f64 (no FMA): squared diff via `_mm256_mul_pd` + `_mm256_add_pd` for Sandy/Ivy Bridge.
555    #[target_feature(enable = "avx")]
556    pub unsafe fn l2_f64_avx(x: &[f64], y: &[f64]) -> f32 {
557        let dim = x.len();
558        let unrolled_len = dim / 4 * 4;
559
560        let mut acc = _mm256_setzero_pd();
561        for i in (0..unrolled_len).step_by(4) {
562            let a = _mm256_loadu_pd(x.as_ptr().add(i));
563            let b = _mm256_loadu_pd(y.as_ptr().add(i));
564            let diff = _mm256_sub_pd(a, b);
565            acc = _mm256_add_pd(acc, _mm256_mul_pd(diff, diff));
566        }
567
568        // Horizontal sum of __m256d -> f64.
569        let lo = _mm256_castpd256_pd128(acc);
570        let hi = _mm256_extractf128_pd(acc, 1);
571        let sum128 = _mm_add_pd(lo, hi);
572        let sum64 = _mm_add_pd(sum128, _mm_unpackhi_pd(sum128, sum128));
573        let acc_sum = _mm_cvtsd_f64(sum64);
574
575        let tail: f64 = x[unrolled_len..]
576            .iter()
577            .zip(y[unrolled_len..].iter())
578            .map(|(&a, &b)| {
579                let diff = a - b;
580                diff * diff
581            })
582            .sum();
583
584        (acc_sum + tail) as f32
585    }
586
587    /// AVX-512 path for f32: 16-wide `__m512` with `vsubps` + `vfmadd231ps` per iteration.
588    #[target_feature(enable = "avx512f")]
589    pub unsafe fn l2_f32_avx512(x: &[f32], y: &[f32]) -> f32 {
590        let dim = x.len();
591        let unrolled_len = dim / 16 * 16;
592
593        let mut acc = _mm512_setzero_ps();
594        for i in (0..unrolled_len).step_by(16) {
595            let a = _mm512_loadu_ps(x.as_ptr().add(i));
596            let b = _mm512_loadu_ps(y.as_ptr().add(i));
597            let diff = _mm512_sub_ps(a, b);
598            acc = _mm512_fmadd_ps(diff, diff, acc);
599        }
600
601        let tail: f32 = x[unrolled_len..]
602            .iter()
603            .zip(y[unrolled_len..].iter())
604            .map(|(&a, &b)| {
605                let diff = a - b;
606                diff * diff
607            })
608            .sum();
609
610        _mm512_reduce_add_ps(acc) + tail
611    }
612
613    /// AVX + FMA path for f32. Covers both AvxFma and AVX2 dispatch (body uses no AVX2-specific intrinsics).
614    #[target_feature(enable = "avx,fma")]
615    pub unsafe fn l2_f32_avx_fma(x: &[f32], y: &[f32]) -> f32 {
616        let dim = x.len();
617        let unrolled_len = dim / 8 * 8;
618
619        let mut acc = _mm256_setzero_ps();
620        for i in (0..unrolled_len).step_by(8) {
621            let a = _mm256_loadu_ps(x.as_ptr().add(i));
622            let b = _mm256_loadu_ps(y.as_ptr().add(i));
623            let diff = _mm256_sub_ps(a, b);
624            acc = _mm256_fmadd_ps(diff, diff, acc);
625        }
626
627        let tail: f32 = x[unrolled_len..]
628            .iter()
629            .zip(y[unrolled_len..].iter())
630            .map(|(&a, &b)| {
631                let diff = a - b;
632                diff * diff
633            })
634            .sum();
635
636        hsum256_ps(acc) + tail
637    }
638
639    /// AVX-only path for f32 (no FMA): squared diff via `_mm256_mul_ps` + `_mm256_add_ps` for Sandy/Ivy Bridge.
640    #[target_feature(enable = "avx")]
641    pub unsafe fn l2_f32_avx(x: &[f32], y: &[f32]) -> f32 {
642        let dim = x.len();
643        let unrolled_len = dim / 8 * 8;
644
645        let mut acc = _mm256_setzero_ps();
646        for i in (0..unrolled_len).step_by(8) {
647            let a = _mm256_loadu_ps(x.as_ptr().add(i));
648            let b = _mm256_loadu_ps(y.as_ptr().add(i));
649            let diff = _mm256_sub_ps(a, b);
650            acc = _mm256_add_ps(acc, _mm256_mul_ps(diff, diff));
651        }
652
653        let tail: f32 = x[unrolled_len..]
654            .iter()
655            .zip(y[unrolled_len..].iter())
656            .map(|(&a, &b)| {
657                let diff = a - b;
658                diff * diff
659            })
660            .sum();
661
662        hsum256_ps(acc) + tail
663    }
664}
665
666#[cfg(not(target_arch = "x86_64"))]
667#[inline]
668fn l2_f64_simd_other(x: &[f64], y: &[f64]) -> f32 {
669    use crate::simd::f64::{f64x4, f64x8};
670    use crate::simd::{FloatSimd, SIMD};
671
672    let dim = x.len();
673    let unrolled_len = dim / 8 * 8;
674
675    let mut acc8 = f64x8::zeros();
676    for i in (0..unrolled_len).step_by(8) {
677        unsafe {
678            let a = f64x8::load_unaligned(x.as_ptr().add(i));
679            let b = f64x8::load_unaligned(y.as_ptr().add(i));
680            let diff = a - b;
681            acc8.multiply_add(diff, diff);
682        }
683    }
684
685    let aligned_len = dim / 4 * 4;
686    let mut acc4 = f64x4::zeros();
687    for i in (unrolled_len..aligned_len).step_by(4) {
688        unsafe {
689            let a = f64x4::load_unaligned(x.as_ptr().add(i));
690            let b = f64x4::load_unaligned(y.as_ptr().add(i));
691            let diff = a - b;
692            acc4.multiply_add(diff, diff);
693        }
694    }
695
696    let tail: f64 = x[aligned_len..]
697        .iter()
698        .zip(y[aligned_len..].iter())
699        .map(|(&a, &b)| {
700            let diff = a - b;
701            diff * diff
702        })
703        .sum();
704
705    (acc8.reduce_sum() + acc4.reduce_sum() + tail) as f32
706}
707
708/// Accumulate squared differences for one dimension into per-target results.
709///
710/// Separated into its own function so that LLVM sees `row` and `result`
711/// as non-aliasing via the function signature (`&[f32]` vs `&mut [f32]`),
712/// enabling packed SIMD vectorization (vbroadcastss + vsubps + vfmadd231ps).
713#[inline(never)]
714fn accumulate_l2_dimension(q: f32, row: &[f32], result: &mut [f32]) {
715    for (dist, &target) in result.iter_mut().zip(row.iter()) {
716        let diff = q - target;
717        *dist += diff * diff;
718    }
719}
720
721/// Pre-transposed target vectors for batched L2 distance computation.
722///
723/// Stores targets in SoA layout `[dimension][num_targets]` so the inner
724/// distance loop iterates over targets contiguously. The AoS-to-SoA
725/// transpose is done once at construction; callers should reuse the
726/// struct across many queries to amortize that cost.
727///
728/// **Cache constraint**: this is designed for cases where
729/// `num_targets × dimension × 4` fits in L1 cache (~32 KB), such as PQ
730/// sub-vector codebooks (e.g. 256 centroids × 16 dims = 16 KB).
731/// For large target sets the SoA layout causes L1 thrashing and
732/// [`l2_distance_batch`] with its AoS per-target locality is faster.
733#[derive(Debug, Clone, DeepSizeOf)]
734pub struct L2Prepared {
735    transposed: Vec<f32>,
736    dimension: usize,
737    num_targets: usize,
738}
739
740impl L2Prepared {
741    /// Transpose `targets` from AoS `[num_targets][dimension]` to SoA layout.
742    pub fn new(targets: &[f32], dimension: usize) -> Self {
743        let num_targets = targets.len() / dimension;
744        debug_assert_eq!(targets.len(), num_targets * dimension);
745
746        let mut transposed = vec![0.0f32; targets.len()];
747        for t in 0..num_targets {
748            for d in 0..dimension {
749                transposed[d * num_targets + t] = targets[t * dimension + d];
750            }
751        }
752
753        Self {
754            transposed,
755            dimension,
756            num_targets,
757        }
758    }
759
760    /// Compute L2 distances from `query` to every target, writing into `out`.
761    ///
762    /// `out` must have length `num_targets`. It will be zeroed before accumulation.
763    pub fn distances_into(&self, query: &[f32], out: &mut [f32]) {
764        debug_assert_eq!(query.len(), self.dimension);
765        debug_assert_eq!(out.len(), self.num_targets);
766
767        out.fill(0.0);
768        for (d, &q) in query.iter().enumerate() {
769            let row = &self.transposed[d * self.num_targets..][..self.num_targets];
770            accumulate_l2_dimension(q, row, out);
771        }
772    }
773
774    /// Compute L2 distances from `query` to every target.
775    pub fn distances(&self, query: &[f32]) -> Vec<f32> {
776        let mut result = vec![0.0f32; self.num_targets];
777        self.distances_into(query, &mut result);
778        result
779    }
780
781    /// Return the index of the nearest target to `query`, using `buf` as scratch space.
782    ///
783    /// `buf` must have length `num_targets`.
784    pub fn nearest_into(&self, query: &[f32], buf: &mut [f32]) -> Option<u32> {
785        self.distances_into(query, buf);
786        crate::kernels::argmin_value_float(buf.iter().copied()).map(|(idx, _)| idx)
787    }
788
789    /// Return the index of the nearest target to `query`.
790    pub fn nearest(&self, query: &[f32]) -> Option<u32> {
791        self.nearest_into(query, &mut vec![0.0f32; self.num_targets])
792    }
793
794    /// Number of targets in this set.
795    pub fn num_targets(&self) -> usize {
796        self.num_targets
797    }
798
799    /// Dimension of each target vector.
800    pub fn dimension(&self) -> usize {
801        self.dimension
802    }
803
804    /// Size of the internal buffer in bytes.
805    pub fn size_bytes(&self) -> usize {
806        self.transposed.len() * std::mem::size_of::<f32>()
807    }
808}
809
810/// Compute L2 distance between two vectors.
811#[inline]
812pub fn l2_distance(from: &[f32], to: &[f32]) -> f32 {
813    l2(from, to)
814}
815
816/// Compute L2 distance between a vector and a batch of vectors.
817///
818/// Parameters
819///
820/// - `from`: the vector to compute distance from.
821/// - `to`: a list of vectors to compute distance to.
822/// - `dimension`: the dimension of the vectors.
823///
824/// Returns
825///
826/// An iterator of pair-wise distance between `from` vector to each vector in the batch.
827pub fn l2_distance_batch<'a, T: L2>(
828    from: &'a [T],
829    to: &'a [T],
830    dimension: usize,
831) -> impl Iterator<Item = f32> + 'a {
832    assume_eq!(from.len(), dimension);
833    assume_eq!(to.len() % dimension, 0);
834
835    T::l2_batch(from, to, dimension)
836}
837
838fn do_l2_distance_arrow_batch<T: ArrowFloatType>(
839    from: &T::ArrayType,
840    to: &FixedSizeListArray,
841) -> Result<Arc<Float32Array>>
842where
843    T::Native: L2,
844{
845    let dimension = to.value_length() as usize;
846    debug_assert_eq!(from.len(), dimension);
847
848    // TODO: if we detect there is a run of nulls, should we skip those?
849    let to_values =
850        to.values()
851            .as_any()
852            .downcast_ref::<T::ArrayType>()
853            .ok_or(Error::ComputeError(format!(
854                "Cannot downcast to the same type: {} != {}",
855                T::FLOAT_TYPE,
856                to.value_type()
857            )))?;
858    let dists = l2_distance_batch(from.as_slice(), to_values.as_slice(), dimension);
859
860    Ok(Arc::new(Float32Array::new(
861        dists.collect(),
862        to.nulls().cloned(),
863    )))
864}
865
866/// Compute L2 distance between a vector and a batch of vectors.
867///
868/// Null buffer of `to` is propagated to the returned array.
869///
870/// Parameters
871///
872/// - `from`: the vector to compute distance from.
873/// - `to`: a list of vectors to compute distance to.
874///
875/// # Panics
876///
877/// Panics if the length of `from` is not equal to the dimension (value length) of `to`.
878pub fn l2_distance_arrow_batch(
879    from: &dyn Array,
880    to: &FixedSizeListArray,
881) -> Result<Arc<Float32Array>> {
882    match *from.data_type() {
883        DataType::Float16 => do_l2_distance_arrow_batch::<Float16Type>(from.as_primitive(), to),
884        DataType::Float32 => do_l2_distance_arrow_batch::<Float32Type>(from.as_primitive(), to),
885        DataType::Float64 => do_l2_distance_arrow_batch::<Float64Type>(from.as_primitive(), to),
886        DataType::Int8 => do_l2_distance_arrow_batch::<Float32Type>(
887            &from
888                .as_primitive::<Int8Type>()
889                .into_iter()
890                .map(|x| x.unwrap() as f32)
891                .collect(),
892            &to.convert_to_floating_point()?,
893        ),
894        _ => Err(Error::ComputeError(format!(
895            "Unsupported data type: {}",
896            from.data_type()
897        ))),
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    use approx::assert_relative_eq;
906    use num_traits::ToPrimitive;
907    use proptest::prelude::*;
908
909    use crate::test_utils::{
910        arbitrary_bf16, arbitrary_f16, arbitrary_f32, arbitrary_f64, arbitrary_vector_pair,
911    };
912
913    #[test]
914    fn test_l2_f32_dispatch_matches_scalar() {
915        // Covers tail handling for lengths around the 16-lane AVX-512 stride.
916        for dim in [1usize, 7, 15, 16, 17, 31, 33, 64, 100, 1024] {
917            let x: Vec<f32> = (0..dim).map(|i| (i as f32) * 0.5 - 3.0).collect();
918            let y: Vec<f32> = (0..dim).map(|i| (i as f32) * -0.25 + 1.5).collect();
919            assert_relative_eq!(l2_f32(&x, &y), l2(&x, &y), max_relative = 1e-5);
920        }
921    }
922
923    #[test]
924    fn test_euclidean_distance() {
925        let mat = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
926            vec![
927                Some((0..8).map(|v| Some(v as f32)).collect::<Vec<_>>()),
928                Some((1..9).map(|v| Some(v as f32)).collect::<Vec<_>>()),
929                Some((2..10).map(|v| Some(v as f32)).collect::<Vec<_>>()),
930                Some((3..11).map(|v| Some(v as f32)).collect::<Vec<_>>()),
931            ],
932            8,
933        );
934        let point = Float32Array::from((2..10).map(|v| Some(v as f32)).collect::<Vec<_>>());
935        let distances = l2_distance_batch(
936            point.values(),
937            mat.values().as_primitive::<Float32Type>().values(),
938            8,
939        )
940        .collect::<Vec<_>>();
941
942        assert_eq!(distances, vec![32.0, 8.0, 0.0, 8.0]);
943    }
944
945    #[test]
946    fn test_not_aligned() {
947        let mat = (0..6)
948            .chain(0..8)
949            .chain(1..9)
950            .chain(2..10)
951            .chain(3..11)
952            .map(|v| v as f32)
953            .collect::<Vec<_>>();
954        let point = Float32Array::from((0..10).map(|v| Some(v as f32)).collect::<Vec<_>>());
955        let distances = l2_distance_batch(&point.values()[2..], &mat[6..], 8).collect::<Vec<_>>();
956
957        assert_eq!(distances, vec![32.0, 8.0, 0.0, 8.0]);
958    }
959
960    #[test]
961    fn test_odd_length_vector() {
962        let mat = Float32Array::from_iter((0..5).map(|v| Some(v as f32)));
963        let point = Float32Array::from((2..7).map(|v| Some(v as f32)).collect::<Vec<_>>());
964        let distances = l2_distance_batch(point.values(), mat.values(), 5).collect::<Vec<_>>();
965
966        assert_eq!(distances, vec![20.0]);
967    }
968
969    #[test]
970    fn test_l2_distance_cases() {
971        let values: Float32Array = vec![
972            0.25335717, 0.24663818, 0.26330215, 0.14988247, 0.06042378, 0.21077952, 0.26687378,
973            0.22145681, 0.18319066, 0.18688454, 0.05216244, 0.11470364, 0.10554603, 0.19964123,
974            0.06387895, 0.18992095, 0.00123718, 0.13500804, 0.09516747, 0.19508345, 0.2582458,
975            0.1211653, 0.21121833, 0.24809816, 0.04078768, 0.19586588, 0.16496408, 0.14766085,
976            0.04898421, 0.14728612, 0.21263947, 0.16763233,
977        ]
978        .into();
979
980        let q: Float32Array = vec![
981            0.18549609,
982            0.29954708,
983            0.28318876,
984            0.05424477,
985            0.093134984,
986            0.21580857,
987            0.2951282,
988            0.19866848,
989            0.13868214,
990            0.19819534,
991            0.23271298,
992            0.047727287,
993            0.14394054,
994            0.023316395,
995            0.18589257,
996            0.037315924,
997            0.07037327,
998            0.32609823,
999            0.07344752,
1000            0.020155912,
1001            0.18485495,
1002            0.32763934,
1003            0.14296658,
1004            0.04498596,
1005            0.06254237,
1006            0.24348071,
1007            0.16009757,
1008            0.053892266,
1009            0.05918874,
1010            0.040363103,
1011            0.19913352,
1012            0.14545348,
1013        ]
1014        .into();
1015
1016        let d = l2_distance_batch(q.values(), values.values(), 32).collect::<Vec<_>>();
1017        assert_relative_eq!(0.319_357_84, d[0]);
1018    }
1019
1020    /// Reference implementation of L2 distance.
1021    ///
1022    /// Note that we skip the final square root step for performance reasons.
1023    fn l2_distance_reference(x: &[f64], y: &[f64]) -> f64 {
1024        x.iter()
1025            .zip(y.iter())
1026            .map(|(x, y)| (*x - *y).powi(2))
1027            .sum::<f64>()
1028    }
1029
1030    fn do_l2_test<T: L2 + ToPrimitive>(x: &[T], y: &[T]) -> std::result::Result<(), TestCaseError> {
1031        let x_f64 = x.iter().map(|v| v.to_f64().unwrap()).collect::<Vec<f64>>();
1032        let y_f64 = y.iter().map(|v| v.to_f64().unwrap()).collect::<Vec<f64>>();
1033
1034        let result = l2(x, y);
1035        let reference = l2_distance_reference(&x_f64, &y_f64) as f32;
1036
1037        prop_assert!(approx::relative_eq!(result, reference, max_relative = 1e-6));
1038        Ok(())
1039    }
1040
1041    #[test]
1042    fn test_l2_distance_f16_max() {
1043        let x = vec![f16::MAX; 4048];
1044        let y = vec![-f16::MAX; 4048];
1045        do_l2_test(&x, &y).unwrap();
1046    }
1047
1048    // Test L2 distance over different types.
1049    // * L2 is valid over the entire range of f16.
1050    // * L2 is valid over f32 and bf16 in the range of +-1e12.
1051    // * L2 for f64 should match the reference implementation.
1052    proptest::proptest! {
1053        #[test]
1054        fn test_l2_distance_f16((x, y) in arbitrary_vector_pair(arbitrary_f16, 4..4048)) {
1055            do_l2_test(&x, &y)?;
1056        }
1057
1058        #[test]
1059        fn test_l2_distance_bf16((x, y) in arbitrary_vector_pair(arbitrary_bf16, 4..4048)){
1060            do_l2_test(&x, &y)?;
1061        }
1062
1063        #[test]
1064        fn test_l2_distance_f32((x, y) in arbitrary_vector_pair(arbitrary_f32, 4..4048)){
1065            do_l2_test(&x, &y)?;
1066        }
1067
1068        #[test]
1069        fn test_l2_distance_f64((x, y) in arbitrary_vector_pair(arbitrary_f64, 4..4048)){
1070            do_l2_test(&x, &y)?;
1071        }
1072
1073        /// Cross-backend parity: scalar fallback must match the dispatched
1074        /// SIMD path within numerical tolerance. Exercises `l2_f64_scalar`
1075        /// directly so the runtime fallback is exercised even on AVX2-capable
1076        /// CI hosts.
1077        #[cfg(target_arch = "x86_64")]
1078        #[test]
1079        fn test_l2_f64_scalar_simd_parity(
1080            (x, y) in arbitrary_vector_pair(arbitrary_f64, 4..4048)
1081        ) {
1082            let scalar = l2_f64_scalar(&x, &y);
1083            let simd = l2_f64_simd(&x, &y);
1084            prop_assert!(approx::relative_eq!(scalar, simd, max_relative = 1e-6));
1085        }
1086
1087        /// Parity check for `l2_f32_dispatched` (Branch B exclusive: the
1088        /// auto-vectorised scalar L2 path). The dispatched kernel must
1089        /// agree with a portable f64-precision scalar reference within
1090        /// numerical tolerance. The reference is hand-rolled here to keep
1091        /// this test architecture-agnostic (the x86_64-only `l2_f64_scalar`
1092        /// helper is gated above).
1093        #[test]
1094        fn test_l2_f32_scalar_simd_parity(
1095            (x, y) in arbitrary_vector_pair(arbitrary_f32, 4..4048)
1096        ) {
1097            let scalar = x
1098                .iter()
1099                .zip(y.iter())
1100                .map(|(&a, &b)| ((a as f64) - (b as f64)).powi(2))
1101                .sum::<f64>() as f32;
1102            let simd = <f32 as L2>::l2(&x, &y);
1103            prop_assert!(approx::relative_eq!(scalar, simd, max_relative = 1e-3));
1104        }
1105
1106        /// AVX-512-direct parity: explicitly compares the scalar fallback
1107        /// against the native AVX-512 inner kernel on AVX-512F-capable hosts
1108        /// (Skylake-X+, Ice Lake, Sapphire Rapids, Zen 4). Early-returns on
1109        /// hosts without AVX-512F.
1110        #[cfg(target_arch = "x86_64")]
1111        #[test]
1112        fn test_l2_f64_scalar_vs_avx512_parity(
1113            (x, y) in arbitrary_vector_pair(arbitrary_f64, 4..4048)
1114        ) {
1115            if !std::is_x86_feature_detected!("avx512f") {
1116                return Ok(());
1117            }
1118            let scalar = l2_f64_scalar(&x, &y);
1119            let avx512 = unsafe { x86::l2_f64_avx512(&x, &y) };
1120            prop_assert!(approx::relative_eq!(scalar, avx512, max_relative = 1e-6));
1121        }
1122
1123        /// AVX + FMA-direct parity for the f64 L2 kernel. Covers the AMD
1124        /// Piledriver / Steamroller / FX-7500 tier. Early-returns on hosts
1125        /// without both AVX and FMA.
1126        #[cfg(target_arch = "x86_64")]
1127        #[test]
1128        fn test_l2_f64_scalar_vs_avx_fma_parity(
1129            (x, y) in arbitrary_vector_pair(arbitrary_f64, 4..4048)
1130        ) {
1131            if !(std::is_x86_feature_detected!("avx") && std::is_x86_feature_detected!("fma")) {
1132                return Ok(());
1133            }
1134            let scalar = l2_f64_scalar(&x, &y);
1135            let avx_fma = unsafe { x86::l2_f64_avx_fma(&x, &y) };
1136            prop_assert!(approx::relative_eq!(scalar, avx_fma, max_relative = 1e-6));
1137        }
1138
1139        /// AVX-only-direct parity for the f64 L2 kernel. Covers the Intel
1140        /// Sandy Bridge / Ivy Bridge tier. Early-returns on hosts without
1141        /// AVX.
1142        #[cfg(target_arch = "x86_64")]
1143        #[test]
1144        fn test_l2_f64_scalar_vs_avx_parity(
1145            (x, y) in arbitrary_vector_pair(arbitrary_f64, 4..4048)
1146        ) {
1147            if !std::is_x86_feature_detected!("avx") {
1148                return Ok(());
1149            }
1150            let scalar = l2_f64_scalar(&x, &y);
1151            let avx = unsafe { x86::l2_f64_avx(&x, &y) };
1152            prop_assert!(approx::relative_eq!(scalar, avx, max_relative = 1e-6));
1153        }
1154
1155        /// AVX-512-direct parity for f32: explicitly compares the scalar
1156        /// fallback against the native f32 AVX-512 inner kernel on
1157        /// AVX-512F-capable hosts.
1158        #[cfg(target_arch = "x86_64")]
1159        #[test]
1160        fn test_l2_f32_scalar_vs_avx512_parity(
1161            (x, y) in arbitrary_vector_pair(arbitrary_f32, 4..4048)
1162        ) {
1163            if !std::is_x86_feature_detected!("avx512f") {
1164                return Ok(());
1165            }
1166            let scalar = l2_f32_scalar(&x, &y);
1167            let avx512 = unsafe { x86::l2_f32_avx512(&x, &y) };
1168            prop_assert!(approx::relative_eq!(scalar, avx512, max_relative = 1e-3));
1169        }
1170
1171        /// AVX + FMA-direct parity for the f32 L2 kernel. Covers the AMD
1172        /// Piledriver / Steamroller / FX-7500 tier. Early-returns on hosts
1173        /// without both AVX and FMA.
1174        #[cfg(target_arch = "x86_64")]
1175        #[test]
1176        fn test_l2_f32_scalar_vs_avx_fma_parity(
1177            (x, y) in arbitrary_vector_pair(arbitrary_f32, 4..4048)
1178        ) {
1179            if !(std::is_x86_feature_detected!("avx") && std::is_x86_feature_detected!("fma")) {
1180                return Ok(());
1181            }
1182            let scalar = l2_f32_scalar(&x, &y);
1183            let avx_fma = unsafe { x86::l2_f32_avx_fma(&x, &y) };
1184            prop_assert!(approx::relative_eq!(scalar, avx_fma, max_relative = 1e-3));
1185        }
1186
1187        /// AVX-only-direct parity for the f32 L2 kernel. Covers the Intel
1188        /// Sandy Bridge / Ivy Bridge tier. Early-returns on hosts without
1189        /// AVX.
1190        #[cfg(target_arch = "x86_64")]
1191        #[test]
1192        fn test_l2_f32_scalar_vs_avx_parity(
1193            (x, y) in arbitrary_vector_pair(arbitrary_f32, 4..4048)
1194        ) {
1195            if !std::is_x86_feature_detected!("avx") {
1196                return Ok(());
1197            }
1198            let scalar = l2_f32_scalar(&x, &y);
1199            let avx = unsafe { x86::l2_f32_avx(&x, &y) };
1200            prop_assert!(approx::relative_eq!(scalar, avx, max_relative = 1e-3));
1201        }
1202    }
1203
1204    #[test]
1205    fn test_uint8_l2_edge_cases() {
1206        let q = vec![0_u8; 2048];
1207        let v = vec![0_u8; 2048];
1208        assert_eq!(l2_distance_uint_scalar(&q, &v), 0.0);
1209
1210        let q = vec![0_u8; 2048];
1211        let v = vec![255_u8; 2048];
1212        assert_eq!(
1213            l2_distance_uint_scalar(&q, &v),
1214            (255_u32.pow(2) * 2048) as f32
1215        );
1216        assert_eq!(
1217            l2_distance_uint_scalar(&v, &q),
1218            (255_u32.pow(2) * 2048) as f32
1219        );
1220    }
1221
1222    #[test]
1223    fn test_l2_targets_matches_scalar() {
1224        let cases = vec![
1225            (16, 8),   // small target count
1226            (16, 16),  // exact SIMD width
1227            (16, 256), // PQ-like: 256 centroids, 16-dim sub-vectors
1228            (16, 17),  // one remainder
1229            (16, 31),  // 15 remainder
1230            (1, 32),   // dim=1
1231            (3, 20),   // odd dimension
1232            (128, 64), // larger dimension
1233        ];
1234
1235        for (dim, num_targets) in cases {
1236            let query: Vec<f32> = (0..dim).map(|i| (i as f32) * 0.1 + 0.05).collect();
1237            let targets: Vec<f32> = (0..dim * num_targets)
1238                .map(|i| ((i * 7 + 3) % 100) as f32 * 0.01)
1239                .collect();
1240
1241            let expected: Vec<f32> = targets
1242                .chunks_exact(dim)
1243                .map(|v| l2_scalar::<f32, f32, 16>(&query, v))
1244                .collect();
1245
1246            let prepared = L2Prepared::new(&targets, dim);
1247            let actual = prepared.distances(&query);
1248
1249            assert_eq!(
1250                actual.len(),
1251                expected.len(),
1252                "length mismatch for dim={dim}, num_targets={num_targets}"
1253            );
1254            for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
1255                assert!(
1256                    approx::relative_eq!(a, e, max_relative = 1e-6),
1257                    "mismatch at index {i} for dim={dim}, num_targets={num_targets}: \
1258                     prepared={a}, scalar={e}"
1259                );
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn test_l2_targets_zeros() {
1266        let dim = 16;
1267        let num_targets = 32;
1268        let query = vec![0.0f32; dim];
1269        let targets = vec![0.0f32; dim * num_targets];
1270
1271        let prepared = L2Prepared::new(&targets, dim);
1272        let distances = prepared.distances(&query);
1273        assert_eq!(distances.len(), num_targets);
1274        for d in &distances {
1275            assert_eq!(*d, 0.0);
1276        }
1277    }
1278
1279    #[test]
1280    fn test_l2_targets_known_values() {
1281        let dim = 2;
1282        let query = vec![1.0f32, 0.0];
1283
1284        // 16 targets: [1,0], [0,1], [2,0], [0,0], then 12x [0,0]
1285        let mut targets = vec![1.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 0.0];
1286        for _ in 4..16 {
1287            targets.extend_from_slice(&[0.0, 0.0]);
1288        }
1289
1290        let prepared = L2Prepared::new(&targets, dim);
1291        let distances = prepared.distances(&query);
1292        assert_eq!(distances.len(), 16);
1293        assert_relative_eq!(distances[0], 0.0);
1294        assert_relative_eq!(distances[1], 2.0);
1295        assert_relative_eq!(distances[2], 1.0);
1296        assert_relative_eq!(distances[3], 1.0);
1297        for d in &distances[4..] {
1298            assert_relative_eq!(*d, 1.0);
1299        }
1300    }
1301
1302    #[test]
1303    fn test_l2_targets_reuse() {
1304        // Verify that the same L2Prepared can be queried multiple times
1305        let dim = 4;
1306        let targets = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1307        let prepared = L2Prepared::new(&targets, dim);
1308
1309        let q1 = vec![1.0, 2.0, 3.0, 4.0];
1310        let q2 = vec![5.0, 6.0, 7.0, 8.0];
1311
1312        let d1 = prepared.distances(&q1);
1313        let d2 = prepared.distances(&q2);
1314
1315        assert_relative_eq!(d1[0], 0.0); // q1 == target[0]
1316        assert_relative_eq!(d2[1], 0.0); // q2 == target[1]
1317    }
1318
1319    /// `l2_batch` must agree with the per-vector `l2` it replaced, on every
1320    /// build: the AVX2-baseline path, the hoisted-dispatch path, and the
1321    /// portable fallback all funnel through here.
1322    #[rstest::rstest]
1323    #[case::dim_8(8)]
1324    #[case::dim_16(16)]
1325    #[case::dim_32(32)]
1326    #[case::dim_1024(1024)]
1327    fn test_l2_batch_f32_matches_per_vector_l2(#[case] dimension: usize) {
1328        let num_vectors = 5;
1329        let x: Vec<f32> = (0..dimension)
1330            .map(|i| ((i % 13) as f32) * 0.25 + 1.0)
1331            .collect();
1332        let batch: Vec<f32> = (0..dimension * num_vectors)
1333            .map(|i| ((i % 11) as f32) * 0.5 - 2.0)
1334            .collect();
1335
1336        let got: Vec<f32> = f32::l2_batch(&x, &batch, dimension).collect();
1337        let want: Vec<f32> = batch
1338            .chunks_exact(dimension)
1339            .map(|y| f32::l2(&x, y))
1340            .collect();
1341
1342        assert_eq!(got.len(), num_vectors);
1343        for (g, w) in got.iter().zip(want.iter()) {
1344            assert!(
1345                approx::relative_eq!(g, w, epsilon = 1e-4),
1346                "dim {dimension}: batch {g} != per-vector {w}"
1347            );
1348        }
1349    }
1350
1351    /// The per-batch `#[target_feature]` kernels are only reached on sub-AVX2
1352    /// builds or AVX-512 hosts, so call them directly to cover them.
1353    #[cfg(all(
1354        target_arch = "x86_64",
1355        not(all(target_feature = "avx2", target_feature = "fma"))
1356    ))]
1357    fn check_l2_batch_kernel(kernel: unsafe fn(&[f32], &[f32], usize) -> Vec<f32>) {
1358        for dimension in [8_usize, 16, 40] {
1359            let num_vectors = 3;
1360            let x: Vec<f32> = (0..dimension).map(|i| (i as f32) * 0.5 + 1.0).collect();
1361            let batch: Vec<f32> = (0..dimension * num_vectors)
1362                .map(|i| ((i % 7) as f32) + 1.0)
1363                .collect();
1364
1365            let got = unsafe { kernel(&x, &batch, dimension) };
1366            assert_eq!(got.len(), num_vectors);
1367            for (chunk, &g) in batch.chunks_exact(dimension).zip(got.iter()) {
1368                let want = l2_scalar::<f32, f32, 16>(&x, chunk);
1369                assert!(
1370                    approx::relative_eq!(g, want, epsilon = 1e-4),
1371                    "dim {dimension}: kernel {g} != scalar {want}"
1372                );
1373            }
1374        }
1375    }
1376
1377    // The runtime-dispatch batch kernels only exist in sub-avx2+fma builds
1378    // (see `x86::l2_batch_f32_avx512`), so gate their tests the same way.
1379    #[cfg(all(
1380        target_arch = "x86_64",
1381        not(all(target_feature = "avx2", target_feature = "fma"))
1382    ))]
1383    #[test]
1384    fn test_l2_batch_avx_fma_matches_scalar() {
1385        if !std::is_x86_feature_detected!("avx") || !std::is_x86_feature_detected!("fma") {
1386            return;
1387        }
1388        check_l2_batch_kernel(x86::l2_batch_f32_avx_fma);
1389    }
1390
1391    #[cfg(all(
1392        target_arch = "x86_64",
1393        not(all(target_feature = "avx2", target_feature = "fma"))
1394    ))]
1395    #[test]
1396    fn test_l2_batch_avx_matches_scalar() {
1397        if !std::is_x86_feature_detected!("avx") {
1398            return;
1399        }
1400        check_l2_batch_kernel(x86::l2_batch_f32_avx);
1401    }
1402
1403    #[cfg(all(
1404        target_arch = "x86_64",
1405        not(all(target_feature = "avx2", target_feature = "fma"))
1406    ))]
1407    #[test]
1408    fn test_l2_batch_avx512_matches_scalar() {
1409        if !std::is_x86_feature_detected!("avx512f") {
1410            return;
1411        }
1412        check_l2_batch_kernel(x86::l2_batch_f32_avx512);
1413    }
1414}