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        #[cfg(all(target_arch = "aarch64", feature = "simd"))]
176        {
177            return unsafe { self.matvec_neon(x, y) };
178        }
179
180        self.matvec_scalar(x, y);
181    }
182
183    #[allow(clippy::needless_range_loop)]
184    fn matvec_scalar(&self, x: &[f32], y: &mut [f32]) {
185        let gs = GROUP_SIZE.min(self.in_feat);
186        let num_pairs = self.num_groups * gs / 2;
187
188        for pair in 0..num_pairs {
189            let r0 = pair * 2;
190            let r1 = pair * 2 + 1;
191            let g = r0 / gs;
192            let x0 = if r0 < self.in_feat { x[r0] } else { 0.0 };
193            let x1 = if r1 < self.in_feat { x[r1] } else { 0.0 };
194            let base = pair * self.out_feat;
195            let scale_base = g * self.out_feat;
196
197            for o in 0..self.out_feat {
198                let byte = self.data[base + o];
199                let lo = sign_extend4(byte & 0x0F) as f32;
200                let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
201                let scale = self.scales[scale_base + o];
202                y[o] += (lo * x0 + hi * x1) * scale;
203            }
204        }
205    }
206
207    #[cfg(all(target_arch = "x86_64", feature = "simd"))]
208    #[target_feature(enable = "avx2")]
209    unsafe fn matvec_avx2(&self, x: &[f32], y: &mut [f32]) {
210        use core::arch::x86_64::*;
211
212        let gs = GROUP_SIZE.min(self.in_feat);
213        let num_pairs = self.num_groups * gs / 2;
214        let out_feat = self.out_feat;
215
216        // AVX2: process 8 output features per SIMD lane.
217        // For each pair: broadcast x0 and x1, then for each block of 8 output features:
218        //   load 8 packed bytes, extract nibbles, sign-extend, convert to f32, FMA.
219        let mask_lo = _mm256_set1_epi32(0x0F0F0F0F_u32 as i32);
220
221        for pair in 0..num_pairs {
222            let r0 = pair * 2;
223            let r1 = pair * 2 + 1;
224            let g = r0 / gs;
225            let x0 = if r0 < self.in_feat { x[r0] } else { 0.0 };
226            let x1 = if r1 < self.in_feat { x[r1] } else { 0.0 };
227            let x0v = _mm256_set1_ps(x0);
228            let x1v = _mm256_set1_ps(x1);
229
230            let data_base = pair * out_feat;
231            let scale_base = g * out_feat;
232
233            // Process 8 output features per iteration
234            let full_blocks = out_feat / 8;
235            let remainder = out_feat % 8;
236
237            for blk in 0..full_blocks {
238                let o = blk * 8;
239
240                // Load 8 packed bytes (one per output feature for this pair)
241                let bytes_128 =
242                    _mm_loadl_epi64(self.data[data_base + o..].as_ptr() as *const __m128i);
243                // Zero-extend to 256-bit (each byte in its own 32-bit lane for nicer ops)
244                let bytes_256 = _mm256_cvtepu8_epi32(bytes_128);
245
246                // Low nibbles: bits [3:0]
247                let lo_256 = _mm256_and_si256(bytes_256, mask_lo);
248                // High nibbles: shift right by 4, then mask
249                let hi_256 = _mm256_and_si256(_mm256_srli_epi32(bytes_256, 4), mask_lo);
250
251                // Sign-extend 4-bit → signed 32-bit:
252                // If bit 3 set: value is negative → set bits [31:4] to all-1
253                let lo_sign = _mm256_and_si256(lo_256, _mm256_set1_epi32(8));
254                let lo_neg_mask = _mm256_cmpeq_epi32(lo_sign, _mm256_set1_epi32(8));
255                let lo_ext = _mm256_and_si256(lo_neg_mask, _mm256_set1_epi32(!0x0F));
256                let lo_i32 = _mm256_or_si256(lo_256, lo_ext);
257
258                let hi_sign = _mm256_and_si256(hi_256, _mm256_set1_epi32(8));
259                let hi_neg_mask = _mm256_cmpeq_epi32(hi_sign, _mm256_set1_epi32(8));
260                let hi_ext = _mm256_and_si256(hi_neg_mask, _mm256_set1_epi32(!0x0F));
261                let hi_i32 = _mm256_or_si256(hi_256, hi_ext);
262
263                // Convert i32 → f32
264                let lo_f32 = _mm256_cvtepi32_ps(lo_i32);
265                let hi_f32 = _mm256_cvtepi32_ps(hi_i32);
266
267                // Load scales for this block
268                let scale_v = _mm256_loadu_ps(self.scales[scale_base + o..].as_ptr());
269
270                // FMA: y[o..o+8] += (lo * x0 + hi * x1) * scale
271                // Compute: contrib = (lo_f32 * x0v + hi_f32 * x1v) * scale_v
272                let contrib_lo = _mm256_mul_ps(lo_f32, x0v);
273                let contrib = _mm256_fmadd_ps(hi_f32, x1v, contrib_lo);
274                let scaled = _mm256_mul_ps(contrib, scale_v);
275
276                // Accumulate into y
277                let y_ptr = y[o..].as_mut_ptr();
278                let y_vec = _mm256_loadu_ps(y_ptr);
279                let y_new = _mm256_add_ps(y_vec, scaled);
280                _mm256_storeu_ps(y_ptr, y_new);
281            }
282
283            // Scalar tail for remaining output features (< 8)
284            if remainder > 0 {
285                let o_base = full_blocks * 8;
286                let scale_off = scale_base + o_base;
287                let data_off = data_base + o_base;
288                for o in 0..remainder {
289                    let byte = self.data[data_off + o];
290                    let lo = sign_extend4(byte & 0x0F) as f32;
291                    let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
292                    let scale = self.scales[scale_off + o];
293                    y[o_base + o] += (lo * x0 + hi * x1) * scale;
294                }
295            }
296        }
297    }
298
299    /// NEON-accelerated matvec for aarch64.
300    ///
301    /// Processes 8 output features per SIMD step using 64-bit NEON loads and two
302    /// float32x4_t lanes. NEON is mandatory on ARMv8 (aarch64) — no runtime check needed.
303    #[cfg(all(target_arch = "aarch64", feature = "simd"))]
304    #[target_feature(enable = "neon")]
305    unsafe fn matvec_neon(&self, x: &[f32], y: &mut [f32]) {
306        use core::arch::aarch64::*;
307
308        let gs = GROUP_SIZE.min(self.in_feat);
309        let num_pairs = self.num_groups * gs / 2;
310        let out_feat = self.out_feat;
311
312        let mask_u8 = vdup_n_u8(0x0F);
313        let eight_u8 = vdup_n_u8(8);
314        let sixteen = vdup_n_u8(16);
315
316        for pair in 0..num_pairs {
317            let r0 = pair * 2;
318            let r1 = pair * 2 + 1;
319            let g = r0 / gs;
320            let x0 = if r0 < self.in_feat {
321                *x.get_unchecked(r0)
322            } else {
323                0.0
324            };
325            let x1 = if r1 < self.in_feat {
326                *x.get_unchecked(r1)
327            } else {
328                0.0
329            };
330            let x0v = vdupq_n_f32(x0);
331            let x1v = vdupq_n_f32(x1);
332
333            let data_base = pair * out_feat;
334            let scale_base = g * out_feat;
335
336            // 8 output features per NEON step (one vld1_u8 = 8 packed bytes).
337            let full8 = out_feat / 8;
338
339            for blk in 0..full8 {
340                let o = blk * 8;
341
342                // Load 8 packed bytes: each byte encodes one pair of nibbles for one output feature.
343                let bytes = vld1_u8(self.data.get_unchecked(data_base + o) as *const u8);
344
345                // Low nibbles [3:0] and high nibbles [7:4] — values 0..15 as u8.
346                let lo_raw = vand_u8(bytes, mask_u8);
347                let hi_raw = vshr_n_u8::<4>(bytes); // logical shift, upper bits = 0
348
349                // Sign-extend 4-bit → i8: values 8..15 become -8..-1 by subtracting 16.
350                let lo_s8 = vreinterpret_s8_u8(vsub_u8(
351                    lo_raw,
352                    vand_u8(vcge_u8(lo_raw, eight_u8), sixteen),
353                ));
354                let hi_s8 = vreinterpret_s8_u8(vsub_u8(
355                    hi_raw,
356                    vand_u8(vcge_u8(hi_raw, eight_u8), sixteen),
357                ));
358
359                // Widen s8x8 → s16x8 → two s32x4 → two f32x4.
360                let lo_s16 = vmovl_s8(lo_s8);
361                let hi_s16 = vmovl_s8(hi_s8);
362
363                // Lower 4 lanes (o..o+4)
364                let lo_f32_lo = vcvtq_f32_s32(vmovl_s16(vget_low_s16(lo_s16)));
365                let hi_f32_lo = vcvtq_f32_s32(vmovl_s16(vget_low_s16(hi_s16)));
366                let scale_lo = vld1q_f32(self.scales.get_unchecked(scale_base + o) as *const f32);
367                let contrib_lo = vmlaq_f32(vmulq_f32(lo_f32_lo, x0v), hi_f32_lo, x1v);
368                let y_ptr_lo = y.get_unchecked_mut(o) as *mut f32;
369                vst1q_f32(
370                    y_ptr_lo,
371                    vaddq_f32(vld1q_f32(y_ptr_lo), vmulq_f32(contrib_lo, scale_lo)),
372                );
373
374                // Upper 4 lanes (o+4..o+8)
375                let lo_f32_hi = vcvtq_f32_s32(vmovl_s16(vget_high_s16(lo_s16)));
376                let hi_f32_hi = vcvtq_f32_s32(vmovl_s16(vget_high_s16(hi_s16)));
377                let scale_hi =
378                    vld1q_f32(self.scales.get_unchecked(scale_base + o + 4) as *const f32);
379                let contrib_hi = vmlaq_f32(vmulq_f32(lo_f32_hi, x0v), hi_f32_hi, x1v);
380                let y_ptr_hi = y.get_unchecked_mut(o + 4) as *mut f32;
381                vst1q_f32(
382                    y_ptr_hi,
383                    vaddq_f32(vld1q_f32(y_ptr_hi), vmulq_f32(contrib_hi, scale_hi)),
384                );
385            }
386
387            // Scalar tail for remaining < 8 output features.
388            let tail_start = full8 * 8;
389            for o in tail_start..out_feat {
390                let byte = *self.data.get_unchecked(data_base + o);
391                let lo = sign_extend4(byte & 0x0F) as f32;
392                let hi = sign_extend4((byte >> 4) & 0x0F) as f32;
393                let scale = *self.scales.get_unchecked(scale_base + o);
394                *y.get_unchecked_mut(o) += (lo * x0 + hi * x1) * scale;
395            }
396        }
397    }
398
399    /// Batched matmul: Y = X W  (X shape [batch, in_feat], Y shape [batch, out_feat]).
400    pub fn matmul(&self, x: &[f32], batch: usize, y: &mut [f32]) {
401        assert_eq!(x.len(), batch * self.in_feat);
402        assert_eq!(y.len(), batch * self.out_feat);
403        for b in 0..batch {
404            self.matvec(
405                &x[b * self.in_feat..(b + 1) * self.in_feat],
406                &mut y[b * self.out_feat..(b + 1) * self.out_feat],
407            );
408        }
409    }
410}
411
412#[inline(always)]
413fn sign_extend4(nibble: u8) -> i8 {
414    if nibble & 0x8 != 0 {
415        (nibble | 0xF0) as i8
416    } else {
417        nibble as i8
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_sign_extend() {
427        assert_eq!(sign_extend4(0x7), 7);
428        assert_eq!(sign_extend4(0x8), -8);
429        assert_eq!(sign_extend4(0xF), -1);
430        assert_eq!(sign_extend4(0x0), 0);
431    }
432
433    #[test]
434    fn test_roundtrip_identity() {
435        let in_feat = 64;
436        let out_feat = 32;
437        let mut w = alloc::vec![0.0f32; in_feat * out_feat];
438        for i in 0..in_feat {
439            for o in 0..out_feat {
440                w[i * out_feat + o] = ((i + o) % 8) as f32;
441            }
442        }
443        let qw = QuantizedWeight::quantize(&w, in_feat, out_feat);
444        let mut out = alloc::vec![0.0f32; in_feat * out_feat];
445        qw.dequantize_to(&mut out);
446
447        for i in 0..w.len() {
448            let diff = (w[i] - out[i]).abs();
449            assert!(diff < 1e-5, "index {i}: expected {}, got {}", w[i], out[i]);
450        }
451    }
452
453    #[test]
454    fn test_matvec_matches_dequantize() {
455        let in_feat = 64;
456        let out_feat = 32;
457        let w: alloc::vec::Vec<f32> = (0..in_feat * out_feat)
458            .map(|i| (i as f32 - 32.0) * 0.05)
459            .collect();
460        let x: alloc::vec::Vec<f32> = (0..in_feat).map(|i| (i as f32 * 0.1).sin()).collect();
461
462        let qw = QuantizedWeight::quantize(&w, in_feat, out_feat);
463
464        // Reference: dequantize then naive matmul
465        let mut dq = alloc::vec![0.0f32; in_feat * out_feat];
466        qw.dequantize_to(&mut dq);
467        let mut y_ref = alloc::vec![0.0f32; out_feat];
468        for o in 0..out_feat {
469            for i in 0..in_feat {
470                y_ref[o] += x[i] * dq[i * out_feat + o];
471            }
472        }
473
474        // matvec must produce the same result
475        let mut y_got = alloc::vec![0.0f32; out_feat];
476        qw.matvec(&x, &mut y_got);
477
478        for o in 0..out_feat {
479            let diff = (y_got[o] - y_ref[o]).abs();
480            // Different accumulation order (per-pair vs per-row) causes ~machine-epsilon relative error.
481            let tol = 1e-3 * (y_ref[o].abs().max(1.0));
482            assert!(
483                diff < tol,
484                "out[{o}]: matvec={} dequant+dot={} diff={diff} tol={tol}",
485                y_got[o],
486                y_ref[o]
487            );
488        }
489    }
490}