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}