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