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 with a zero nibble,
32//! which is exact: the padded lane multiplies against an activation that is never read.
33//!
34//! Storing the nibble biased by +8 (so [-7, 7] becomes [1, 15]) makes unpacking a shift-and-mask
35//! with no sign extension, and the bias cancels exactly in the dot product — see
36//! [`dot_i32_q4`], where it becomes a single correction term computed from the activation sum.
37
38/// Nibbles per packed byte.
39const PER_BYTE: usize = 2;
40
41/// The bias added to every nibble so the stored value is unsigned `[1, 15]`.
42///
43/// Chosen so unpacking never needs sign extension: `(byte & 0xF) as i32 - BIAS` recovers the
44/// signed weight with one subtract, and across a whole dot product the subtraction can be hoisted
45/// into one correction term rather than paid per element.
46const BIAS: i32 = 8;
47
48/// A weight matrix quantized to symmetric int4, packed two values per byte.
49///
50/// Layout mirrors [`crate::int8::QuantizedMatrix`]: `[n, k]` row-major in the checkpoint's own
51/// `nn.Linear` orientation, one f32 scale per output channel, so no transpose is ever materialized.
52#[derive(Clone, Debug, PartialEq)]
53pub struct QuantizedMatrixQ4 {
54    /// `n * k.div_ceil(2)` bytes: row-major, two biased nibbles per byte, low nibble first.
55    pub data: Vec<u8>,
56    /// One scale per output channel.
57    pub scales: Vec<f32>,
58    pub n: usize,
59    pub k: usize,
60}
61
62impl QuantizedMatrixQ4 {
63    /// Quantizes an `[n, k]` f32 weight matrix.
64    ///
65    /// # Panics
66    ///
67    /// If `weight.len() != n * k`, or a weight is non-finite — a NaN reaching the quantizer means
68    /// the graph upstream is already corrupt, and refusing loudly beats baking it into an artifact.
69    #[must_use]
70    pub fn quantize(weight: &[f32], n: usize, k: usize) -> Self {
71        assert_eq!(weight.len(), n * k, "weight must be [n, k]");
72        let packed_row = k.div_ceil(PER_BYTE);
73        let mut data = vec![0_u8; n * packed_row];
74        let mut scales = Vec::with_capacity(n);
75
76        for row in 0..n {
77            let source = &weight[row * k..row * k + k];
78            let mut maximum = 0.0_f32;
79            for (index, &value) in source.iter().enumerate() {
80                assert!(
81                    value.is_finite(),
82                    "non-finite value {value} at index {index} reached the Q4 quantizer"
83                );
84                maximum = maximum.max(value.abs());
85            }
86            // A zero row quantizes to the zero row it already is; scale 1.0 keeps the dequantized
87            // result exactly zero rather than introducing a NaN through a zero divisor.
88            let scale = if maximum == 0.0 { 0.0 } else { maximum / 7.0 };
89            scales.push(if scale == 0.0 { 1.0 } else { scale });
90
91            let target = &mut data[row * packed_row..(row + 1) * packed_row];
92            if scale == 0.0 {
93                // Every nibble is the biased zero, so the row dequantizes to exact zeros.
94                target.fill(((BIAS as u8) << 4) | BIAS as u8);
95                continue;
96            }
97            for (index, &value) in source.iter().enumerate() {
98                // Ties-to-even and a clamp that excludes -8, matching the Q8 contract's exclusion
99                // of -128: symmetry is what makes negation exact.
100                let level = (value / scale).clamp(-7.0, 7.0).round_ties_even() as i32;
101                let biased = (level + BIAS) as u8;
102                let byte = &mut target[index / PER_BYTE];
103                if index % PER_BYTE == 0 {
104                    *byte = (*byte & 0xF0) | biased;
105                } else {
106                    *byte = (*byte & 0x0F) | (biased << 4);
107                }
108            }
109            // An odd k leaves the final high nibble as the RAW zero the buffer was
110            // initialized with (0b0000, i.e. biased value -8), NOT the biased zero (8) the
111            // zero-scale fill uses. No current reader ever touches it — `dot_i32_q4` and
112            // `dequantize_row` both stop at k — but a future whole-byte SIMD kernel must
113            // either pad activations with a literal 0 (which nullifies any padding nibble)
114            // or normalize this padding first; assuming it is the biased zero would be wrong.
115        }
116
117        Self { data, scales, n, k }
118    }
119
120    /// Dequantizes one output channel back to f32, for parity comparison against the f32 weights.
121    #[must_use]
122    pub fn dequantize_row(&self, row: usize) -> Vec<f32> {
123        let packed_row = self.k.div_ceil(PER_BYTE);
124        let bytes = &self.data[row * packed_row..(row + 1) * packed_row];
125        let scale = self.scales[row];
126        (0..self.k)
127            .map(|index| {
128                let byte = bytes[index / PER_BYTE];
129                let nibble = if index % PER_BYTE == 0 {
130                    i32::from(byte & 0x0F)
131                } else {
132                    i32::from(byte >> 4)
133                };
134                #[allow(clippy::cast_precision_loss)]
135                {
136                    (nibble - BIAS) as f32 * scale
137                }
138            })
139            .collect()
140    }
141
142    /// Bytes of weight storage, the number this lever exists to shrink.
143    #[must_use]
144    pub fn packed_bytes(&self) -> usize {
145        self.data.len()
146    }
147}
148
149/// Exact i32 dot product of an int8 activation row against one packed int4 weight row.
150///
151/// # The bias cancellation, which is the whole trick
152///
153/// Each stored nibble is `w + 8`. Expanding the dot product:
154///
155/// ```text
156///   sum_i x[i] * w[i]  ==  sum_i x[i] * (nibble[i] - 8)
157///                      ==  sum_i x[i] * nibble[i]  -  8 * sum_i x[i]
158/// ```
159///
160/// So the per-element subtraction disappears: accumulate against the *unsigned* nibbles, then
161/// apply one correction of `8 * sum(x)` at the end. That leaves the inner loop as mask, shift,
162/// multiply-add — no sign extension, no per-element bias — which is what makes unpacking cheap
163/// enough to be worth the halved bytes (NE-INH-004's escape clause is exactly this: int4 pays off
164/// only if the unpack folds into the MAC).
165///
166/// Accumulation is exact i32 throughout; scales are applied once by the caller, after.
167///
168/// # Panics
169///
170/// If `packed` is too short for `k` values.
171#[must_use]
172pub fn dot_i32_q4(x: &[i8], packed: &[u8], k: usize) -> i32 {
173    assert!(
174        packed.len() >= k.div_ceil(PER_BYTE),
175        "packed row shorter than k nibbles"
176    );
177    assert!(x.len() >= k, "activation row shorter than k");
178
179    let mut unsigned_accumulator = 0_i32;
180    let mut activation_sum = 0_i32;
181
182    let pairs = k / PER_BYTE;
183    for pair in 0..pairs {
184        let byte = packed[pair];
185        let low = i32::from(byte & 0x0F);
186        let high = i32::from(byte >> 4);
187        let first = i32::from(x[pair * PER_BYTE]);
188        let second = i32::from(x[pair * PER_BYTE + 1]);
189        unsigned_accumulator += first * low + second * high;
190        activation_sum += first + second;
191    }
192    if k % PER_BYTE == 1 {
193        let byte = packed[pairs];
194        let value = i32::from(x[k - 1]);
195        unsigned_accumulator += value * i32::from(byte & 0x0F);
196        activation_sum += value;
197    }
198
199    unsigned_accumulator - BIAS * activation_sum
200}
201
202/// W4A8 linear: `out[m, n] = x[m, k] @ weight^T`, scales applied once per element.
203///
204/// # Panics
205///
206/// On shape mismatch.
207pub fn linear_q4(
208    x_q: &[i8],
209    x_scales: &[f32],
210    weight: &QuantizedMatrixQ4,
211    bias: Option<&[f32]>,
212    m: usize,
213    out: &mut [f32],
214) {
215    let (n, k) = (weight.n, weight.k);
216    assert_eq!(x_q.len(), m * k, "activations must be [m, k]");
217    assert_eq!(x_scales.len(), m, "one activation scale per row");
218    assert_eq!(out.len(), m * n, "out must be [m, n]");
219    let packed_row = k.div_ceil(PER_BYTE);
220
221    for row in 0..m {
222        let x_row = &x_q[row * k..row * k + k];
223        for column in 0..n {
224            let w_row = &weight.data[column * packed_row..(column + 1) * packed_row];
225            let accumulated = dot_i32_q4(x_row, w_row, k);
226            #[allow(clippy::cast_precision_loss)]
227            let value = accumulated as f32 * (x_scales[row] * weight.scales[column]);
228            out[row * n + column] = bias.map_or(value, |values| value + values[column]);
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::int8::quantize_row_q8;
237
238    fn deterministic(count: usize, seed: u64) -> Vec<f32> {
239        let mut state = seed | 1;
240        (0..count)
241            .map(|_| {
242                state ^= state << 13;
243                state ^= state >> 7;
244                state ^= state << 17;
245                ((state >> 40) as f32 / 8192.0) - 0.5
246            })
247            .collect()
248    }
249
250    /// The bias-cancellation identity must hold EXACTLY, not approximately.
251    ///
252    /// This is the load-bearing claim of the whole module: accumulating against biased nibbles and
253    /// correcting once at the end must equal the straightforward signed dot. If it drifts by even
254    /// one integer the error is silent and shows up as audio artifacts much later.
255    #[test]
256    fn bias_cancellation_is_exact_against_a_signed_reference() {
257        for (index, &k) in [1_usize, 2, 3, 7, 8, 15, 64, 127, 1024].iter().enumerate() {
258            let weight = deterministic(k, 0x4B1A_0000 + index as u64);
259            let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
260            let activation = deterministic(k, 0xA0C7_0000 + index as u64);
261            let mut x_q = vec![0_i8; k];
262            quantize_row_q8(&activation, &mut x_q);
263
264            // Reference: dequantize the nibbles to signed levels and dot them plainly.
265            let packed_row = k.div_ceil(PER_BYTE);
266            let mut expected = 0_i32;
267            for (position, &activation) in x_q.iter().enumerate().take(k) {
268                let byte = matrix.data[position / PER_BYTE];
269                let nibble = if position % PER_BYTE == 0 {
270                    i32::from(byte & 0x0F)
271                } else {
272                    i32::from(byte >> 4)
273                };
274                expected += i32::from(activation) * (nibble - BIAS);
275            }
276            assert_eq!(
277                dot_i32_q4(&x_q, &matrix.data[..packed_row], k),
278                expected,
279                "k={k}: biased accumulation with a single correction diverged from the signed dot"
280            );
281        }
282    }
283
284    /// Every quantized level stays inside the symmetric range, and -8 is never emitted.
285    #[test]
286    fn levels_are_symmetric_and_never_emit_negative_eight() {
287        let k = 4096;
288        // Deliberately includes the extremes and values that round exactly onto .5 boundaries.
289        let mut weight = deterministic(k, 0xD00D);
290        weight[0] = 1.0;
291        weight[1] = -1.0;
292        weight[2] = 0.0;
293        let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
294        let scale = matrix.scales[0];
295        for position in 0..k {
296            let byte = matrix.data[position / PER_BYTE];
297            let nibble = if position % PER_BYTE == 0 {
298                i32::from(byte & 0x0F)
299            } else {
300                i32::from(byte >> 4)
301            };
302            let level = nibble - BIAS;
303            assert!(
304                (-7..=7).contains(&level),
305                "level {level} outside the symmetric range at {position}"
306            );
307        }
308        // Negation must be exact, which is the property the -8 exclusion buys.
309        let negated: Vec<f32> = weight.iter().map(|value| -value).collect();
310        let mirror = QuantizedMatrixQ4::quantize(&negated, 1, k);
311        assert!((mirror.scales[0] - scale).abs() <= f32::EPSILON * scale.max(1.0));
312        // Value equality, not bit equality: a zero weight dequantizes to +0.0 on both sides, and
313        // `-(+0.0)` is `-0.0`, whose bits differ while the value does not. For every non-zero
314        // level, f32 equality here is still exact — the levels are small integers times a shared
315        // scale, so no rounding can hide a mismatch.
316        let forward = matrix.dequantize_row(0);
317        let backward = mirror.dequantize_row(0);
318        for position in 0..k {
319            assert!(
320                forward[position] == -backward[position],
321                "negation was not exact at {position}: {} vs {}",
322                forward[position],
323                backward[position]
324            );
325        }
326    }
327
328    /// Q4 must be materially smaller than Q8 — the entire premise of the lever.
329    #[test]
330    fn packed_storage_is_half_of_q8() {
331        let (n, k) = (2048, 1024);
332        let weight = deterministic(n * k, 0xFEED);
333        let q4 = QuantizedMatrixQ4::quantize(&weight, n, k);
334        assert_eq!(q4.packed_bytes(), n * k / 2);
335        let q8 = crate::int8::QuantizedMatrix::quantize(&weight, n, k);
336        assert_eq!(q4.packed_bytes() * 2, q8.data.len());
337    }
338
339    /// Accuracy is WORSE than Q8 by roughly the level ratio, and this test states that honestly
340    /// rather than asserting a tolerance that hides it.
341    ///
342    /// 15 levels against 255 means ~17x the quantization step. The assertion is deliberately loose
343    /// — it exists to catch a broken quantizer (orders of magnitude off), not to claim Q4 is
344    /// accurate. Whether this error is AUDIBLE is a listening-protocol question, not a unit test,
345    /// and doctrine #2 requires that gate before any routing decision.
346    #[test]
347    fn quantization_error_is_bounded_by_the_level_step() {
348        let k = 8192;
349        let weight = deterministic(k, 0xBEEF);
350        let matrix = QuantizedMatrixQ4::quantize(&weight, 1, k);
351        let restored = matrix.dequantize_row(0);
352        let scale = matrix.scales[0];
353        for (position, (&original, &back)) in weight.iter().zip(restored.iter()).enumerate() {
354            assert!(
355                (original - back).abs() <= scale * 0.5 + f32::EPSILON * 8.0,
356                "position {position}: |{original} - {back}| exceeds half a level ({scale})"
357            );
358        }
359    }
360}