Skip to main content

kime_cpu/
qgemm.rs

1//! INT8 matrix products, `y = x wᵀ + b` with both sides rounded to eight bits: the weights per
2//! output channel and the activations per row, each symmetric with a scale of `max |v| / 127`.
3//! The products are summed in i32, which is exact, so a sum does not depend on the order it is
4//! taken in. Every kernel, every split and every machine gives the same bits, and a row's result
5//! does not depend on the rows around it.
6//!
7//! Both sides are stored the way Arm's `smmla` reads them. Rows go in tiles of eight, and a tile
8//! holds, for each group of eight values of `k`, its four row pairs one after the other, each pair
9//! as eight values from its first row then eight from its second: `[rows / 8][k / 8][4][2][8]`,
10//! with the rows past the end and the tail of `k` padded with zeros. One `smmla` multiplies a pair
11//! of rows of `x` by a pair of rows of `w` over eight values of `k` into a 2 by 2 block of sums,
12//! so a tile of `x` against a tile of `w` is sixteen of them for each group, all from 128 bytes
13//! read in order.
14
15use kime_tensor::Epilogue;
16
17use crate::ops::gelu;
18use crate::par::Shared;
19
20/// Values of `k` per group.
21const KG: usize = 8;
22/// Rows per tile, on both sides.
23const TILE: usize = 8;
24/// Rows of `x` per task, a multiple of TILE.
25const MB: usize = 64;
26
27/// A matrix rounded to INT8 row by row, in the layout of the module docs.
28#[derive(Debug, Clone, Default, PartialEq)]
29pub struct QMatrix {
30    /// Rows.
31    pub rows: usize,
32    /// Values per row.
33    pub k: usize,
34    /// `[rows.div_ceil(8)][k.div_ceil(8)][4][2][8]`.
35    pub q: Vec<i8>,
36    /// `max |row| / 127` for each row, so a row is its values times its scale.
37    pub scale: Vec<f32>,
38}
39
40impl QMatrix {
41    /// Rounds `x`, `[rows, k]`, to INT8.
42    ///
43    /// # Panics
44    ///
45    /// If `x` is not `[rows, k]`.
46    #[must_use]
47    pub fn quantize(x: &[f32], rows: usize, k: usize) -> Self {
48        assert_eq!(x.len(), rows * k, "x is not [rows, k]");
49        let mut q = vec![0; tiled_len(rows, k)];
50        let mut scale = vec![0.0; rows];
51        for (r, row) in x.chunks_exact(k.max(1)).take(rows).enumerate() {
52            scale[r] = quantize_row(row, r, &mut q);
53        }
54        Self { rows, k, q, scale }
55    }
56
57    /// The values as f32, `[rows, k]`, for tests and for checking what the rounding lost.
58    #[must_use]
59    pub fn dequantize(&self) -> Vec<f32> {
60        let mut out = vec![0.0; self.rows * self.k];
61        for r in 0..self.rows {
62            for p in 0..self.k {
63                out[r * self.k + p] = f32::from(self.q[at(r, p, self.k)]) * self.scale[r];
64            }
65        }
66        out
67    }
68}
69
70/// Bytes of a tiled matrix of `rows` by `k`.
71#[must_use]
72pub fn tiled_len(rows: usize, k: usize) -> usize {
73    rows.div_ceil(TILE) * TILE * k.div_ceil(KG) * KG
74}
75
76/// Where value `p` of row `r` sits in a tiled matrix with rows of `k`.
77#[inline(always)]
78fn at(r: usize, p: usize, k: usize) -> usize {
79    let (tile, pair, half) = (r / TILE, r % TILE / 2, r % 2);
80    tile * k.div_ceil(KG) * TILE * KG + p / KG * TILE * KG + pair * 2 * KG + half * KG + p % KG
81}
82
83/// Rounds `row` into row `r` of the tiled `q` and returns its scale.
84fn quantize_row(row: &[f32], r: usize, q: &mut [i8]) -> f32 {
85    let k = row.len();
86    let amax = row.iter().fold(0f32, |m, v| m.max(v.abs()));
87    if amax == 0.0 || !amax.is_finite() {
88        for p in 0..k {
89            q[at(r, p, k)] = 0;
90        }
91        return if amax == 0.0 { 0.0 } else { f32::NAN };
92    }
93    let inv = 127.0 / amax;
94    let base = at(r, 0, k);
95    for (g, chunk) in row.chunks(KG).enumerate() {
96        let o = base + g * TILE * KG;
97        for (e, &v) in chunk.iter().enumerate() {
98            // |v| <= amax, so the product is within [-127, 127] and the cast does not clamp.
99            q[o + e] = (v * inv).round() as i8;
100        }
101    }
102    amax / 127.0
103}
104
105/// Bytes of scratch a task needs for its rows of `x`, as f32 values.
106#[must_use]
107pub fn scratch_len(k: usize) -> usize {
108    tiled_len(MB, k).div_ceil(4) + MB
109}
110
111/// One INT8 GEMM with its epilogue, `y = ep(x wᵀ + b)`, where `x` is rounded row by row as each
112/// task reads it and `w` was rounded once with [`QMatrix::quantize`].
113#[derive(Debug, Clone, Copy)]
114pub struct QGemm<'a> {
115    /// `[m, k]`.
116    pub x: &'a [f32],
117    /// Rows of `x` and `y`.
118    pub m: usize,
119    /// `[n, k]`, rounded.
120    pub w: &'a QMatrix,
121    /// `[n]`.
122    pub b: Option<&'a [f32]>,
123    /// What happens to each result.
124    pub ep: Epilogue,
125}
126
127impl QGemm<'_> {
128    /// Runs the GEMM into `y`, handing `spawn` a task count and the task body to run for each.
129    /// A task gets [`scratch_len`] floats of scratch of its own. The results are exact sums, so
130    /// the split only has to keep the threads busy.
131    ///
132    /// # Panics
133    ///
134    /// If a length does not match the shape.
135    pub fn run(
136        &self,
137        y: &mut [f32],
138        threads: usize,
139        spawn: impl FnOnce(usize, &(dyn Fn(usize, &mut [f32]) + Sync)),
140    ) {
141        let Self { x, m, w, b, ep } = *self;
142        let (k, n) = (w.k, w.rows);
143        assert_eq!(x.len(), m * k, "x is not [m, k]");
144        assert_eq!(y.len(), m * n, "y is not [m, n]");
145        if let Some(b) = b {
146            assert_eq!(b.len(), n, "b is not [n]");
147        }
148        if m == 0 || n == 0 {
149            return;
150        }
151        let y = Shared::new(y);
152        let mt = m.div_ceil(MB);
153        let tiles = n.div_ceil(TILE);
154        // Column blocks of whole tiles, enough of them for three tasks a thread.
155        let per = tiles.div_ceil((3 * threads.max(1)).div_ceil(mt)).max(1);
156        let nt = tiles.div_ceil(per);
157        let kernel = pick();
158        spawn(mt * nt, &|t, scratch| {
159            let (bi, bj) = (t % mt, t / mt);
160            let (r0, rows) = (bi * MB, MB.min(m - bi * MB));
161            let (scale, bytes) = scratch[..scratch_len(k)].split_at_mut(MB);
162            // SAFETY: any bit pattern is an i8, and i8 needs no alignment.
163            let (_, bytes, _) = unsafe { bytes.align_to_mut::<i8>() };
164            let xq = &mut bytes[..tiled_len(MB, k)];
165            for r in 0..rows {
166                scale[r] = quantize_row(&x[(r0 + r) * k..(r0 + r + 1) * k], r, xq);
167            }
168            // The scratch is reused, so the rows past the end of x in the last tile and the tail of
169            // k hold whatever was there. The first give sums that are dropped, and the second meet
170            // the zeros w is padded with.
171            let tile_bytes = k.div_ceil(KG) * TILE * KG;
172            for tj in bj * per..((bj + 1) * per).min(tiles) {
173                let wt = &w.q[tj * tile_bytes..(tj + 1) * tile_bytes];
174                for ti in 0..rows.div_ceil(TILE) {
175                    let xt = &xq[ti * tile_bytes..(ti + 1) * tile_bytes];
176                    let sums = kernel(xt, wt);
177                    for (i, row) in sums.iter().enumerate().take(rows - ti * TILE) {
178                        let (r, j0) = (ti * TILE + i, tj * TILE);
179                        for (jj, &s) in row.iter().enumerate().take(n - j0) {
180                            let j = j0 + jj;
181                            let v = s as f32 * (scale[r] * w.scale[j]);
182                            let v = match b {
183                                Some(b) => v + b[j],
184                                None => v,
185                            };
186                            let at = (r0 + r) * n + j;
187                            // SAFETY: each output belongs to exactly one task.
188                            unsafe {
189                                y.set(
190                                    at,
191                                    match ep {
192                                        Epilogue::None => v,
193                                        Epilogue::Gelu => gelu(v),
194                                        Epilogue::Relu => v.max(0.0),
195                                        Epilogue::Accumulate => y.get(at) + v,
196                                    },
197                                );
198                            }
199                        }
200                    }
201                }
202            }
203        });
204    }
205}
206
207/// A tile of `x` against a tile of `w`: `[8][8]` sums, row `i` of `x` against row `j` of `w`.
208type Kernel = fn(&[i8], &[i8]) -> [[i32; TILE]; TILE];
209
210fn pick() -> Kernel {
211    #[cfg(target_arch = "aarch64")]
212    if std::arch::is_aarch64_feature_detected!("i8mm") {
213        return |x, w| {
214            // SAFETY: i8mm was detected on this machine.
215            unsafe { i8mm::tile(x, w) }
216        };
217    }
218    tile_scalar
219}
220
221/// The portable kernel, and the one the others are tested against.
222fn tile_scalar(x: &[i8], w: &[i8]) -> [[i32; TILE]; TILE] {
223    assert_eq!(x.len(), w.len());
224    let mut out = [[0i32; TILE]; TILE];
225    for (xg, wg) in x.as_chunks::<{ TILE * KG }>().0.iter().zip(w.as_chunks::<{ TILE * KG }>().0) {
226        for (i, row) in out.iter_mut().enumerate() {
227            let xr = &xg[i / 2 * 2 * KG + i % 2 * KG..][..KG];
228            for (j, o) in row.iter_mut().enumerate() {
229                let wr = &wg[j / 2 * 2 * KG + j % 2 * KG..][..KG];
230                *o += xr.iter().zip(wr).map(|(&a, &b)| i32::from(a) * i32::from(b)).sum::<i32>();
231            }
232        }
233    }
234    out
235}
236
237#[cfg(target_arch = "aarch64")]
238mod i8mm {
239    use std::arch::aarch64::{int8x16_t, int32x4_t, vdupq_n_s32, vld1q_s8, vst1q_s32};
240    use std::arch::asm;
241
242    use super::{KG, TILE};
243
244    /// `acc += a bᵀ` for a and b each two rows of eight, the 2 by 2 result row major.
245    /// `vmmlaq_s32` does the same but is not stable yet.
246    #[inline(always)]
247    fn mmla(acc: int32x4_t, a: int8x16_t, b: int8x16_t) -> int32x4_t {
248        let mut acc = acc;
249        // SAFETY: smmla reads and writes registers only, and the callers run where i8mm exists.
250        unsafe {
251            asm!(
252                "smmla {d:v}.4s, {a:v}.16b, {b:v}.16b",
253                d = inout(vreg) acc,
254                a = in(vreg) a,
255                b = in(vreg) b,
256                options(pure, nomem, nostack),
257            );
258        }
259        acc
260    }
261
262    /// # Safety
263    ///
264    /// The CPU must have i8mm, and `x` and `w` must be whole tiles of the same length.
265    #[target_feature(enable = "neon,i8mm")]
266    pub(super) unsafe fn tile(x: &[i8], w: &[i8]) -> [[i32; TILE]; TILE] {
267        assert!(x.len() == w.len() && x.len().is_multiple_of(TILE * KG));
268        let mut acc = [[vdupq_n_s32(0); 4]; 4];
269        for (xg, wg) in
270            x.as_chunks::<{ TILE * KG }>().0.iter().zip(w.as_chunks::<{ TILE * KG }>().0)
271        {
272            // SAFETY: each group is 64 bytes, four loads of 16.
273            let wv = unsafe { [0, 1, 2, 3].map(|b| vld1q_s8(wg.as_ptr().add(16 * b))) };
274            for (a, acc) in acc.iter_mut().enumerate() {
275                // SAFETY: as above.
276                let xa = unsafe { vld1q_s8(xg.as_ptr().add(16 * a)) };
277                for (acc, &wb) in acc.iter_mut().zip(&wv) {
278                    *acc = mmla(*acc, xa, wb);
279                }
280            }
281        }
282        let mut out = [[0i32; TILE]; TILE];
283        for (a, acc) in acc.iter().enumerate() {
284            for (b, &v) in acc.iter().enumerate() {
285                let mut s = [0i32; 4];
286                // SAFETY: s holds four i32.
287                unsafe { vst1q_s32(s.as_mut_ptr(), v) };
288                out[2 * a][2 * b] = s[0];
289                out[2 * a][2 * b + 1] = s[1];
290                out[2 * a + 1][2 * b] = s[2];
291                out[2 * a + 1][2 * b + 1] = s[3];
292            }
293        }
294        out
295    }
296}
297
298/// `y = x wᵀ + b` in INT8, rounding `w` on every call. For tests and benchmarks.
299///
300/// # Panics
301///
302/// If a length does not match the shape.
303#[allow(clippy::too_many_arguments)]
304pub fn linear(
305    x: &[f32],
306    m: usize,
307    k: usize,
308    w: &[f32],
309    n: usize,
310    b: Option<&[f32]>,
311    y: &mut [f32],
312    threads: usize,
313) {
314    let w = QMatrix::quantize(w, n, k);
315    let g = QGemm { x, m, w: &w, b, ep: Epilogue::None };
316    let len = scratch_len(k);
317    g.run(y, threads, |tasks, f| {
318        crate::par::for_each(tasks, threads, |t| f(t, &mut vec![0.0; len]));
319    });
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::testing::Rng;
326
327    #[test]
328    fn quantize_round_trips_within_half_a_step() {
329        let mut rng = Rng(3);
330        for (rows, k) in [(1, 1), (3, 7), (9, 17), (16, 64), (5, 0)] {
331            let x = rng.vec(rows * k);
332            let q = QMatrix::quantize(&x, rows, k);
333            let back = q.dequantize();
334            for r in 0..rows {
335                for p in 0..k {
336                    let (a, b) = (x[r * k + p], back[r * k + p]);
337                    assert!((a - b).abs() <= q.scale[r] * 0.5 + 1e-7, "{rows}x{k} {r},{p}");
338                }
339            }
340        }
341        let q = QMatrix::quantize(&[0.0, 0.0, 2.0, -1.0], 2, 2);
342        assert_eq!((q.scale[0], q.q[at(1, 0, 2)], q.q[at(1, 1, 2)]), (0.0, 127, -64));
343    }
344
345    /// The exact result from the rounded values, in f64.
346    fn want(x: &QMatrix, w: &QMatrix, b: Option<&[f32]>) -> Vec<f32> {
347        let k = x.k;
348        let mut y = vec![0.0; x.rows * w.rows];
349        for i in 0..x.rows {
350            for j in 0..w.rows {
351                let s: i64 =
352                    (0..k).map(|p| i64::from(x.q[at(i, p, k)]) * i64::from(w.q[at(j, p, k)])).sum();
353                let v = s as f32 * (x.scale[i] * w.scale[j]);
354                y[i * w.rows + j] = b.map_or(v, |b| v + b[j]);
355            }
356        }
357        y
358    }
359
360    #[test]
361    fn exact_on_awkward_shapes() {
362        let mut rng = Rng(7);
363        for (m, k, n) in
364            [(1, 1, 1), (3, 5, 2), (9, 17, 9), (64, 64, 64), (70, 300, 130), (130, 8, 7)]
365        {
366            let (x, w, b) = (rng.vec(m * k), rng.vec(n * k), rng.vec(n));
367            let (xq, wq) = (QMatrix::quantize(&x, m, k), QMatrix::quantize(&w, n, k));
368            for bias in [None, Some(&b[..])] {
369                let want = want(&xq, &wq, bias);
370                for threads in [1, 3, 16] {
371                    let mut y = vec![f32::NAN; m * n];
372                    linear(&x, m, k, &w, n, bias, &mut y, threads);
373                    assert!(
374                        y.iter().zip(&want).all(|(a, b)| a.to_bits() == b.to_bits()),
375                        "{m}x{k}x{n} on {threads}"
376                    );
377                }
378            }
379        }
380    }
381
382    #[test]
383    fn kernels_agree() {
384        let mut rng = Rng(9);
385        for k in [8, 64, 1024] {
386            let (x, w) = (rng.vec(TILE * k), rng.vec(TILE * k));
387            let (x, w) = (QMatrix::quantize(&x, TILE, k), QMatrix::quantize(&w, TILE, k));
388            assert_eq!(pick()(&x.q, &w.q), tile_scalar(&x.q, &w.q), "k {k}");
389        }
390    }
391
392    #[test]
393    fn close_to_f32() {
394        let mut rng = Rng(11);
395        let (m, k, n) = (20, 1024, 96);
396        let (x, w) = (rng.vec(m * k), rng.vec(n * k));
397        let mut y = vec![0.0; m * n];
398        linear(&x, m, k, &w, n, None, &mut y, 4);
399        let mut exact = vec![0.0; m * n];
400        crate::gemm::linear(&x, m, k, &w, n, None, &mut exact, 4);
401        // Uniform values in [-1, 1) give sums with a spread of about sqrt(k / 9), and the
402        // rounding adds an error of about a hundredth of that.
403        let spread = (k as f32 / 9.0).sqrt();
404        let err = y.iter().zip(&exact).fold(0f32, |e, (a, b)| e.max((a - b).abs()));
405        assert!(err < 0.05 * spread, "error {err} against a spread of {spread}");
406    }
407
408    #[test]
409    fn epilogues() {
410        let mut rng = Rng(5);
411        let (m, k, n) = (70, 40, 90);
412        let (x, w, b, y0) = (rng.vec(m * k), rng.vec(n * k), rng.vec(n), rng.vec(m * n));
413        let (xq, wq) = (QMatrix::quantize(&x, m, k), QMatrix::quantize(&w, n, k));
414        let lin = want(&xq, &wq, Some(&b));
415        for ep in [Epilogue::None, Epilogue::Gelu, Epilogue::Relu, Epilogue::Accumulate] {
416            let mut y = y0.clone();
417            let g = QGemm { x: &x, m, w: &wq, b: Some(&b), ep };
418            g.run(&mut y, 4, |tasks, f| {
419                crate::par::for_each(tasks, 4, |t| f(t, &mut vec![0.0; scratch_len(k)]));
420            });
421            for (i, (&got, (&v, &y))) in y.iter().zip(lin.iter().zip(&y0)).enumerate() {
422                let want = match ep {
423                    Epilogue::None => v,
424                    Epilogue::Gelu => gelu(v),
425                    Epilogue::Relu => v.max(0.0),
426                    Epilogue::Accumulate => y + v,
427                };
428                assert_eq!(got.to_bits(), want.to_bits(), "{ep:?} at {i}");
429            }
430        }
431    }
432}