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}