Skip to main content

lattice_embed/simd/
int4.rs

1//! INT4 quantization for ultra-compact embedding storage.
2//!
3//! Two 4-bit values packed per byte (8x compression vs f32).
4//! Uses symmetric unsigned quantization: maps [-max_abs, max_abs] to [0, 15].
5//!
6//! ## Packing format
7//!
8//! High nibble = even index, low nibble = odd index.
9//! For D dimensions, storage is `ceil(D / 2)` bytes.
10//!
11//! ## Dot product
12//!
13//! Dot products dequantize before accumulation so the unsigned INT4 offset is
14//! handled identically on every target.
15
16#[cfg(target_arch = "aarch64")]
17use std::arch::aarch64::*;
18
19#[cfg(target_arch = "aarch64")]
20use super::simd_config;
21
22/// **Unstable**: INT4 quantization internals; scale/bias scheme may change.
23///
24/// Quantization parameters for INT4 conversion.
25///
26/// Uses symmetric unsigned quantization: the float range [-max_abs, max_abs]
27/// is mapped to the integer range [0, 15].
28#[derive(Debug, Clone, Copy)]
29pub struct Int4Params {
30    /// **Unstable**: scale factor; formula may change with quantization scheme update.
31    pub scale: f32,
32    /// **Unstable**: maximum absolute value; field may be removed.
33    pub max_abs: f32,
34}
35
36impl Int4Params {
37    /// **Unstable**: quantization parameter computation; may be folded into `Int4Vector::from_f32`.
38    pub fn from_vector(vector: &[f32]) -> Self {
39        let mut max_abs: f32 = 0.0;
40        for &v in vector {
41            if v.is_finite() {
42                max_abs = max_abs.max(v.abs());
43            }
44        }
45
46        // Epsilon guard: avoid division by near-zero
47        let scale = if max_abs > 1e-10 {
48            15.0 / (2.0 * max_abs)
49        } else {
50            1.0
51        };
52
53        Self { scale, max_abs }
54    }
55}
56
57/// **Unstable**: INT4 quantization format is under active design; struct layout may change.
58///
59/// Quantized INT4 vector with packed nibble storage.
60#[derive(Debug, Clone)]
61pub struct Int4Vector {
62    /// **Unstable**: packed nibble data; bit packing scheme may change.
63    pub data: Vec<u8>,
64    /// **Unstable**: number of original dimensions.
65    pub dims: usize,
66    /// **Unstable**: quantization parameters; may be separated from the vector.
67    pub params: Int4Params,
68    /// **Unstable**: L2 norm; may be removed or moved.
69    pub norm: f32,
70}
71
72impl Int4Vector {
73    /// **Unstable**: quantization format; nibble packing may change.
74    ///
75    /// Each pair of consecutive dimensions is packed into one byte:
76    /// - High nibble (bits 7..4) = even-indexed value
77    /// - Low nibble (bits 3..0) = odd-indexed value
78    pub fn from_f32(vector: &[f32]) -> Self {
79        let params = Int4Params::from_vector(vector);
80        let dims = vector.len();
81
82        // Compute L2 norm
83        let mut norm_sq = 0.0f32;
84        for &v in vector {
85            if v.is_finite() {
86                norm_sq += v * v;
87            }
88        }
89        let norm = norm_sq.sqrt();
90
91        // Quantize each value to [0, 15] and pack pairs into bytes
92        let packed_len = dims.div_ceil(2);
93        let mut data = vec![0u8; packed_len];
94
95        for (i, &elem) in vector[..dims].iter().enumerate() {
96            let v = if elem.is_finite() { elem } else { 0.0 };
97            // Map [-max_abs, max_abs] -> [0, 15]
98            let q = ((v + params.max_abs) * params.scale)
99                .round()
100                .clamp(0.0, 15.0) as u8;
101
102            let byte_idx = i / 2;
103            if i % 2 == 0 {
104                // Even index -> high nibble
105                data[byte_idx] |= q << 4;
106            } else {
107                // Odd index -> low nibble
108                data[byte_idx] |= q;
109            }
110        }
111
112        Self {
113            data,
114            dims,
115            params,
116            norm,
117        }
118    }
119
120    /// **Unstable**: dequantization output semantics may change.
121    ///
122    /// Reverses the quantization: `v[i] = q[i] / scale - max_abs`
123    ///
124    /// Returns an empty `Vec` if the packed buffer is shorter than `dims.div_ceil(2)`
125    /// bytes — i.e. the vector was constructed with mismatched fields.
126    ///
127    /// # Precision
128    ///
129    /// INT4 unsigned symmetric quantization maps `[-max_abs, max_abs]` to `[0, 15]`
130    /// (16 levels), so the quantization step size is `2 * max_abs / 15`. The maximum
131    /// per-element round-trip error is bounded by half a step: `max_abs / 15`.
132    ///
133    /// For a 384-dim unit-norm embedding (`max_abs` ≈ 1.0), expect element-wise
134    /// absolute error ≤ 0.067 and relative dot-product error ≤ 15% (see
135    /// `test_int4_dot_product_vs_f32` and `test_int4_roundtrip_accuracy`).
136    /// Use `Int8` tier when higher fidelity is required.
137    pub fn to_f32(&self) -> Vec<f32> {
138        let required_bytes = self.dims.div_ceil(2);
139        if self.data.len() < required_bytes {
140            return Vec::new();
141        }
142
143        let scale = if self.params.scale.is_finite() && self.params.scale != 0.0 {
144            self.params.scale
145        } else {
146            1.0
147        };
148
149        let mut result = Vec::with_capacity(self.dims);
150        for i in 0..self.dims {
151            let byte_idx = i / 2;
152            let q = if i % 2 == 0 {
153                (self.data[byte_idx] >> 4) & 0x0F
154            } else {
155                self.data[byte_idx] & 0x0F
156            };
157            result.push(q as f32 / scale - self.params.max_abs);
158        }
159        result
160    }
161
162    /// **Unstable**: INT4 dot product approximation; formula may change.
163    ///
164    /// Returns the dequantized dot product suitable for cosine distance computation.
165    #[inline]
166    pub fn dot_product(&self, other: &Int4Vector) -> f32 {
167        dot_product_int4(self, other)
168    }
169
170    /// **Unstable**: INT4 cosine similarity approximation; delegates to `dot_product`.
171    #[inline]
172    pub fn cosine_similarity(&self, other: &Int4Vector) -> f32 {
173        let denom = self.norm * other.norm;
174        if denom == 0.0 || !denom.is_finite() {
175            return 0.0;
176        }
177        self.dot_product(other) / denom
178    }
179
180    /// **Unstable**: complement of `cosine_similarity`; definition may evolve.
181    #[inline]
182    pub fn cosine_distance(&self, other: &Int4Vector) -> f32 {
183        1.0 - self.cosine_similarity(other)
184    }
185}
186
187/// **Unstable**: SIMD INT4 dot product; NEON/scalar dispatch may change.
188///
189/// Unpacks nibbles, computes dot product of quantized values, then applies
190/// dequantization scaling: `result = (raw_dot / (scale_a * scale_b)) - correction`
191///
192/// The correction accounts for the unsigned offset in the quantization formula.
193#[inline]
194pub fn dot_product_int4(a: &Int4Vector, b: &Int4Vector) -> f32 {
195    if a.dims != b.dims {
196        return 0.0;
197    }
198
199    let scale_a = a.params.scale;
200    let scale_b = b.params.scale;
201    if scale_a == 0.0 || scale_b == 0.0 || !scale_a.is_finite() || !scale_b.is_finite() {
202        return 0.0;
203    }
204
205    let packed_len = a.dims.div_ceil(2);
206    if a.data.len() < packed_len || b.data.len() < packed_len {
207        return 0.0;
208    }
209
210    #[cfg(target_arch = "aarch64")]
211    {
212        let config = simd_config();
213        if config.neon_enabled {
214            // SAFETY: aarch64 NEON is available by config, the packed data length guard
215            // above prevents out-of-bounds loads, and the callee handles odd dimensions
216            // without reading the padding nibble as a real dimension.
217            let (raw_dot, sum_a, sum_b) =
218                unsafe { dot_product_int4_neon_unrolled(&a.data, &b.data, a.dims) };
219            return finish_int4_dot(raw_dot, sum_a, sum_b, a, b);
220        }
221    }
222
223    let (raw_dot, sum_a, sum_b) = dot_product_int4_packed_scalar(&a.data, &b.data, a.dims);
224    finish_int4_dot(raw_dot, sum_a, sum_b, a, b)
225}
226
227#[inline]
228fn finish_int4_dot(raw_dot: i32, sum_a: i32, sum_b: i32, a: &Int4Vector, b: &Int4Vector) -> f32 {
229    let raw_dot = raw_dot as f32;
230    let sum_a = sum_a as f32;
231    let sum_b = sum_b as f32;
232    let scale_a = a.params.scale;
233    let scale_b = b.params.scale;
234
235    raw_dot / (scale_a * scale_b)
236        - (b.params.max_abs * sum_a / scale_a)
237        - (a.params.max_abs * sum_b / scale_b)
238        + (a.dims as f32 * a.params.max_abs * b.params.max_abs)
239}
240
241#[inline]
242fn dot_product_int4_packed_scalar(a: &[u8], b: &[u8], dims: usize) -> (i32, i32, i32) {
243    let full_bytes = dims / 2;
244    let mut raw_dot = 0i32;
245    let mut sum_a = 0i32;
246    let mut sum_b = 0i32;
247
248    for i in 0..full_bytes {
249        let av = a[i];
250        let bv = b[i];
251        let ah = ((av >> 4) & 0x0f) as i32;
252        let al = (av & 0x0f) as i32;
253        let bh = ((bv >> 4) & 0x0f) as i32;
254        let bl = (bv & 0x0f) as i32;
255        raw_dot += ah * bh + al * bl;
256        sum_a += ah + al;
257        sum_b += bh + bl;
258    }
259
260    if dims % 2 == 1 {
261        let av = a[full_bytes];
262        let bv = b[full_bytes];
263        let ah = ((av >> 4) & 0x0f) as i32;
264        let bh = ((bv >> 4) & 0x0f) as i32;
265        raw_dot += ah * bh;
266        sum_a += ah;
267        sum_b += bh;
268    }
269
270    (raw_dot, sum_a, sum_b)
271}
272
273#[cfg(target_arch = "aarch64")]
274#[target_feature(enable = "neon")]
275#[inline]
276unsafe fn dot_product_int4_neon_unrolled(a: &[u8], b: &[u8], dims: usize) -> (i32, i32, i32) {
277    debug_assert!(a.len() >= dims.div_ceil(2));
278    debug_assert!(b.len() >= dims.div_ceil(2));
279
280    const BLOCK_BYTES: usize = 16;
281    const UNROLL: usize = 4;
282    const CHUNK_BYTES: usize = BLOCK_BYTES * UNROLL;
283
284    // Only bytes containing two valid dimensions are processed in SIMD.
285    // If dims is odd, the final high nibble is handled separately and the low
286    // padding nibble is ignored to preserve current to_f32 semantics.
287    let full_bytes = dims / 2;
288    let chunks = full_bytes / CHUNK_BYTES;
289
290    let mut raw0 = vdupq_n_u32(0);
291    let mut raw1 = vdupq_n_u32(0);
292    let mut raw2 = vdupq_n_u32(0);
293    let mut raw3 = vdupq_n_u32(0);
294    let mut sum_a = vdupq_n_u32(0);
295    let mut sum_b = vdupq_n_u32(0);
296    let mask = vdupq_n_u8(0x0f);
297
298    macro_rules! accumulate_block {
299        ($base:expr, $raw:ident) => {{
300            let a_bytes = vld1q_u8(a.as_ptr().add($base));
301            let b_bytes = vld1q_u8(b.as_ptr().add($base));
302
303            let a_hi = vshrq_n_u8::<4>(a_bytes);
304            let b_hi = vshrq_n_u8::<4>(b_bytes);
305            let a_lo = vandq_u8(a_bytes, mask);
306            let b_lo = vandq_u8(b_bytes, mask);
307
308            $raw = vpadalq_u16($raw, vmull_u8(vget_low_u8(a_hi), vget_low_u8(b_hi)));
309            $raw = vpadalq_u16($raw, vmull_u8(vget_high_u8(a_hi), vget_high_u8(b_hi)));
310            $raw = vpadalq_u16($raw, vmull_u8(vget_low_u8(a_lo), vget_low_u8(b_lo)));
311            $raw = vpadalq_u16($raw, vmull_u8(vget_high_u8(a_lo), vget_high_u8(b_lo)));
312
313            sum_a = vpadalq_u16(sum_a, vpaddlq_u8(a_hi));
314            sum_a = vpadalq_u16(sum_a, vpaddlq_u8(a_lo));
315            sum_b = vpadalq_u16(sum_b, vpaddlq_u8(b_hi));
316            sum_b = vpadalq_u16(sum_b, vpaddlq_u8(b_lo));
317        }};
318    }
319
320    for i in 0..chunks {
321        let base = i * CHUNK_BYTES;
322        accumulate_block!(base, raw0);
323        accumulate_block!(base + BLOCK_BYTES, raw1);
324        accumulate_block!(base + BLOCK_BYTES * 2, raw2);
325        accumulate_block!(base + BLOCK_BYTES * 3, raw3);
326    }
327
328    let raw_vec = vaddq_u32(vaddq_u32(raw0, raw1), vaddq_u32(raw2, raw3));
329    let mut raw_total = (vgetq_lane_u32::<0>(raw_vec)
330        + vgetq_lane_u32::<1>(raw_vec)
331        + vgetq_lane_u32::<2>(raw_vec)
332        + vgetq_lane_u32::<3>(raw_vec)) as i32;
333    let mut sum_a_total = (vgetq_lane_u32::<0>(sum_a)
334        + vgetq_lane_u32::<1>(sum_a)
335        + vgetq_lane_u32::<2>(sum_a)
336        + vgetq_lane_u32::<3>(sum_a)) as i32;
337    let mut sum_b_total = (vgetq_lane_u32::<0>(sum_b)
338        + vgetq_lane_u32::<1>(sum_b)
339        + vgetq_lane_u32::<2>(sum_b)
340        + vgetq_lane_u32::<3>(sum_b)) as i32;
341
342    let remainder_start = chunks * CHUNK_BYTES;
343    for byte_idx in remainder_start..full_bytes {
344        let av = *a.get_unchecked(byte_idx);
345        let bv = *b.get_unchecked(byte_idx);
346        let ah = ((av >> 4) & 0x0f) as i32;
347        let al = (av & 0x0f) as i32;
348        let bh = ((bv >> 4) & 0x0f) as i32;
349        let bl = (bv & 0x0f) as i32;
350
351        raw_total += ah * bh + al * bl;
352        sum_a_total += ah + al;
353        sum_b_total += bh + bl;
354    }
355
356    if dims % 2 == 1 {
357        let av = *a.get_unchecked(full_bytes);
358        let bv = *b.get_unchecked(full_bytes);
359        let ah = ((av >> 4) & 0x0f) as i32;
360        let bh = ((bv >> 4) & 0x0f) as i32;
361
362        raw_total += ah * bh;
363        sum_a_total += ah;
364        sum_b_total += bh;
365    }
366
367    (raw_total, sum_a_total, sum_b_total)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn generate_vector(dim: usize, seed: u64) -> Vec<f32> {
375        let mut state = seed ^ ((dim as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
376        (0..dim)
377            .map(|i| {
378                state = state
379                    .wrapping_mul(6364136223846793005)
380                    .wrapping_add(1442695040888963407)
381                    .wrapping_add(i as u64);
382                let unit = ((state >> 32) as u32) as f32 / u32::MAX as f32;
383                unit * 2.0 - 1.0
384            })
385            .collect()
386    }
387
388    #[test]
389    fn test_int4_roundtrip_accuracy() {
390        let original = generate_vector(384, 42);
391        let quantized = Int4Vector::from_f32(&original);
392        let dequantized = quantized.to_f32();
393
394        assert_eq!(dequantized.len(), original.len());
395
396        // INT4 has only 16 levels, so error is larger than INT8.
397        // Max error should be within 1/15 of the range.
398        let max_abs = original
399            .iter()
400            .filter(|v| v.is_finite())
401            .map(|v| v.abs())
402            .fold(0.0f32, f32::max);
403        let expected_max_error = 2.0 * max_abs / 15.0;
404
405        for (i, (orig, deq)) in original.iter().zip(dequantized.iter()).enumerate() {
406            let error = (orig - deq).abs();
407            assert!(
408                error <= expected_max_error + 1e-5,
409                "INT4 roundtrip error too large at index {i}: orig={orig}, deq={deq}, error={error}, max_allowed={expected_max_error}"
410            );
411        }
412    }
413
414    #[test]
415    fn test_int4_packing_correctness() {
416        // Verify nibble packing: even index -> high nibble, odd -> low
417        let v = vec![0.5, -0.5, 0.0, 1.0]; // 4 values -> 2 packed bytes
418        let q = Int4Vector::from_f32(&v);
419        assert_eq!(q.data.len(), 2);
420        assert_eq!(q.dims, 4);
421
422        // Verify roundtrip preserves approximate values
423        let deq = q.to_f32();
424        assert_eq!(deq.len(), 4);
425        // 0.5 should map to roughly the right region
426        assert!((deq[0] - 0.5).abs() < 0.15, "deq[0]={}", deq[0]);
427        assert!((deq[1] - (-0.5)).abs() < 0.15, "deq[1]={}", deq[1]);
428    }
429
430    #[test]
431    fn test_int4_odd_dimensions() {
432        // Odd number of dimensions: last nibble has a padding zero
433        let v = generate_vector(383, 77);
434        let q = Int4Vector::from_f32(&v);
435        assert_eq!(q.data.len(), 192); // ceil(383/2) = 192
436        assert_eq!(q.dims, 383);
437
438        let deq = q.to_f32();
439        assert_eq!(deq.len(), 383);
440    }
441
442    #[test]
443    fn test_int4_zero_vector() {
444        let v = vec![0.0; 384];
445        let q = Int4Vector::from_f32(&v);
446        let deq = q.to_f32();
447        for &val in &deq {
448            assert!(
449                val.abs() < 1e-5,
450                "Zero vector should dequantize to near-zero"
451            );
452        }
453    }
454
455    #[test]
456    fn test_int4_dot_product_vs_f32() {
457        // Use correlated vectors so the true dot product is large relative to noise.
458        // For uncorrelated random vectors, the expected dot product is ~0 while
459        // quantization noise is O(dims * step^2), so relative error is unbounded.
460        let a = generate_vector(384, 101);
461        let b: Vec<f32> = a
462            .iter()
463            .enumerate()
464            .map(|(i, &x)| x + 0.2 * (i as f32 * 0.3).sin())
465            .collect();
466
467        // f32 reference
468        let f32_dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
469
470        let qa = Int4Vector::from_f32(&a);
471        let qb = Int4Vector::from_f32(&b);
472        let int4_dot = qa.dot_product(&qb);
473
474        // INT4 has 16 levels; for correlated vectors the relative error should be
475        // within ~15% (quantization step = 2*max_abs/15 per component).
476        let rel_error = (f32_dot - int4_dot).abs() / f32_dot.abs().max(1.0);
477        assert!(
478            rel_error < 0.15,
479            "INT4 dot product relative error too large: f32={f32_dot}, int4={int4_dot}, rel_error={rel_error}"
480        );
481    }
482
483    #[cfg(target_arch = "aarch64")]
484    #[test]
485    fn test_packed_scalar_matches_neon_exact() {
486        // Directly compare dot_product_int4_packed_scalar against
487        // dot_product_int4_neon_unrolled on integer tuples. Both return
488        // (raw_dot, sum_a, sum_b) as i32 — integer domain, exact equality expected.
489        // This is the executing parity proof for the non-aarch64 fallback kernel.
490        for dim in [1usize, 3, 31, 127, 383, 384] {
491            let a_f32 = generate_vector(dim, 500 + dim as u64);
492            let b_f32 = generate_vector(dim, 600 + dim as u64);
493            let qa = Int4Vector::from_f32(&a_f32);
494            let qb = Int4Vector::from_f32(&b_f32);
495
496            let scalar_result = dot_product_int4_packed_scalar(&qa.data, &qb.data, dim);
497            // SAFETY: aarch64 always has NEON; data slices are correctly sized by
498            // Int4Vector::from_f32 (len = dims.div_ceil(2)).
499            let neon_result = unsafe { dot_product_int4_neon_unrolled(&qa.data, &qb.data, dim) };
500
501            assert_eq!(
502                scalar_result, neon_result,
503                "packed_scalar vs NEON integer mismatch at dim={dim}: scalar={scalar_result:?}, neon={neon_result:?}"
504            );
505        }
506    }
507
508    #[cfg(target_arch = "aarch64")]
509    #[test]
510    fn test_int4_neon_matches_dequantized_scalar() {
511        for dim in [1, 2, 31, 64, 127, 384, 768] {
512            let a = generate_vector(dim, 501);
513            let b = generate_vector(dim, 777);
514            let qa = Int4Vector::from_f32(&a);
515            let qb = Int4Vector::from_f32(&b);
516
517            let a_deq = qa.to_f32();
518            let b_deq = qb.to_f32();
519            let expected: f32 = a_deq.iter().zip(b_deq.iter()).map(|(&x, &y)| x * y).sum();
520            let got = qa.dot_product(&qb);
521
522            assert!(
523                (expected - got).abs() < 1e-4,
524                "INT4 NEON mismatch for dim={dim}: expected={expected}, got={got}"
525            );
526        }
527    }
528
529    #[test]
530    fn test_int4_cosine_similarity() {
531        let a = generate_vector(384, 301);
532        let b = generate_vector(384, 302);
533
534        let qa = Int4Vector::from_f32(&a);
535        let qb = Int4Vector::from_f32(&b);
536        let int4_cos = qa.cosine_similarity(&qb);
537
538        // Compute f32 reference cosine
539        let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
540        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
541        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
542        let f32_cos = dot / (norm_a * norm_b);
543
544        assert!(
545            (f32_cos - int4_cos).abs() < 0.1,
546            "INT4 cosine too far from f32: f32={f32_cos}, int4={int4_cos}"
547        );
548    }
549
550    #[test]
551    fn test_int4_memory_savings() {
552        let v = generate_vector(384, 999);
553        let q = Int4Vector::from_f32(&v);
554
555        // f32: 384 * 4 = 1536 bytes
556        // INT4: ceil(384/2) = 192 bytes = 8x compression
557        assert_eq!(q.data.len(), 192);
558        assert_eq!(v.len() * 4, 1536);
559    }
560
561    #[test]
562    fn test_int4_nan_inf_handling() {
563        let v = vec![
564            1.0,
565            f32::NAN,
566            f32::INFINITY,
567            f32::NEG_INFINITY,
568            -1.0,
569            0.5,
570            0.0,
571            -0.3,
572        ];
573        let q = Int4Vector::from_f32(&v);
574        let deq = q.to_f32();
575        assert_eq!(deq.len(), 8);
576        // NaN and Inf should be treated as 0
577        // The dequantized value for the "0" slot should be near -max_abs + something,
578        // but the key invariant is no panics and finite output.
579        for &val in &deq {
580            assert!(val.is_finite(), "Dequantized value should be finite");
581        }
582    }
583
584    // --- Issue #211 regression tests -------------------------------------------------
585
586    #[test]
587    fn test_int4_to_f32_short_data_returns_empty() {
588        // dims=128 requires 64 bytes; supply only 4.
589        let q = Int4Vector {
590            dims: 128,
591            data: vec![0xFFu8; 4],
592            params: Int4Params {
593                scale: 7.5,
594                max_abs: 1.0,
595            },
596            norm: 1.0,
597        };
598        let result = q.to_f32();
599        assert!(
600            result.is_empty(),
601            "to_f32 on malformed Int4Vector must return empty Vec"
602        );
603    }
604
605    #[test]
606    fn test_int4_to_f32_exact_length_works() {
607        // Exactly the right number of bytes — must succeed and not index OOB.
608        let v: Vec<f32> = (0..128).map(|i| (i as f32) / 64.0 - 1.0).collect();
609        let q = Int4Vector::from_f32(&v);
610        let deq = q.to_f32();
611        assert_eq!(deq.len(), 128);
612        for &val in &deq {
613            assert!(val.is_finite());
614        }
615    }
616}