Skip to main content

nuvec_cpu/
matmul.rs

1//! Matrix multiplication over strided operands, backed by the pure-Rust `gemm` crate.
2
3use std::fmt;
4
5use half::f16;
6
7/// Why [`matmul`] refused its operands (before touching any memory).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum MatmulError {
11    /// `m·n`, or a view's extent, overflows.
12    Overflow,
13    /// The output does not hold `m·n` elements.
14    OutputLength,
15    /// A view reaches outside its data.
16    OutOfBounds,
17}
18
19impl fmt::Display for MatmulError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        f.write_str(match self {
22            Self::Overflow => "matmul: sizes overflow",
23            Self::OutputLength => "matmul: output length does not match m·n",
24            Self::OutOfBounds => "matmul: matrix view reaches outside its data",
25        })
26    }
27}
28
29impl std::error::Error for MatmulError {}
30
31/// A strided matrix: element `(i, j)` lives at `data[offset + i·row_stride + j·col_stride]`.
32#[derive(Debug, Clone, Copy)]
33pub struct MatView<'a, T> {
34    /// The underlying storage.
35    pub data: &'a [T],
36    /// Position of element `(0, 0)`, in elements.
37    pub offset: isize,
38    /// Step between rows, in elements. May be zero or negative.
39    pub row_stride: isize,
40    /// Step between columns, in elements. May be zero or negative.
41    pub col_stride: isize,
42}
43
44impl<T> MatView<'_, T> {
45    /// Checks that every element of a `rows × cols` matrix lies inside `data`, with
46    /// checked arithmetic throughout.
47    fn check_in_bounds(&self, rows: usize, cols: usize) -> Result<(), MatmulError> {
48        if rows == 0 || cols == 0 {
49            return Ok(());
50        }
51        let span = |n: usize, stride: isize| {
52            isize::try_from(n - 1)
53                .ok()
54                .and_then(|n| n.checked_mul(stride))
55                .ok_or(MatmulError::Overflow)
56        };
57        let (r, c) = (span(rows, self.row_stride)?, span(cols, self.col_stride)?);
58        let reach = |a: isize, b: isize| {
59            self.offset
60                .checked_add(a)
61                .and_then(|x| x.checked_add(b))
62                .ok_or(MatmulError::Overflow)
63        };
64        let lo = reach(r.min(0), c.min(0))?;
65        let hi = reach(r.max(0), c.max(0))?;
66        if lo >= 0 && (hi as usize) < self.data.len() {
67            Ok(())
68        } else {
69            Err(MatmulError::OutOfBounds)
70        }
71    }
72}
73
74mod sealed {
75    pub trait Sealed {}
76}
77
78/// An element type with a GEMM kernel: `f32`, `f64`, `f16`, and `[f32; 2]` (complex64 as
79/// `(re, im)`).
80pub trait Gemm: Copy + Default + sealed::Sealed + 'static {
81    /// `acc + a·b`, used by the reference loop (and under Miri).
82    fn mul_add(acc: Self, a: Self, b: Self) -> Self;
83
84    /// Calls `gemm::gemm` for `dst (row-major m×n) = lhs · rhs`.
85    ///
86    /// # Safety
87    ///
88    /// Every element reachable through the views must lie inside their data, and `dst` must
89    /// hold `m·n` elements.
90    #[cfg(not(miri))]
91    unsafe fn gemm(
92        m: usize,
93        n: usize,
94        k: usize,
95        dst: &mut [Self],
96        a: MatView<'_, Self>,
97        b: MatView<'_, Self>,
98    );
99}
100
101macro_rules! impl_gemm {
102    ($t:ty, $g:ty, $mul_add:expr, $one:expr) => {
103        impl sealed::Sealed for $t {}
104        impl Gemm for $t {
105            fn mul_add(acc: Self, a: Self, b: Self) -> Self {
106                let f: fn(Self, Self, Self) -> Self = $mul_add;
107                f(acc, a, b)
108            }
109
110            #[cfg(not(miri))]
111            unsafe fn gemm(
112                m: usize,
113                n: usize,
114                k: usize,
115                dst: &mut [Self],
116                a: MatView<'_, Self>,
117                b: MatView<'_, Self>,
118            ) {
119                let lhs = a.data.as_ptr().wrapping_offset(a.offset).cast::<$g>();
120                let rhs = b.data.as_ptr().wrapping_offset(b.offset).cast::<$g>();
121                // SAFETY: the caller guarantees that every element reachable through `a`
122                // and `b` is in bounds and that `dst` holds `m·n` elements, written with
123                // row stride `n` and column stride 1. `$t` and `$g` have the same size and
124                // layout. `read_dst = false`, so `dst` is only written.
125                unsafe {
126                    gemm::gemm::<$g>(
127                        m,
128                        n,
129                        k,
130                        dst.as_mut_ptr().cast::<$g>(),
131                        1,
132                        n as isize,
133                        false,
134                        lhs,
135                        a.col_stride,
136                        a.row_stride,
137                        rhs,
138                        b.col_stride,
139                        b.row_stride,
140                        $one,
141                        $one,
142                        false,
143                        false,
144                        false,
145                        gemm::Parallelism::Rayon(0),
146                    );
147                }
148            }
149        }
150    };
151}
152
153impl_gemm!(f32, f32, |acc, a, b| acc + a * b, 1.0);
154impl_gemm!(f64, f64, |acc, a, b| acc + a * b, 1.0);
155
156impl sealed::Sealed for f16 {}
157impl Gemm for f16 {
158    fn mul_add(acc: Self, a: Self, b: Self) -> Self {
159        f16::from_f32(acc.to_f32() + a.to_f32() * b.to_f32())
160    }
161
162    /// As MLX: widened to `f32`, multiplied with `f32` accumulation, rounded once (the
163    /// `gemm` crate's f16 kernel accumulates in f16, losing bits over a long `k`).
164    #[cfg(not(miri))]
165    unsafe fn gemm(
166        m: usize,
167        n: usize,
168        k: usize,
169        dst: &mut [Self],
170        a: MatView<'_, Self>,
171        b: MatView<'_, Self>,
172    ) {
173        let widen = |v: MatView<'_, Self>, rows: usize, cols: usize| -> Vec<f32> {
174            (0..rows)
175                .flat_map(|i| {
176                    (0..cols).map(move |j| {
177                        let at = v.offset + i as isize * v.row_stride + j as isize * v.col_stride;
178                        v.data[at as usize].to_f32()
179                    })
180                })
181                .collect()
182        };
183        let (a32, b32) = (widen(a, m, k), widen(b, k, n));
184        let view = |data, cols: usize| MatView {
185            data,
186            offset: 0,
187            row_stride: cols as isize,
188            col_stride: 1,
189        };
190        let mut out = vec![0.0f32; m * n];
191        // SAFETY: `a32` (`m × k`) and `b32` (`k × n`) are row-contiguous and hold exactly
192        // the elements the views reach, and `out` holds `m·n` elements.
193        unsafe { f32::gemm(m, n, k, &mut out, view(&a32, k), view(&b32, n)) };
194        for (d, v) in dst.iter_mut().zip(out) {
195            *d = f16::from_f32(v);
196        }
197    }
198}
199impl_gemm!(
200    [f32; 2],
201    gemm::c32,
202    |acc, a, b| [
203        acc[0] + a[0] * b[0] - a[1] * b[1],
204        acc[1] + a[0] * b[1] + a[1] * b[0]
205    ],
206    gemm::c32::new(1.0, 0.0)
207);
208
209/// `out = a · b` for an `m × k` matrix `a` and a `k × n` matrix `b`; `out` is row-major
210/// `m × n`. Operands may have any strides (transposed, reversed, broadcast).
211///
212/// # Errors
213///
214/// [`MatmulError`] if `m·n` or a view's extent overflows, `out.len() != m·n`, or a view
215/// reaches outside its data; nothing is written then.
216pub fn matmul<T: Gemm>(
217    m: usize,
218    n: usize,
219    k: usize,
220    a: MatView<'_, T>,
221    b: MatView<'_, T>,
222    out: &mut [T],
223) -> Result<(), MatmulError> {
224    let size = m.checked_mul(n).ok_or(MatmulError::Overflow)?;
225    if out.len() != size {
226        return Err(MatmulError::OutputLength);
227    }
228    a.check_in_bounds(m, k)?;
229    b.check_in_bounds(k, n)?;
230    if m == 0 || n == 0 {
231        return Ok(());
232    }
233    if k == 0 {
234        out.fill(T::default());
235        return Ok(());
236    }
237    #[cfg(not(miri))]
238    // SAFETY: both views were checked to be in bounds for their extents above (with
239    // checked arithmetic), and `out` holds exactly `m·n` elements.
240    unsafe {
241        T::gemm(m, n, k, out, a, b);
242    }
243    #[cfg(miri)]
244    naive(m, n, k, a, b, out);
245    Ok(())
246}
247
248/// The textbook triple loop: the reference for tests, and the kernel under Miri (which
249/// cannot execute SIMD intrinsics).
250#[cfg_attr(not(any(miri, test)), allow(dead_code))]
251fn naive<T: Gemm>(
252    m: usize,
253    n: usize,
254    k: usize,
255    a: MatView<'_, T>,
256    b: MatView<'_, T>,
257    out: &mut [T],
258) {
259    let at = |v: &MatView<'_, T>, i: usize, j: usize| {
260        v.data[(v.offset + i as isize * v.row_stride + j as isize * v.col_stride) as usize]
261    };
262    for i in 0..m {
263        for j in 0..n {
264            out[i * n + j] = (0..k).fold(T::default(), |acc, p| {
265                T::mul_add(acc, at(&a, i, p), at(&b, p, j))
266            });
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn matches_naive_on_strided_operands() {
277        let (m, n, k) = (7, 5, 9);
278        let data: Vec<f64> = (0..200).map(|i| (i as f64 * 0.37).sin()).collect();
279        // `a` read transposed from the middle, `b` with a negative row stride.
280        let a = MatView {
281            data: &data,
282            offset: 10,
283            row_stride: 1,
284            col_stride: 11,
285        };
286        let b = MatView {
287            data: &data,
288            offset: 150,
289            row_stride: -12,
290            col_stride: 2,
291        };
292        let (mut fast, mut slow) = (vec![0.0; m * n], vec![0.0; m * n]);
293        matmul(m, n, k, a, b, &mut fast).unwrap();
294        naive(m, n, k, a, b, &mut slow);
295        for (x, y) in fast.iter().zip(&slow) {
296            assert!((x - y).abs() < 1e-12, "{x} vs {y}");
297        }
298    }
299
300    #[test]
301    fn complex_and_zero_k() {
302        let a = [[1.0f32, 2.0], [0.0, 1.0]]; // 1x2: [1+2i, i]
303        let b = [[3.0f32, 0.0], [0.0, -1.0]]; // 2x1: [3, -i]
304        let mut out = [[0.0f32; 2]; 1];
305        let av = MatView {
306            data: &a,
307            offset: 0,
308            row_stride: 2,
309            col_stride: 1,
310        };
311        let bv = MatView {
312            data: &b,
313            offset: 0,
314            row_stride: 1,
315            col_stride: 1,
316        };
317        matmul(1, 1, 2, av, bv, &mut out).unwrap();
318        assert_eq!(out, [[4.0, 6.0]]); // (1+2i)·3 + i·(-i) = 4 + 6i
319        let mut zeros = [9.0f32; 4];
320        let empty = MatView {
321            data: &[0.0f32; 0],
322            offset: 0,
323            row_stride: 0,
324            col_stride: 0,
325        };
326        matmul(2, 2, 0, empty, empty, &mut zeros).unwrap();
327        assert_eq!(zeros, [0.0; 4]);
328    }
329
330    #[test]
331    fn rejects_out_of_bounds_views() {
332        let data = [0.0f32; 4];
333        let v = MatView {
334            data: &data,
335            offset: 0,
336            row_stride: 2,
337            col_stride: 1,
338        };
339        let mut out = [0.0f32; 9];
340        assert_eq!(
341            matmul(3, 3, 2, v, v, &mut out),
342            Err(MatmulError::OutOfBounds)
343        );
344    }
345
346    #[test]
347    fn overflowing_sizes_and_wrong_outputs_are_errors() {
348        // Zero strides keep every view in bounds, so only `m·n` can catch this: it wraps to
349        // 0 = `out.len()`, and the kernel would write 2^64 elements.
350        let data = [1.0f32];
351        let v = MatView {
352            data: &data,
353            offset: 0,
354            row_stride: 0,
355            col_stride: 0,
356        };
357        let mut out = [7.0f32; 0];
358        assert_eq!(
359            matmul(1 << 33, 1 << 31, 1, v, v, &mut out),
360            Err(MatmulError::Overflow)
361        );
362        let mut out = [7.0f32; 5];
363        assert_eq!(
364            matmul(2, 3, 1, v, v, &mut out),
365            Err(MatmulError::OutputLength)
366        );
367        assert_eq!(out, [7.0; 5], "written before the check");
368        // A stride span past `isize`.
369        let far = MatView {
370            data: &data,
371            offset: 0,
372            row_stride: isize::MAX,
373            col_stride: 0,
374        };
375        let mut out = [0.0f32; 3];
376        assert_eq!(
377            matmul(3, 1, 1, far, v, &mut out),
378            Err(MatmulError::Overflow)
379        );
380        let negative = MatView {
381            data: &data,
382            offset: 0,
383            row_stride: isize::MIN,
384            col_stride: 0,
385        };
386        assert_eq!(
387            matmul(3, 1, 1, negative, v, &mut out),
388            Err(MatmulError::Overflow)
389        );
390    }
391
392    #[test]
393    #[cfg_attr(miri, ignore = "half's f16 conversions use inline assembly")]
394    fn f16_accumulates_like_f32() {
395        // k = 4096: f16 accumulation would lose about log2(4096) bits; MLX accumulates in
396        // f32 and rounds once.
397        let (m, n, k) = (4, 3, 4096);
398        let a: Vec<f16> = (0..m * k)
399            .map(|i| f16::from_f32(((i as f32) * 0.618).sin()))
400            .collect();
401        let b: Vec<f16> = (0..k * n)
402            .map(|i| f16::from_f32(((i as f32) * 0.377).cos()))
403            .collect();
404        let av = MatView {
405            data: &a,
406            offset: 0,
407            row_stride: k as isize,
408            col_stride: 1,
409        };
410        let bv = MatView {
411            data: &b,
412            offset: 0,
413            row_stride: n as isize,
414            col_stride: 1,
415        };
416        let mut out = vec![f16::ZERO; m * n];
417        matmul(m, n, k, av, bv, &mut out).unwrap();
418        for i in 0..m {
419            for j in 0..n {
420                let want: f32 = (0..k)
421                    .map(|p| a[i * k + p].to_f32() * b[p * n + j].to_f32())
422                    .sum();
423                let got = out[i * n + j].to_f32();
424                // One f16 rounding of the result, plus f32 summation-order noise.
425                let tol = want.abs() * 2.0f32.powi(-10) + 1e-3;
426                assert!((got - want).abs() <= tol, "({i}, {j}): {got} vs {want}");
427            }
428        }
429    }
430}