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}