Skip to main content

ftts_kernels/
int4.rs

1//! W4A8 int4 weights for the microdecoder — packed two-per-byte, unpacked in registers.
2//!
3//! # Why the microdecoder, and why now
4//!
5//! Doctrine #2 sends int4 to the microdecoder FIRST, and the measurement now agrees. Its 5-layer
6//! body is re-read **fifteen times per frame** — the single largest repeated read in the model —
7//! so halving its weight bytes attacks the one place cache residency is plausibly winnable:
8//! roughly 79 MB of Q8 becomes ~40 MB of Q4, which is the difference between spilling to DRAM
9//! every depth step and staying resident across all fifteen.
10//!
11//! Until 2026-08-10 this was a rounding error: the codec was 92% of browser frame time and the
12//! talker+microdecoder 7.9%. After the packed GEMM and the kernel team took the codec down 13x,
13//! the split is codec 65% / talker+micro 33% — so this now targets a third of the frame.
14//!
15//! # The quantization contract, and how it differs from Q8
16//!
17//! Symmetric, per-output-channel, ties-to-even — the same shape as
18//! [`crate::int8::quantize_row_q8`], with one deliberate asymmetry preserved: the most negative
19//! representable value is never emitted. Q8 excludes -128 and keeps [-127, 127]; Q4 excludes -8
20//! and keeps **[-7, 7]**. That symmetry is what makes `-w` exactly representable whenever `w` is,
21//! so negating a row negates its quantization exactly, and it keeps the accumulator's worst case
22//! symmetric.
23//!
24//! The cost is real and must not be glossed: 15 levels instead of 255. Quantization error is ~17x
25//! larger per weight, which is precisely why doctrine #2 gates this behind BOTH a per-ISA speed
26//! test that includes unpack cost AND a blind-listening equivalence test. **This module ships the
27//! arithmetic, not the decision.** Nothing routes to it until those gates are run.
28//!
29//! # Packing
30//!
31//! Two nibbles per byte, low nibble first, along `k`. A row of odd length pads its final high
32//! nibble with the BIASED zero (`8`), not a raw `0`.
33//!
34//! That distinction matters and is easy to get backwards. A raw `0` nibble decodes to `0 - 8 = -8`,
35//! the largest negative weight in the range — so zero-initialized padding is not neutral, it is
36//! maximally *non*-neutral. Today nothing reads past `k` and it would not matter, but the entire
37//! reason to store int4 is a future SIMD unpack that processes whole BYTES, and such a kernel would
38//! silently fold that `-8` into the last accumulator. Padding with the biased zero makes the pad
39//! decode to `0.0` and keeps any whole-byte kernel correct by construction.
40//!
41//! Storing the nibble biased by +8 (so [-7, 7] becomes [1, 15]) makes unpacking a shift-and-mask
42//! with no sign extension, and the bias cancels exactly in the dot product — see
43//! [`dot_i32_q4`], where it becomes a single correction term computed from the activation sum.
44
45/// Nibbles per packed byte.
46const PER_BYTE: usize = 2;
47
48/// The bias added to every nibble so the stored value is unsigned `[1, 15]`.
49///
50/// Chosen so unpacking never needs sign extension: `(byte & 0xF) as i32 - BIAS` recovers the
51/// signed weight with one subtract, and across a whole dot product the subtraction can be hoisted
52/// into one correction term rather than paid per element.
53const BIAS: i32 = 8;
54
55/// A weight matrix quantized to symmetric int4, packed two values per byte.
56///
57/// Layout mirrors [`crate::int8::QuantizedMatrix`]: `[n, k]` row-major in the checkpoint's own
58/// `nn.Linear` orientation, one f32 scale per output channel, so no transpose is ever materialized.
59#[derive(Clone, Debug, PartialEq)]
60pub struct QuantizedMatrixQ4 {
61    /// `n * k.div_ceil(2)` bytes: row-major, two biased nibbles per byte, low nibble first.
62    pub data: Vec<u8>,
63    /// One scale per output channel.
64    pub scales: Vec<f32>,
65    pub n: usize,
66    pub k: usize,
67}
68
69impl QuantizedMatrixQ4 {
70    /// Quantizes an `[n, k]` f32 weight matrix.
71    ///
72    /// # Panics
73    ///
74    /// If `weight.len() != n * k`, or a weight is non-finite — a NaN reaching the quantizer means
75    /// the graph upstream is already corrupt, and refusing loudly beats baking it into an artifact.
76    #[must_use]
77    pub fn quantize(weight: &[f32], n: usize, k: usize) -> Self {
78        assert_eq!(weight.len(), n * k, "weight must be [n, k]");
79        let packed_row = k.div_ceil(PER_BYTE);
80        // Initialized to the BIASED zero in both nibbles (`0x88`), never to `0x00`: an unwritten
81        // nibble must decode to 0.0, and a raw zero nibble decodes to -8. See the packing note in
82        // the module docs.
83        let mut data = vec![0x88_u8; n * packed_row];
84        let mut scales = Vec::with_capacity(n);
85
86        for row in 0..n {
87            let source = &weight[row * k..row * k + k];
88            let mut maximum = 0.0_f32;
89            for (index, &value) in source.iter().enumerate() {
90                assert!(
91                    value.is_finite(),
92                    "non-finite value {value} at index {index} reached the Q4 quantizer"
93                );
94                maximum = maximum.max(value.abs());
95            }
96            // A zero row quantizes to the zero row it already is; scale 1.0 keeps the dequantized
97            // result exactly zero rather than introducing a NaN through a zero divisor.
98            let scale = if maximum == 0.0 { 0.0 } else { maximum / 7.0 };
99            scales.push(if scale == 0.0 { 1.0 } else { scale });
100
101            let target = &mut data[row * packed_row..(row + 1) * packed_row];
102            if scale == 0.0 {
103                // Every nibble is the biased zero, so the row dequantizes to exact zeros.
104                target.fill(((BIAS as u8) << 4) | BIAS as u8);
105                continue;
106            }
107            for (index, &value) in source.iter().enumerate() {
108                // Ties-to-even and a clamp that excludes -8, matching the Q8 contract's exclusion
109                // of -128: symmetry is what makes negation exact.
110                let level = (value / scale).clamp(-7.0, 7.0).round_ties_even() as i32;
111                let biased = (level + BIAS) as u8;
112                let byte = &mut target[index / PER_BYTE];
113                if index % PER_BYTE == 0 {
114                    *byte = (*byte & 0xF0) | biased;
115                } else {
116                    *byte = (*byte & 0x0F) | (biased << 4);
117                }
118            }
119            // An odd k leaves the final high nibble as the buffer's initialization value,
120            // which is the BIASED zero (0x88 fills both nibbles), so the pad decodes to
121            // exactly 0.0 — see the init comment above and the module docs. No current
122            // reader touches it (`dot_i32_q4` and `dequantize_row` both stop at k), and a
123            // future whole-byte SIMD kernel may consume it freely as long as the matching
124            // activation pad is 0, since biased-zero times anything contributes nothing.
125        }
126
127        Self { data, scales, n, k }
128    }
129
130    /// Dequantizes one output channel back to f32, for parity comparison against the f32 weights.
131    #[must_use]
132    pub fn dequantize_row(&self, row: usize) -> Vec<f32> {
133        let packed_row = self.k.div_ceil(PER_BYTE);
134        let bytes = &self.data[row * packed_row..(row + 1) * packed_row];
135        let scale = self.scales[row];
136        (0..self.k)
137            .map(|index| {
138                let byte = bytes[index / PER_BYTE];
139                let nibble = if index % PER_BYTE == 0 {
140                    i32::from(byte & 0x0F)
141                } else {
142                    i32::from(byte >> 4)
143                };
144                #[allow(clippy::cast_precision_loss)]
145                {
146                    (nibble - BIAS) as f32 * scale
147                }
148            })
149            .collect()
150    }
151
152    /// Bytes of weight storage, the number this lever exists to shrink.
153    #[must_use]
154    pub fn packed_bytes(&self) -> usize {
155        self.data.len()
156    }
157}
158
159/// Exact i32 dot product of an int8 activation row against one packed int4 weight row.
160///
161/// # The bias cancellation, which is the whole trick
162///
163/// Each stored nibble is `w + 8`. Expanding the dot product:
164///
165/// ```text
166///   sum_i x[i] * w[i]  ==  sum_i x[i] * (nibble[i] - 8)
167///                      ==  sum_i x[i] * nibble[i]  -  8 * sum_i x[i]
168/// ```
169///
170/// So the per-element subtraction disappears: accumulate against the *unsigned* nibbles, then
171/// apply one correction of `8 * sum(x)` at the end. That leaves the inner loop as mask, shift,
172/// multiply-add — no sign extension, no per-element bias — which is what makes unpacking cheap
173/// enough to be worth the halved bytes (NE-INH-004's escape clause is exactly this: int4 pays off
174/// only if the unpack folds into the MAC).
175///
176/// Accumulation is exact i32 throughout; scales are applied once by the caller, after.
177///
178/// # Panics
179///
180/// If `packed` is too short for `k` values.
181#[must_use]
182pub fn dot_i32_q4(x: &[i8], packed: &[u8], k: usize) -> i32 {
183    assert!(
184        packed.len() >= k.div_ceil(PER_BYTE),
185        "packed row shorter than k nibbles"
186    );
187    assert!(x.len() >= k, "activation row shorter than k");
188
189    let mut unsigned_accumulator = 0_i32;
190    let mut activation_sum = 0_i32;
191
192    let pairs = k / PER_BYTE;
193    for pair in 0..pairs {
194        let byte = packed[pair];
195        let low = i32::from(byte & 0x0F);
196        let high = i32::from(byte >> 4);
197        let first = i32::from(x[pair * PER_BYTE]);
198        let second = i32::from(x[pair * PER_BYTE + 1]);
199        unsigned_accumulator += first * low + second * high;
200        activation_sum += first + second;
201    }
202    if k % PER_BYTE == 1 {
203        let byte = packed[pairs];
204        let value = i32::from(x[k - 1]);
205        unsigned_accumulator += value * i32::from(byte & 0x0F);
206        activation_sum += value;
207    }
208
209    unsigned_accumulator - BIAS * activation_sum
210}
211
212/// W4A8 linear: `out[m, n] = x[m, k] @ weight^T`, scales applied once per element.
213///
214/// # Panics
215///
216/// On shape mismatch.
217pub fn linear_q4(
218    x_q: &[i8],
219    x_scales: &[f32],
220    weight: &QuantizedMatrixQ4,
221    bias: Option<&[f32]>,
222    m: usize,
223    out: &mut [f32],
224) {
225    let (n, k) = (weight.n, weight.k);
226    assert_eq!(x_q.len(), m * k, "activations must be [m, k]");
227    assert_eq!(x_scales.len(), m, "one activation scale per row");
228    assert_eq!(out.len(), m * n, "out must be [m, n]");
229    // Every sibling kernel pins this; without it a too-long bias is silently
230    // prefix-consumed and a too-short one panics mid-row with a bare index message.
231    if let Some(bias) = bias {
232        assert_eq!(bias.len(), n, "bias must have one entry per output channel");
233    }
234    let packed_row = k.div_ceil(PER_BYTE);
235
236    for row in 0..m {
237        let x_row = &x_q[row * k..row * k + k];
238        for column in 0..n {
239            let w_row = &weight.data[column * packed_row..(column + 1) * packed_row];
240            let accumulated = dot_i32_q4(x_row, w_row, k);
241            #[allow(clippy::cast_precision_loss)]
242            let value = accumulated as f32 * (x_scales[row] * weight.scales[column]);
243            out[row * n + column] = bias.map_or(value, |values| value + values[column]);
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::int8::quantize_row_q8;
252
253    fn deterministic(count: usize, seed: u64) -> Vec<f32> {
254        let mut state = seed | 1;
255        (0..count)
256            .map(|_| {
257                state ^= state << 13;
258                state ^= state >> 7;
259                state ^= state << 17;
260                ((state >> 40) as f32 / 8192.0) - 0.5
261            })
262            .collect()
263    }
264
265    /// The bias-cancellation identity must hold EXACTLY, not approximately.
266    ///
267    /// This is the load-bearing claim of the whole module: accumulating against biased nibbles and
268    /// correcting once at the end must equal the straightforward signed dot. If it drifts by even
269    /// one integer the error is silent and shows up as audio artifacts much later.
270    #[test]
271    fn bias_cancellation_is_exact_against_a_signed_reference() {
272        for (index, &k) in [1_usize, 2, 3, 7, 8, 15, 64, 127, 1024].iter().enumerate() {
273            let weight = deterministic(k, 0x4B1A_0000 + index as u64);
274            let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
275            let activation = deterministic(k, 0xA0C7_0000 + index as u64);
276            let mut x_q = vec![0_i8; k];
277            quantize_row_q8(&activation, &mut x_q);
278
279            // Reference: dequantize the nibbles to signed levels and dot them plainly.
280            let packed_row = k.div_ceil(PER_BYTE);
281            let mut expected = 0_i32;
282            for (position, &activation) in x_q.iter().enumerate().take(k) {
283                let byte = matrix.data[position / PER_BYTE];
284                let nibble = if position % PER_BYTE == 0 {
285                    i32::from(byte & 0x0F)
286                } else {
287                    i32::from(byte >> 4)
288                };
289                expected += i32::from(activation) * (nibble - BIAS);
290            }
291            assert_eq!(
292                dot_i32_q4(&x_q, &matrix.data[..packed_row], k),
293                expected,
294                "k={k}: biased accumulation with a single correction diverged from the signed dot"
295            );
296        }
297    }
298
299    /// Every quantized level stays inside the symmetric range, and -8 is never emitted.
300    #[test]
301    fn levels_are_symmetric_and_never_emit_negative_eight() {
302        let k = 4096;
303        // Deliberately includes the extremes and values that round exactly onto .5 boundaries.
304        let mut weight = deterministic(k, 0xD00D);
305        weight[0] = 1.0;
306        weight[1] = -1.0;
307        weight[2] = 0.0;
308        let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
309        let scale = matrix.scales[0];
310        for position in 0..k {
311            let byte = matrix.data[position / PER_BYTE];
312            let nibble = if position % PER_BYTE == 0 {
313                i32::from(byte & 0x0F)
314            } else {
315                i32::from(byte >> 4)
316            };
317            let level = nibble - BIAS;
318            assert!(
319                (-7..=7).contains(&level),
320                "level {level} outside the symmetric range at {position}"
321            );
322        }
323        // Negation must be exact, which is the property the -8 exclusion buys.
324        let negated: Vec<f32> = weight.iter().map(|value| -value).collect();
325        let mirror = QuantizedMatrixQ4::quantize(&negated, 1, k);
326        assert!((mirror.scales[0] - scale).abs() <= f32::EPSILON * scale.max(1.0));
327        // Value equality, not bit equality: a zero weight dequantizes to +0.0 on both sides, and
328        // `-(+0.0)` is `-0.0`, whose bits differ while the value does not. For every non-zero
329        // level, f32 equality here is still exact — the levels are small integers times a shared
330        // scale, so no rounding can hide a mismatch.
331        let forward = matrix.dequantize_row(0);
332        let backward = mirror.dequantize_row(0);
333        for position in 0..k {
334            assert!(
335                forward[position] == -backward[position],
336                "negation was not exact at {position}: {} vs {}",
337                forward[position],
338                backward[position]
339            );
340        }
341    }
342
343    /// The padding nibble of an odd-length row must decode to ZERO, not to -8.
344    ///
345    /// Nothing reads past `k` today, so this cannot bite yet. It is pinned because the point of
346    /// int4 is a SIMD unpack over whole bytes, and such a kernel WILL read the pad; if it decodes
347    /// to -8 the last accumulator is silently wrong in a way no shape or size check would catch.
348    #[test]
349    fn the_padding_nibble_of_an_odd_row_is_a_neutral_zero() {
350        for k in [1_usize, 3, 5, 7, 65] {
351            let weight = deterministic(k, 0x0DD0_0000 + k as u64);
352            let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
353            let last = *matrix.data.last().expect("a packed row");
354            let pad = i32::from(last >> 4) - BIAS;
355            assert_eq!(pad, 0, "k={k}: padding nibble decodes to {pad}, not 0");
356        }
357    }
358
359    /// Q4 must be materially smaller than Q8 — the entire premise of the lever.
360    #[test]
361    fn packed_storage_is_half_of_q8() {
362        let (n, k) = (2048, 1024);
363        let weight = deterministic(n * k, 0xFEED);
364        let q4 = QuantizedMatrixQ4::quantize(&weight, n, k);
365        assert_eq!(q4.packed_bytes(), n * k / 2);
366        let q8 = crate::int8::QuantizedMatrix::quantize(&weight, n, k);
367        assert_eq!(q4.packed_bytes() * 2, q8.data.len());
368    }
369
370    /// The packed layout decodes identically through the kernel and through a naive
371    /// independent unpacking — bit-for-bit, no tolerance. (The loose-tolerance prose
372    /// that used to sit here described `quantization_error_is_bounded_by_the_level_step`
373    /// below, not this test.)
374    #[test]
375    fn linear_q4_matches_an_independent_nibble_unpack_bit_for_bit() {
376        // `linear_q4` itself had zero coverage: `dot_i32_q4` was tested, but not the packed
377        // row stride, the multi-row walk, the activation scales, or the bias path. The
378        // reference here unpacks nibbles directly from storage (independently of
379        // `dot_i32_q4`) and reproduces the kernel's exact arithmetic order, so equality is
380        // bit-for-bit, not approximate. Odd k exercises the padded final nibble.
381        for (m, n, k, seed) in [
382            (1_usize, 4_usize, 16_usize, 1_u64),
383            (2, 5, 13, 2),
384            (3, 7, 31, 3),
385        ] {
386            let weight_f32 = deterministic(n * k, seed * 100 + 7);
387            let weight = QuantizedMatrixQ4::quantize(&weight_f32, n, k);
388            let bias: Vec<f32> = deterministic(n, seed * 100 + 11);
389            let mut x_q = Vec::with_capacity(m * k);
390            let mut x_scales = Vec::with_capacity(m);
391            for row in 0..m {
392                let activation = deterministic(k, seed * 100 + 13 + row as u64);
393                let mut quantized = vec![0_i8; k];
394                let scale = quantize_row_q8(&activation, &mut quantized);
395                x_q.extend_from_slice(&quantized);
396                x_scales.push(scale);
397            }
398
399            let mut out = vec![0.0_f32; m * n];
400            linear_q4(&x_q, &x_scales, &weight, Some(&bias), m, &mut out);
401
402            let packed_row = k.div_ceil(PER_BYTE);
403            for row in 0..m {
404                for column in 0..n {
405                    let bytes = &weight.data[column * packed_row..(column + 1) * packed_row];
406                    let mut accumulated = 0_i32;
407                    for index in 0..k {
408                        let byte = bytes[index / PER_BYTE];
409                        let nibble = if index % PER_BYTE == 0 {
410                            i32::from(byte & 0x0F)
411                        } else {
412                            i32::from(byte >> 4)
413                        };
414                        accumulated += i32::from(x_q[row * k + index]) * (nibble - BIAS);
415                    }
416                    #[allow(clippy::cast_precision_loss)]
417                    let expected =
418                        accumulated as f32 * (x_scales[row] * weight.scales[column]) + bias[column];
419                    assert_eq!(
420                        out[row * n + column].to_bits(),
421                        expected.to_bits(),
422                        "m={m} n={n} k={k} row={row} column={column}"
423                    );
424                }
425            }
426        }
427    }
428
429    #[test]
430    #[should_panic(expected = "bias must have one entry per output channel")]
431    fn linear_q4_refuses_a_missized_bias() {
432        let weight = QuantizedMatrixQ4::quantize(&deterministic(8, 5), 2, 4);
433        let mut out = vec![0.0_f32; 2];
434        linear_q4(&[1, 2, 3, 4], &[1.0], &weight, Some(&[0.5]), 1, &mut out);
435    }
436
437    #[test]
438    fn quantization_error_is_bounded_by_the_level_step() {
439        let k = 8192;
440        let weight = deterministic(k, 0xBEEF);
441        let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
442        let restored = matrix.dequantize_row(0);
443        let scale = matrix.scales[0];
444        for (position, (&original, &back)) in weight.iter().zip(restored.iter()).enumerate() {
445            assert!(
446                (original - back).abs() <= scale * 0.5 + f32::EPSILON * 8.0,
447                "position {position}: |{original} - {back}| exceeds half a level ({scale})"
448            );
449        }
450    }
451}