Skip to main content

torsh_tensor/
matmul_ops.rs

1//! Matrix multiplication with PyTorch broadcasting semantics and a real GEMM.
2//!
3//! # Dispatch
4//!
5//! The 2-D kernel ([`gemm_into`]) picks the fastest available path:
6//!
7//! 1. `f32` / `f64` contiguous inputs are handed to
8//!    `scirs2_core::ndarray::linalg::general_mat_mul`, the blocked, SIMD
9//!    (`matrixmultiply`) GEMM behind `ndarray`'s `Dot` implementation. Input and
10//!    output buffers are wrapped as views, so no matrix is copied.
11//! 2. Any other element type uses a cache-blocked `i-k-j` kernel with register
12//!    tiling over the output row. This keeps the innermost loop sequential in
13//!    both `B` and `C`, unlike the classic `i-j-k` dot-product order which
14//!    strides `B` by `n`.
15//!
16//! # Shape rules (`torch.matmul`)
17//!
18//! | lhs | rhs | result |
19//! |-----|-----|--------|
20//! | `[k]` | `[k]` | `[]` (dot product) |
21//! | `[k]` | `[k, n]` | `[n]` |
22//! | `[m, k]` | `[k]` | `[m]` |
23//! | `[m, k]` | `[k, n]` | `[m, n]` |
24//! | `[..b, m, k]` | `[..b, k, n]` | `[..broadcast(b), m, n]` |
25//!
26//! # Autograd
27//!
28//! Every rank listed above is recorded on the autograd tape: the backward rule
29//! in `core_ops::autograd` promotes 1-D operands exactly like the forward pass,
30//! walks the broadcast batch axes, and accumulates each operand's gradient over
31//! the batch axes it was broadcast along.
32
33use std::sync::Arc;
34
35use torsh_core::{
36    dtype::TensorElement,
37    error::{Result, TorshError},
38};
39
40use crate::core_ops::Tensor;
41
42/// Cache blocking parameters for the generic fallback kernel.
43const BLOCK_K: usize = 64;
44const BLOCK_N: usize = 128;
45
46/// Compute `c = a @ b` for row-major `a` (`m x k`), `b` (`k x n`), `c` (`m x n`).
47///
48/// `c` is fully overwritten. Returns an error when the slice lengths do not
49/// match the requested extents.
50pub(crate) fn gemm_into<T>(
51    m: usize,
52    k: usize,
53    n: usize,
54    a: &[T],
55    b: &[T],
56    c: &mut [T],
57) -> Result<()>
58where
59    T: TensorElement + Copy + num_traits::Float,
60{
61    if a.len() < m * k || b.len() < k * n || c.len() < m * n {
62        return Err(TorshError::InvalidArgument(format!(
63            "matmul: buffer too small for {}x{} @ {}x{} (got {}, {}, {})",
64            m,
65            k,
66            k,
67            n,
68            a.len(),
69            b.len(),
70            c.len()
71        )));
72    }
73
74    if m == 0 || n == 0 {
75        return Ok(());
76    }
77
78    if gemm_blas_f32(m, k, n, a, b, c) || gemm_blas_f64(m, k, n, a, b, c) {
79        return Ok(());
80    }
81
82    gemm_blocked(m, k, n, a, b, c);
83    Ok(())
84}
85
86/// `f32` fast path through `ndarray`'s blocked SIMD GEMM. Returns `false` when
87/// `T` is not `f32`.
88fn gemm_blas_f32<T: TensorElement + Copy>(
89    m: usize,
90    k: usize,
91    n: usize,
92    a: &[T],
93    b: &[T],
94    c: &mut [T],
95) -> bool {
96    if std::any::TypeId::of::<T>() != std::any::TypeId::of::<f32>() {
97        return false;
98    }
99    // Safety: TypeId confirmed T == f32, so the slices have identical layout.
100    let a_f32: &[f32] = unsafe { std::slice::from_raw_parts(a.as_ptr() as *const f32, m * k) };
101    let b_f32: &[f32] = unsafe { std::slice::from_raw_parts(b.as_ptr() as *const f32, k * n) };
102    let c_f32: &mut [f32] =
103        unsafe { std::slice::from_raw_parts_mut(c.as_mut_ptr() as *mut f32, m * n) };
104    gemm_ndarray_f32(m, k, n, a_f32, b_f32, c_f32)
105}
106
107/// `f64` fast path through `ndarray`'s blocked SIMD GEMM.
108fn gemm_blas_f64<T: TensorElement + Copy>(
109    m: usize,
110    k: usize,
111    n: usize,
112    a: &[T],
113    b: &[T],
114    c: &mut [T],
115) -> bool {
116    if std::any::TypeId::of::<T>() != std::any::TypeId::of::<f64>() {
117        return false;
118    }
119    // Safety: TypeId confirmed T == f64, so the slices have identical layout.
120    let a_f64: &[f64] = unsafe { std::slice::from_raw_parts(a.as_ptr() as *const f64, m * k) };
121    let b_f64: &[f64] = unsafe { std::slice::from_raw_parts(b.as_ptr() as *const f64, k * n) };
122    let c_f64: &mut [f64] =
123        unsafe { std::slice::from_raw_parts_mut(c.as_mut_ptr() as *mut f64, m * n) };
124    gemm_ndarray_f64(m, k, n, a_f64, b_f64, c_f64)
125}
126
127macro_rules! ndarray_gemm_impl {
128    ($name:ident, $ty:ty) => {
129        /// Run `general_mat_mul` over borrowed row-major buffers.
130        fn $name(m: usize, k: usize, n: usize, a: &[$ty], b: &[$ty], c: &mut [$ty]) -> bool {
131            use scirs2_core::ndarray::{linalg::general_mat_mul, ArrayView2, ArrayViewMut2};
132
133            let a_view = match ArrayView2::from_shape((m, k), &a[..m * k]) {
134                Ok(view) => view,
135                Err(_) => return false,
136            };
137            let b_view = match ArrayView2::from_shape((k, n), &b[..k * n]) {
138                Ok(view) => view,
139                Err(_) => return false,
140            };
141            let mut c_view = match ArrayViewMut2::from_shape((m, n), &mut c[..m * n]) {
142                Ok(view) => view,
143                Err(_) => return false,
144            };
145            general_mat_mul(1.0, &a_view, &b_view, 0.0, &mut c_view);
146            true
147        }
148    };
149}
150
151ndarray_gemm_impl!(gemm_ndarray_f32, f32);
152ndarray_gemm_impl!(gemm_ndarray_f64, f64);
153
154/// Cache-blocked generic GEMM used for element types without a BLAS kernel.
155fn gemm_blocked<T>(m: usize, k: usize, n: usize, a: &[T], b: &[T], c: &mut [T])
156where
157    T: TensorElement + Copy + num_traits::Float,
158{
159    let zero = <T as num_traits::Zero>::zero();
160    for value in c[..m * n].iter_mut() {
161        *value = zero;
162    }
163
164    for k_block in (0..k).step_by(BLOCK_K) {
165        let k_end = (k_block + BLOCK_K).min(k);
166        for n_block in (0..n).step_by(BLOCK_N) {
167            let n_end = (n_block + BLOCK_N).min(n);
168            for i in 0..m {
169                let a_row = &a[i * k..i * k + k];
170                let c_row = &mut c[i * n..i * n + n];
171                for (kk, &a_ik) in a_row.iter().enumerate().take(k_end).skip(k_block) {
172                    if a_ik == zero {
173                        continue;
174                    }
175                    let b_row = &b[kk * n + n_block..kk * n + n_end];
176                    for (c_val, &b_val) in c_row[n_block..n_end].iter_mut().zip(b_row.iter()) {
177                        *c_val = *c_val + a_ik * b_val;
178                    }
179                }
180            }
181        }
182    }
183}
184
185/// Broadcast two batch-dimension lists following NumPy/PyTorch rules.
186fn broadcast_batch_dims(lhs: &[usize], rhs: &[usize]) -> Result<Vec<usize>> {
187    let rank = lhs.len().max(rhs.len());
188    let mut out = vec![1usize; rank];
189    for i in 0..rank {
190        let l = if i + lhs.len() >= rank {
191            lhs[i + lhs.len() - rank]
192        } else {
193            1
194        };
195        let r = if i + rhs.len() >= rank {
196            rhs[i + rhs.len() - rank]
197        } else {
198            1
199        };
200        out[i] = if l == r {
201            l
202        } else if l == 1 {
203            r
204        } else if r == 1 {
205            l
206        } else {
207            return Err(TorshError::ShapeMismatch {
208                expected: lhs.to_vec(),
209                got: rhs.to_vec(),
210            });
211        };
212    }
213    Ok(out)
214}
215
216/// Flat batch offset of `coords` inside a (possibly broadcast) batch shape.
217fn batch_offset(coords: &[usize], shape: &[usize], matrix_stride: usize) -> usize {
218    let rank = shape.len();
219    let mut offset = 0usize;
220    let mut stride = matrix_stride;
221    for axis in (0..rank).rev() {
222        let coord_idx = coords.len() + axis - rank;
223        let coord = if shape[axis] == 1 {
224            0
225        } else {
226            coords[coord_idx]
227        };
228        offset += coord * stride;
229        stride *= shape[axis];
230    }
231    offset
232}
233
234impl<T: TensorElement + Copy> Tensor<T> {
235    /// Matrix multiplication following `torch.matmul` shape rules.
236    ///
237    /// Supports 1-D/2-D operands and batched (N-D) operands with broadcasting of
238    /// the leading batch dimensions. Autograd is recorded for every one of those
239    /// shapes — the backward rule mirrors the promotion and broadcasting done
240    /// here.
241    pub fn matmul(&self, other: &Self) -> Result<Self>
242    where
243        T: num_traits::Float + std::iter::Sum,
244    {
245        let mut result = self.basic_matmul(other)?;
246
247        // Record the matmul operation for autograd. Backward computes, per batch,
248        // dL/dlhs = grad @ rhsᵀ and dL/drhs = lhsᵀ @ grad, summing each operand's
249        // contribution over the batch axes it was broadcast along.
250        if crate::should_record_grad(self.requires_grad || other.requires_grad) {
251            result.requires_grad = true;
252            result.operation = crate::core_ops::Operation::MatMul {
253                lhs: Arc::new(self.clone()),
254                rhs: Arc::new(other.clone()),
255            };
256        }
257        Ok(result)
258    }
259
260    /// Shape-aware matrix multiplication kernel shared by [`Tensor::matmul`] and
261    /// [`Tensor::matmul_scirs2`].
262    pub(crate) fn basic_matmul(&self, other: &Self) -> Result<Self>
263    where
264        T: num_traits::Float + std::iter::Sum,
265    {
266        let lhs_binding = self.shape();
267        let lhs_shape = lhs_binding.dims().to_vec();
268        let rhs_binding = other.shape();
269        let rhs_shape = rhs_binding.dims().to_vec();
270
271        if lhs_shape.is_empty() || rhs_shape.is_empty() {
272            return Err(TorshError::InvalidArgument(
273                "matmul requires tensors with at least one dimension".to_string(),
274            ));
275        }
276
277        // Promote 1-D operands the way PyTorch does, remembering what to strip.
278        let lhs_was_vector = lhs_shape.len() == 1;
279        let rhs_was_vector = rhs_shape.len() == 1;
280
281        let mut lhs_dims = lhs_shape.clone();
282        if lhs_was_vector {
283            lhs_dims.insert(0, 1);
284        }
285        let mut rhs_dims = rhs_shape.clone();
286        if rhs_was_vector {
287            rhs_dims.push(1);
288        }
289
290        let m = lhs_dims[lhs_dims.len() - 2];
291        let k = lhs_dims[lhs_dims.len() - 1];
292        let k_rhs = rhs_dims[rhs_dims.len() - 2];
293        let n = rhs_dims[rhs_dims.len() - 1];
294
295        if k != k_rhs {
296            return Err(TorshError::ShapeMismatch {
297                expected: vec![m, k],
298                got: vec![k_rhs, n],
299            });
300        }
301
302        let lhs_batch = lhs_dims[..lhs_dims.len() - 2].to_vec();
303        let rhs_batch = rhs_dims[..rhs_dims.len() - 2].to_vec();
304        let batch_shape = broadcast_batch_dims(&lhs_batch, &rhs_batch)?;
305        let batch_count: usize = batch_shape.iter().product();
306
307        let mut result_data = vec![<T as num_traits::Zero>::zero(); batch_count * m * n];
308
309        // Materialise the right-hand side before borrowing the left one: both
310        // operands can be backed by the *same* `RwLock` (`a.matmul(&a)`, or any
311        // pair sharing an `Arc` after a clone), and taking two read guards on one
312        // lock from a single thread is allowed to deadlock or panic.
313        let rhs_data = other.to_vec()?;
314
315        self.with_contiguous_data(|lhs_data| {
316            {
317                let rhs_data: &[T] = &rhs_data;
318                if batch_shape.is_empty() {
319                    return gemm_into(m, k, n, lhs_data, rhs_data, &mut result_data);
320                }
321
322                let mut coords = vec![0usize; batch_shape.len()];
323                for batch in 0..batch_count {
324                    // Decode the flat batch index into per-axis coordinates.
325                    let mut remaining = batch;
326                    for axis in (0..batch_shape.len()).rev() {
327                        coords[axis] = remaining % batch_shape[axis];
328                        remaining /= batch_shape[axis];
329                    }
330
331                    let lhs_offset = batch_offset(&coords, &lhs_batch, m * k);
332                    let rhs_offset = batch_offset(&coords, &rhs_batch, k * n);
333                    let out_offset = batch * m * n;
334
335                    gemm_into(
336                        m,
337                        k,
338                        n,
339                        &lhs_data[lhs_offset..lhs_offset + m * k],
340                        &rhs_data[rhs_offset..rhs_offset + k * n],
341                        &mut result_data[out_offset..out_offset + m * n],
342                    )?;
343                }
344                Ok(())
345            }
346        })?;
347
348        // Assemble the output shape and strip the promoted vector axes.
349        let mut out_shape = batch_shape;
350        out_shape.push(m);
351        out_shape.push(n);
352        if rhs_was_vector {
353            out_shape.pop();
354        }
355        if lhs_was_vector {
356            let row_axis = out_shape.len() - if rhs_was_vector { 1 } else { 2 };
357            out_shape.remove(row_axis);
358        }
359
360        Self::from_data(result_data, out_shape, self.device())
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use torsh_core::device::DeviceType;
368
369    #[test]
370    fn gemm_into_matches_reference_for_f32() {
371        let (m, k, n) = (3usize, 4usize, 2usize);
372        let a: Vec<f32> = (0..m * k).map(|i| i as f32 * 0.5).collect();
373        let b: Vec<f32> = (0..k * n).map(|i| i as f32 - 3.0).collect();
374        let mut c = vec![0.0f32; m * n];
375        gemm_into(m, k, n, &a, &b, &mut c).expect("gemm should succeed");
376
377        let mut expected = vec![0.0f32; m * n];
378        for (i, row) in expected.chunks_mut(n).enumerate() {
379            for kk in 0..k {
380                for (j, out) in row.iter_mut().enumerate() {
381                    *out += a[i * k + kk] * b[kk * n + j];
382                }
383            }
384        }
385        assert_eq!(c, expected);
386    }
387
388    #[test]
389    fn gemm_blocked_matches_reference() {
390        // Directly exercise the generic fallback kernel (used for non f32/f64).
391        let (m, k, n) = (5usize, 7usize, 6usize);
392        let a: Vec<f64> = (0..m * k).map(|i| (i % 5) as f64 - 2.0).collect();
393        let b: Vec<f64> = (0..k * n).map(|i| (i % 7) as f64 - 3.0).collect();
394        let mut c = vec![0.0f64; m * n];
395        gemm_blocked(m, k, n, &a, &b, &mut c);
396
397        let mut expected = vec![0.0f64; m * n];
398        for (i, row) in expected.chunks_mut(n).enumerate() {
399            for kk in 0..k {
400                for (j, out) in row.iter_mut().enumerate() {
401                    *out += a[i * k + kk] * b[kk * n + j];
402                }
403            }
404        }
405        assert_eq!(c, expected);
406    }
407
408    #[test]
409    fn matmul_batched_autograd_is_recorded() {
410        let a = Tensor::from_data(
411            (1..=12).map(|v| v as f32).collect(),
412            vec![2, 2, 3],
413            DeviceType::Cpu,
414        )
415        .expect("tensor creation should succeed")
416        .requires_grad_(true);
417        let b = Tensor::from_data(
418            (1..=12).map(|v| v as f32).collect(),
419            vec![2, 3, 2],
420            DeviceType::Cpu,
421        )
422        .expect("tensor creation should succeed");
423
424        let product = a
425            .matmul(&b)
426            .expect("batched matmul must record the graph, not error out");
427        assert_eq!(product.shape().dims(), &[2, 2, 2]);
428        assert!(
429            product.requires_grad(),
430            "a batched matmul over a requires_grad operand must join the graph"
431        );
432        assert!(
433            matches!(product.operation, crate::core_ops::Operation::MatMul { .. }),
434            "the recorded node must be a MatMul"
435        );
436
437        product.sum().expect("sum").backward().expect("backward");
438        let grad = a.grad().expect("lhs gradient").to_vec().expect("to_vec");
439        assert_eq!(grad.len(), 12);
440    }
441
442    /// Both operands may be backed by the same storage lock; taking two read
443    /// guards on one `RwLock` from one thread can deadlock, so the kernel must
444    /// borrow at most one operand at a time.
445    #[test]
446    fn matmul_with_itself_and_with_a_clone_does_not_deadlock() {
447        // Small enough to land in lock-based storage rather than lock-free SimdOptimized.
448        let a = Tensor::from_data(vec![1.0f32, 2.0, 3.0, 4.0], vec![2, 2], DeviceType::Cpu)
449            .expect("tensor creation should succeed");
450        let self_product = a.matmul(&a).expect("a @ a should succeed");
451        assert_eq!(
452            self_product.to_vec().expect("to_vec"),
453            vec![7.0, 10.0, 15.0, 22.0]
454        );
455
456        let b = a.clone();
457        let clone_product = a.matmul(&b).expect("a @ a.clone() should succeed");
458        assert_eq!(
459            clone_product.to_vec().expect("to_vec"),
460            vec![7.0, 10.0, 15.0, 22.0]
461        );
462    }
463
464    #[test]
465    fn broadcast_batch_dims_rules() {
466        assert_eq!(
467            broadcast_batch_dims(&[2, 1], &[3]).expect("broadcast"),
468            vec![2, 3]
469        );
470        assert_eq!(broadcast_batch_dims(&[], &[4]).expect("broadcast"), vec![4]);
471        assert!(broadcast_batch_dims(&[2], &[3]).is_err());
472    }
473}