Skip to main content

lattice_embed/simd/
quantized.rs

1//! INT8 quantization for efficient embedding storage and similarity computation.
2//!
3//! Quantized vectors provide ~3x speedup and 4x memory reduction with 99%+ accuracy.
4
5#[cfg(target_arch = "x86_64")]
6use std::arch::x86_64::*;
7
8#[cfg(target_arch = "aarch64")]
9use std::arch::aarch64::*;
10
11use std::sync::OnceLock;
12
13use super::simd_config;
14
15/// **Unstable**: INT8 quantization parameters; scale/bias scheme may change.
16///
17/// Quantization parameters for int8 conversion.
18#[derive(Debug, Clone, Copy)]
19pub struct QuantizationParams {
20    /// **Unstable**: scale factor; formula may change with scheme update.
21    pub scale: f32,
22    /// **Unstable**: zero point offset; may be removed for symmetric-only quantization.
23    pub zero_point: i8,
24    /// **Unstable**: min float value; may be removed.
25    pub min_val: f32,
26    /// **Unstable**: max float value; may be removed.
27    pub max_val: f32,
28}
29
30impl QuantizationParams {
31    /// **Unstable**: parameter computation; may be folded into `QuantizedVector::from_f32`.
32    ///
33    /// Handles edge cases: empty vectors, NaN, Inf, near-zero vectors.
34    pub fn from_vector(vector: &[f32]) -> Self {
35        // Single pass over finite values to handle NaN/Inf gracefully.
36        let mut min_val = f32::INFINITY;
37        let mut max_val = f32::NEG_INFINITY;
38
39        for &v in vector {
40            if v.is_finite() {
41                min_val = min_val.min(v);
42                max_val = max_val.max(v);
43            }
44        }
45
46        // Handle edge case: empty or all non-finite.
47        if !min_val.is_finite() || !max_val.is_finite() {
48            min_val = 0.0;
49            max_val = 0.0;
50        }
51
52        // Symmetric quantization: map [-max_abs, max_abs] to [-127, 127]
53        let max_abs = min_val.abs().max(max_val.abs());
54
55        // Epsilon guard to avoid division by near-zero
56        let scale = if max_abs > 1e-10 {
57            127.0 / max_abs
58        } else {
59            1.0 // All zeros or near-zero case
60        };
61
62        Self {
63            scale,
64            zero_point: 0,
65            min_val,
66            max_val,
67        }
68    }
69}
70
71/// **Unstable**: INT8 quantized vector; struct layout and invariants may change.
72///
73/// Quantized int8 vector with its parameters.
74#[derive(Debug, Clone)]
75pub struct QuantizedVector {
76    /// Invariant: all values in `[-127, 127]`. Enforced by `from_f32` clamping.
77    /// Private — the invariant makes release-mode assert scans unnecessary.
78    data: Vec<i8>,
79    /// **Unstable**: quantization parameters; may be separated from the vector.
80    pub params: QuantizationParams,
81    /// **Unstable**: L2 norm; may be removed or moved.
82    pub norm: f32,
83}
84
85impl QuantizedVector {
86    /// Returns the quantized data as a slice. All values are in `[-127, 127]`.
87    #[inline]
88    pub fn data(&self) -> &[i8] {
89        &self.data
90    }
91
92    /// Returns the number of quantized elements.
93    #[inline]
94    pub fn len(&self) -> usize {
95        self.data.len()
96    }
97
98    /// Returns `true` if the quantized vector has no elements.
99    #[inline]
100    pub fn is_empty(&self) -> bool {
101        self.data.is_empty()
102    }
103}
104
105impl QuantizedVector {
106    /// **Unstable**: quantization constructor; clamping behavior may change.
107    pub fn from_f32(vector: &[f32]) -> Self {
108        let mut params = QuantizationParams::from_vector(vector);
109
110        // Defensive guard: avoid NaN/Inf/zero scale.
111        if !params.scale.is_finite() || params.scale == 0.0 {
112            params.scale = 1.0;
113        }
114
115        // Compute L2 norm of finite values (NaN/Inf are treated as 0.0).
116        let mut norm_sq = 0.0f32;
117        for &v in vector {
118            if v.is_finite() {
119                norm_sq += v * v;
120            }
121        }
122        let norm = norm_sq.sqrt();
123
124        let data: Vec<i8> = vector
125            .iter()
126            .map(|&v| {
127                if !v.is_finite() {
128                    0
129                } else {
130                    (v * params.scale).round().clamp(-127.0, 127.0) as i8
131                }
132            })
133            .collect();
134
135        Self { data, params, norm }
136    }
137
138    /// **Unstable**: dequantization; output precision may change with scheme update.
139    ///
140    /// # Precision
141    ///
142    /// INT8 symmetric quantization maps `[-max_abs, max_abs]` to `[-127, 127]`,
143    /// so the quantization step size is `max_abs / 127`. The maximum per-element
144    /// round-trip error is bounded by half a quantization step: `max_abs / 254`.
145    ///
146    /// For a 384-dim unit-norm embedding (`max_abs` ≈ 1.0), expect element-wise
147    /// absolute error ≤ 0.004 and cosine-similarity error ≤ 0.5%.
148    pub fn to_f32(&self) -> Vec<f32> {
149        let scale = if self.params.scale.is_finite() && self.params.scale != 0.0 {
150            self.params.scale
151        } else {
152            1.0
153        };
154
155        self.data.iter().map(|&v| v as f32 / scale).collect()
156    }
157
158    /// **Unstable**: delegates to `dot_product_i8`; SIMD dispatch may change.
159    #[inline]
160    pub fn dot_product(&self, other: &QuantizedVector) -> f32 {
161        dot_product_i8(self, other)
162    }
163
164    /// **Unstable**: delegates to `cosine_similarity_i8`; SIMD dispatch may change.
165    #[inline]
166    pub fn cosine_similarity(&self, other: &QuantizedVector) -> f32 {
167        cosine_similarity_i8(self, other)
168    }
169}
170
171/// **Unstable**: SIMD INT8 dot product; VNNI/AVX2/NEON dispatch may change.
172///
173/// Returns the approximate float dot product.
174/// Returns 0.0 if vectors have different lengths.
175///
176/// # Invariant
177///
178/// Both vectors must satisfy the `[-127, 127]` range invariant. The value `-128`
179/// causes silent corruption on the AVX2 and AVX-512-VNNI paths, which apply an
180/// operand's sign by negating a byte (`_mm256_sign_epi8` / a `0 - b` emulation):
181/// negating `-128` wraps back to `-128` in two's complement instead of `+128`,
182/// so the kernel accumulates the wrong sign. The `data` field is private, so
183/// only `from_f32` (which clamps) can populate it — `debug_assert!` is sufficient.
184#[inline]
185pub fn dot_product_i8(a: &QuantizedVector, b: &QuantizedVector) -> f32 {
186    debug_assert!(a.data.iter().all(|&v| v != -128i8));
187    debug_assert!(b.data.iter().all(|&v| v != -128i8));
188
189    if a.data.len() != b.data.len() {
190        return 0.0;
191    }
192
193    let denom = a.params.scale * b.params.scale;
194    if denom == 0.0 || !denom.is_finite() {
195        return 0.0;
196    }
197
198    dot_product_i8_dispatch(&a.data, &b.data) / denom
199}
200
201/// Trusted INT8 dot product for constructor-owned vectors in prepared-query paths.
202///
203/// Uses `debug_assert!` instead of `assert!`; callers must guarantee vectors
204/// were produced by `QuantizedVector::from_f32` or equivalent (clamped to [-127,127]).
205#[inline]
206pub(crate) fn dot_product_i8_trusted(a: &QuantizedVector, b: &QuantizedVector) -> f32 {
207    if a.data.len() != b.data.len() {
208        return 0.0;
209    }
210    let denom = a.params.scale * b.params.scale;
211    if denom == 0.0 || !denom.is_finite() {
212        return 0.0;
213    }
214    debug_assert!(a.data.iter().all(|&v| v != i8::MIN));
215    debug_assert!(b.data.iter().all(|&v| v != i8::MIN));
216    dot_product_i8_dispatch(&a.data, &b.data) / denom
217}
218
219/// **Unstable**: SIMD INT8 cosine similarity; norm storage approach may change.
220///
221/// Uses pre-computed norms for efficiency.
222#[inline]
223pub fn cosine_similarity_i8(a: &QuantizedVector, b: &QuantizedVector) -> f32 {
224    let denom = a.norm * b.norm;
225    if denom == 0.0 || !denom.is_finite() {
226        return 0.0;
227    }
228    dot_product_i8(a, b) / denom
229}
230
231/// Trusted INT8 cosine similarity for constructor-owned vectors in prepared-query paths.
232///
233/// Uses `dot_product_i8_trusted` instead of `dot_product_i8` to skip release-mode
234/// O(N) invariant scans. Callers must guarantee vectors were produced by
235/// `QuantizedVector::from_f32` or equivalent (clamped to [-127, 127]).
236#[inline]
237pub(crate) fn cosine_similarity_i8_trusted(a: &QuantizedVector, b: &QuantizedVector) -> f32 {
238    let denom = a.norm * b.norm;
239    if denom == 0.0 || !denom.is_finite() {
240        return 0.0;
241    }
242    dot_product_i8_trusted(a, b) / denom
243}
244
245/// NEON int8 dot product using vmull/vpadal with 4x unrolling and software prefetch.
246///
247/// Processes 64 int8s per iteration with 4 accumulators. Prefetches the next
248/// cache line (64 bytes) one iteration ahead to hide DRAM latency for large dims.
249///
250/// # Safety
251///
252/// Caller must ensure:
253/// - Running on aarch64 with FEAT_DotProd (uses SDOT instruction)
254/// - `a` and `b` have equal length (checked by caller)
255/// - No element is i8::MIN (-128) — see SIMD invariant docs
256///
257/// Memory safety:
258/// - Uses `vld1q_s8` for loads (handles any alignment)
259/// - Pointer arithmetic stays within slice bounds via chunk calculation
260/// - Remainder handled via safe slice iteration
261/// - Prefetch pointers are bounds-checked before issuing (only prefetch if within slice)
262#[cfg(target_arch = "aarch64")]
263#[target_feature(enable = "dotprod")]
264unsafe fn dot_product_i8_neon_unrolled(a: &[i8], b: &[i8]) -> f32 {
265    const SIMD_WIDTH: usize = 16;
266    const UNROLL: usize = 4;
267    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
268    const PREFETCH_DISTANCE: usize = CHUNK_SIZE;
269    let n = a.len();
270    debug_assert_eq!(n, b.len());
271    let chunks = n / CHUNK_SIZE;
272
273    let mut sum0 = vdupq_n_s32(0);
274    let mut sum1 = vdupq_n_s32(0);
275    let mut sum2 = vdupq_n_s32(0);
276    let mut sum3 = vdupq_n_s32(0);
277
278    // Use SDOT (ARMv8.2 dotprod) via inline asm — 1 instruction per 16 i8 elements
279    // vs 6 instructions (split + vmull×2 + vpadalq×2) with the widening approach.
280    // Apple Silicon (M1+) supports dotprod; runtime check at dispatch level.
281    for i in 0..chunks {
282        let base = i * CHUNK_SIZE;
283
284        let next_base = base + PREFETCH_DISTANCE;
285        if next_base + CHUNK_SIZE <= n {
286            core::arch::asm!(
287                "prfm pldl1keep, [{ptr}]",
288                ptr = in(reg) a.as_ptr().add(next_base),
289                options(nostack, readonly, preserves_flags)
290            );
291            core::arch::asm!(
292                "prfm pldl1keep, [{ptr}]",
293                ptr = in(reg) b.as_ptr().add(next_base),
294                options(nostack, readonly, preserves_flags)
295            );
296        }
297
298        let a0 = vld1q_s8(a.as_ptr().add(base));
299        let b0 = vld1q_s8(b.as_ptr().add(base));
300        let a1 = vld1q_s8(a.as_ptr().add(base + SIMD_WIDTH));
301        let b1 = vld1q_s8(b.as_ptr().add(base + SIMD_WIDTH));
302        let a2 = vld1q_s8(a.as_ptr().add(base + SIMD_WIDTH * 2));
303        let b2 = vld1q_s8(b.as_ptr().add(base + SIMD_WIDTH * 2));
304        let a3 = vld1q_s8(a.as_ptr().add(base + SIMD_WIDTH * 3));
305        let b3 = vld1q_s8(b.as_ptr().add(base + SIMD_WIDTH * 3));
306
307        core::arch::asm!(
308            "sdot {s0:v}.4s, {a0:v}.16b, {b0:v}.16b",
309            "sdot {s1:v}.4s, {a1:v}.16b, {b1:v}.16b",
310            "sdot {s2:v}.4s, {a2:v}.16b, {b2:v}.16b",
311            "sdot {s3:v}.4s, {a3:v}.16b, {b3:v}.16b",
312            s0 = inout(vreg) sum0,
313            a0 = in(vreg) a0,
314            b0 = in(vreg) b0,
315            s1 = inout(vreg) sum1,
316            a1 = in(vreg) a1,
317            b1 = in(vreg) b1,
318            s2 = inout(vreg) sum2,
319            a2 = in(vreg) a2,
320            b2 = in(vreg) b2,
321            s3 = inout(vreg) sum3,
322            a3 = in(vreg) a3,
323            b3 = in(vreg) b3,
324            options(nomem, nostack, preserves_flags)
325        );
326    }
327
328    let sum01 = vaddq_s32(sum0, sum1);
329    let sum23 = vaddq_s32(sum2, sum3);
330    let mut sum_vec = vaddq_s32(sum01, sum23);
331
332    // Tail: remaining full 16-byte vectors using sdot
333    let tail_start = chunks * CHUNK_SIZE;
334    let tail_chunks = (n - tail_start) / SIMD_WIDTH;
335    for j in 0..tail_chunks {
336        let base = tail_start + j * SIMD_WIDTH;
337        let at = vld1q_s8(a.as_ptr().add(base));
338        let bt = vld1q_s8(b.as_ptr().add(base));
339        core::arch::asm!(
340            "sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
341            acc = inout(vreg) sum_vec,
342            a = in(vreg) at,
343            b = in(vreg) bt,
344            options(nomem, nostack, preserves_flags)
345        );
346    }
347
348    let sum = vaddvq_s32(sum_vec);
349
350    // Scalar tail: only the final < SIMD_WIDTH elements
351    let remainder_start = tail_start + tail_chunks * SIMD_WIDTH;
352    let remainder: i32 = a[remainder_start..]
353        .iter()
354        .zip(b[remainder_start..].iter())
355        .map(|(&x, &y)| x as i32 * y as i32)
356        .sum();
357
358    (sum + remainder) as f32
359}
360
361/// Emulate `mm512_sign_epi8(b, a)` which doesn't exist in AVX-512.
362///
363/// Returns: b[i] if a[i] > 0, -b[i] if a[i] < 0, 0 if a[i] == 0.
364///
365/// # Safety
366/// Requires AVX-512BW.
367#[cfg(all(target_arch = "x86_64", feature = "avx512"))]
368#[target_feature(enable = "avx512f", enable = "avx512bw")]
369#[inline]
370unsafe fn mm512_sign_epi8(b: __m512i, a: __m512i) -> __m512i {
371    let zero = _mm512_setzero_si512();
372    let neg_b = _mm512_sub_epi8(zero, b);
373    // mask where a < 0
374    let mask_neg = _mm512_cmplt_epi8_mask(a, zero);
375    // mask where a == 0
376    let mask_zero = _mm512_cmpeq_epi8_mask(a, zero);
377    // Start with b, replace with -b where a < 0
378    let result = _mm512_mask_blend_epi8(mask_neg, b, neg_b);
379    // Replace with 0 where a == 0
380    _mm512_mask_blend_epi8(mask_zero, result, zero)
381}
382
383/// AVX-512 VNNI int8 dot product using _mm512_dpbusd_epi32.
384///
385/// Processes 256 int8s per iteration (4x64 with 4 accumulators).
386/// Note: VNNI expects unsigned x signed, so we handle signs carefully.
387///
388/// # Safety
389///
390/// Caller must ensure:
391/// - CPU supports AVX-512F, AVX-512VNNI, and AVX-512BW (verified via `simd_config()`)
392/// - `a` and `b` have equal length (checked by caller)
393///
394/// Memory safety:
395/// - Uses `_mm512_loadu_si512` for unaligned loads (safe for any alignment)
396/// - Pointer arithmetic stays within slice bounds via chunk calculation
397/// - Remainder handled via safe slice iteration
398#[cfg(all(target_arch = "x86_64", feature = "avx512"))]
399#[target_feature(enable = "avx512f", enable = "avx512vnni", enable = "avx512bw")]
400unsafe fn dot_product_i8_avx512vnni(a: &[i8], b: &[i8]) -> f32 {
401    const SIMD_WIDTH: usize = 64; // 64 int8s per 512-bit register
402    const UNROLL: usize = 4;
403    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
404    let n = a.len();
405    debug_assert_eq!(n, b.len());
406    debug_assert!(a.iter().all(|&v| v != i8::MIN));
407    debug_assert!(b.iter().all(|&v| v != i8::MIN));
408    let chunks = n / CHUNK_SIZE;
409
410    // 4 independent int32 accumulators (16 int32s each)
411    let mut sum0 = _mm512_setzero_si512();
412    let mut sum1 = _mm512_setzero_si512();
413    let mut sum2 = _mm512_setzero_si512();
414    let mut sum3 = _mm512_setzero_si512();
415
416    for i in 0..chunks {
417        let base = i * CHUNK_SIZE;
418
419        // VNNI: dpbusd computes sum += a[unsigned] * b[signed]
420        // For signed * signed, we use: abs(a) * sign(b, a)
421        let a0 = _mm512_loadu_si512(a.as_ptr().add(base) as *const __m512i);
422        let b0 = _mm512_loadu_si512(b.as_ptr().add(base) as *const __m512i);
423        let a0_abs = _mm512_abs_epi8(a0);
424        let b0_signed = mm512_sign_epi8(b0, a0);
425        sum0 = _mm512_dpbusd_epi32(sum0, a0_abs, b0_signed);
426
427        let a1 = _mm512_loadu_si512(a.as_ptr().add(base + SIMD_WIDTH) as *const __m512i);
428        let b1 = _mm512_loadu_si512(b.as_ptr().add(base + SIMD_WIDTH) as *const __m512i);
429        let a1_abs = _mm512_abs_epi8(a1);
430        let b1_signed = mm512_sign_epi8(b1, a1);
431        sum1 = _mm512_dpbusd_epi32(sum1, a1_abs, b1_signed);
432
433        let a2 = _mm512_loadu_si512(a.as_ptr().add(base + SIMD_WIDTH * 2) as *const __m512i);
434        let b2 = _mm512_loadu_si512(b.as_ptr().add(base + SIMD_WIDTH * 2) as *const __m512i);
435        let a2_abs = _mm512_abs_epi8(a2);
436        let b2_signed = mm512_sign_epi8(b2, a2);
437        sum2 = _mm512_dpbusd_epi32(sum2, a2_abs, b2_signed);
438
439        let a3 = _mm512_loadu_si512(a.as_ptr().add(base + SIMD_WIDTH * 3) as *const __m512i);
440        let b3 = _mm512_loadu_si512(b.as_ptr().add(base + SIMD_WIDTH * 3) as *const __m512i);
441        let a3_abs = _mm512_abs_epi8(a3);
442        let b3_signed = mm512_sign_epi8(b3, a3);
443        sum3 = _mm512_dpbusd_epi32(sum3, a3_abs, b3_signed);
444    }
445
446    // Combine accumulators
447    let sum01 = _mm512_add_epi32(sum0, sum1);
448    let sum23 = _mm512_add_epi32(sum2, sum3);
449    let sum_vec = _mm512_add_epi32(sum01, sum23);
450
451    // Horizontal sum of 16 int32s
452    let sum = _mm512_reduce_add_epi32(sum_vec);
453
454    // Handle remainder with scalar
455    let remainder_start = chunks * CHUNK_SIZE;
456    let remainder: i32 = a[remainder_start..]
457        .iter()
458        .zip(b[remainder_start..].iter())
459        .map(|(&x, &y)| x as i32 * y as i32)
460        .sum();
461
462    (sum + remainder) as f32
463}
464
465/// AVX2 int8 dot product with 4x unrolling and software prefetch.
466///
467/// # Safety
468///
469/// Caller must ensure:
470/// - CPU supports AVX2 (verified via `simd_config()`)
471/// - `a` and `b` have equal length (checked by caller)
472///
473/// Memory safety:
474/// - Uses `_mm256_loadu_si256` for unaligned loads (safe for any alignment)
475/// - Pointer arithmetic stays within slice bounds via chunk calculation
476/// - Remainder handled via safe slice iteration
477/// - Prefetch hint issues `_MM_HINT_T0` only when next chunk is within slice bounds
478#[cfg(target_arch = "x86_64")]
479#[target_feature(enable = "avx2")]
480unsafe fn dot_product_i8_avx2_unrolled(a: &[i8], b: &[i8]) -> f32 {
481    const SIMD_WIDTH: usize = 32;
482    const UNROLL: usize = 4;
483    const CHUNK_SIZE: usize = SIMD_WIDTH * UNROLL;
484    // Prefetch one full chunk ahead for both input arrays.
485    const PREFETCH_DISTANCE: usize = CHUNK_SIZE;
486    let n = a.len();
487    debug_assert_eq!(n, b.len());
488    debug_assert!(a.iter().all(|&v| v != i8::MIN));
489    debug_assert!(b.iter().all(|&v| v != i8::MIN));
490    let chunks = n / CHUNK_SIZE;
491
492    // 4 independent int32 accumulators
493    let mut sum0 = _mm256_setzero_si256();
494    let mut sum1 = _mm256_setzero_si256();
495    let mut sum2 = _mm256_setzero_si256();
496    let mut sum3 = _mm256_setzero_si256();
497
498    let ones = _mm256_set1_epi16(1);
499
500    for i in 0..chunks {
501        let base = i * CHUNK_SIZE;
502
503        // Software prefetch for the next chunk.
504        let next_base = base + PREFETCH_DISTANCE;
505        if next_base + CHUNK_SIZE <= n {
506            _mm_prefetch(a.as_ptr().add(next_base), _MM_HINT_T0);
507            _mm_prefetch(b.as_ptr().add(next_base), _MM_HINT_T0);
508        }
509
510        // Unroll 0
511        let a0 = _mm256_loadu_si256(a.as_ptr().add(base) as *const __m256i);
512        let b0 = _mm256_loadu_si256(b.as_ptr().add(base) as *const __m256i);
513        let prod0 = _mm256_maddubs_epi16(_mm256_abs_epi8(a0), _mm256_sign_epi8(b0, a0));
514        let prod0_32 = _mm256_madd_epi16(prod0, ones);
515        sum0 = _mm256_add_epi32(sum0, prod0_32);
516
517        // Unroll 1
518        let a1 = _mm256_loadu_si256(a.as_ptr().add(base + SIMD_WIDTH) as *const __m256i);
519        let b1 = _mm256_loadu_si256(b.as_ptr().add(base + SIMD_WIDTH) as *const __m256i);
520        let prod1 = _mm256_maddubs_epi16(_mm256_abs_epi8(a1), _mm256_sign_epi8(b1, a1));
521        let prod1_32 = _mm256_madd_epi16(prod1, ones);
522        sum1 = _mm256_add_epi32(sum1, prod1_32);
523
524        // Unroll 2
525        let a2 = _mm256_loadu_si256(a.as_ptr().add(base + SIMD_WIDTH * 2) as *const __m256i);
526        let b2 = _mm256_loadu_si256(b.as_ptr().add(base + SIMD_WIDTH * 2) as *const __m256i);
527        let prod2 = _mm256_maddubs_epi16(_mm256_abs_epi8(a2), _mm256_sign_epi8(b2, a2));
528        let prod2_32 = _mm256_madd_epi16(prod2, ones);
529        sum2 = _mm256_add_epi32(sum2, prod2_32);
530
531        // Unroll 3
532        let a3 = _mm256_loadu_si256(a.as_ptr().add(base + SIMD_WIDTH * 3) as *const __m256i);
533        let b3 = _mm256_loadu_si256(b.as_ptr().add(base + SIMD_WIDTH * 3) as *const __m256i);
534        let prod3 = _mm256_maddubs_epi16(_mm256_abs_epi8(a3), _mm256_sign_epi8(b3, a3));
535        let prod3_32 = _mm256_madd_epi16(prod3, ones);
536        sum3 = _mm256_add_epi32(sum3, prod3_32);
537    }
538
539    // Combine accumulators
540    let sum01 = _mm256_add_epi32(sum0, sum1);
541    let sum23 = _mm256_add_epi32(sum2, sum3);
542    let sum_vec = _mm256_add_epi32(sum01, sum23);
543
544    // Horizontal sum
545    let sum128_lo = _mm256_castsi256_si128(sum_vec);
546    let sum128_hi = _mm256_extracti128_si256(sum_vec, 1);
547    let sum128 = _mm_add_epi32(sum128_lo, sum128_hi);
548    let sum64 = _mm_add_epi32(sum128, _mm_srli_si128(sum128, 8));
549    let sum32 = _mm_add_epi32(sum64, _mm_srli_si128(sum64, 4));
550    let sum = _mm_cvtsi128_si32(sum32);
551
552    // Handle remainder
553    let remainder_start = chunks * CHUNK_SIZE;
554    let remainder: i32 = a[remainder_start..]
555        .iter()
556        .zip(b[remainder_start..].iter())
557        .map(|(&x, &y)| x as i32 * y as i32)
558        .sum();
559
560    (sum + remainder) as f32
561}
562
563// ============================================================================
564// INT8 kernel dispatch cache (mirrors f32 DotKernel pattern in dot_product.rs)
565// ============================================================================
566
567/// INT8 dot-product kernel function pointer type.
568pub type I8DotKernel = fn(&[i8], &[i8]) -> f32;
569
570static I8_DOT_KERNEL: OnceLock<I8DotKernel> = OnceLock::new();
571
572/// Return the cached INT8 dot-product kernel.
573///
574/// Callers that invoke INT8 dot product in a tight loop can hoist this call
575/// outside the loop so the OnceLock check runs once, not per-iteration.
576#[inline]
577pub fn resolved_i8_dot_kernel() -> I8DotKernel {
578    *I8_DOT_KERNEL.get_or_init(resolve_i8_dot_kernel)
579}
580
581fn resolve_i8_dot_kernel() -> I8DotKernel {
582    let config = simd_config();
583
584    #[cfg(target_arch = "aarch64")]
585    {
586        // The NEON kernel uses SDOT (ARMv8.2 FEAT_DotProd), which is optional on
587        // Armv8.2/v8.3. Only dispatch when dotprod_enabled is confirmed at runtime.
588        if config.neon_enabled && config.dotprod_enabled {
589            return dot_product_i8_neon_kernel;
590        }
591    }
592
593    #[cfg(target_arch = "x86_64")]
594    {
595        #[cfg(feature = "avx512")]
596        {
597            if config.avx512vnni_enabled {
598                return dot_product_i8_avx512vnni_kernel;
599            }
600        }
601        if config.avx2_enabled {
602            return dot_product_i8_avx2_kernel;
603        }
604    }
605
606    dot_product_i8_scalar_kernel
607}
608
609#[cfg(target_arch = "aarch64")]
610fn dot_product_i8_neon_kernel(a: &[i8], b: &[i8]) -> f32 {
611    // SAFETY: stored only when NEON+dotprod detected at init time.
612    unsafe { dot_product_i8_neon_unrolled(a, b) }
613}
614
615#[cfg(all(target_arch = "x86_64", feature = "avx512"))]
616fn dot_product_i8_avx512vnni_kernel(a: &[i8], b: &[i8]) -> f32 {
617    debug_assert!(a.iter().all(|&v| v != i8::MIN));
618    debug_assert!(b.iter().all(|&v| v != i8::MIN));
619    // SAFETY: stored only when AVX-512F+VNNI+BW were detected at init time.
620    unsafe { dot_product_i8_avx512vnni(a, b) }
621}
622
623#[cfg(target_arch = "x86_64")]
624fn dot_product_i8_avx2_kernel(a: &[i8], b: &[i8]) -> f32 {
625    debug_assert!(a.iter().all(|&v| v != i8::MIN));
626    debug_assert!(b.iter().all(|&v| v != i8::MIN));
627    // SAFETY: stored only when AVX2 was detected at init time.
628    unsafe { dot_product_i8_avx2_unrolled(a, b) }
629}
630
631fn dot_product_i8_scalar_kernel(a: &[i8], b: &[i8]) -> f32 {
632    a.iter()
633        .zip(b.iter())
634        .map(|(&x, &y)| x as i32 * y as i32)
635        .sum::<i32>() as f32
636}
637
638/// **Unstable**: raw SIMD INT8 hot path; signature and scaling semantics may change.
639///
640/// This is the hot-path function for HNSW quantized search. Unlike `dot_product_i8`,
641/// it takes raw `&[i8]` slices and does NOT divide by scale factors -- the caller
642/// handles scaling. This avoids allocating `QuantizedVector` wrappers.
643///
644/// Returns 0.0 if slices have different lengths.
645///
646/// # Invariant
647///
648/// Both slices must satisfy the `[-127, 127]` range invariant. The value `-128`
649/// causes silent corruption on the AVX2 and AVX-512-VNNI paths, which apply an
650/// operand's sign by negating a byte (`_mm256_sign_epi8` / a `0 - b` emulation):
651/// negating `-128` wraps back to `-128` in two's complement instead of `+128`,
652/// so the kernel accumulates the wrong sign. The invariant is enforced with a
653/// **`debug_assert!`** (debug builds only); callers MUST guarantee it. The
654/// `QuantizedVector::from_f32` constructor satisfies it by clamping to `[-127, 127]`.
655/// Promoting this to a release `assert!` would add an O(n) scan to a documented hot
656/// path and is intentionally avoided.
657///
658/// # Performance
659///
660#[inline]
661fn dot_product_i8_dispatch(a: &[i8], b: &[i8]) -> f32 {
662    resolved_i8_dot_kernel()(a, b)
663}
664
665/// **Unstable**: raw INT8 dot product on slices; SIMD strategy may change.
666///
667/// Uses the same SIMD paths as `dot_product_i8`:
668/// - aarch64: NEON with 4x unrolling + tail SIMD chunks
669/// - x86_64: AVX-512 VNNI > AVX2 > scalar
670///
671/// The key difference is zero allocation overhead: no `Vec<i8>`, no
672/// `QuantizedVector`, no `QuantizationParams`. Just raw slices in, f32 out.
673///
674/// # Invariant (caller-guaranteed)
675///
676/// Every element of `a` and `b` must lie in `[-127, 127]`, i.e. the value
677/// `-128` (`i8::MIN`) must not appear. The AVX2 and AVX-512-VNNI kernels apply
678/// an operand's sign by negating a byte (`_mm256_sign_epi8` / a `0 - b`
679/// emulation); negating `-128` wraps back to `-128` in two's complement instead
680/// of `+128`, so the kernel silently accumulates the wrong sign and the dispatch
681/// returns a wrong distance with no panic. The precondition is checked only by
682/// `debug_assert!`, which is compiled out in release builds, so a release caller
683/// gets no diagnostic. `-128` is memory-safe (no UB), only numerically
684/// out-of-contract.
685///
686/// Vectors produced by [`QuantizedVector::from_f32`] satisfy this automatically
687/// (it clamps to `[-127, 127]`). Callers feeding slices from an external or
688/// differently-configured quantizer must clamp `i8::MIN` up to `-127` first.
689/// The invariant is a `debug_assert!` rather than a release `assert!` on
690/// purpose: a release-time scan would add O(n) work to this documented hot path.
691#[inline]
692pub fn dot_product_i8_raw(a: &[i8], b: &[i8]) -> f32 {
693    if a.len() != b.len() {
694        return 0.0;
695    }
696    debug_assert!(
697        a.iter().all(|&v| v != -128i8),
698        "dot_product_i8_raw: slice a contains -128, violating the [-127, 127] SIMD invariant"
699    );
700    debug_assert!(
701        b.iter().all(|&v| v != -128i8),
702        "dot_product_i8_raw: slice b contains -128, violating the [-127, 127] SIMD invariant"
703    );
704    dot_product_i8_dispatch(a, b)
705}
706
707#[cfg(test)]
708mod simd_parity_tests {
709    use super::*;
710
711    fn gen_vec(dim: usize, seed: u64) -> Vec<f32> {
712        let mut state = seed ^ ((dim as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
713        (0..dim)
714            .map(|i| {
715                state = state
716                    .wrapping_mul(6364136223846793005)
717                    .wrapping_add(1442695040888963407)
718                    .wrapping_add(i as u64);
719                let unit = ((state >> 32) as u32) as f32 / u32::MAX as f32;
720                unit * 2.0 - 1.0
721            })
722            .collect()
723    }
724
725    // FP-034: NEON SDOT vs scalar parity for INT8 dot product.
726    // Gated on dotprod: SDOT is FEAT_DotProd, not baseline NEON.
727    #[test]
728    fn test_i8_neon_scalar_parity() {
729        #[cfg(target_arch = "aarch64")]
730        {
731            if !super::super::SimdConfig::detect().dotprod_enabled {
732                eprintln!("skipping SDOT parity test: dotprod not available");
733                return;
734            }
735        }
736        #[cfg(target_arch = "aarch64")]
737        for dim in [7usize, 16, 64, 128, 384, 768] {
738            let a_q = QuantizedVector::from_f32(&gen_vec(dim, 200 + dim as u64));
739            let b_q = QuantizedVector::from_f32(&gen_vec(dim, 300 + dim as u64));
740
741            // SAFETY: dotprod confirmed above; slices have equal length from from_f32.
742            let neon = unsafe { dot_product_i8_neon_unrolled(&a_q.data, &b_q.data) };
743            let scalar: f32 = a_q
744                .data
745                .iter()
746                .zip(b_q.data.iter())
747                .map(|(&x, &y)| x as i32 * y as i32)
748                .sum::<i32>() as f32;
749
750            let diff = (neon - scalar).abs();
751            assert!(
752                diff <= 1.0,
753                "NEON vs scalar i8 dot product dim={dim}: neon={neon} scalar={scalar} diff={diff}"
754            );
755        }
756    }
757
758    // FP-034: AVX2 vs scalar parity for INT8 dot product.
759    #[test]
760    fn test_i8_avx2_scalar_parity() {
761        #[cfg(target_arch = "x86_64")]
762        if std::arch::is_x86_feature_detected!("avx2") {
763            for dim in [7usize, 16, 64, 128, 384, 768] {
764                let a_q = QuantizedVector::from_f32(&gen_vec(dim, 400 + dim as u64));
765                let b_q = QuantizedVector::from_f32(&gen_vec(dim, 500 + dim as u64));
766
767                // SAFETY: AVX2 verified by is_x86_feature_detected! above; slices have equal length.
768                let avx2 = unsafe { dot_product_i8_avx2_unrolled(&a_q.data, &b_q.data) };
769                let scalar: f32 = a_q
770                    .data
771                    .iter()
772                    .zip(b_q.data.iter())
773                    .map(|(&x, &y)| x as i32 * y as i32)
774                    .sum::<i32>() as f32;
775
776                let diff = (avx2 - scalar).abs();
777                assert!(
778                    diff <= 1.0,
779                    "AVX2 vs scalar i8 dot product dim={dim}: avx2={avx2} scalar={scalar} diff={diff}"
780                );
781            }
782        }
783    }
784}