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}