Skip to main content

kopitiam_tensor/tensor/
matmul.rs

1//! Matrix multiplication: the hot path of every transformer layer
2//! (attention projections, the FFN's two big matmuls) and therefore the one
3//! op in this crate most worth getting right before getting fast.
4//!
5//! Phase 1 targets *correct* `f32` matmul; a blocked/SIMD/threaded kernel
6//! is a follow-up once there is a real model to benchmark against (see the
7//! crate docs' "what's deliberately not here" list).
8
9use kopitiam_core::{DType, Error, Result, Shape};
10
11use crate::quant;
12use crate::storage::Storage;
13
14use super::Tensor;
15
16/// Whether [`Tensor::quantized_matmul`] has a *fused*, dequantize-free kernel
17/// for `dtype` in this build — the single source of truth every layer that
18/// must choose "compute on the quantized bytes directly" vs. "dequantize to
19/// `f32` first" shares, so the two decisions can never drift apart.
20///
21/// Today only [`DType::Q4_0`] and [`DType::Q8_0`] have such a kernel (the
22/// integer per-block dot products in [`crate::quant`]). Every other quantized
23/// format — `Q4_1`/`Q5_0`/`Q5_1` and every K-quant (`Q2_K`..`Q6_K`, `Q8_K`) —
24/// dequantizes correctly via [`Tensor::to_dtype`] but has no fused path here,
25/// so a caller wanting to matmul against one must dequantize it to `f32` and
26/// take the ordinary [`Tensor::matmul`] route.
27///
28/// # Why this is a predicate, not a hard-coded match in each caller
29///
30/// Three places must agree on this set: [`Tensor::quantized_matmul`] (which
31/// rejects a weight it cannot fuse), `kopitiam-runtime`'s `load_matmul_weight`
32/// (which keeps a weight quantized only when it can be fused, and otherwise
33/// dequantizes it at load so the forward pass never dead-ends in an
34/// `UnsupportedDType`), and that crate's `linear` dispatch. If any of them
35/// spelled the set out independently, adding a format to one and forgetting
36/// another would silently reintroduce the load-time/compute-time mismatch this
37/// function exists to prevent.
38///
39/// # Why it lives here and not on [`DType`]
40///
41/// "Which formats have a fused kernel" is a fact about *this crate's* kernels,
42/// not about what the bytes mean — [`DType`] in `kopitiam-core` deliberately
43/// says nothing about how a format is computed (see its docs). A future
44/// wgpu/GPU backend may fuse more formats (or a CPU kernel here may be replaced
45/// by one), and when it does, only this crate changes; `kopitiam-core` stays a
46/// pure description of the byte layout.
47pub const fn has_fused_matmul_kernel(dtype: DType) -> bool {
48    matches!(dtype, DType::Q4_0 | DType::Q8_0)
49}
50
51impl Tensor {
52    /// Batched matrix multiplication: `self` is `[..batch, m, k]`, `other`
53    /// is `[..batch, k, n]`, and the result is `[..batch, m, n]`.
54    ///
55    /// Both operands must be rank >= 2 and `f32`. The leading ("batch")
56    /// dimensions are broadcast against each other with the same
57    /// right-aligned rule as [`kopitiam_core::Shape::broadcast`] (so a
58    /// single `[k, n]` weight matrix multiplies against a `[batch, m, k]`
59    /// activation without the caller manually replicating it) — this is
60    /// what makes a single implementation handle both the plain 2D case
61    /// (empty batch shape) and the batched case identically, rather than
62    /// special-casing 2D.
63    ///
64    /// # Errors
65    ///
66    /// Shape problems (`rank < 2`, or the inner dimension `k` disagreeing
67    /// between operands) are reported as [`Error::ShapeMismatch`]. That
68    /// variant's field names (`expected`/`actual`) were designed for
69    /// reshape; here they are reused to mean "these two shapes are not
70    /// compatible for matrix multiplication" — the closest fit among
71    /// `kopitiam-core`'s error variants, which this crate cannot modify.
72    /// Incompatible batch dimensions surface as
73    /// [`Error::NotBroadcastable`], propagated directly from
74    /// [`Shape::broadcast`].
75    pub fn matmul(&self, other: &Tensor) -> Result<Tensor> {
76        self.require_dtype(DType::F32)?;
77        other.require_dtype(DType::F32)?;
78
79        let (ra, rb) = (self.rank(), other.rank());
80        if ra < 2 || rb < 2 {
81            return Err(Error::ShapeMismatch {
82                expected: self.shape.clone(),
83                actual: other.shape.clone(),
84            });
85        }
86
87        let (m, k_a) = (self.shape.dims()[ra - 2], self.shape.dims()[ra - 1]);
88        let (k_b, n) = (other.shape.dims()[rb - 2], other.shape.dims()[rb - 1]);
89        if k_a != k_b {
90            return Err(Error::ShapeMismatch {
91                expected: self.shape.clone(),
92                actual: other.shape.clone(),
93            });
94        }
95
96        let batch_a = Shape::new(self.shape.dims()[..ra - 2].to_vec());
97        let batch_b = Shape::new(other.shape.dims()[..rb - 2].to_vec());
98        let batch = batch_a.broadcast(&batch_b)?;
99        let batch_count = batch.elem_count();
100
101        let a_full_shape = Shape::new([batch.dims(), &[m, k_a]].concat());
102        let b_full_shape = Shape::new([batch.dims(), &[k_b, n]].concat());
103        let a_view = self.broadcast_to(a_full_shape)?;
104        let b_view = other.broadcast_to(b_full_shape)?;
105
106        // Materialize both operands contiguously in [batch, m, k] / [batch,
107        // k, n] order so the inner loop can use plain slice indexing
108        // instead of re-deriving strided offsets per element.
109        let Storage::F32(a_raw) = a_view.storage.as_ref() else { unreachable!() };
110        let Storage::F32(b_raw) = b_view.storage.as_ref() else { unreachable!() };
111        let a_mat: Vec<f32> = a_view.logical_offsets().map(|i| a_raw[i]).collect();
112        let b_mat: Vec<f32> = b_view.logical_offsets().map(|i| b_raw[i]).collect();
113
114        let mut out = vec![0f32; batch_count * m * n];
115        for batch_idx in 0..batch_count {
116            let a_off = batch_idx * m * k_a;
117            let b_off = batch_idx * k_a * n;
118            let o_off = batch_idx * m * n;
119            // i-k-j loop order: the inner loop walks both `b_mat` and `out`
120            // contiguously, which is friendlier to the cache than the
121            // naive i-j-k order that strides through `b_mat` by `n` on
122            // every multiply-add.
123            for i in 0..m {
124                for p in 0..k_a {
125                    let a_val = a_mat[a_off + i * k_a + p];
126                    if a_val == 0.0 {
127                        continue;
128                    }
129                    let b_row = &b_mat[b_off + p * n..b_off + p * n + n];
130                    let out_row = &mut out[o_off + i * n..o_off + i * n + n];
131                    for (o, &b_val) in out_row.iter_mut().zip(b_row) {
132                        *o += a_val * b_val;
133                    }
134                }
135            }
136        }
137
138        let out_shape = Shape::new([batch.dims(), &[m, n]].concat());
139        Tensor::from_f32(out, out_shape)
140    }
141
142    /// `self @ weight^T` — the same semantics [`crate::linear::linear`]'s
143    /// (in `kopitiam-runtime`) transpose-then-[`Tensor::matmul`] gives an
144    /// `f32` weight — but for a block-quantized `weight` ([`DType::Q4_0`]
145    /// or [`DType::Q8_0`]), computed *without* ever dequantizing `weight`
146    /// to `f32`.
147    ///
148    /// # The algorithm: quantize the activation, dot in integer space
149    ///
150    /// The "correct before fast" version of a quantized matmul is
151    /// `weight.to_dtype(DType::F32)` once, then plain [`Tensor::matmul`] —
152    /// that remains this crate's default and its permanent reference
153    /// implementation (see "Correctness" below). It is also why a
154    /// 7B-parameter Q4_0 model, genuinely about 4GB on disk, balloons to
155    /// roughly 28GB of resident `f32` the instant it is loaded that way:
156    /// dequantizing trades away the entire point of shipping a quantized
157    /// model (fitting in memory) for arithmetic convenience.
158    ///
159    /// This method is the technique `ggml`/`llama.cpp` use instead — the
160    /// reason quantized inference is *fast*, not merely *small* — and it
161    /// never produces an `f32` copy of `weight` at all. Every row of
162    /// `self` is quantized to Q8_0 blocks on the fly
163    /// ([`quant::quantize_row_q8_0`]), and each output element becomes a
164    /// per-block *integer* dot product ([`quant::q4_0_dot_q8_0`] /
165    /// [`quant::q8_0_dot_q8_0`]) between that freshly-quantized activation
166    /// block and `weight`'s already-quantized block, with both sides'
167    /// scales multiplied in once at the very end of each block. `weight`'s
168    /// bytes are read exactly as they sit in [`Storage::Quantized`] —
169    /// nothing about `weight` is ever expanded to a wider type. This is a
170    /// genuinely different algorithm from "dequantize then multiply", not
171    /// an optimization of it: the former spends `2 * out_features *
172    /// in_features` `f32` multiply-adds; this spends that many `i32`
173    /// multiply-adds (integer arithmetic a CPU — including a phone-class
174    /// one — does at several times `f32`'s throughput) plus only `2 *
175    /// out_features * (in_features / 32)` `f32` multiplies for the
176    /// per-block scale combination.
177    ///
178    /// `self` is `[..., in_features]`, `f32`, any rank >= 1; `weight` is
179    /// `[out_features, in_features]`, exactly rank 2, quantized. Returns
180    /// `[..., out_features]`. `in_features` must be a whole number of
181    /// 32-element blocks (true of every real GGUF Q4_0/Q8_0 export, since a
182    /// block may never straddle a row boundary).
183    ///
184    /// # Correctness
185    ///
186    /// This method's own tests assert, for both Q4_0 and Q8_0, that its
187    /// output agrees with `weight.to_dtype(DType::F32)` followed by
188    /// [`Tensor::matmul`] within the error the *activation's* Q8_0
189    /// quantization alone can introduce (both paths read the identical
190    /// weight bits, so any difference comes only from `self` going through
191    /// [`quant::quantize_row_q8_0`] on this path and not on the reference
192    /// path) — see this module's `quantized_matmul_agrees_with_...` tests.
193    /// That reference path is why dequantizing to `f32` stays in this
194    /// crate permanently: it is not just a fallback, it is the oracle this
195    /// method is checked against.
196    ///
197    /// # Errors
198    ///
199    /// [`Error::DTypeMismatch`] if `self` is not `f32`.
200    /// [`Error::UnsupportedDType`] if `weight`'s dtype is not one of the
201    /// two formats this method has a fused kernel for. The other quantized
202    /// formats — `Q4_1`/`Q5_0`/`Q5_1` and every K-quant
203    /// (`Q4_K`/`Q5_K`/`Q6_K`/...) — dequantize fine via [`Tensor::to_dtype`]
204    /// but have no fused path here yet, so a caller wanting to matmul against
205    /// one must dequantize it to `f32` first; see the parent crate's Phase 2
206    /// scope. Also errors if `weight` is not quantized at all (use
207    /// [`Tensor::matmul`] directly for a plain `f32` weight).
208    /// [`Error::ShapeMismatch`] if `weight` is not rank 2, if `self`'s last
209    /// dimension does not equal `weight`'s `in_features`, or if
210    /// `in_features` is not a multiple of 32.
211    pub fn quantized_matmul(&self, weight: &Tensor) -> Result<Tensor> {
212        self.require_dtype(DType::F32)?;
213        if !has_fused_matmul_kernel(weight.dtype()) {
214            return Err(Error::UnsupportedDType { op: "quantized_matmul", dtype: weight.dtype() });
215        }
216        if weight.rank() != 2 {
217            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
218        }
219        let wdims = weight.shape.dims();
220        let (out_features, in_features) = (wdims[0], wdims[1]);
221
222        let xdims = self.shape.dims();
223        let Some((&last, leading)) = xdims.split_last() else {
224            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
225        };
226        if last != in_features || in_features == 0 || !in_features.is_multiple_of(32) {
227            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
228        }
229
230        // logical_offsets()-ordered, so this is correct for a non-contiguous
231        // `self` (a transposed or narrowed activation view) too, not just
232        // the common contiguous case.
233        let x_data = self.to_vec_f32()?;
234
235        let Storage::Quantized { dtype, bytes } = weight.storage.as_ref() else {
236            unreachable!("has_fused_matmul_kernel is true only for quantized dtypes, so the guard above guarantees Storage::Quantized")
237        };
238        let dtype = *dtype;
239        let block_bytes = dtype.block_bytes();
240        let blocks_per_row = in_features / 32;
241        let row_bytes = blocks_per_row * block_bytes;
242
243        let rows = leading.iter().product::<usize>();
244        let mut out = vec![0f32; rows * out_features];
245        for r in 0..rows {
246            let x_row = &x_data[r * in_features..(r + 1) * in_features];
247            let (x_scales, x_q) = quant::quantize_row_q8_0(x_row);
248            for o in 0..out_features {
249                let w_row = &bytes[o * row_bytes..(o + 1) * row_bytes];
250                let mut acc = 0f32;
251                for (b, w_block) in w_row.chunks_exact(block_bytes).enumerate() {
252                    let xq_block = &x_q[b * 32..(b + 1) * 32];
253                    acc += match dtype {
254                        DType::Q4_0 => quant::q4_0_dot_q8_0(w_block, xq_block, x_scales[b]),
255                        DType::Q8_0 => quant::q8_0_dot_q8_0(w_block, xq_block, x_scales[b]),
256                        _ => unreachable!("has_fused_matmul_kernel admits only Q4_0 and Q8_0"),
257                    };
258                }
259                out[r * out_features + o] = acc;
260            }
261        }
262
263        let mut out_shape = leading.to_vec();
264        out_shape.push(out_features);
265        Tensor::from_f32(out, out_shape)
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn matmul_2x3_by_3x2_matches_hand_computation() {
275        // [[1,2,3],[4,5,6]] * [[7,8],[9,10],[11,12]]
276        // row0: [1*7+2*9+3*11, 1*8+2*10+3*12] = [58, 64]
277        // row1: [4*7+5*9+6*11, 4*8+5*10+6*12] = [139, 154]
278        let a = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
279        let b = Tensor::from_f32(vec![7.0, 8.0, 9.0, 10.0, 11.0, 12.0], [3, 2]).unwrap();
280        let c = a.matmul(&b).unwrap();
281        assert_eq!(c.shape().dims(), &[2, 2]);
282        assert_eq!(c.to_vec_f32().unwrap(), vec![58.0, 64.0, 139.0, 154.0]);
283    }
284
285    #[test]
286    fn matmul_by_identity_is_the_identity() {
287        let a = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [2, 2]).unwrap();
288        let identity = Tensor::from_f32(vec![1.0, 0.0, 0.0, 1.0], [2, 2]).unwrap();
289        let c = a.matmul(&identity).unwrap();
290        assert_eq!(c.to_vec_f32().unwrap(), vec![1.0, 2.0, 3.0, 4.0]);
291    }
292
293    #[test]
294    fn matmul_rejects_incompatible_inner_dimensions() {
295        let a = Tensor::from_f32(vec![1.0; 6], [2, 3]).unwrap();
296        let b = Tensor::from_f32(vec![1.0; 8], [4, 2]).unwrap();
297        assert!(matches!(a.matmul(&b), Err(Error::ShapeMismatch { .. })));
298    }
299
300    #[test]
301    fn matmul_rejects_rank_1_operands() {
302        let a = Tensor::from_f32(vec![1.0, 2.0], [2]).unwrap();
303        let b = Tensor::from_f32(vec![1.0, 2.0], [2]).unwrap();
304        assert!(matches!(a.matmul(&b), Err(Error::ShapeMismatch { .. })));
305    }
306
307    #[test]
308    fn batched_matmul_broadcasts_a_shared_weight_matrix_over_the_batch() {
309        // Two batches of a [1,2] "activation" times a shared [2,2] weight.
310        let a = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [2, 1, 2]).unwrap(); // [batch=2, m=1, k=2]
311        let w = Tensor::from_f32(vec![1.0, 0.0, 0.0, 1.0], [2, 2]).unwrap(); // [k=2, n=2], no batch dim
312        let c = a.matmul(&w).unwrap();
313        assert_eq!(c.shape().dims(), &[2, 1, 2]);
314        // Multiplying by the identity should reproduce each batch's row.
315        assert_eq!(c.to_vec_f32().unwrap(), vec![1.0, 2.0, 3.0, 4.0]);
316    }
317
318    #[test]
319    fn batched_matmul_matches_per_batch_hand_computation() {
320        // batch=2, m=2,k=2,n=2. Batch 0 = identity-ish, batch 1 = doubling.
321        let a = Tensor::from_f32(
322            vec![
323                1.0, 2.0, 3.0, 4.0, // batch 0
324                1.0, 0.0, 0.0, 1.0, // batch 1
325            ],
326            [2, 2, 2],
327        )
328        .unwrap();
329        let b = Tensor::from_f32(
330            vec![
331                1.0, 0.0, 0.0, 1.0, // batch 0: identity
332                2.0, 0.0, 0.0, 2.0, // batch 1: 2*identity
333            ],
334            [2, 2, 2],
335        )
336        .unwrap();
337        let c = a.matmul(&b).unwrap();
338        assert_eq!(
339            c.to_vec_f32().unwrap(),
340            vec![1.0, 2.0, 3.0, 4.0, 2.0, 0.0, 0.0, 2.0]
341        );
342    }
343
344    #[test]
345    fn matmul_only_accepts_f32() {
346        let a = Tensor::from_i32(vec![1, 2, 3, 4], [2, 2]).unwrap();
347        let b = Tensor::from_i32(vec![1, 2, 3, 4], [2, 2]).unwrap();
348        assert!(matches!(a.matmul(&b), Err(Error::DTypeMismatch { .. })));
349    }
350
351    // -- quantized_matmul --
352
353    /// A small, dependency-free deterministic PRNG (xorshift64*), purely for
354    /// generating non-degenerate test data — the same pattern this
355    /// workspace's other test fixtures use (see e.g.
356    /// `kopitiam-runtime`'s `test_support::synthetic_gguf::Xorshift64`) so a
357    /// fixed seed makes every test below reproducible without a `rand`
358    /// dependency.
359    fn random_f32s(seed: u64, n: usize, scale: f32) -> Vec<f32> {
360        let mut state = seed | 1; // xorshift requires a nonzero state.
361        (0..n)
362            .map(|_| {
363                state ^= state << 13;
364                state ^= state >> 7;
365                state ^= state << 17;
366                let unit = (state >> 40) as u32 as f32 / (1u32 << 24) as f32; // [0, 1)
367                (unit - 0.5) * 2.0 * scale
368            })
369            .collect()
370    }
371
372    /// Encodes `values` (row-major, `shape = [rows, cols]`, `cols` a
373    /// multiple of 32) as a Q8_0 tensor, matching [`crate::quant`]'s
374    /// `qs[j] * d` decode formula exactly (this is a from-scratch,
375    /// test-only encoder — `kopitiam-tensor` deliberately has no *public*
376    /// f32 -> quantized encoder; see [`crate::quant::quantize_row_q8_0`]'s
377    /// docs for why activation-only quantization is a narrower thing).
378    fn encode_q8_0(values: &[f32], shape: [usize; 2]) -> Tensor {
379        let mut bytes = Vec::with_capacity(values.len() / 32 * 34);
380        for chunk in values.chunks_exact(32) {
381            let amax = chunk.iter().fold(0f32, |m, &v| m.max(v.abs()));
382            let d = amax / 127.0;
383            let id = if d != 0.0 { 1.0 / d } else { 0.0 };
384            bytes.extend_from_slice(&crate::half::f32_to_f16(d).to_le_bytes());
385            for &v in chunk {
386                bytes.push((v * id).round().clamp(-127.0, 127.0) as i8 as u8);
387            }
388        }
389        Tensor::from_quantized(DType::Q8_0, bytes, shape).unwrap()
390    }
391
392    /// Same idea as [`encode_q8_0`] but for Q4_0: the decode formula is
393    /// `(nibble - 8) * d`, so the encoder picks `d = amax / 7` (the
394    /// largest magnitude the signed-nibble range `[-8, 7]` can represent
395    /// without saturating on the positive side) and packs two elements per
396    /// byte exactly as [`crate::quant::dequant_q4_0`] expects: byte `j`
397    /// holds elements `j` (low nibble) and `j + 16` (high nibble).
398    fn encode_q4_0(values: &[f32], shape: [usize; 2]) -> Tensor {
399        let mut bytes = Vec::with_capacity(values.len() / 32 * 18);
400        for chunk in values.chunks_exact(32) {
401            let amax = chunk.iter().fold(0f32, |m, &v| m.max(v.abs()));
402            let d = amax / 7.0;
403            let id = if d != 0.0 { 1.0 / d } else { 0.0 };
404            bytes.extend_from_slice(&crate::half::f32_to_f16(d).to_le_bytes());
405            let nibble = |v: f32| -> u8 { ((v * id).round().clamp(-8.0, 7.0) as i32 + 8) as u8 & 0x0F };
406            for j in 0..16 {
407                bytes.push(nibble(chunk[j]) | (nibble(chunk[j + 16]) << 4));
408            }
409        }
410        Tensor::from_quantized(DType::Q4_0, bytes, shape).unwrap()
411    }
412
413    /// `x @ weight^T` computed by dequantizing `weight` to `f32` first —
414    /// the reference [`Tensor::quantized_matmul`] is checked against (see
415    /// that method's "Correctness" docs).
416    fn reference_linear(x: &Tensor, weight: &Tensor) -> Tensor {
417        let weight_f32 = weight.to_dtype(DType::F32).unwrap();
418        x.matmul(&weight_f32.transpose(0, 1).unwrap()).unwrap()
419    }
420
421    fn assert_allclose(actual: &[f32], expected: &[f32], rel: f32, abs_eps: f32) {
422        assert_eq!(actual.len(), expected.len());
423        for (i, (&a, &e)) in actual.iter().zip(expected).enumerate() {
424            let tol = abs_eps + rel * e.abs();
425            assert!((a - e).abs() <= tol, "index {i}: expected {e}, got {a} (tolerance {tol})");
426        }
427    }
428
429    /// The exact worst-case bound on [`Tensor::quantized_matmul`]'s output
430    /// error against [`reference_linear`]'s, derived from first principles
431    /// rather than picked to make a test pass. Both paths read *identical*
432    /// weight bits (`reference_linear` dequantizes the same already-quantized
433    /// `weight` the fast path reads directly), so the only error source is
434    /// the fast path's on-the-fly Q8_0 quantization of the activation row.
435    /// Round-to-nearest guarantees every quantized activation element
436    /// differs from its true value by at most half its block's step
437    /// (`x_scale / 2`), so by the triangle inequality the whole row's dot
438    /// product error is at most `sum_j |weight_j| * (x_scale_of_j's_block / 2)`
439    /// — computed here per output row/column pair from the *dequantized*
440    /// weight (what both paths agree the weight "means") and the real
441    /// per-block Q8_0 scales [`quant::quantize_row_q8_0`] actually chose.
442    /// A small additive slack accounts for ordinary `f32` summation-order
443    /// noise (both paths sum 32-or-more terms in different groupings).
444    fn max_activation_quantization_error(weight_row: &[f32], x_row: &[f32]) -> f32 {
445        let (x_scales, _) = quant::quantize_row_q8_0(x_row);
446        let bound: f32 = weight_row
447            .chunks_exact(32)
448            .zip(&x_scales)
449            .map(|(w_block, &scale)| (scale / 2.0) * w_block.iter().map(|v| v.abs()).sum::<f32>())
450            .sum();
451        bound + 1e-3
452    }
453
454    /// Runs [`Tensor::quantized_matmul`] against [`reference_linear`] and
455    /// checks every output element against
456    /// [`max_activation_quantization_error`]'s bound, not a flat tolerance.
457    fn assert_agrees_within_activation_quantization_bound(
458        x_rows: &[Vec<f32>],
459        weight: &Tensor,
460        weight_rows_f32: &[Vec<f32>],
461    ) {
462        let in_features = x_rows[0].len();
463        let x = Tensor::from_f32(x_rows.concat(), [x_rows.len(), in_features]).unwrap();
464        let fast = x.quantized_matmul(weight).unwrap().to_vec_f32().unwrap();
465        let reference = reference_linear(&x, weight).to_vec_f32().unwrap();
466
467        let out_features = weight_rows_f32.len();
468        for (idx, (&f, &r)) in fast.iter().zip(&reference).enumerate() {
469            let row = idx / out_features;
470            let col = idx % out_features;
471            let bound = max_activation_quantization_error(&weight_rows_f32[col], &x_rows[row]);
472            assert!(
473                (f - r).abs() <= bound,
474                "row {row} col {col}: expected {r}, got {f} (bound {bound})"
475            );
476        }
477    }
478
479    #[test]
480    fn quantized_matmul_q8_0_agrees_with_dequantize_then_matmul_reference() {
481        let (out_features, in_features) = (6, 64); // 2 blocks per row.
482        let weight_vals = random_f32s(1, out_features * in_features, 3.0);
483        let weight = encode_q8_0(&weight_vals, [out_features, in_features]);
484        let weight_rows_f32: Vec<Vec<f32>> = weight
485            .to_dtype(DType::F32)
486            .unwrap()
487            .to_vec_f32()
488            .unwrap()
489            .chunks_exact(in_features)
490            .map(<[f32]>::to_vec)
491            .collect();
492
493        let x_rows: Vec<Vec<f32>> = (0..3).map(|r| random_f32s(2 + r as u64 * 100, in_features, 2.0)).collect();
494
495        assert_agrees_within_activation_quantization_bound(&x_rows, &weight, &weight_rows_f32);
496    }
497
498    #[test]
499    fn quantized_matmul_q4_0_agrees_with_dequantize_then_matmul_reference() {
500        let (out_features, in_features) = (5, 96); // 3 blocks per row.
501        let weight_vals = random_f32s(3, out_features * in_features, 3.0);
502        let weight = encode_q4_0(&weight_vals, [out_features, in_features]);
503        let weight_rows_f32: Vec<Vec<f32>> = weight
504            .to_dtype(DType::F32)
505            .unwrap()
506            .to_vec_f32()
507            .unwrap()
508            .chunks_exact(in_features)
509            .map(<[f32]>::to_vec)
510            .collect();
511
512        let x_rows: Vec<Vec<f32>> = (0..2).map(|r| random_f32s(4 + r as u64 * 100, in_features, 2.0)).collect();
513
514        assert_agrees_within_activation_quantization_bound(&x_rows, &weight, &weight_rows_f32);
515    }
516
517    #[test]
518    fn quantized_matmul_exact_when_activation_needs_no_rounding() {
519        // A Q4_0 block built directly from chosen nibble bytes (not via
520        // `encode_q4_0`'s amax-derived scale, which -- like any single
521        // shared per-block scale -- exactly represents only the extreme
522        // value and rounds every other one) so weight decoding is exact by
523        // construction. The activation is likewise chosen to be an exact
524        // multiple of its own Q8_0 step (amax = 127 -> d = 1.0, and every
525        // value below is an integer). With both sources of rounding
526        // eliminated, the fast and reference paths must agree exactly, not
527        // just within a bound.
528        let in_features = 32;
529        let d_w = 1.0f32;
530        let mut weight_bytes = crate::half::f32_to_f16(d_w).to_le_bytes().to_vec();
531        for j in 0..16 {
532            let lo = (j % 16) as u8; // nibble -> decoded value (j - 8) exactly.
533            let hi = ((j + 3) % 16) as u8;
534            weight_bytes.push(lo | (hi << 4));
535        }
536        let weight = Tensor::from_quantized(DType::Q4_0, weight_bytes, [1, in_features]).unwrap();
537
538        // THE SUBTLETY THIS TEST EXISTS TO PIN, and which its first draft got
539        // wrong: "integers, comfortably inside +/-127" is NOT enough to make the
540        // activation round-free.
541        //
542        // Q8_0 derives its step from the block's own amax: `d = amax / 127`.
543        // The step is 1.0 -- and integers are therefore exactly representable --
544        // only when amax is EXACTLY 127. The original fixture used values
545        // spanning -60..=64, giving amax = 64 and d = 64/127 ~= 0.504, under
546        // which those integers are *not* multiples of the step at all. It then
547        // asserted exactness to 1e-3 and failed by ~0.6%, which was Q8_0
548        // rounding behaving perfectly correctly.
549        //
550        // So: integers, and amax pinned to exactly 127.
551        let mut x_vals = [0f32; 32];
552        for (j, v) in x_vals.iter_mut().enumerate() {
553            *v = (j as f32) * 8.0 - 127.0; // -127 ..= 121; amax == 127 exactly -> d == 1.0
554        }
555        let amax = x_vals.iter().fold(0f32, |m, v| m.max(v.abs()));
556        assert_eq!(amax, 127.0, "the whole point of this fixture is d == amax/127 == 1.0");
557
558        let x = Tensor::from_f32(x_vals.to_vec(), [1, in_features]).unwrap();
559
560        let fast = x.quantized_matmul(&weight).unwrap().to_vec_f32().unwrap();
561        let reference = reference_linear(&x, &weight).to_vec_f32().unwrap();
562
563        // With BOTH sources of rounding eliminated by construction, the fused
564        // integer path and the dequantize-then-f32 path must now agree to within
565        // f32 accumulation noise -- not merely within a quantization bound.
566        assert_allclose(&fast, &reference, 0.0, 1e-3);
567    }
568
569    #[test]
570    fn quantized_matmul_supports_leading_batch_dimensions() {
571        let in_features = 32;
572        let weight_vals = random_f32s(5, 4 * in_features, 3.0);
573        let weight = encode_q8_0(&weight_vals, [4, in_features]);
574
575        let x_vals = random_f32s(6, 2 * 3 * in_features, 2.0);
576        let x = Tensor::from_f32(x_vals, [2, 3, in_features]).unwrap();
577
578        let out = x.quantized_matmul(&weight).unwrap();
579        assert_eq!(out.shape().dims(), &[2, 3, 4]);
580
581        // Must match doing it one flattened row at a time.
582        let flat = x.reshape([6, in_features]).unwrap();
583        let flat_out = flat.quantized_matmul(&weight).unwrap();
584        assert_eq!(out.to_vec_f32().unwrap(), flat_out.to_vec_f32().unwrap());
585    }
586
587    #[test]
588    fn quantized_matmul_rejects_a_non_quantized_weight() {
589        let x = Tensor::from_f32(vec![1.0; 32], [1, 32]).unwrap();
590        let weight = Tensor::from_f32(vec![1.0; 32], [1, 32]).unwrap();
591        assert!(matches!(x.quantized_matmul(&weight), Err(Error::UnsupportedDType { .. })));
592    }
593
594    #[test]
595    fn quantized_matmul_rejects_a_quantized_format_with_no_fused_kernel() {
596        // Q4_1 dequantizes fine (see quant::dequantize) but has no fused
597        // integer dot product implemented here yet.
598        let x = Tensor::from_f32(vec![1.0; 32], [1, 32]).unwrap();
599        let weight = Tensor::from_quantized(DType::Q4_1, vec![0u8; 20], [1, 32]).unwrap();
600        assert!(matches!(x.quantized_matmul(&weight), Err(Error::UnsupportedDType { .. })));
601    }
602
603    #[test]
604    fn quantized_matmul_rejects_non_f32_activations() {
605        let x = Tensor::from_quantized(DType::Q8_0, vec![0u8; 34], [1, 32]).unwrap();
606        let weight = encode_q8_0(&[0.0; 32], [1, 32]);
607        assert!(matches!(x.quantized_matmul(&weight), Err(Error::DTypeMismatch { .. })));
608    }
609
610    #[test]
611    fn quantized_matmul_rejects_an_in_features_mismatch() {
612        let x = Tensor::from_f32(vec![1.0; 32], [1, 32]).unwrap();
613        let weight = encode_q8_0(&[0.0; 64], [1, 64]);
614        assert!(matches!(x.quantized_matmul(&weight), Err(Error::ShapeMismatch { .. })));
615    }
616
617    #[test]
618    fn quantized_matmul_rejects_an_in_features_not_a_multiple_of_32() {
619        // A Q4_0 tensor can only exist at all with a whole number of
620        // 32-element blocks, but a rank-2 [out, in] shape can still smuggle
621        // a non-block-aligned `in` past `Tensor::from_quantized` as long as
622        // `out * in` is itself a whole number of blocks (e.g. out=2, in=16
623        // -> 32 total elements, one block, but rows straddle the block).
624        let weight = Tensor::from_quantized(DType::Q4_0, vec![0u8; 18], [2, 16]).unwrap();
625        let x = Tensor::from_f32(vec![1.0; 16], [1, 16]).unwrap();
626        assert!(matches!(x.quantized_matmul(&weight), Err(Error::ShapeMismatch { .. })));
627    }
628
629    #[test]
630    fn quantized_matmul_rejects_a_rank_1_weight() {
631        let x = Tensor::from_f32(vec![1.0; 32], [1, 32]).unwrap();
632        let weight = Tensor::from_quantized(DType::Q8_0, vec![0u8; 34], [32]).unwrap();
633        assert!(matches!(x.quantized_matmul(&weight), Err(Error::ShapeMismatch { .. })));
634    }
635
636    #[test]
637    fn quantized_matmul_rejects_a_k_quant_weight_with_no_fused_kernel() {
638        // Q4_K dequantizes fine (see quant::dequant_q4_k) but, like every
639        // K-quant, has no fused integer dot product here — it must be
640        // dequantized to f32 and go through `matmul` instead. One Q4_K
641        // super-block is 256 elements / 144 bytes.
642        let x = Tensor::from_f32(vec![1.0; 256], [1, 256]).unwrap();
643        let weight = Tensor::from_quantized(DType::Q4_K, vec![0u8; 144], [1, 256]).unwrap();
644        assert!(matches!(x.quantized_matmul(&weight), Err(Error::UnsupportedDType { .. })));
645    }
646
647    #[test]
648    fn has_fused_matmul_kernel_is_true_for_exactly_q4_0_and_q8_0() {
649        // The single source of truth every layer keys off. Q4_0 and Q8_0 are
650        // the only formats with a fused kernel; everything else (float, int,
651        // the other classic quants, and every K-quant) dequantizes to f32.
652        for dtype in [DType::Q4_0, DType::Q8_0] {
653            assert!(has_fused_matmul_kernel(dtype), "{dtype} should have a fused kernel");
654        }
655        for dtype in [
656            DType::F32,
657            DType::F16,
658            DType::BF16,
659            DType::I8,
660            DType::I32,
661            DType::Q4_1,
662            DType::Q5_0,
663            DType::Q5_1,
664            DType::Q2_K,
665            DType::Q3_K,
666            DType::Q4_K,
667            DType::Q5_K,
668            DType::Q6_K,
669            DType::Q8_K,
670        ] {
671            assert!(!has_fused_matmul_kernel(dtype), "{dtype} should not have a fused kernel");
672        }
673    }
674}