Skip to main content

slate_simd/
lib.rs

1//! # slate-simd
2//!
3//! SIMD distance kernels for Slate-ANN with runtime CPU-feature dispatch.
4//!
5//! Provides L2², inner-product, and cosine distance over `f32`, plus
6//! asymmetric `f32`-query vs narrow-stored kernels ([`distance_f16`],
7//! [`distance_i8`]) that widen the on-disk representation inside the SIMD
8//! reduction so narrow stores skip a decode-to-`f32` pass. Four implementation
9//! tiers are selected at runtime — AVX-512, AVX2+FMA, ARM NEON, and a portable
10//! scalar fallback (also the correctness oracle for the vectorized paths).
11//!
12//! ## Ranking convention
13//! Mirrors [`slate_core::Metric`]: all distances rank by **ascending** score
14//! (smaller = closer).
15//! - [`l2_sq`] — squared Euclidean (no `sqrt`; preserves ordering).
16//! - [`inner_product`] — **negated** dot product (`−⟨a,b⟩`).
17//! - [`cosine`] — `1 − cos(a,b)` over raw inputs;
18//!   [`cosine_normalized`] is the cheaper `1 − ⟨a,b⟩` for pre-normalized inputs.
19//!
20//! ## Safety model
21//! Vectorized kernels use `#[target_feature]` intrinsics (`unsafe`). They are
22//! only ever invoked behind the runtime dispatcher in [`dispatch`], which
23//! confirms CPU support before selecting a tier. The **public API below is
24//! entirely safe** and validates that input slices have equal length.
25//!
26//! Populated in Phase 1 (f32 kernels + dispatch); narrow-store kernels added in
27//! the Phase-9.5 deferred clean-ups.
28
29#![doc(html_root_url = "https://docs.rs/slate-simd")]
30// The vectorized tiers require raw-intrinsic `unsafe`. We forbid *implicit*
31// unsafe (every intrinsic call must sit in an explicit `unsafe` block with a
32// safety comment) rather than forbidding unsafe entirely.
33#![deny(unsafe_op_in_unsafe_fn)]
34#![cfg_attr(feature = "portable_simd", feature(portable_simd))]
35
36#[cfg(target_arch = "x86_64")]
37mod avx2;
38#[cfg(target_arch = "x86_64")]
39mod avx512;
40mod dispatch;
41#[cfg(target_arch = "aarch64")]
42mod neon;
43pub mod scalar;
44
45pub use dispatch::{active_tier, detect_tier, Tier};
46use slate_core::{Error, Result};
47
48/// Validate that two operand slices have identical, non-zero length.
49#[inline]
50fn check(a: &[f32], b: &[f32]) -> Result<()> {
51    if a.len() != b.len() {
52        return Err(Error::DimensionMismatch {
53            expected: a.len(),
54            got: b.len(),
55        });
56    }
57    Ok(())
58}
59
60/// Squared Euclidean (L2²) distance, dispatched to the best available tier.
61///
62/// # Errors
63/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
64#[inline]
65pub fn l2_sq(a: &[f32], b: &[f32]) -> Result<f32> {
66    check(a, b)?;
67    Ok(dispatch::l2_sq_kernel()(a, b))
68}
69
70/// Inner-product distance `−⟨a,b⟩`, dispatched to the best available tier.
71///
72/// # Errors
73/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
74#[inline]
75pub fn inner_product(a: &[f32], b: &[f32]) -> Result<f32> {
76    check(a, b)?;
77    Ok(-dispatch::dot_kernel()(a, b))
78}
79
80/// Raw inner product `⟨a,b⟩` (not negated), dispatched to the best tier.
81///
82/// Exposed for callers that need the similarity directly (e.g. PQ table
83/// construction). Most search code wants [`inner_product`].
84///
85/// # Errors
86/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
87#[inline]
88pub fn dot(a: &[f32], b: &[f32]) -> Result<f32> {
89    check(a, b)?;
90    Ok(dispatch::dot_kernel()(a, b))
91}
92
93/// Cosine distance `1 − cos(a,b)` over raw (un-normalized) inputs.
94///
95/// Zero-norm operands yield distance `1.0`.
96///
97/// # Errors
98/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
99#[inline]
100pub fn cosine(a: &[f32], b: &[f32]) -> Result<f32> {
101    check(a, b)?;
102    let (d, na, nb) = dispatch::cosine_parts(a, b);
103    let denom = (na * nb).sqrt();
104    if denom == 0.0 {
105        Ok(1.0)
106    } else {
107        Ok(1.0 - d / denom)
108    }
109}
110
111/// Cosine distance for pre-normalized inputs: `1 − ⟨a,b⟩`.
112///
113/// # Errors
114/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
115#[inline]
116pub fn cosine_normalized(a: &[f32], b: &[f32]) -> Result<f32> {
117    check(a, b)?;
118    Ok(1.0 - dispatch::dot_kernel()(a, b))
119}
120
121/// Dispatch a distance computation by [`slate_core::Metric`].
122///
123/// Convenience for callers that hold a runtime `Metric`. `Cosine` here uses the
124/// raw-input path ([`cosine`]); when the storage layer pre-normalizes vectors,
125/// prefer calling [`cosine_normalized`] (or [`inner_product`]) directly to skip
126/// the redundant norm computation.
127///
128/// # Errors
129/// Returns [`Error::DimensionMismatch`] if `a.len() != b.len()`.
130#[inline]
131pub fn distance(metric: slate_core::Metric, a: &[f32], b: &[f32]) -> Result<f32> {
132    use slate_core::Metric;
133    match metric {
134        Metric::L2 => l2_sq(a, b),
135        Metric::InnerProduct => inner_product(a, b),
136        Metric::Cosine => cosine(a, b),
137    }
138}
139
140/// Distance between an `f32` query and an `f16`-stored vector, by [`Metric`].
141///
142/// `stored` holds `query.len()` little-endian `f16` elements (`2·len` bytes),
143/// exactly as written by the storage codec. The result is numerically identical
144/// to decoding `stored` to `f32` and calling [`distance`] — the native kernels
145/// just fuse the widen into the SIMD reduction. `Cosine` uses the raw-input
146/// path; zero-norm operands yield `1.0`.
147///
148/// [`Metric`]: slate_core::Metric
149///
150/// # Errors
151/// Returns [`Error::DimensionMismatch`] if `stored.len() != 2 * query.len()`.
152#[inline]
153pub fn distance_f16(metric: slate_core::Metric, query: &[f32], stored: &[u8]) -> Result<f32> {
154    use slate_core::Metric;
155    if stored.len() != 2 * query.len() {
156        return Err(Error::DimensionMismatch {
157            expected: 2 * query.len(),
158            got: stored.len(),
159        });
160    }
161    match metric {
162        Metric::L2 => Ok(dispatch::l2_sq_f16(query, stored)),
163        Metric::InnerProduct => Ok(-dispatch::dot_f16(query, stored)),
164        Metric::Cosine => {
165            let (d, nq, ns) = dispatch::cosine_parts_f16(query, stored);
166            let denom = (nq * ns).sqrt();
167            if denom == 0.0 {
168                Ok(1.0)
169            } else {
170                Ok(1.0 - d / denom)
171            }
172        }
173    }
174}
175
176/// Distance between an `f32` query and an `i8`-stored vector, by [`Metric`].
177///
178/// `codes` holds `query.len()` signed codes whose dequantized value is
179/// `code * scale` (the symmetric per-vector scale written by the storage codec).
180/// The result is numerically identical to decoding to `f32` and calling
181/// [`distance`]. `Cosine` uses the raw-input path; zero-norm operands yield
182/// `1.0`.
183///
184/// [`Metric`]: slate_core::Metric
185///
186/// # Errors
187/// Returns [`Error::DimensionMismatch`] if `codes.len() != query.len()`.
188#[inline]
189pub fn distance_i8(
190    metric: slate_core::Metric,
191    query: &[f32],
192    scale: f32,
193    codes: &[i8],
194) -> Result<f32> {
195    use slate_core::Metric;
196    if codes.len() != query.len() {
197        return Err(Error::DimensionMismatch {
198            expected: query.len(),
199            got: codes.len(),
200        });
201    }
202    match metric {
203        Metric::L2 => Ok(dispatch::l2_sq_i8(query, scale, codes)),
204        Metric::InnerProduct => Ok(-dispatch::dot_i8(query, scale, codes)),
205        Metric::Cosine => {
206            let (d, nq, ns) = dispatch::cosine_parts_i8(query, scale, codes);
207            let denom = (nq * ns).sqrt();
208            if denom == 0.0 {
209                Ok(1.0)
210            } else {
211                Ok(1.0 - d / denom)
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn dimension_mismatch_is_reported() {
223        let a = [1.0f32, 2.0, 3.0];
224        let b = [1.0f32, 2.0];
225        assert!(matches!(
226            l2_sq(&a, &b),
227            Err(Error::DimensionMismatch { expected: 3, got: 2 })
228        ));
229    }
230
231    #[test]
232    fn l2_sq_known_value() {
233        let a = [0.0f32, 0.0, 0.0];
234        let b = [1.0f32, 2.0, 2.0];
235        // 1 + 4 + 4 = 9
236        assert!((l2_sq(&a, &b).unwrap() - 9.0).abs() < 1e-6);
237    }
238
239    #[test]
240    fn inner_product_is_negated() {
241        let a = [1.0f32, 2.0, 3.0];
242        let b = [1.0f32, 1.0, 1.0];
243        // dot = 6 -> distance = -6
244        assert!((inner_product(&a, &b).unwrap() + 6.0).abs() < 1e-6);
245        assert!((dot(&a, &b).unwrap() - 6.0).abs() < 1e-6);
246    }
247
248    #[test]
249    fn cosine_identical_is_zero() {
250        let a = [1.0f32, 2.0, 3.0, 4.0];
251        assert!(cosine(&a, &a).unwrap().abs() < 1e-6);
252    }
253
254    #[test]
255    fn cosine_orthogonal_is_one() {
256        let a = [1.0f32, 0.0];
257        let b = [0.0f32, 1.0];
258        assert!((cosine(&a, &b).unwrap() - 1.0).abs() < 1e-6);
259    }
260
261    #[test]
262    fn cosine_zero_norm_is_one() {
263        let a = [0.0f32, 0.0, 0.0];
264        let b = [1.0f32, 2.0, 3.0];
265        assert!((cosine(&a, &b).unwrap() - 1.0).abs() < 1e-6);
266    }
267
268    #[test]
269    fn cosine_normalized_matches_cosine_on_unit_vectors() {
270        // Build two unit vectors.
271        let a = [0.6f32, 0.8];
272        let b = [1.0f32, 0.0];
273        let raw = cosine(&a, &b).unwrap();
274        let norm = cosine_normalized(&a, &b).unwrap();
275        assert!((raw - norm).abs() < 1e-6);
276    }
277
278    #[test]
279    fn active_tier_is_reported() {
280        // Just ensure detection runs and returns a stable value.
281        let t = active_tier();
282        assert_eq!(t, active_tier());
283        println!("active tier: {}", t.as_str());
284    }
285
286    #[test]
287    fn distance_f16_equals_decode_then_distance() {
288        use half::f16;
289        use slate_core::Metric;
290        let query = [0.5f32, -1.25, 3.0, 0.0, -2.5, 7.5, -0.125, 4.0, 1.0];
291        let raw = [0.4f32, -1.0, 3.25, 0.5, -2.0, 7.0, -0.25, 4.5, 0.75];
292        let stored: Vec<u8> = raw
293            .iter()
294            .flat_map(|&x| f16::from_f32(x).to_le_bytes())
295            .collect();
296        // half -> single is exact, so the native kernel must equal decode-then-f32.
297        let decoded: Vec<f32> = raw.iter().map(|&x| f16::from_f32(x).to_f32()).collect();
298        for metric in [Metric::L2, Metric::InnerProduct, Metric::Cosine] {
299            let native = distance_f16(metric, &query, &stored).unwrap();
300            let reference = distance(metric, &query, &decoded).unwrap();
301            assert!(
302                (native - reference).abs() <= 1e-6,
303                "metric={metric:?} native={native} reference={reference}"
304            );
305        }
306    }
307
308    #[test]
309    fn distance_i8_equals_decode_then_distance() {
310        use slate_core::Metric;
311        let query = [0.5f32, -1.25, 3.0, 0.0, -2.5, 7.5, -0.125, 4.0, 1.0];
312        let scale = 0.05f32;
313        let codes = [10i8, -20, 60, 0, -50, 127, -3, 80, 15];
314        let decoded: Vec<f32> = codes.iter().map(|&c| f32::from(c) * scale).collect();
315        for metric in [Metric::L2, Metric::InnerProduct, Metric::Cosine] {
316            let native = distance_i8(metric, &query, scale, &codes).unwrap();
317            let reference = distance(metric, &query, &decoded).unwrap();
318            assert!(
319                (native - reference).abs() <= 1e-6,
320                "metric={metric:?} native={native} reference={reference}"
321            );
322        }
323    }
324
325    #[test]
326    fn narrow_distance_rejects_wrong_length() {
327        use slate_core::Metric;
328        let query = [1.0f32, 2.0, 3.0];
329        // f16 stored must be 2*len bytes.
330        assert!(matches!(
331            distance_f16(Metric::L2, &query, &[0u8; 4]),
332            Err(Error::DimensionMismatch { expected: 6, got: 4 })
333        ));
334        assert!(matches!(
335            distance_i8(Metric::L2, &query, 1.0, &[0i8; 2]),
336            Err(Error::DimensionMismatch { expected: 3, got: 2 })
337        ));
338    }
339}
340
341/// Property tests: the dispatched (vectorized) public API must agree with the
342/// scalar oracle within a relative epsilon, for arbitrary vectors and lengths.
343///
344/// On this machine the dispatcher selects AVX2, so these directly validate the
345/// AVX2 tier against scalar. On AVX-512 / NEON hardware the same tests validate
346/// those tiers. Lengths deliberately include non-multiples of every lane width
347/// (8 for AVX2, 16 for AVX-512, 4 for NEON) to exercise the remainder/mask tail.
348#[cfg(test)]
349mod proptests {
350    use super::*;
351    use proptest::prelude::*;
352
353    /// Absolute-error comparison whose tolerance scales with an explicit error
354    /// bound `scale`. For sums/dot-products the rounding error is bounded by
355    /// `ε · Σ|terms|`, NOT by the magnitude of the (possibly cancelled) result —
356    /// so callers pass the sum of absolute term magnitudes as `scale`. This is
357    /// the numerically correct way to compare a sequential (scalar) reduction
358    /// against a tree (SIMD) reduction.
359    fn approx_eq_scaled(got: f32, want: f32, scale: f32) -> bool {
360        let tol = 1e-4 * scale.max(1.0);
361        (got - want).abs() <= tol
362    }
363
364    /// Sum of absolute element-wise products `Σ|aᵢ·bᵢ|` — the summation error
365    /// scale for dot products and (negated) inner products.
366    fn dot_scale(a: &[f32], b: &[f32]) -> f32 {
367        a.iter().zip(b).map(|(x, y)| (x * y).abs()).sum()
368    }
369
370    /// Sum of absolute squared differences `Σ(aᵢ−bᵢ)²` — the error scale for L2².
371    fn l2_scale(a: &[f32], b: &[f32]) -> f32 {
372        a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
373    }
374
375    // Vectors in a bounded range keep accumulation well-conditioned; lengths
376    // 0..=257 span several lane-width multiples plus odd tails.
377    prop_compose! {
378        fn vec_pair()(len in 0usize..=257)
379                     (a in prop::collection::vec(-10.0f32..10.0, len),
380                      b in prop::collection::vec(-10.0f32..10.0, len))
381                     -> (Vec<f32>, Vec<f32>) {
382            (a, b)
383        }
384    }
385
386    proptest! {
387        #![proptest_config(ProptestConfig::with_cases(2000))]
388
389        #[test]
390        fn l2_sq_matches_oracle((a, b) in vec_pair()) {
391            let got = l2_sq(&a, &b).unwrap();
392            let want = scalar::l2_sq(&a, &b);
393            let scale = l2_scale(&a, &b);
394            prop_assert!(approx_eq_scaled(got, want, scale),
395                "got={got} want={want} scale={scale} len={}", a.len());
396        }
397
398        #[test]
399        fn dot_matches_oracle((a, b) in vec_pair()) {
400            let got = dot(&a, &b).unwrap();
401            let want = scalar::dot(&a, &b);
402            let scale = dot_scale(&a, &b);
403            prop_assert!(approx_eq_scaled(got, want, scale),
404                "got={got} want={want} scale={scale} len={}", a.len());
405        }
406
407        #[test]
408        fn inner_product_is_negated_dot((a, b) in vec_pair()) {
409            let got = inner_product(&a, &b).unwrap();
410            let want = -scalar::dot(&a, &b);
411            let scale = dot_scale(&a, &b);
412            prop_assert!(approx_eq_scaled(got, want, scale));
413        }
414
415        #[test]
416        fn cosine_matches_oracle((a, b) in vec_pair()) {
417            let got = cosine(&a, &b).unwrap();
418            let want = scalar::cosine_distance(&a, &b);
419            // Cosine is bounded in [0, 2]; its denominator normalizes magnitudes,
420            // so a small fixed absolute tolerance is appropriate here.
421            prop_assert!((got - want).abs() <= 1e-4,
422                "got={got} want={want} len={}", a.len());
423        }
424    }
425
426    use half::f16;
427    use slate_core::Metric;
428
429    /// Encode an `f32` vector to the on-disk f16 byte layout (2 bytes/elem, LE),
430    /// matching the storage codec without depending on `slate-storage`.
431    fn encode_f16(v: &[f32]) -> Vec<u8> {
432        let mut out = Vec::with_capacity(2 * v.len());
433        for &x in v {
434            out.extend_from_slice(&f16::from_f32(x).to_le_bytes());
435        }
436        out
437    }
438
439    /// Symmetric per-vector i8 quantization, mirroring the storage codec:
440    /// `scale = max|x| / 127`, `code = round(x / scale)` clamped to `[-127, 127]`;
441    /// an all-zero vector yields `scale = 0` and all-zero codes.
442    fn encode_i8(v: &[f32]) -> (f32, Vec<i8>) {
443        let max_abs = v.iter().fold(0.0f32, |m, &x| m.max(x.abs()));
444        let scale = if max_abs == 0.0 { 0.0 } else { max_abs / 127.0 };
445        let codes = v
446            .iter()
447            .map(|&x| {
448                if scale == 0.0 {
449                    0i8
450                } else {
451                    (x / scale).round().clamp(-127.0, 127.0) as i8
452                }
453            })
454            .collect();
455        (scale, codes)
456    }
457
458    /// Scalar reference for f16 distance: decode-then-compose, identical shape to
459    /// the public `distance_f16` but always through the scalar kernels.
460    fn scalar_distance_f16(metric: Metric, query: &[f32], stored: &[u8]) -> f32 {
461        match metric {
462            Metric::L2 => scalar::l2_sq_f16(query, stored),
463            Metric::InnerProduct => -scalar::dot_f16(query, stored),
464            Metric::Cosine => {
465                let (d, nq, ns) = scalar::cosine_parts_f16(query, stored);
466                let denom = (nq * ns).sqrt();
467                if denom == 0.0 { 1.0 } else { 1.0 - d / denom }
468            }
469        }
470    }
471
472    fn scalar_distance_i8(metric: Metric, query: &[f32], scale: f32, codes: &[i8]) -> f32 {
473        match metric {
474            Metric::L2 => scalar::l2_sq_i8(query, scale, codes),
475            Metric::InnerProduct => -scalar::dot_i8(query, scale, codes),
476            Metric::Cosine => {
477                let (d, nq, ns) = scalar::cosine_parts_i8(query, scale, codes);
478                let denom = (nq * ns).sqrt();
479                if denom == 0.0 { 1.0 } else { 1.0 - d / denom }
480            }
481        }
482    }
483
484    /// `Σ|query·stored|` error scale for the f16/i8 dot reductions.
485    fn dot_scale_decoded(query: &[f32], stored: &[f32]) -> f32 {
486        query.iter().zip(stored).map(|(x, y)| (x * y).abs()).sum()
487    }
488
489    /// `Σ(query−stored)²` error scale for the f16/i8 L2 reductions.
490    fn l2_scale_decoded(query: &[f32], stored: &[f32]) -> f32 {
491        query.iter().zip(stored).map(|(x, y)| (x - y) * (x - y)).sum()
492    }
493
494    proptest! {
495        #![proptest_config(ProptestConfig::with_cases(2000))]
496
497        #[test]
498        fn l2_sq_f16_matches_oracle((q, v) in vec_pair()) {
499            let stored = encode_f16(&v);
500            let decoded: Vec<f32> = v.iter().map(|&x| f16::from_f32(x).to_f32()).collect();
501            let got = distance_f16(Metric::L2, &q, &stored).unwrap();
502            let want = scalar_distance_f16(Metric::L2, &q, &stored);
503            let scale = l2_scale_decoded(&q, &decoded);
504            prop_assert!(approx_eq_scaled(got, want, scale),
505                "got={got} want={want} scale={scale} len={}", q.len());
506        }
507
508        #[test]
509        fn dot_f16_matches_oracle((q, v) in vec_pair()) {
510            let stored = encode_f16(&v);
511            let decoded: Vec<f32> = v.iter().map(|&x| f16::from_f32(x).to_f32()).collect();
512            let got = distance_f16(Metric::InnerProduct, &q, &stored).unwrap();
513            let want = scalar_distance_f16(Metric::InnerProduct, &q, &stored);
514            let scale = dot_scale_decoded(&q, &decoded);
515            prop_assert!(approx_eq_scaled(got, want, scale),
516                "got={got} want={want} scale={scale} len={}", q.len());
517        }
518
519        #[test]
520        fn cosine_f16_matches_oracle((q, v) in vec_pair()) {
521            let stored = encode_f16(&v);
522            let got = distance_f16(Metric::Cosine, &q, &stored).unwrap();
523            let want = scalar_distance_f16(Metric::Cosine, &q, &stored);
524            prop_assert!((got - want).abs() <= 1e-4,
525                "got={got} want={want} len={}", q.len());
526        }
527
528        #[test]
529        fn l2_sq_i8_matches_oracle((q, v) in vec_pair()) {
530            let (scale_q, codes) = encode_i8(&v);
531            let decoded: Vec<f32> = codes.iter().map(|&c| f32::from(c) * scale_q).collect();
532            let got = distance_i8(Metric::L2, &q, scale_q, &codes).unwrap();
533            let want = scalar_distance_i8(Metric::L2, &q, scale_q, &codes);
534            let scale = l2_scale_decoded(&q, &decoded);
535            prop_assert!(approx_eq_scaled(got, want, scale),
536                "got={got} want={want} scale={scale} len={}", q.len());
537        }
538
539        #[test]
540        fn dot_i8_matches_oracle((q, v) in vec_pair()) {
541            let (scale_q, codes) = encode_i8(&v);
542            let decoded: Vec<f32> = codes.iter().map(|&c| f32::from(c) * scale_q).collect();
543            let got = distance_i8(Metric::InnerProduct, &q, scale_q, &codes).unwrap();
544            let want = scalar_distance_i8(Metric::InnerProduct, &q, scale_q, &codes);
545            let scale = dot_scale_decoded(&q, &decoded);
546            prop_assert!(approx_eq_scaled(got, want, scale),
547                "got={got} want={want} scale={scale} len={}", q.len());
548        }
549
550        #[test]
551        fn cosine_i8_matches_oracle((q, v) in vec_pair()) {
552            let (scale_q, codes) = encode_i8(&v);
553            let got = distance_i8(Metric::Cosine, &q, scale_q, &codes).unwrap();
554            let want = scalar_distance_i8(Metric::Cosine, &q, scale_q, &codes);
555            prop_assert!((got - want).abs() <= 1e-4,
556                "got={got} want={want} len={}", q.len());
557        }
558    }
559}