Skip to main content

needle_core/
quant.rs

1//! INT4 weight quantization/dequantization, bit-for-bit parity with Python quantize.py.
2//!
3//! Scheme: symmetric group-wise INT4, group_size=32 along input axis.
4//! Scale = max(|group|) / 7.0, clamped >= 1e-8.
5//! Values packed as two nibbles per byte: low nibble = even row, high nibble = odd row.
6//! Stored signed as two's-complement 4-bit: range [-8, 7].
7//!
8//! Data layout: **row-major** (pair-major) — `data[pair * out_feat + o]`.
9//! This makes the inner loop over output features contiguous, enabling AVX2
10//! auto-vectorization in `matvec_inner_avx2` and plain scalar vectorization elsewhere.
11
12use alloc::vec;
13use alloc::vec::Vec;
14#[cfg(all(target_arch = "x86_64", feature = "simd"))]
15use core::sync::atomic::{AtomicU8, Ordering};
16
17pub const GROUP_SIZE: usize = 32;
18pub const SCALE_MIN: f32 = 1e-8;
19
20// ─── Runtime AVX2 detection ───────────────────────────────────────────────────
21// 0 = not yet checked, 1 = present, 2 = absent.
22// AtomicU8 lives in core, so this is no_std compatible.
23#[cfg(all(target_arch = "x86_64", feature = "simd"))]
24static AVX2_DETECTED: AtomicU8 = AtomicU8::new(0);
25
26#[cfg(all(target_arch = "x86_64", feature = "simd"))]
27#[inline]
28pub fn has_avx2() -> bool {
29    let cached = AVX2_DETECTED.load(Ordering::Relaxed);
30    if cached != 0 {
31        return cached == 1;
32    }
33    // Check CPUID leaf 7, subleaf 0: EBX bit 5 = AVX2.
34    // CPUID is always available on x86_64.
35    #[allow(unused_unsafe)]
36    let result = unsafe {
37        use core::arch::x86_64::__cpuid_count;
38        let r = __cpuid_count(7, 0);
39        (r.ebx >> 5) & 1 == 1
40    };
41    AVX2_DETECTED.store(if result { 1 } else { 2 }, Ordering::Relaxed);
42    result
43}
44
45/// Packed INT4 weight tensor with per-group f32 scales.
46///
47/// `data`: row-major packed nibbles `[pair * out_feat + o]`,
48///         total `(in_padded / 2) * out_feat` bytes.
49/// `scales`: `[num_groups * out_feat]` f32, row-major `[g * out_feat + o]`.
50/// `in_feat`: original (unpadded) input dimension.
51/// `out_feat`: output dimension.
52pub struct QuantizedWeight {
53    pub data: Vec<u8>,
54    pub scales: Vec<f32>,
55    pub in_feat: usize,
56    pub out_feat: usize,
57    pub num_groups: usize,
58}
59
60impl QuantizedWeight {
61    /// Quantize an f32 weight matrix `w` of shape `[in_feat, out_feat]`.
62    /// Matches Python `_fake_quantize_int4` exactly.
63    pub fn quantize(w: &[f32], in_feat: usize, out_feat: usize) -> Self {
64        assert_eq!(w.len(), in_feat * out_feat);
65
66        let gs = GROUP_SIZE.min(in_feat);
67        let pad = (gs - in_feat % gs) % gs;
68        let in_padded = in_feat + pad;
69        let num_groups = in_padded / gs;
70        let num_pairs = in_padded / 2;
71
72        // scales[g * out_feat + o] = max(|group|) / 7.0, clamped >= SCALE_MIN
73        let mut scales = vec![0.0f32; num_groups * out_feat];
74        for g in 0..num_groups {
75            let row_start = g * gs;
76            for o in 0..out_feat {
77                let mut max_abs = 0.0f32;
78                for r in 0..gs {
79                    let global_row = row_start + r;
80                    let val = if global_row < in_feat {
81                        w[global_row * out_feat + o].abs()
82                    } else {
83                        0.0
84                    };
85                    if val > max_abs {
86                        max_abs = val;
87                    }
88                }
89                scales[g * out_feat + o] = (max_abs / 7.0).max(SCALE_MIN);
90            }
91        }
92
93        // Pack nibbles in row-major order: data[pair * out_feat + o]
94        // For pair p: r0 = 2*p, r1 = 2*p+1
95        // byte = lo_nibble(w_q[r0, o]) | hi_nibble(w_q[r1, o])
96        let mut data = vec![0u8; num_pairs * out_feat];
97        for pair in 0..num_pairs {
98            let r0 = pair * 2;
99            let r1 = pair * 2 + 1;
100            let g = r0 / gs; // both r0, r1 are in the same group (gs is even)
101            for o in 0..out_feat {
102                let scale = scales[g * out_feat + o];
103                let v0 = if r0 < in_feat {
104                    w[r0 * out_feat + o]
105                } else {
106                    0.0
107                };
108                let v1 = if r1 < in_feat {
109                    w[r1 * out_feat + o]
110                } else {
111                    0.0
112                };
113                let q0 = crate::math::round(v0 / scale).clamp(-8.0, 7.0) as i8;
114                let q1 = crate::math::round(v1 / scale).clamp(-8.0, 7.0) as i8;
115                let lo = (q0 as u8) & 0x0F;
116                let hi = ((q1 as u8) & 0x0F) << 4;
117                data[pair * out_feat + o] = lo | hi;
118            }
119        }
120
121        Self {
122            data,
123            scales,
124            in_feat,
125            out_feat,
126            num_groups,
127        }
128    }
129
130    /// Dequantize to f32 into `out` buffer of shape `[in_feat, out_feat]`.
131    pub fn dequantize_to(&self, out: &mut [f32]) {
132        assert_eq!(out.len(), self.in_feat * self.out_feat);
133
134        let gs = GROUP_SIZE.min(self.in_feat);
135        let num_pairs = self.num_groups * gs / 2;
136
137        for pair in 0..num_pairs {
138            let r0 = pair * 2;
139            let r1 = pair * 2 + 1;
140            let g = r0 / gs;
141            let base = pair * self.out_feat;
142            for o in 0..self.out_feat {
143                let byte = self.data[base + o];
144                let lo = sign_extend4(byte & 0x0F);
145                let hi = sign_extend4((byte >> 4) & 0x0F);
146                let scale = self.scales[g * self.out_feat + o];
147                if r0 < self.in_feat {
148                    out[r0 * self.out_feat + o] = lo as f32 * scale;
149                }
150                if r1 < self.in_feat {
151                    out[r1 * self.out_feat + o] = hi as f32 * scale;
152                }
153            }
154        }
155    }
156
157    /// Matrix-vector multiply: y = W^T x  (y shape [out_feat], x shape [in_feat]).
158    /// Dequantizes on the fly — never materializes the full weight matrix.
159    /// Uses AVX2 auto-vectorized inner loop on x86_64 when the feature is present.
160    pub fn matvec(&self, x: &[f32], y: &mut [f32]) {
161        assert_eq!(x.len(), self.in_feat);
162        assert_eq!(y.len(), self.out_feat);
163
164        for v in y.iter_mut() {
165            *v = 0.0;
166        }
167
168        #[cfg(all(target_arch = "x86_64", feature = "simd"))]
169        if has_avx2() {
170            // Safety: has_avx2() confirmed AVX2 via CPUID before this call.
171            return unsafe { self.matvec_avx2(x, y) };
172        }
173
174        // NEON is mandatory on aarch64 (ARMv8 baseline) — no runtime check needed.
175        // The scalar fallback is `#[cfg]`-ed out rather than left after an
176        // unconditional return, so an aarch64 build does not warn about an
177        // unreachable statement. That warning is invisible on x86_64, where the
178        // fallback really is reachable, and so failed `-D warnings` only when
179        // building for the ARM targets this crate exists to serve.
180        #[cfg(all(target_arch = "aarch64", feature = "simd"))]
181        // Safety: NEON is part of the ARMv8-A baseline, so the intrinsics in
182        // matvec_neon are always available on this target.
183        unsafe {
184            self.matvec_neon(x, y)
185        }
186
187        #[cfg(not(all(target_arch = "aarch64", feature = "simd")))]
188        self.matvec_scalar(x, y);
189    }
190
191    /// Portable reference path.
192    ///
193    /// Retained on every target: it is the fallback for wasm32 and for x86_64
194    /// without AVX2, and the differential reference the SIMD kernels are checked
195    /// against in `simd_matches_scalar_reference`. On aarch64 the NEON path is
196    /// unconditional, so nothing in a non-test build reaches it.
197    #[cfg_attr(all(target_arch = "aarch64", feature = "simd"), allow(dead_code))]
198    #[allow(clippy::needless_range_loop)]
199    fn matvec_scalar(&self, x: &[f32], y: &mut [f32]) {
200        let gs = GROUP_SIZE.min(self.in_feat);
201        let num_pairs = self.num_groups * gs / 2;
202
203        for pair in 0..num_pairs {
204            let r0 = pair * 2;
205            let r1 = pair * 2 + 1;
206            let g = r0 / gs;
207            let x0 = if r0 < self.in_feat { x[r0] } else { 0.0 };
208            let x1 = if r1 < self.in_feat { x[r1] } else { 0.0 };
209            let base = pair * self.out_feat;
210            let scale_base = g * self.out_feat;
211
212            for o in 0..self.out_feat {
213                let byte = self.data[base + o];
214                let lo = sign_extend4(byte & 0x0F) as f32;
215                let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
216                let scale = self.scales[scale_base + o];
217                y[o] += (lo * x0 + hi * x1) * scale;
218            }
219        }
220    }
221
222    #[cfg(all(target_arch = "x86_64", feature = "simd"))]
223    #[target_feature(enable = "avx2")]
224    unsafe fn matvec_avx2(&self, x: &[f32], y: &mut [f32]) {
225        use core::arch::x86_64::*;
226
227        let gs = GROUP_SIZE.min(self.in_feat);
228        let num_pairs = self.num_groups * gs / 2;
229        let out_feat = self.out_feat;
230
231        // AVX2: process 8 output features per SIMD lane.
232        // For each pair: broadcast x0 and x1, then for each block of 8 output features:
233        //   load 8 packed bytes, extract nibbles, sign-extend, convert to f32, FMA.
234        let mask_lo = _mm256_set1_epi32(0x0F0F0F0F_u32 as i32);
235
236        for pair in 0..num_pairs {
237            let r0 = pair * 2;
238            let r1 = pair * 2 + 1;
239            let g = r0 / gs;
240            let x0 = if r0 < self.in_feat { x[r0] } else { 0.0 };
241            let x1 = if r1 < self.in_feat { x[r1] } else { 0.0 };
242            let x0v = _mm256_set1_ps(x0);
243            let x1v = _mm256_set1_ps(x1);
244
245            let data_base = pair * out_feat;
246            let scale_base = g * out_feat;
247
248            // Process 8 output features per iteration
249            let full_blocks = out_feat / 8;
250            let remainder = out_feat % 8;
251
252            for blk in 0..full_blocks {
253                let o = blk * 8;
254
255                // Load 8 packed bytes (one per output feature for this pair)
256                let bytes_128 =
257                    _mm_loadl_epi64(self.data[data_base + o..].as_ptr() as *const __m128i);
258                // Zero-extend to 256-bit (each byte in its own 32-bit lane for nicer ops)
259                let bytes_256 = _mm256_cvtepu8_epi32(bytes_128);
260
261                // Low nibbles: bits [3:0]
262                let lo_256 = _mm256_and_si256(bytes_256, mask_lo);
263                // High nibbles: shift right by 4, then mask
264                let hi_256 = _mm256_and_si256(_mm256_srli_epi32(bytes_256, 4), mask_lo);
265
266                // Sign-extend 4-bit → signed 32-bit:
267                // If bit 3 set: value is negative → set bits [31:4] to all-1
268                let lo_sign = _mm256_and_si256(lo_256, _mm256_set1_epi32(8));
269                let lo_neg_mask = _mm256_cmpeq_epi32(lo_sign, _mm256_set1_epi32(8));
270                let lo_ext = _mm256_and_si256(lo_neg_mask, _mm256_set1_epi32(!0x0F));
271                let lo_i32 = _mm256_or_si256(lo_256, lo_ext);
272
273                let hi_sign = _mm256_and_si256(hi_256, _mm256_set1_epi32(8));
274                let hi_neg_mask = _mm256_cmpeq_epi32(hi_sign, _mm256_set1_epi32(8));
275                let hi_ext = _mm256_and_si256(hi_neg_mask, _mm256_set1_epi32(!0x0F));
276                let hi_i32 = _mm256_or_si256(hi_256, hi_ext);
277
278                // Convert i32 → f32
279                let lo_f32 = _mm256_cvtepi32_ps(lo_i32);
280                let hi_f32 = _mm256_cvtepi32_ps(hi_i32);
281
282                // Load scales for this block
283                let scale_v = _mm256_loadu_ps(self.scales[scale_base + o..].as_ptr());
284
285                // FMA: y[o..o+8] += (lo * x0 + hi * x1) * scale
286                // Compute: contrib = (lo_f32 * x0v + hi_f32 * x1v) * scale_v
287                let contrib_lo = _mm256_mul_ps(lo_f32, x0v);
288                let contrib = _mm256_fmadd_ps(hi_f32, x1v, contrib_lo);
289                let scaled = _mm256_mul_ps(contrib, scale_v);
290
291                // Accumulate into y
292                let y_ptr = y[o..].as_mut_ptr();
293                let y_vec = _mm256_loadu_ps(y_ptr);
294                let y_new = _mm256_add_ps(y_vec, scaled);
295                _mm256_storeu_ps(y_ptr, y_new);
296            }
297
298            // Scalar tail for remaining output features (< 8)
299            if remainder > 0 {
300                let o_base = full_blocks * 8;
301                let scale_off = scale_base + o_base;
302                let data_off = data_base + o_base;
303                for o in 0..remainder {
304                    let byte = self.data[data_off + o];
305                    let lo = sign_extend4(byte & 0x0F) as f32;
306                    let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
307                    let scale = self.scales[scale_off + o];
308                    y[o_base + o] += (lo * x0 + hi * x1) * scale;
309                }
310            }
311        }
312    }
313
314    /// NEON-accelerated matvec for aarch64.
315    ///
316    /// Processes 8 output features per SIMD step using 64-bit NEON loads and two
317    /// float32x4_t lanes. NEON is mandatory on ARMv8 (aarch64) — no runtime check needed.
318    #[cfg(all(target_arch = "aarch64", feature = "simd"))]
319    #[target_feature(enable = "neon")]
320    unsafe fn matvec_neon(&self, x: &[f32], y: &mut [f32]) {
321        use core::arch::aarch64::*;
322
323        let gs = GROUP_SIZE.min(self.in_feat);
324        let num_pairs = self.num_groups * gs / 2;
325        let out_feat = self.out_feat;
326
327        let mask_u8 = vdup_n_u8(0x0F);
328        let eight_u8 = vdup_n_u8(8);
329        let sixteen = vdup_n_u8(16);
330
331        for pair in 0..num_pairs {
332            let r0 = pair * 2;
333            let r1 = pair * 2 + 1;
334            let g = r0 / gs;
335            let x0 = if r0 < self.in_feat {
336                *x.get_unchecked(r0)
337            } else {
338                0.0
339            };
340            let x1 = if r1 < self.in_feat {
341                *x.get_unchecked(r1)
342            } else {
343                0.0
344            };
345            let x0v = vdupq_n_f32(x0);
346            let x1v = vdupq_n_f32(x1);
347
348            let data_base = pair * out_feat;
349            let scale_base = g * out_feat;
350
351            // 8 output features per NEON step (one vld1_u8 = 8 packed bytes).
352            let full8 = out_feat / 8;
353
354            for blk in 0..full8 {
355                let o = blk * 8;
356
357                // Load 8 packed bytes: each byte encodes one pair of nibbles for one output feature.
358                let bytes = vld1_u8(self.data.get_unchecked(data_base + o) as *const u8);
359
360                // Low nibbles [3:0] and high nibbles [7:4] — values 0..15 as u8.
361                let lo_raw = vand_u8(bytes, mask_u8);
362                let hi_raw = vshr_n_u8::<4>(bytes); // logical shift, upper bits = 0
363
364                // Sign-extend 4-bit → i8: values 8..15 become -8..-1 by subtracting 16.
365                let lo_s8 = vreinterpret_s8_u8(vsub_u8(
366                    lo_raw,
367                    vand_u8(vcge_u8(lo_raw, eight_u8), sixteen),
368                ));
369                let hi_s8 = vreinterpret_s8_u8(vsub_u8(
370                    hi_raw,
371                    vand_u8(vcge_u8(hi_raw, eight_u8), sixteen),
372                ));
373
374                // Widen s8x8 → s16x8 → two s32x4 → two f32x4.
375                let lo_s16 = vmovl_s8(lo_s8);
376                let hi_s16 = vmovl_s8(hi_s8);
377
378                // Lower 4 lanes (o..o+4)
379                let lo_f32_lo = vcvtq_f32_s32(vmovl_s16(vget_low_s16(lo_s16)));
380                let hi_f32_lo = vcvtq_f32_s32(vmovl_s16(vget_low_s16(hi_s16)));
381                let scale_lo = vld1q_f32(self.scales.get_unchecked(scale_base + o) as *const f32);
382                let contrib_lo = vmlaq_f32(vmulq_f32(lo_f32_lo, x0v), hi_f32_lo, x1v);
383                let y_ptr_lo = y.get_unchecked_mut(o) as *mut f32;
384                vst1q_f32(
385                    y_ptr_lo,
386                    vaddq_f32(vld1q_f32(y_ptr_lo), vmulq_f32(contrib_lo, scale_lo)),
387                );
388
389                // Upper 4 lanes (o+4..o+8)
390                let lo_f32_hi = vcvtq_f32_s32(vmovl_s16(vget_high_s16(lo_s16)));
391                let hi_f32_hi = vcvtq_f32_s32(vmovl_s16(vget_high_s16(hi_s16)));
392                let scale_hi =
393                    vld1q_f32(self.scales.get_unchecked(scale_base + o + 4) as *const f32);
394                let contrib_hi = vmlaq_f32(vmulq_f32(lo_f32_hi, x0v), hi_f32_hi, x1v);
395                let y_ptr_hi = y.get_unchecked_mut(o + 4) as *mut f32;
396                vst1q_f32(
397                    y_ptr_hi,
398                    vaddq_f32(vld1q_f32(y_ptr_hi), vmulq_f32(contrib_hi, scale_hi)),
399                );
400            }
401
402            // Scalar tail for remaining < 8 output features.
403            let tail_start = full8 * 8;
404            for o in tail_start..out_feat {
405                let byte = *self.data.get_unchecked(data_base + o);
406                let lo = sign_extend4(byte & 0x0F) as f32;
407                let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
408                let scale = *self.scales.get_unchecked(scale_base + o);
409                *y.get_unchecked_mut(o) += (lo * x0 + hi * x1) * scale;
410            }
411        }
412    }
413
414    /// Batched matmul: Y = X W  (X shape [batch, in_feat], Y shape [batch, out_feat]).
415    pub fn matmul(&self, x: &[f32], batch: usize, y: &mut [f32]) {
416        assert_eq!(x.len(), batch * self.in_feat);
417        assert_eq!(y.len(), batch * self.out_feat);
418        for b in 0..batch {
419            self.matvec(
420                &x[b * self.in_feat..(b + 1) * self.in_feat],
421                &mut y[b * self.out_feat..(b + 1) * self.out_feat],
422            );
423        }
424    }
425}
426
427#[inline(always)]
428fn sign_extend4(nibble: u8) -> i8 {
429    if nibble & 0x8 != 0 {
430        (nibble | 0xF0) as i8
431    } else {
432        nibble as i8
433    }
434}
435
436#[cfg(test)]
437mod tests {
438
439    /// Whichever SIMD kernel `matvec` dispatches to must agree with the portable
440    /// scalar path. Nothing else checks the NEON and AVX2 kernels against a
441    /// reference, and they are selected by target and CPUID rather than by any
442    /// test, so this is the only place a divergence would surface.
443    #[test]
444    fn simd_matches_scalar_reference() {
445        for &(in_feat, out_feat) in &[(32usize, 8usize), (64, 16), (512, 512), (100, 12), (33, 7)] {
446            // Deterministic weights spanning positive and negative values.
447            let w: Vec<f32> = (0..in_feat * out_feat)
448                .map(|i| ((i * 31 % 197) as f32 - 98.0) / 37.0)
449                .collect();
450            let q = QuantizedWeight::quantize(&w, in_feat, out_feat);
451            let x: Vec<f32> = (0..in_feat)
452                .map(|i| (i as f32 * 0.37).sin() * 3.0 - 0.5)
453                .collect();
454
455            let mut dispatched = vec![0.0f32; out_feat];
456            q.matvec(&x, &mut dispatched);
457
458            let mut scalar = vec![0.0f32; out_feat];
459            q.matvec_scalar(&x, &mut scalar);
460
461            let l1: f32 = x.iter().map(|v| v.abs()).sum();
462            for o in 0..out_feat {
463                // Same arithmetic, different accumulation order: the gap is
464                // bounded by the input's L1 norm, not by the result's size.
465                assert!(
466                    (dispatched[o] - scalar[o]).abs() <= 1e-4 * l1.max(1.0),
467                    "{in_feat}x{out_feat} out[{o}]: dispatched {} vs scalar {}",
468                    dispatched[o],
469                    scalar[o]
470                );
471            }
472        }
473    }
474    use super::*;
475
476    #[test]
477    fn test_sign_extend() {
478        assert_eq!(sign_extend4(0x7), 7);
479        assert_eq!(sign_extend4(0x8), -8);
480        assert_eq!(sign_extend4(0xF), -1);
481        assert_eq!(sign_extend4(0x0), 0);
482    }
483
484    #[test]
485    fn test_roundtrip_identity() {
486        let in_feat = 64;
487        let out_feat = 32;
488        let mut w = alloc::vec![0.0f32; in_feat * out_feat];
489        for i in 0..in_feat {
490            for o in 0..out_feat {
491                w[i * out_feat + o] = ((i + o) % 8) as f32;
492            }
493        }
494        let qw = QuantizedWeight::quantize(&w, in_feat, out_feat);
495        let mut out = alloc::vec![0.0f32; in_feat * out_feat];
496        qw.dequantize_to(&mut out);
497
498        for i in 0..w.len() {
499            let diff = (w[i] - out[i]).abs();
500            assert!(diff < 1e-5, "index {i}: expected {}, got {}", w[i], out[i]);
501        }
502    }
503
504    #[test]
505    fn test_matvec_matches_dequantize() {
506        let in_feat = 64;
507        let out_feat = 32;
508        let w: alloc::vec::Vec<f32> = (0..in_feat * out_feat)
509            .map(|i| (i as f32 - 32.0) * 0.05)
510            .collect();
511        let x: alloc::vec::Vec<f32> = (0..in_feat).map(|i| (i as f32 * 0.1).sin()).collect();
512
513        let qw = QuantizedWeight::quantize(&w, in_feat, out_feat);
514
515        // Reference: dequantize then naive matmul
516        let mut dq = alloc::vec![0.0f32; in_feat * out_feat];
517        qw.dequantize_to(&mut dq);
518        let mut y_ref = alloc::vec![0.0f32; out_feat];
519        for o in 0..out_feat {
520            for i in 0..in_feat {
521                y_ref[o] += x[i] * dq[i * out_feat + o];
522            }
523        }
524
525        // matvec must produce the same result
526        let mut y_got = alloc::vec![0.0f32; out_feat];
527        qw.matvec(&x, &mut y_got);
528
529        for o in 0..out_feat {
530            let diff = (y_got[o] - y_ref[o]).abs();
531            // Different accumulation order (per-pair vs per-row) causes ~machine-epsilon relative error.
532            let tol = 1e-3 * (y_ref[o].abs().max(1.0));
533            assert!(
534                diff < tol,
535                "out[{o}]: matvec={} dequant+dot={} diff={diff} tol={tol}",
536                y_got[o],
537                y_ref[o]
538            );
539        }
540    }
541}