Skip to main content

lattice_embed/simd/
int4.rs

1//! INT4 packed-vector quantization and approximate dot products.
2//!
3//! Nibble layout and offset correction are shared by scalar and NEON paths.
4//!
5//! See docs/simd.md for the packed format and corrected dot-product derivation.
6
7#[cfg(target_arch = "aarch64")]
8use std::arch::aarch64::*;
9
10#[cfg(target_arch = "x86_64")]
11use std::arch::x86_64::*;
12
13#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
14use super::simd_config;
15
16/// **Unstable**: INT4 quantization internals; scale/bias scheme may change.
17///
18/// Quantization parameters for INT4 conversion.
19///
20/// Uses symmetric unsigned quantization: the float range [-max_abs, max_abs]
21/// is mapped to the integer range [0, 15].
22#[derive(Debug, Clone, Copy)]
23pub struct Int4Params {
24    /// **Unstable**: scale factor; formula may change with quantization scheme update.
25    pub scale: f32,
26    /// **Unstable**: maximum absolute value; field may be removed.
27    pub max_abs: f32,
28}
29
30impl Int4Params {
31    /// **Unstable**: quantization parameter computation; may be folded into `Int4Vector::from_f32`.
32    pub fn from_vector(vector: &[f32]) -> Self {
33        let max_abs = max_abs_finite(vector);
34
35        // Epsilon guard: avoid division by near-zero
36        let scale = if max_abs > 1e-10 {
37            15.0 / (2.0 * max_abs)
38        } else {
39            1.0
40        };
41
42        Self { scale, max_abs }
43    }
44}
45
46fn max_abs_finite(vector: &[f32]) -> f32 {
47    #[cfg(target_arch = "x86_64")]
48    {
49        if simd_config().avx2_enabled {
50            // SAFETY: AVX2 was detected at runtime; the kernel bounds every load.
51            return unsafe { max_abs_finite_avx2(vector) };
52        }
53    }
54    #[cfg(target_arch = "aarch64")]
55    {
56        if simd_config().neon_enabled {
57            // SAFETY: NEON was detected at runtime; the kernel bounds every load.
58            return unsafe { max_abs_finite_neon(vector) };
59        }
60    }
61    max_abs_finite_scalar(vector)
62}
63
64fn max_abs_finite_scalar(vector: &[f32]) -> f32 {
65    vector
66        .iter()
67        .filter(|value| value.is_finite())
68        .map(|value| value.abs())
69        .fold(0.0, f32::max)
70}
71
72#[cfg(test)]
73thread_local! {
74    static INT4_MAX_ABS_SIMD_HITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
75}
76
77#[cfg(target_arch = "x86_64")]
78#[target_feature(enable = "avx2")]
79unsafe fn max_abs_finite_avx2(vector: &[f32]) -> f32 {
80    #[cfg(test)]
81    INT4_MAX_ABS_SIMD_HITS.with(|hits| hits.set(hits.get() + 1));
82
83    let chunks = vector.len() / 8;
84    let sign = _mm256_set1_ps(-0.0);
85    let inf = _mm256_set1_ps(f32::INFINITY);
86    let mut maximum = _mm256_setzero_ps();
87
88    for i in 0..chunks {
89        let input = _mm256_loadu_ps(vector.as_ptr().add(i * 8));
90        let abs = _mm256_andnot_ps(sign, input);
91        let finite = _mm256_cmp_ps(abs, inf, _CMP_LT_OQ);
92        maximum = _mm256_max_ps(maximum, _mm256_and_ps(abs, finite));
93    }
94
95    let mut lanes = [0.0f32; 8];
96    _mm256_storeu_ps(lanes.as_mut_ptr(), maximum);
97    let mut max_abs = lanes.into_iter().fold(0.0f32, f32::max);
98    for &value in &vector[chunks * 8..] {
99        if value.is_finite() {
100            max_abs = max_abs.max(value.abs());
101        }
102    }
103    max_abs
104}
105
106#[cfg(target_arch = "aarch64")]
107#[target_feature(enable = "neon")]
108unsafe fn max_abs_finite_neon(vector: &[f32]) -> f32 {
109    #[cfg(test)]
110    INT4_MAX_ABS_SIMD_HITS.with(|hits| hits.set(hits.get() + 1));
111
112    let chunks = vector.len() / 4;
113    let inf = vdupq_n_f32(f32::INFINITY);
114    let zero = vdupq_n_f32(0.0);
115    let mut maximum = zero;
116
117    for i in 0..chunks {
118        let input = vld1q_f32(vector.as_ptr().add(i * 4));
119        let abs = vabsq_f32(input);
120        let finite = vcltq_f32(abs, inf);
121        maximum = vmaxq_f32(maximum, vbslq_f32(finite, abs, zero));
122    }
123
124    let mut max_abs = vmaxvq_f32(maximum);
125    for &value in &vector[chunks * 4..] {
126        if value.is_finite() {
127            max_abs = max_abs.max(value.abs());
128        }
129    }
130    max_abs
131}
132
133/// **Unstable**: INT4 quantization format is under active design; struct layout may change.
134///
135/// Quantized INT4 vector with packed nibble storage.
136#[derive(Debug, Clone)]
137pub struct Int4Vector {
138    /// **Unstable**: packed nibble data; bit packing scheme may change.
139    pub data: Vec<u8>,
140    /// **Unstable**: number of original dimensions.
141    pub dims: usize,
142    /// **Unstable**: quantization parameters; may be separated from the vector.
143    pub params: Int4Params,
144    /// **Unstable**: L2 norm; may be removed or moved.
145    pub norm: f32,
146}
147
148impl Int4Vector {
149    /// **Unstable**: quantization format; nibble packing may change.
150    ///
151    /// Each pair of consecutive dimensions is packed into one byte:
152    /// - High nibble (bits 7..4) = even-indexed value
153    /// - Low nibble (bits 3..0) = odd-indexed value
154    pub fn from_f32(vector: &[f32]) -> Self {
155        let params = Int4Params::from_vector(vector);
156        let dims = vector.len();
157
158        // Compute L2 norm
159        let mut norm_sq = 0.0f32;
160        for &v in vector {
161            if v.is_finite() {
162                norm_sq += v * v;
163            }
164        }
165        let norm = norm_sq.sqrt();
166
167        let data = quantize_int4(vector, params);
168
169        Self {
170            data,
171            dims,
172            params,
173            norm,
174        }
175    }
176
177    /// **Unstable**: dequantizes packed INT4 data, or returns empty for a malformed buffer.
178    ///
179    /// See [`docs/simd.md`](../../docs/simd.md#int4-vectors) for format and precision bounds.
180    pub fn to_f32(&self) -> Vec<f32> {
181        let required_bytes = self.dims.div_ceil(2);
182        if self.data.len() < required_bytes {
183            return Vec::new();
184        }
185
186        let scale = if self.params.scale.is_finite() && self.params.scale != 0.0 {
187            self.params.scale
188        } else {
189            1.0
190        };
191
192        let mut result = Vec::with_capacity(self.dims);
193        for i in 0..self.dims {
194            let byte_idx = i / 2;
195            let q = if i % 2 == 0 {
196                (self.data[byte_idx] >> 4) & 0x0F
197            } else {
198                self.data[byte_idx] & 0x0F
199            };
200            result.push(q as f32 / scale - self.params.max_abs);
201        }
202        result
203    }
204
205    /// **Unstable**: INT4 dot product approximation; formula may change.
206    ///
207    /// Returns the dequantized dot product suitable for cosine distance computation.
208    #[inline]
209    pub fn dot_product(&self, other: &Int4Vector) -> f32 {
210        dot_product_int4(self, other)
211    }
212
213    /// **Unstable**: INT4 cosine similarity approximation; delegates to `dot_product`.
214    #[inline]
215    pub fn cosine_similarity(&self, other: &Int4Vector) -> f32 {
216        let denom = self.norm * other.norm;
217        if denom == 0.0 || !denom.is_finite() {
218            return 0.0;
219        }
220        self.dot_product(other) / denom
221    }
222
223    /// **Unstable**: complement of `cosine_similarity`; definition may evolve.
224    #[inline]
225    pub fn cosine_distance(&self, other: &Int4Vector) -> f32 {
226        1.0 - self.cosine_similarity(other)
227    }
228}
229
230fn quantize_int4(vector: &[f32], params: Int4Params) -> Vec<u8> {
231    #[cfg(target_arch = "x86_64")]
232    {
233        if simd_config().avx2_enabled {
234            // SAFETY: AVX2 was detected at runtime; the kernel bounds every load and store.
235            return unsafe { quantize_int4_avx2(vector, params) };
236        }
237    }
238    #[cfg(target_arch = "aarch64")]
239    {
240        if simd_config().neon_enabled {
241            // SAFETY: NEON was detected at runtime; the kernel bounds every load and store.
242            return unsafe { quantize_int4_neon(vector, params) };
243        }
244    }
245    quantize_int4_scalar(vector, params)
246}
247
248fn quantize_int4_scalar(vector: &[f32], params: Int4Params) -> Vec<u8> {
249    let mut data = vec![0u8; vector.len().div_ceil(2)];
250    quantize_int4_scalar_tail(vector, params, &mut data, 0);
251    data
252}
253
254fn quantize_int4_scalar_tail(vector: &[f32], params: Int4Params, data: &mut [u8], start: usize) {
255    for (i, &value) in vector.iter().enumerate().skip(start) {
256        let quantized = quantize_int4_value(value, params);
257        if i % 2 == 0 {
258            data[i / 2] |= quantized << 4;
259        } else {
260            data[i / 2] |= quantized;
261        }
262    }
263}
264
265#[inline]
266fn quantize_int4_value(value: f32, params: Int4Params) -> u8 {
267    let finite_value = if value.is_finite() { value } else { 0.0 };
268    ((finite_value + params.max_abs) * params.scale)
269        .round()
270        .clamp(0.0, 15.0) as u8
271}
272
273#[cfg(test)]
274thread_local! {
275    static INT4_QUANTIZE_SIMD_HITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
276}
277
278#[cfg(target_arch = "x86_64")]
279#[target_feature(enable = "avx2")]
280unsafe fn quantize_int4_avx2(vector: &[f32], params: Int4Params) -> Vec<u8> {
281    #[cfg(test)]
282    INT4_QUANTIZE_SIMD_HITS.with(|hits| hits.set(hits.get() + 1));
283
284    let mut data = vec![0u8; vector.len().div_ceil(2)];
285    let chunks = vector.len() / 8;
286    let sign = _mm256_set1_ps(-0.0);
287    let inf = _mm256_set1_ps(f32::INFINITY);
288    let max_abs = _mm256_set1_ps(params.max_abs);
289    let scale = _mm256_set1_ps(params.scale);
290    let zero = _mm256_setzero_ps();
291    let high = _mm256_set1_ps(15.0);
292    let half = _mm256_set1_ps(0.5);
293    let one = _mm256_set1_epi32(1);
294
295    for i in 0..chunks {
296        let base = i * 8;
297        let input = _mm256_loadu_ps(vector.as_ptr().add(base));
298        let abs = _mm256_andnot_ps(sign, input);
299        let finite = _mm256_cmp_ps(abs, inf, _CMP_LT_OQ);
300        let values = _mm256_and_ps(input, finite);
301        let scaled = _mm256_mul_ps(_mm256_add_ps(values, max_abs), scale);
302        let clamped = _mm256_min_ps(_mm256_max_ps(scaled, zero), high);
303        let truncated = _mm256_cvttps_epi32(clamped);
304        let fraction = _mm256_sub_ps(clamped, _mm256_cvtepi32_ps(truncated));
305        let round_up = _mm256_castps_si256(_mm256_cmp_ps(fraction, half, _CMP_GE_OQ));
306        let rounded = _mm256_add_epi32(truncated, _mm256_and_si256(round_up, one));
307        let mut lanes = [0i32; 8];
308        _mm256_storeu_si256(lanes.as_mut_ptr().cast::<__m256i>(), rounded);
309        for pair in 0..4 {
310            data[base / 2 + pair] = ((lanes[pair * 2] as u8) << 4) | lanes[pair * 2 + 1] as u8;
311        }
312    }
313
314    quantize_int4_scalar_tail(vector, params, &mut data, chunks * 8);
315    data
316}
317
318#[cfg(target_arch = "aarch64")]
319#[target_feature(enable = "neon")]
320unsafe fn quantize_int4_neon(vector: &[f32], params: Int4Params) -> Vec<u8> {
321    #[cfg(test)]
322    INT4_QUANTIZE_SIMD_HITS.with(|hits| hits.set(hits.get() + 1));
323
324    let mut data = vec![0u8; vector.len().div_ceil(2)];
325    let chunks = vector.len() / 4;
326    let inf = vdupq_n_f32(f32::INFINITY);
327    let zero = vdupq_n_f32(0.0);
328    let max_abs = vdupq_n_f32(params.max_abs);
329    let scale = vdupq_n_f32(params.scale);
330    let high = vdupq_n_f32(15.0);
331
332    for i in 0..chunks {
333        let base = i * 4;
334        let input = vld1q_f32(vector.as_ptr().add(base));
335        let finite = vcaltq_f32(input, inf);
336        let values = vbslq_f32(finite, input, zero);
337        let scaled = vmulq_f32(vaddq_f32(values, max_abs), scale);
338        let clamped = vminq_f32(vmaxq_f32(scaled, zero), high);
339        let rounded = vcvtaq_s32_f32(clamped);
340        let mut lanes = [0i32; 4];
341        vst1q_s32(lanes.as_mut_ptr(), rounded);
342        data[base / 2] = ((lanes[0] as u8) << 4) | lanes[1] as u8;
343        data[base / 2 + 1] = ((lanes[2] as u8) << 4) | lanes[3] as u8;
344    }
345
346    quantize_int4_scalar_tail(vector, params, &mut data, chunks * 4);
347    data
348}
349
350/// **Unstable**: dequantized INT4 dot product; dispatch may change.
351#[inline]
352pub fn dot_product_int4(a: &Int4Vector, b: &Int4Vector) -> f32 {
353    if a.dims != b.dims {
354        return 0.0;
355    }
356
357    let scale_a = a.params.scale;
358    let scale_b = b.params.scale;
359    if scale_a == 0.0 || scale_b == 0.0 || !scale_a.is_finite() || !scale_b.is_finite() {
360        return 0.0;
361    }
362
363    let packed_len = a.dims.div_ceil(2);
364    if a.data.len() < packed_len || b.data.len() < packed_len {
365        return 0.0;
366    }
367
368    #[cfg(target_arch = "aarch64")]
369    {
370        let config = simd_config();
371        if config.neon_enabled {
372            // SAFETY: aarch64 NEON is available by config, the packed data length guard
373            // above prevents out-of-bounds loads, and the callee handles odd dimensions
374            // without reading the padding nibble as a real dimension.
375            let (raw_dot, sum_a, sum_b) =
376                unsafe { dot_product_int4_neon_unrolled(&a.data, &b.data, a.dims) };
377            return finish_int4_dot(raw_dot, sum_a, sum_b, a, b);
378        }
379    }
380
381    let (raw_dot, sum_a, sum_b) = dot_product_int4_packed_scalar(&a.data, &b.data, a.dims);
382    finish_int4_dot(raw_dot, sum_a, sum_b, a, b)
383}
384
385#[inline]
386fn finish_int4_dot(raw_dot: i32, sum_a: i32, sum_b: i32, a: &Int4Vector, b: &Int4Vector) -> f32 {
387    let raw_dot = raw_dot as f32;
388    let sum_a = sum_a as f32;
389    let sum_b = sum_b as f32;
390    let scale_a = a.params.scale;
391    let scale_b = b.params.scale;
392
393    raw_dot / (scale_a * scale_b)
394        - (b.params.max_abs * sum_a / scale_a)
395        - (a.params.max_abs * sum_b / scale_b)
396        + (a.dims as f32 * a.params.max_abs * b.params.max_abs)
397}
398
399#[inline]
400fn dot_product_int4_packed_scalar(a: &[u8], b: &[u8], dims: usize) -> (i32, i32, i32) {
401    let full_bytes = dims / 2;
402    let mut raw_dot = 0i32;
403    let mut sum_a = 0i32;
404    let mut sum_b = 0i32;
405
406    for i in 0..full_bytes {
407        let av = a[i];
408        let bv = b[i];
409        let ah = ((av >> 4) & 0x0f) as i32;
410        let al = (av & 0x0f) as i32;
411        let bh = ((bv >> 4) & 0x0f) as i32;
412        let bl = (bv & 0x0f) as i32;
413        raw_dot += ah * bh + al * bl;
414        sum_a += ah + al;
415        sum_b += bh + bl;
416    }
417
418    if dims % 2 == 1 {
419        let av = a[full_bytes];
420        let bv = b[full_bytes];
421        let ah = ((av >> 4) & 0x0f) as i32;
422        let bh = ((bv >> 4) & 0x0f) as i32;
423        raw_dot += ah * bh;
424        sum_a += ah;
425        sum_b += bh;
426    }
427
428    (raw_dot, sum_a, sum_b)
429}
430
431#[cfg(target_arch = "aarch64")]
432#[target_feature(enable = "neon")]
433#[inline]
434unsafe fn dot_product_int4_neon_unrolled(a: &[u8], b: &[u8], dims: usize) -> (i32, i32, i32) {
435    debug_assert!(a.len() >= dims.div_ceil(2));
436    debug_assert!(b.len() >= dims.div_ceil(2));
437
438    const BLOCK_BYTES: usize = 16;
439    const UNROLL: usize = 4;
440    const CHUNK_BYTES: usize = BLOCK_BYTES * UNROLL;
441
442    // Only bytes containing two valid dimensions are processed in SIMD.
443    // If dims is odd, the final high nibble is handled separately and the low
444    // padding nibble is ignored to preserve current to_f32 semantics.
445    let full_bytes = dims / 2;
446    let chunks = full_bytes / CHUNK_BYTES;
447
448    let mut raw0 = vdupq_n_u32(0);
449    let mut raw1 = vdupq_n_u32(0);
450    let mut raw2 = vdupq_n_u32(0);
451    let mut raw3 = vdupq_n_u32(0);
452    let mut sum_a = vdupq_n_u32(0);
453    let mut sum_b = vdupq_n_u32(0);
454    let mask = vdupq_n_u8(0x0f);
455
456    macro_rules! accumulate_block {
457        ($base:expr, $raw:ident) => {{
458            let a_bytes = vld1q_u8(a.as_ptr().add($base));
459            let b_bytes = vld1q_u8(b.as_ptr().add($base));
460
461            let a_hi = vshrq_n_u8::<4>(a_bytes);
462            let b_hi = vshrq_n_u8::<4>(b_bytes);
463            let a_lo = vandq_u8(a_bytes, mask);
464            let b_lo = vandq_u8(b_bytes, mask);
465
466            $raw = vpadalq_u16($raw, vmull_u8(vget_low_u8(a_hi), vget_low_u8(b_hi)));
467            $raw = vpadalq_u16($raw, vmull_u8(vget_high_u8(a_hi), vget_high_u8(b_hi)));
468            $raw = vpadalq_u16($raw, vmull_u8(vget_low_u8(a_lo), vget_low_u8(b_lo)));
469            $raw = vpadalq_u16($raw, vmull_u8(vget_high_u8(a_lo), vget_high_u8(b_lo)));
470
471            sum_a = vpadalq_u16(sum_a, vpaddlq_u8(a_hi));
472            sum_a = vpadalq_u16(sum_a, vpaddlq_u8(a_lo));
473            sum_b = vpadalq_u16(sum_b, vpaddlq_u8(b_hi));
474            sum_b = vpadalq_u16(sum_b, vpaddlq_u8(b_lo));
475        }};
476    }
477
478    for i in 0..chunks {
479        let base = i * CHUNK_BYTES;
480        accumulate_block!(base, raw0);
481        accumulate_block!(base + BLOCK_BYTES, raw1);
482        accumulate_block!(base + BLOCK_BYTES * 2, raw2);
483        accumulate_block!(base + BLOCK_BYTES * 3, raw3);
484    }
485
486    let raw_vec = vaddq_u32(vaddq_u32(raw0, raw1), vaddq_u32(raw2, raw3));
487    let mut raw_total = (vgetq_lane_u32::<0>(raw_vec)
488        + vgetq_lane_u32::<1>(raw_vec)
489        + vgetq_lane_u32::<2>(raw_vec)
490        + vgetq_lane_u32::<3>(raw_vec)) as i32;
491    let mut sum_a_total = (vgetq_lane_u32::<0>(sum_a)
492        + vgetq_lane_u32::<1>(sum_a)
493        + vgetq_lane_u32::<2>(sum_a)
494        + vgetq_lane_u32::<3>(sum_a)) as i32;
495    let mut sum_b_total = (vgetq_lane_u32::<0>(sum_b)
496        + vgetq_lane_u32::<1>(sum_b)
497        + vgetq_lane_u32::<2>(sum_b)
498        + vgetq_lane_u32::<3>(sum_b)) as i32;
499
500    let remainder_start = chunks * CHUNK_BYTES;
501    for byte_idx in remainder_start..full_bytes {
502        let av = *a.get_unchecked(byte_idx);
503        let bv = *b.get_unchecked(byte_idx);
504        let ah = ((av >> 4) & 0x0f) as i32;
505        let al = (av & 0x0f) as i32;
506        let bh = ((bv >> 4) & 0x0f) as i32;
507        let bl = (bv & 0x0f) as i32;
508
509        raw_total += ah * bh + al * bl;
510        sum_a_total += ah + al;
511        sum_b_total += bh + bl;
512    }
513
514    if dims % 2 == 1 {
515        let av = *a.get_unchecked(full_bytes);
516        let bv = *b.get_unchecked(full_bytes);
517        let ah = ((av >> 4) & 0x0f) as i32;
518        let bh = ((bv >> 4) & 0x0f) as i32;
519
520        raw_total += ah * bh;
521        sum_a_total += ah;
522        sum_b_total += bh;
523    }
524
525    (raw_total, sum_a_total, sum_b_total)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn generate_vector(dim: usize, seed: u64) -> Vec<f32> {
533        let mut state = seed ^ ((dim as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
534        (0..dim)
535            .map(|i| {
536                state = state
537                    .wrapping_mul(6364136223846793005)
538                    .wrapping_add(1442695040888963407)
539                    .wrapping_add(i as u64);
540                let unit = ((state >> 32) as u32) as f32 / u32::MAX as f32;
541                unit * 2.0 - 1.0
542            })
543            .collect()
544    }
545
546    #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
547    #[test]
548    fn test_int4_quantize_explicit_simd_matches_scalar_and_is_dispatched() {
549        #[cfg(target_arch = "x86_64")]
550        if !std::arch::is_x86_feature_detected!("avx2") {
551            return;
552        }
553
554        let params = Int4Params {
555            scale: 7.5,
556            max_abs: 1.0,
557        };
558        let tie_params = Int4Params {
559            scale: 1.0,
560            max_abs: 7.5,
561        };
562        let tie_input = [-7.5, -1.0, 1.0, 7.5, -1.0, 1.0, 0.0, -0.0];
563        let scalar_ties = quantize_int4_scalar(&tie_input, tie_params);
564        assert_eq!(scalar_ties, [0x07, 0x9f, 0x79, 0x88]);
565        #[cfg(target_arch = "aarch64")]
566        // SAFETY: baseline aarch64 provides NEON; the kernel bounds every access.
567        let simd_ties = unsafe { quantize_int4_neon(&tie_input, tie_params) };
568        #[cfg(target_arch = "x86_64")]
569        // SAFETY: AVX2 was detected above; the kernel bounds every access.
570        let simd_ties = unsafe { quantize_int4_avx2(&tie_input, tie_params) };
571        assert_eq!(
572            simd_ties, scalar_ties,
573            "explicit SIMD must preserve ties-away rounding"
574        );
575
576        for dim in [0usize, 1, 3, 4, 7, 8, 9, 31, 32, 33, 383, 384, 385] {
577            let mut input = generate_vector(dim, 900 + dim as u64);
578            if dim > 0 {
579                input[0] = f32::NAN;
580            }
581            if dim > 1 {
582                input[1] = f32::INFINITY;
583            }
584            if dim > 2 {
585                input[2] = f32::NEG_INFINITY;
586            }
587            if dim > 3 {
588                let boundary = -1.0 + 0.5 / params.scale;
589                input[3] = f32::from_bits(boundary.to_bits() - 1);
590            }
591            if dim > 4 {
592                let boundary = -1.0 + 0.5 / params.scale;
593                input[4] = f32::from_bits(boundary.to_bits() + 1);
594            }
595
596            let scalar = quantize_int4_scalar(&input, params);
597            #[cfg(target_arch = "aarch64")]
598            // SAFETY: baseline aarch64 provides NEON; the kernel bounds every access.
599            let simd = unsafe { quantize_int4_neon(&input, params) };
600            #[cfg(target_arch = "x86_64")]
601            // SAFETY: AVX2 was detected above; the kernel bounds every access.
602            let simd = unsafe { quantize_int4_avx2(&input, params) };
603            assert_eq!(simd, scalar, "explicit SIMD mismatch at dim={dim}");
604        }
605
606        let input = generate_vector(385, 1_063);
607        let before = INT4_QUANTIZE_SIMD_HITS.with(std::cell::Cell::get);
608        let quantized = Int4Vector::from_f32(&input);
609        let after = INT4_QUANTIZE_SIMD_HITS.with(std::cell::Cell::get);
610        assert_eq!(
611            after,
612            before + 1,
613            "Int4Vector::from_f32 did not execute its explicit SIMD quantizer"
614        );
615        assert_eq!(
616            quantized.data,
617            quantize_int4_scalar(&input, quantized.params)
618        );
619    }
620
621    #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
622    #[test]
623    fn test_int4_max_abs_explicit_simd_matches_scalar() {
624        #[cfg(target_arch = "x86_64")]
625        if !std::arch::is_x86_feature_detected!("avx2") {
626            return;
627        }
628
629        for dim in [0usize, 1, 3, 4, 7, 8, 9, 31, 32, 33, 383, 384, 385] {
630            let mut input = generate_vector(dim, 1_100 + dim as u64);
631            if dim > 0 {
632                input[0] = f32::NAN;
633            }
634            if dim > 1 {
635                input[1] = f32::INFINITY;
636            }
637            if dim > 2 {
638                input[2] = f32::NEG_INFINITY;
639            }
640
641            let scalar = max_abs_finite_scalar(&input);
642            #[cfg(target_arch = "aarch64")]
643            // SAFETY: baseline aarch64 provides NEON; the kernel bounds every access.
644            let simd = unsafe { max_abs_finite_neon(&input) };
645            #[cfg(target_arch = "x86_64")]
646            // SAFETY: AVX2 was detected above; the kernel bounds every access.
647            let simd = unsafe { max_abs_finite_avx2(&input) };
648            assert_eq!(simd, scalar, "finite max-abs mismatch at dim={dim}");
649        }
650
651        let input = generate_vector(385, 1_063);
652        let before = INT4_MAX_ABS_SIMD_HITS.with(std::cell::Cell::get);
653        let params = Int4Params::from_vector(&input);
654        let after = INT4_MAX_ABS_SIMD_HITS.with(std::cell::Cell::get);
655        assert_eq!(
656            after,
657            before + 1,
658            "Int4Params::from_vector did not execute its explicit SIMD reducer"
659        );
660        assert_eq!(params.max_abs, max_abs_finite_scalar(&input));
661    }
662
663    #[test]
664    fn test_int4_roundtrip_accuracy() {
665        let original = generate_vector(384, 42);
666        let quantized = Int4Vector::from_f32(&original);
667        let dequantized = quantized.to_f32();
668
669        assert_eq!(dequantized.len(), original.len());
670
671        // INT4 has only 16 levels, so error is larger than INT8.
672        // Max error should be within 1/15 of the range.
673        let max_abs = original
674            .iter()
675            .filter(|v| v.is_finite())
676            .map(|v| v.abs())
677            .fold(0.0f32, f32::max);
678        let expected_max_error = 2.0 * max_abs / 15.0;
679
680        for (i, (orig, deq)) in original.iter().zip(dequantized.iter()).enumerate() {
681            let error = (orig - deq).abs();
682            assert!(
683                error <= expected_max_error + 1e-5,
684                "INT4 roundtrip error too large at index {i}: orig={orig}, deq={deq}, error={error}, max_allowed={expected_max_error}"
685            );
686        }
687    }
688
689    #[test]
690    fn test_int4_packing_correctness() {
691        // Verify nibble packing: even index -> high nibble, odd -> low
692        let v = vec![0.5, -0.5, 0.0, 1.0]; // 4 values -> 2 packed bytes
693        let q = Int4Vector::from_f32(&v);
694        assert_eq!(q.data.len(), 2);
695        assert_eq!(q.dims, 4);
696
697        // Verify roundtrip preserves approximate values
698        let deq = q.to_f32();
699        assert_eq!(deq.len(), 4);
700        // 0.5 should map to roughly the right region
701        assert!((deq[0] - 0.5).abs() < 0.15, "deq[0]={}", deq[0]);
702        assert!((deq[1] - (-0.5)).abs() < 0.15, "deq[1]={}", deq[1]);
703    }
704
705    #[test]
706    fn test_int4_odd_dimensions() {
707        // Odd number of dimensions: last nibble has a padding zero
708        let v = generate_vector(383, 77);
709        let q = Int4Vector::from_f32(&v);
710        assert_eq!(q.data.len(), 192); // ceil(383/2) = 192
711        assert_eq!(q.dims, 383);
712
713        let deq = q.to_f32();
714        assert_eq!(deq.len(), 383);
715    }
716
717    #[test]
718    fn test_int4_zero_vector() {
719        let v = vec![0.0; 384];
720        let q = Int4Vector::from_f32(&v);
721        let deq = q.to_f32();
722        for &val in &deq {
723            assert!(
724                val.abs() < 1e-5,
725                "Zero vector should dequantize to near-zero"
726            );
727        }
728    }
729
730    #[test]
731    fn test_int4_dot_product_vs_f32() {
732        // Use correlated vectors so the true dot product is large relative to noise.
733        // For uncorrelated random vectors, the expected dot product is ~0 while
734        // quantization noise is O(dims * step^2), so relative error is unbounded.
735        let a = generate_vector(384, 101);
736        let b: Vec<f32> = a
737            .iter()
738            .enumerate()
739            .map(|(i, &x)| x + 0.2 * (i as f32 * 0.3).sin())
740            .collect();
741
742        // f32 reference
743        let f32_dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
744
745        let qa = Int4Vector::from_f32(&a);
746        let qb = Int4Vector::from_f32(&b);
747        let int4_dot = qa.dot_product(&qb);
748
749        // INT4 has 16 levels; for correlated vectors the relative error should be
750        // within ~15% (quantization step = 2*max_abs/15 per component).
751        let rel_error = (f32_dot - int4_dot).abs() / f32_dot.abs().max(1.0);
752        assert!(
753            rel_error < 0.15,
754            "INT4 dot product relative error too large: f32={f32_dot}, int4={int4_dot}, rel_error={rel_error}"
755        );
756    }
757
758    #[cfg(target_arch = "aarch64")]
759    #[test]
760    fn test_packed_scalar_matches_neon_exact() {
761        // Directly compare dot_product_int4_packed_scalar against
762        // dot_product_int4_neon_unrolled on integer tuples. Both return
763        // (raw_dot, sum_a, sum_b) as i32 — integer domain, exact equality expected.
764        // This is the executing parity proof for the non-aarch64 fallback kernel.
765        for dim in [1usize, 3, 31, 127, 383, 384] {
766            let a_f32 = generate_vector(dim, 500 + dim as u64);
767            let b_f32 = generate_vector(dim, 600 + dim as u64);
768            let qa = Int4Vector::from_f32(&a_f32);
769            let qb = Int4Vector::from_f32(&b_f32);
770
771            let scalar_result = dot_product_int4_packed_scalar(&qa.data, &qb.data, dim);
772            // SAFETY: aarch64 always has NEON; data slices are correctly sized by
773            // Int4Vector::from_f32 (len = dims.div_ceil(2)).
774            let neon_result = unsafe { dot_product_int4_neon_unrolled(&qa.data, &qb.data, dim) };
775
776            assert_eq!(
777                scalar_result, neon_result,
778                "packed_scalar vs NEON integer mismatch at dim={dim}: scalar={scalar_result:?}, neon={neon_result:?}"
779            );
780        }
781    }
782
783    #[cfg(target_arch = "aarch64")]
784    #[test]
785    fn test_int4_neon_matches_dequantized_scalar() {
786        for dim in [1, 2, 31, 64, 127, 384, 768] {
787            let a = generate_vector(dim, 501);
788            let b = generate_vector(dim, 777);
789            let qa = Int4Vector::from_f32(&a);
790            let qb = Int4Vector::from_f32(&b);
791
792            let a_deq = qa.to_f32();
793            let b_deq = qb.to_f32();
794            let expected: f32 = a_deq.iter().zip(b_deq.iter()).map(|(&x, &y)| x * y).sum();
795            let got = qa.dot_product(&qb);
796
797            assert!(
798                (expected - got).abs() < 1e-4,
799                "INT4 NEON mismatch for dim={dim}: expected={expected}, got={got}"
800            );
801        }
802    }
803
804    #[test]
805    fn test_int4_cosine_similarity() {
806        let a = generate_vector(384, 301);
807        let b = generate_vector(384, 302);
808
809        let qa = Int4Vector::from_f32(&a);
810        let qb = Int4Vector::from_f32(&b);
811        let int4_cos = qa.cosine_similarity(&qb);
812
813        // Compute f32 reference cosine
814        let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
815        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
816        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
817        let f32_cos = dot / (norm_a * norm_b);
818
819        assert!(
820            (f32_cos - int4_cos).abs() < 0.1,
821            "INT4 cosine too far from f32: f32={f32_cos}, int4={int4_cos}"
822        );
823    }
824
825    #[test]
826    fn test_int4_memory_savings() {
827        let v = generate_vector(384, 999);
828        let q = Int4Vector::from_f32(&v);
829
830        // f32: 384 * 4 = 1536 bytes
831        // INT4: ceil(384/2) = 192 bytes = 8x compression
832        assert_eq!(q.data.len(), 192);
833        assert_eq!(v.len() * 4, 1536);
834    }
835
836    #[test]
837    fn test_int4_nan_inf_handling() {
838        let v = vec![
839            1.0,
840            f32::NAN,
841            f32::INFINITY,
842            f32::NEG_INFINITY,
843            -1.0,
844            0.5,
845            0.0,
846            -0.3,
847        ];
848        let q = Int4Vector::from_f32(&v);
849        let deq = q.to_f32();
850        assert_eq!(deq.len(), 8);
851        // NaN and Inf should be treated as 0
852        // The dequantized value for the "0" slot should be near -max_abs + something,
853        // but the key invariant is no panics and finite output.
854        for &val in &deq {
855            assert!(val.is_finite(), "Dequantized value should be finite");
856        }
857    }
858
859    // --- Issue #211 regression tests -------------------------------------------------
860
861    #[test]
862    fn test_int4_to_f32_short_data_returns_empty() {
863        // dims=128 requires 64 bytes; supply only 4.
864        let q = Int4Vector {
865            dims: 128,
866            data: vec![0xFFu8; 4],
867            params: Int4Params {
868                scale: 7.5,
869                max_abs: 1.0,
870            },
871            norm: 1.0,
872        };
873        let result = q.to_f32();
874        assert!(
875            result.is_empty(),
876            "to_f32 on malformed Int4Vector must return empty Vec"
877        );
878    }
879
880    #[test]
881    fn test_int4_to_f32_exact_length_works() {
882        // Exactly the right number of bytes — must succeed and not index OOB.
883        let v: Vec<f32> = (0..128).map(|i| (i as f32) / 64.0 - 1.0).collect();
884        let q = Int4Vector::from_f32(&v);
885        let deq = q.to_f32();
886        assert_eq!(deq.len(), 128);
887        for &val in &deq {
888            assert!(val.is_finite());
889        }
890    }
891}