Skip to main content

oxicuda_sparse/ops/
spmm.rs

1//! Sparse matrix-dense matrix multiplication (SpMM).
2//!
3//! Computes `C = alpha * A * B + beta * C` where `A` is a sparse CSR matrix
4//! and `B`, `C` are dense matrices.
5//!
6//! ## Strategy
7//!
8//! Each warp processes one row of `A` and tiles across columns of `B`.
9//! Within each row, the warp collaboratively loads non-zero entries and
10//! accumulates partial sums into the output columns.
11
12use std::sync::Arc;
13
14use oxicuda_blas::GpuFloat;
15use oxicuda_blas::types::{MatrixDesc, MatrixDescMut};
16use oxicuda_driver::Module;
17use oxicuda_launch::{Kernel, LaunchParams, grid_size_for};
18use oxicuda_ptx::prelude::*;
19
20use crate::error::{SparseError, SparseResult};
21use crate::format::CsrMatrix;
22use crate::handle::SparseHandle;
23use crate::ptx_helpers::{
24    add_float, fma_float, load_float_imm, load_global_float, mul_float, reinterpret_bits_to_float,
25    store_global_float,
26};
27
28/// Default block size for SpMM kernel.
29const SPMM_BLOCK_SIZE: u32 = 256;
30
31/// Number of columns of B processed per thread in one tile.
32const SPMM_TILE_COLS: u32 = 4;
33
34/// Sparse matrix-dense matrix multiplication: `C = alpha * A * B + beta * C`.
35///
36/// `A` is a sparse CSR matrix of shape `(m, k)`. `B` is a dense matrix of
37/// shape `(k, n)`. `C` is a dense output matrix of shape `(m, n)`.
38///
39/// # Arguments
40///
41/// * `handle` -- Sparse handle.
42/// * `alpha` -- Scalar multiplier for `A * B`.
43/// * `a` -- Sparse CSR matrix `A`.
44/// * `b` -- Dense matrix descriptor for `B`.
45/// * `beta` -- Scalar multiplier for existing `C`.
46/// * `c` -- Dense mutable matrix descriptor for `C`.
47///
48/// # Errors
49///
50/// Returns [`SparseError::DimensionMismatch`] if dimensions are incompatible.
51/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
52/// Returns [`SparseError::Cuda`] on kernel launch failure.
53pub fn spmm<T: GpuFloat>(
54    handle: &SparseHandle,
55    alpha: T,
56    a: &CsrMatrix<T>,
57    b: &MatrixDesc<T>,
58    beta: T,
59    c: &mut MatrixDescMut<T>,
60) -> SparseResult<()> {
61    // Validate dimensions: A(m, k) * B(k, n) = C(m, n)
62    if a.cols() != b.rows {
63        return Err(SparseError::DimensionMismatch(format!(
64            "A.cols ({}) != B.rows ({})",
65            a.cols(),
66            b.rows
67        )));
68    }
69    if a.rows() != c.rows {
70        return Err(SparseError::DimensionMismatch(format!(
71            "A.rows ({}) != C.rows ({})",
72            a.rows(),
73            c.rows
74        )));
75    }
76    if b.cols != c.cols {
77        return Err(SparseError::DimensionMismatch(format!(
78            "B.cols ({}) != C.cols ({})",
79            b.cols, c.cols
80        )));
81    }
82
83    if a.rows() == 0 || a.cols() == 0 || b.cols == 0 {
84        return Ok(());
85    }
86
87    let ptx = emit_spmm_kernel::<T>(handle.sm_version())?;
88    let module = Arc::new(Module::from_ptx(&ptx)?);
89    let kernel = Kernel::from_module(module, "spmm")?;
90
91    // Grid: one thread per (row, col_tile) pair
92    let block_size = SPMM_BLOCK_SIZE;
93    let total_work = a.rows() * b.cols.div_ceil(SPMM_TILE_COLS);
94    let grid_size = grid_size_for(total_work, block_size);
95    let params = LaunchParams::new(grid_size, block_size);
96
97    kernel.launch(
98        &params,
99        handle.stream(),
100        &(
101            a.row_ptr().as_device_ptr(),
102            a.col_idx().as_device_ptr(),
103            a.values().as_device_ptr(),
104            b.ptr,
105            c.ptr,
106            alpha.to_bits_u64(),
107            beta.to_bits_u64(),
108            a.rows(),
109            b.cols,
110            b.ld,
111            c.ld,
112        ),
113    )?;
114
115    Ok(())
116}
117
118/// Generates PTX for the SpMM kernel.
119///
120/// Each thread handles one row of A and a tile of SPMM_TILE_COLS columns of B.
121/// For simplicity, we implement a scalar approach: each thread iterates over
122/// the non-zeros of its row and accumulates into multiple output columns.
123fn emit_spmm_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
124    let elem_bytes = T::size_u32();
125    let is_f64 = T::SIZE == 8;
126    let tile_cols = SPMM_TILE_COLS;
127
128    KernelBuilder::new("spmm")
129        .target(sm)
130        .param("row_ptr", PtxType::U64)
131        .param("col_idx", PtxType::U64)
132        .param("values", PtxType::U64)
133        .param("b_ptr", PtxType::U64)
134        .param("c_ptr", PtxType::U64)
135        .param("alpha_bits", PtxType::U64)
136        .param("beta_bits", PtxType::U64)
137        .param("m", PtxType::U32)
138        .param("n", PtxType::U32)
139        .param("ldb", PtxType::U32)
140        .param("ldc", PtxType::U32)
141        .body(move |b| {
142            let gid = b.global_thread_id_x();
143
144            // Compute which row and col tile we handle
145            let n_param = b.load_param_u32("n");
146            let m_param = b.load_param_u32("m");
147
148            // tiles_per_row = (n + tile_cols - 1) / tile_cols
149            let tiles_per_row = b.alloc_reg(PtxType::U32);
150            let n_plus = b.alloc_reg(PtxType::U32);
151            b.raw_ptx(&format!("add.u32 {n_plus}, {n_param}, {};", tile_cols - 1));
152            b.raw_ptx(&format!(
153                "div.u32 {tiles_per_row}, {n_plus}, {};",
154                tile_cols
155            ));
156
157            // row = gid / tiles_per_row, tile_id = gid % tiles_per_row
158            let row = b.alloc_reg(PtxType::U32);
159            let tile_id = b.alloc_reg(PtxType::U32);
160            b.raw_ptx(&format!("div.u32 {row}, {gid}, {tiles_per_row};"));
161            b.raw_ptx(&format!("rem.u32 {tile_id}, {gid}, {tiles_per_row};"));
162
163            let row_inner = row.clone();
164            let tile_id_inner = tile_id.clone();
165            b.if_lt_u32(row, m_param, move |b| {
166                let row = row_inner;
167                let tile_id = tile_id_inner;
168
169                let row_ptr_base = b.load_param_u64("row_ptr");
170                let col_idx_base = b.load_param_u64("col_idx");
171                let values_base = b.load_param_u64("values");
172                let b_ptr = b.load_param_u64("b_ptr");
173                let c_ptr = b.load_param_u64("c_ptr");
174                let alpha_bits = b.load_param_u64("alpha_bits");
175                let beta_bits = b.load_param_u64("beta_bits");
176                let n_param = b.load_param_u32("n");
177                let ldb = b.load_param_u32("ldb");
178                let ldc = b.load_param_u32("ldc");
179
180                let alpha = reinterpret_bits_to_float::<T>(b, alpha_bits);
181                let beta = reinterpret_bits_to_float::<T>(b, beta_bits);
182
183                // col_start = tile_id * tile_cols
184                let col_start = b.alloc_reg(PtxType::U32);
185                b.raw_ptx(&format!(
186                    "mul.lo.u32 {col_start}, {tile_id}, {};",
187                    tile_cols
188                ));
189
190                // Load row bounds
191                let rp_addr = b.byte_offset_addr(row_ptr_base.clone(), row.clone(), 4);
192                let rs_i32 = b.load_global_i32(rp_addr);
193                let rs = b.alloc_reg(PtxType::U32);
194                b.raw_ptx(&format!("mov.b32 {rs}, {rs_i32};"));
195
196                let row_p1 = b.alloc_reg(PtxType::U32);
197                b.raw_ptx(&format!("add.u32 {row_p1}, {row}, 1;"));
198                let rp_addr_next = b.byte_offset_addr(row_ptr_base, row_p1, 4);
199                let re_i32 = b.load_global_i32(rp_addr_next);
200                let re = b.alloc_reg(PtxType::U32);
201                b.raw_ptx(&format!("mov.b32 {re}, {re_i32};"));
202
203                // This thread owns the whole tile of `tile_cols` consecutive
204                // output columns starting at `col_start`. The grid spawns
205                // exactly one thread per (row, tile) pair, so *every* column in
206                // the tile must be computed here -- handling only `col_start`
207                // would leave any column whose index is not a multiple of
208                // `tile_cols` permanently unwritten.
209                let mov_suffix = if is_f64 { "f64" } else { "f32" };
210                for tc in 0..tile_cols {
211                    let col = b.alloc_reg(PtxType::U32);
212                    b.raw_ptx(&format!("add.u32 {col}, {col_start}, {tc};"));
213
214                    // Skip out-of-range columns (col >= n). Inverted skip-branch
215                    // (`setp.lo` -> `setp.hs`) routed through the structured
216                    // `branch_if` so the target matches the `$`-prefixed label.
217                    let col_oob = b.alloc_reg(PtxType::Pred);
218                    b.raw_ptx(&format!("setp.hs.u32 {col_oob}, {col}, {n_param};"));
219                    let skip_col = b.fresh_label("spmm_skip_col");
220                    b.branch_if(col_oob, &skip_col);
221
222                    // Accumulate: acc = sum(A[row,k] * B[k, col])
223                    let acc = load_float_imm::<T>(b, 0.0);
224                    let k_reg = b.alloc_reg(PtxType::U32);
225                    b.raw_ptx(&format!("mov.u32 {k_reg}, {rs};"));
226
227                    let loop_label = b.fresh_label("spmm_loop");
228                    let done_label = b.fresh_label("spmm_done");
229
230                    b.label(&loop_label);
231                    // Exit when k >= row_end (inverted skip-branch via branch_if).
232                    let pred = b.alloc_reg(PtxType::Pred);
233                    b.raw_ptx(&format!("setp.hs.u32 {pred}, {k_reg}, {re};"));
234                    b.branch_if(pred, &done_label);
235
236                    // Load A value and column
237                    let ci_addr = b.byte_offset_addr(col_idx_base.clone(), k_reg.clone(), 4);
238                    let a_col_i32 = b.load_global_i32(ci_addr);
239                    let a_col = b.alloc_reg(PtxType::U32);
240                    b.raw_ptx(&format!("mov.b32 {a_col}, {a_col_i32};"));
241
242                    let v_addr = b.byte_offset_addr(values_base.clone(), k_reg.clone(), elem_bytes);
243                    let a_val = load_global_float::<T>(b, v_addr);
244
245                    // Load B[a_col, col] = b_ptr + (a_col * ldb + col) * elem_bytes
246                    // Row-major: B[a_col][col] = b_ptr + a_col * ldb + col
247                    let b_row_off = b.alloc_reg(PtxType::U32);
248                    b.raw_ptx(&format!("mul.lo.u32 {b_row_off}, {a_col}, {ldb};"));
249                    let b_idx = b.alloc_reg(PtxType::U32);
250                    b.raw_ptx(&format!("add.u32 {b_idx}, {b_row_off}, {col};"));
251                    let b_addr = b.byte_offset_addr(b_ptr.clone(), b_idx, elem_bytes);
252                    let b_val = load_global_float::<T>(b, b_addr);
253
254                    // acc += a_val * b_val
255                    let new_acc = fma_float::<T>(b, a_val, b_val, acc.clone());
256                    b.raw_ptx(&format!("mov.{mov_suffix} {acc}, {new_acc};"));
257
258                    b.raw_ptx(&format!("add.u32 {k_reg}, {k_reg}, 1;"));
259                    b.branch(&loop_label);
260                    b.label(&done_label);
261
262                    // Write C[row, col] = alpha * acc + beta * C_old
263                    let c_row_off = b.alloc_reg(PtxType::U32);
264                    b.raw_ptx(&format!("mul.lo.u32 {c_row_off}, {row}, {ldc};"));
265                    let c_idx = b.alloc_reg(PtxType::U32);
266                    b.raw_ptx(&format!("add.u32 {c_idx}, {c_row_off}, {col};"));
267                    let c_addr = b.byte_offset_addr(c_ptr.clone(), c_idx, elem_bytes);
268                    let c_old = load_global_float::<T>(b, c_addr.clone());
269
270                    let alpha_acc = mul_float::<T>(b, alpha.clone(), acc);
271                    let beta_c = mul_float::<T>(b, beta.clone(), c_old);
272                    let result = add_float::<T>(b, alpha_acc, beta_c);
273                    store_global_float::<T>(b, c_addr, result);
274
275                    b.label(&skip_col);
276                }
277            });
278
279            b.ret();
280        })
281        .build()
282        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::ptx_helpers::test_support::assert_assembles_and_clean;
289
290    /// The SpMM kernel must assemble for sm_86 in both precisions with
291    /// `$`-prefixed branch targets and no `.b64` shuffle.
292    #[test]
293    fn spmm_f32_f64_assemble_sm86() {
294        let f32_ptx = emit_spmm_kernel::<f32>(SmVersion::Sm86).expect("f32 SpMM PTX");
295        assert_assembles_and_clean("spmm_f32", &f32_ptx);
296
297        let f64_ptx = emit_spmm_kernel::<f64>(SmVersion::Sm86).expect("f64 SpMM PTX");
298        assert_assembles_and_clean("spmm_f64", &f64_ptx);
299        assert!(
300            !f64_ptx.contains("0F00000000"),
301            "f64 SpMM kernel must not materialize an f32 0.0 immediate:\n{f64_ptx}"
302        );
303    }
304
305    // ---------------------------------------------------------------------------
306    // CPU reference SpMM for numerical accuracy verification
307    // ---------------------------------------------------------------------------
308
309    /// CPU reference CSR SpMM: computes C = A * B (no alpha/beta scaling).
310    ///
311    /// * `row_ptr`, `col_idx`, `values` — CSR representation of A (m×k sparse).
312    /// * `b` — row-major dense matrix B of shape (k, n) with leading dimension `ldb`.
313    /// * `n` — number of columns in B (and C).
314    ///
315    /// Returns C as a row-major Vec<f32> of shape m×n (leading dimension n).
316    fn cpu_csr_spmm(
317        row_ptr: &[usize],
318        col_idx: &[usize],
319        values: &[f32],
320        b: &[f32],
321        n: usize,
322        ldb: usize,
323    ) -> Vec<f32> {
324        let m = row_ptr.len() - 1;
325        let mut c = vec![0.0_f32; m * n];
326        for row in 0..m {
327            for nnz_idx in row_ptr[row]..row_ptr[row + 1] {
328                let a_col = col_idx[nnz_idx];
329                let a_val = values[nnz_idx];
330                // A[row, a_col] * B[a_col, col] for all cols
331                for col in 0..n {
332                    c[row * n + col] += a_val * b[a_col * ldb + col];
333                }
334            }
335        }
336        c
337    }
338
339    // ---------------------------------------------------------------------------
340    // PTX generation tests
341    // ---------------------------------------------------------------------------
342
343    #[test]
344    fn spmm_ptx_generates_f32() {
345        let ptx = emit_spmm_kernel::<f32>(SmVersion::Sm80);
346        assert!(ptx.is_ok());
347        let ptx = ptx.expect("test: PTX gen should succeed");
348        assert!(ptx.contains(".entry spmm"));
349    }
350
351    #[test]
352    fn spmm_ptx_generates_f64() {
353        let ptx = emit_spmm_kernel::<f64>(SmVersion::Sm80);
354        assert!(ptx.is_ok());
355    }
356
357    #[test]
358    fn spmm_ptx_contains_arithmetic_instructions() {
359        let ptx = emit_spmm_kernel::<f32>(SmVersion::Sm80);
360        assert!(ptx.is_ok());
361        let ptx = ptx.expect("test: PTX gen should succeed");
362        // Should contain FMA for the accumulation step
363        assert!(
364            ptx.contains("fma") || ptx.contains("mul"),
365            "SpMM PTX should contain arithmetic instructions"
366        );
367    }
368
369    // ---------------------------------------------------------------------------
370    // CPU reference numerical accuracy tests
371    // ---------------------------------------------------------------------------
372
373    /// 4×4 identity sparse × 4×3 dense = 4×3 dense (same as dense matrix).
374    ///
375    /// A = I_4, B:
376    ///   [1 2 3]
377    ///   [4 5 6]
378    ///   [7 8 9]
379    ///   [10 11 12]
380    ///
381    /// C = A * B = B.
382    #[test]
383    fn spmm_identity_times_dense_equals_dense() {
384        let row_ptr = vec![0usize, 1, 2, 3, 4];
385        let col_idx = vec![0usize, 1, 2, 3];
386        let values = vec![1.0_f32; 4];
387
388        // B: 4×3 row-major
389        let b = vec![
390            1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
391        ];
392        let n = 3usize;
393        let ldb = 3usize;
394
395        let c = cpu_csr_spmm(&row_ptr, &col_idx, &values, &b, n, ldb);
396
397        // C should equal B
398        assert_eq!(c.len(), 4 * 3);
399        for (i, (&ci, &bi)) in c.iter().zip(b.iter()).enumerate() {
400            assert!((ci - bi).abs() < 1e-6, "C[{}] = {ci} expected {bi}", i);
401        }
402    }
403
404    /// 2×3 sparse A × 3×2 dense B = 2×2 dense C with known values.
405    ///
406    /// A (CSR):
407    ///   Row 0: A[0,0]=1, A[0,2]=3
408    ///   Row 1: A[1,1]=2, A[1,2]=4
409    ///
410    /// B (row-major, 3×2):
411    ///   [1 2]
412    ///   [3 4]
413    ///   [5 6]
414    ///
415    /// C = A*B:
416    ///   C[0,0] = 1*1 + 3*5 = 16,  C[0,1] = 1*2 + 3*6 = 20
417    ///   C[1,0] = 2*3 + 4*5 = 26,  C[1,1] = 2*4 + 4*6 = 32
418    #[test]
419    fn spmm_small_sparse_times_dense_known_values() {
420        let row_ptr = vec![0usize, 2, 4];
421        let col_idx = vec![0usize, 2, 1, 2];
422        let values = vec![1.0_f32, 3.0, 2.0, 4.0];
423
424        let b = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]; // 3×2 row-major
425        let n = 2usize;
426        let ldb = 2usize;
427
428        let c = cpu_csr_spmm(&row_ptr, &col_idx, &values, &b, n, ldb);
429
430        assert_eq!(c.len(), 4);
431        assert!((c[0] - 16.0).abs() < 1e-5, "C[0,0] = {} expected 16", c[0]);
432        assert!((c[1] - 20.0).abs() < 1e-5, "C[0,1] = {} expected 20", c[1]);
433        assert!((c[2] - 26.0).abs() < 1e-5, "C[1,0] = {} expected 26", c[2]);
434        assert!((c[3] - 32.0).abs() < 1e-5, "C[1,1] = {} expected 32", c[3]);
435    }
436
437    /// 4×4 diagonal sparse A × 4×3 dense B = 4×3 dense C.
438    ///
439    /// A = diag(2, 3, 4, 5), B rows are [1,0,0], [0,1,0], [0,0,1], [1,1,1].
440    ///
441    /// C[i] = A[i,i] * B[i] for each row i.
442    #[test]
443    fn spmm_diagonal_times_dense_row_scaling() {
444        let row_ptr = vec![0usize, 1, 2, 3, 4];
445        let col_idx = vec![0usize, 1, 2, 3];
446        let values = vec![2.0_f32, 3.0, 4.0, 5.0];
447
448        // B: 4×3, each row is a unit vector or all-ones
449        let b = vec![
450            1.0_f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0,
451        ];
452        let n = 3usize;
453        let ldb = 3usize;
454
455        let c = cpu_csr_spmm(&row_ptr, &col_idx, &values, &b, n, ldb);
456
457        // Row 0: 2 * [1,0,0] = [2,0,0]
458        assert!((c[0] - 2.0).abs() < 1e-6, "C[0,0] = {}", c[0]);
459        assert!(c[1].abs() < 1e-6, "C[0,1] = {}", c[1]);
460        assert!(c[2].abs() < 1e-6, "C[0,2] = {}", c[2]);
461
462        // Row 1: 3 * [0,1,0] = [0,3,0]
463        assert!(c[3].abs() < 1e-6, "C[1,0] = {}", c[3]);
464        assert!((c[4] - 3.0).abs() < 1e-6, "C[1,1] = {}", c[4]);
465        assert!(c[5].abs() < 1e-6, "C[1,2] = {}", c[5]);
466
467        // Row 2: 4 * [0,0,1] = [0,0,4]
468        assert!(c[6].abs() < 1e-6, "C[2,0] = {}", c[6]);
469        assert!(c[7].abs() < 1e-6, "C[2,1] = {}", c[7]);
470        assert!((c[8] - 4.0).abs() < 1e-6, "C[2,2] = {}", c[8]);
471
472        // Row 3: 5 * [1,1,1] = [5,5,5]
473        assert!((c[9] - 5.0).abs() < 1e-6, "C[3,0] = {}", c[9]);
474        assert!((c[10] - 5.0).abs() < 1e-6, "C[3,1] = {}", c[10]);
475        assert!((c[11] - 5.0).abs() < 1e-6, "C[3,2] = {}", c[11]);
476    }
477
478    /// Verify SpMM with a zero sparse matrix produces an all-zero output.
479    #[test]
480    fn spmm_zero_sparse_matrix_produces_zero_output() {
481        let row_ptr = vec![0usize, 0, 0, 0];
482        let col_idx: Vec<usize> = vec![];
483        let values: Vec<f32> = vec![];
484
485        let b = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]; // 3×2
486        let n = 2usize;
487        let ldb = 2usize;
488
489        let c = cpu_csr_spmm(&row_ptr, &col_idx, &values, &b, n, ldb);
490
491        assert_eq!(c.len(), 6);
492        for (i, &ci) in c.iter().enumerate() {
493            assert!(
494                ci.abs() < 1e-6,
495                "C[{i}] = {ci}, expected 0.0 for zero sparse matrix"
496            );
497        }
498    }
499}
500
501// ---------------------------------------------------------------------------
502// On-device numeric validation (feature = "gpu-tests")
503// ---------------------------------------------------------------------------
504
505#[cfg(all(test, feature = "gpu-tests"))]
506mod gpu_device_tests {
507    use super::*;
508    use crate::gpu_test_support::{assert_close, gpu_handle};
509    use crate::host_csr::{f64_to_gpu, gpu_to_f64};
510    use oxicuda_blas::types::Layout;
511    use oxicuda_memory::DeviceBuffer;
512
513    /// CPU oracle for `C = alpha * A * B + beta * C0`, all row-major dense.
514    #[allow(clippy::too_many_arguments)]
515    fn cpu_spmm(
516        m: usize,
517        n: usize,
518        row_ptr: &[i32],
519        col_idx: &[i32],
520        values: &[f64],
521        b: &[f64],
522        c0: &[f64],
523        alpha: f64,
524        beta: f64,
525    ) -> Vec<f64> {
526        let mut c = vec![0.0_f64; m * n];
527        for row in 0..m {
528            let mut acc = vec![0.0_f64; n];
529            for k in row_ptr[row] as usize..row_ptr[row + 1] as usize {
530                let a_col = col_idx[k] as usize;
531                let a_val = values[k];
532                for (col, slot) in acc.iter_mut().enumerate() {
533                    *slot += a_val * b[a_col * n + col];
534                }
535            }
536            for col in 0..n {
537                c[row * n + col] = alpha * acc[col] + beta * c0[row * n + col];
538            }
539        }
540        c
541    }
542
543    /// Drive the production `spmm` op and compare to the CPU oracle.
544    #[allow(clippy::too_many_arguments)]
545    fn run_spmm<T: GpuFloat>(
546        m: u32,
547        k: u32,
548        n: u32,
549        row_ptr: &[i32],
550        col_idx: &[i32],
551        values: &[f64],
552        b_dense: &[f64],
553        c0: &[f64],
554        alpha: f64,
555        beta: f64,
556        tol: f64,
557        tag: &str,
558    ) {
559        let Some(handle) = gpu_handle() else {
560            return;
561        };
562        let dev_values: Vec<T> = values.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
563        let a = CsrMatrix::<T>::from_host(m, k, row_ptr, col_idx, &dev_values)
564            .expect("test: build CSR");
565
566        let dev_b: Vec<T> = b_dense.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
567        let dev_c: Vec<T> = c0.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
568        let b_buf = DeviceBuffer::from_host(&dev_b).expect("test: upload B");
569        let c_buf = DeviceBuffer::from_host(&dev_c).expect("test: upload C");
570
571        let b_desc = MatrixDesc::<T>::from_raw(b_buf.as_device_ptr(), k, n, n, Layout::RowMajor);
572        let mut c_desc =
573            MatrixDescMut::<T>::from_raw(c_buf.as_device_ptr(), m, n, n, Layout::RowMajor);
574
575        spmm::<T>(
576            &handle,
577            f64_to_gpu::<T>(alpha),
578            &a,
579            &b_desc,
580            f64_to_gpu::<T>(beta),
581            &mut c_desc,
582        )
583        .expect("test: spmm launch");
584        handle.stream().synchronize().expect("test: sync");
585
586        let mut out = vec![T::gpu_zero(); (m * n) as usize];
587        c_buf.copy_to_host(&mut out).expect("test: download C");
588        let got: Vec<f64> = out.iter().map(|&v| gpu_to_f64(v)).collect();
589        let want = cpu_spmm(
590            m as usize, n as usize, row_ptr, col_idx, values, b_dense, c0, alpha, beta,
591        );
592        assert_close(&got, &want, tol, tag);
593    }
594
595    /// A = 3x4 CSR:
596    /// [1 0 2 0]
597    /// [0 3 0 4]
598    /// [5 0 0 6]
599    fn matrix_3x4() -> (u32, u32, Vec<i32>, Vec<i32>, Vec<f64>) {
600        let row_ptr = vec![0, 2, 4, 6];
601        let col_idx = vec![0, 2, 1, 3, 0, 3];
602        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
603        (3, 4, row_ptr, col_idx, values)
604    }
605
606    /// B is k x n row-major. Build a deterministic dense matrix.
607    fn dense(k: usize, n: usize, base: f64) -> Vec<f64> {
608        (0..k * n)
609            .map(|idx| base + 0.5 * (idx as f64) - 0.1 * ((idx % 3) as f64))
610            .collect()
611    }
612
613    #[test]
614    fn spmm_f64_n3_alpha_beta() {
615        // n = 3 is NOT a multiple of the internal tile width (4): this is the
616        // exact configuration that exposes a one-column-per-tile kernel bug.
617        let (m, k, rp, ci, v) = matrix_3x4();
618        let n = 3usize;
619        let b = dense(k as usize, n, 1.0);
620        let c0 = dense(m as usize, n, 7.0);
621        run_spmm::<f64>(
622            m,
623            k,
624            n as u32,
625            &rp,
626            &ci,
627            &v,
628            &b,
629            &c0,
630            1.75,
631            -0.5,
632            1e-10,
633            "spmm_f64_n3",
634        );
635    }
636
637    #[test]
638    fn spmm_f64_n5_alpha_beta() {
639        // n = 5 spans more than one tile and is not a multiple of 4.
640        let (m, k, rp, ci, v) = matrix_3x4();
641        let n = 5usize;
642        let b = dense(k as usize, n, -2.0);
643        let c0 = dense(m as usize, n, 0.25);
644        run_spmm::<f64>(
645            m,
646            k,
647            n as u32,
648            &rp,
649            &ci,
650            &v,
651            &b,
652            &c0,
653            2.0,
654            0.5,
655            1e-10,
656            "spmm_f64_n5",
657        );
658    }
659
660    #[test]
661    fn spmm_f32_n3_alpha_beta() {
662        let (m, k, rp, ci, v) = matrix_3x4();
663        let n = 3usize;
664        let b = dense(k as usize, n, 0.5);
665        let c0 = dense(m as usize, n, 3.0);
666        run_spmm::<f32>(
667            m,
668            k,
669            n as u32,
670            &rp,
671            &ci,
672            &v,
673            &b,
674            &c0,
675            1.25,
676            -0.75,
677            1e-4,
678            "spmm_f32_n3",
679        );
680    }
681
682    #[test]
683    fn spmm_f64_single_column_beta_zero() {
684        // n = 1 (the only width the old kernel handled) with beta = 0.
685        let (m, k, rp, ci, v) = matrix_3x4();
686        let n = 1usize;
687        let b = dense(k as usize, n, 1.0);
688        let c0 = vec![1e8; (m as usize) * n];
689        run_spmm::<f64>(
690            m,
691            k,
692            n as u32,
693            &rp,
694            &ci,
695            &v,
696            &b,
697            &c0,
698            1.0,
699            0.0,
700            1e-10,
701            "spmm_f64_n1",
702        );
703    }
704}