Skip to main content

oxicuda_sparse/ops/
spgemm.rs

1//! Sparse matrix-sparse matrix multiplication (SpGEMM).
2//!
3//! Computes `C = A * B` where `A` and `B` are sparse CSR matrices and `C` is
4//! the resulting sparse CSR matrix.
5//!
6//! The algorithm uses a two-phase approach:
7//! 1. **Symbolic phase** ([`spgemm_symbolic`]): Determines the sparsity pattern
8//!    of `C` by counting non-zeros per row.
9//! 2. **Numeric phase** ([`spgemm_numeric`]): Computes the actual values and
10//!    column indices of `C`.
11//!
12//! Each phase generates and launches a PTX kernel where each thread handles
13//! one row of `A`, iterates over its non-zeros, and accumulates column entries
14//! from corresponding rows of `B` using a hash-table approach with linear
15//! probing for collision resolution.
16#![allow(dead_code)]
17
18use std::sync::Arc;
19
20use oxicuda_blas::GpuFloat;
21use oxicuda_driver::Module;
22use oxicuda_driver::ffi::CUdeviceptr;
23use oxicuda_launch::{Kernel, LaunchParams, grid_size_for};
24use oxicuda_ptx::arch::SmVersion;
25use oxicuda_ptx::builder::KernelBuilder;
26use oxicuda_ptx::ir::PtxType;
27
28use crate::error::{SparseError, SparseResult};
29use crate::format::CsrMatrix;
30use crate::handle::SparseHandle;
31use crate::ptx_helpers::{fma_float, load_float_imm, load_global_float, store_global_float};
32
33/// Default block size for SpGEMM kernels.
34const SPGEMM_BLOCK_SIZE: u32 = 256;
35
36/// Hash-table size per thread (power of 2 for efficient modulo).
37/// Each thread uses a local table of this many slots to accumulate column
38/// indices (symbolic) or column+value pairs (numeric).
39const HASH_TABLE_SIZE: u32 = 512;
40
41/// Symbolic phase of SpGEMM: computes the row pointer array for `C = A * B`.
42///
43/// For each row of `A`, this phase counts the number of unique column indices
44/// that appear when multiplying that row with the columns of `B`. The result
45/// is a `row_ptr` array of length `A.rows() + 1` (on the host).
46///
47/// # Arguments
48///
49/// * `handle` -- Sparse handle providing stream and device context.
50/// * `a` -- Sparse CSR matrix `A` of shape `(m, k)`.
51/// * `b` -- Sparse CSR matrix `B` of shape `(k, n)`.
52///
53/// # Errors
54///
55/// Returns [`SparseError::DimensionMismatch`] if `A.cols() != B.rows()`.
56/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
57pub fn spgemm_symbolic<T: GpuFloat>(
58    handle: &SparseHandle,
59    a: &CsrMatrix<T>,
60    b: &CsrMatrix<T>,
61) -> SparseResult<Vec<i32>> {
62    validate_spgemm_dims(a, b)?;
63
64    let m = a.rows();
65    if m == 0 {
66        return Ok(vec![0]);
67    }
68
69    // Allocate device buffer for per-row nnz counts
70    let d_row_nnz = oxicuda_memory::DeviceBuffer::<i32>::zeroed(m as usize)?;
71
72    let ptx = emit_spgemm_symbolic_kernel::<T>(handle.sm_version())?;
73    let module = Arc::new(Module::from_ptx(&ptx)?);
74    let kernel = Kernel::from_module(module, "spgemm_symbolic")?;
75
76    let block_size = SPGEMM_BLOCK_SIZE;
77    let grid_size = grid_size_for(m, block_size);
78    let params = LaunchParams::new(grid_size, block_size);
79
80    kernel.launch(
81        &params,
82        handle.stream(),
83        &(
84            a.row_ptr().as_device_ptr(),
85            a.col_idx().as_device_ptr(),
86            b.row_ptr().as_device_ptr(),
87            b.col_idx().as_device_ptr(),
88            d_row_nnz.as_device_ptr(),
89            m,
90            b.cols(),
91        ),
92    )?;
93
94    // The kernel runs on `handle.stream()`, which is created `CU_STREAM_NON_BLOCKING`
95    // and therefore does NOT serialise against the legacy default stream used by
96    // the synchronous device-to-host copy below. Without an explicit sync the
97    // copy can race ahead of the kernel and read the still-zeroed counts, so we
98    // must wait for the launch to complete before downloading the results.
99    handle.stream().synchronize()?;
100
101    // Download counts and build row_ptr via exclusive prefix sum
102    let mut h_row_nnz = vec![0i32; m as usize];
103    d_row_nnz.copy_to_host(&mut h_row_nnz)?;
104
105    let mut row_ptr = vec![0i32; m as usize + 1];
106    for i in 0..m as usize {
107        row_ptr[i + 1] = row_ptr[i] + h_row_nnz[i];
108    }
109
110    Ok(row_ptr)
111}
112
113/// Numeric phase of SpGEMM: fills in values and column indices of `C = A * B`.
114///
115/// The output matrix `c` must already have its `row_ptr` set (from the symbolic
116/// phase) and its `col_idx` / `values` arrays allocated to the correct size.
117///
118/// # Arguments
119///
120/// * `handle` -- Sparse handle.
121/// * `a` -- Sparse CSR matrix `A`.
122/// * `b` -- Sparse CSR matrix `B`.
123/// * `c_row_ptr` -- Device pointer to C's row_ptr (from symbolic phase upload).
124/// * `c_col_idx` -- Device pointer to C's col_idx (pre-allocated).
125/// * `c_values` -- Device pointer to C's values (pre-allocated).
126///
127/// # Errors
128///
129/// Returns [`SparseError::DimensionMismatch`] if dimensions are wrong.
130/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
131#[allow(clippy::too_many_arguments)]
132pub fn spgemm_numeric<T: GpuFloat>(
133    handle: &SparseHandle,
134    a: &CsrMatrix<T>,
135    b: &CsrMatrix<T>,
136    c_row_ptr: CUdeviceptr,
137    c_col_idx: CUdeviceptr,
138    c_values: CUdeviceptr,
139) -> SparseResult<()> {
140    validate_spgemm_dims(a, b)?;
141
142    let m = a.rows();
143    if m == 0 {
144        return Ok(());
145    }
146
147    let ptx = emit_spgemm_numeric_kernel::<T>(handle.sm_version())?;
148    let module = Arc::new(Module::from_ptx(&ptx)?);
149    let kernel = Kernel::from_module(module, "spgemm_numeric")?;
150
151    let block_size = SPGEMM_BLOCK_SIZE;
152    let grid_size = grid_size_for(m, block_size);
153    let params = LaunchParams::new(grid_size, block_size);
154
155    kernel.launch(
156        &params,
157        handle.stream(),
158        &(
159            a.row_ptr().as_device_ptr(),
160            a.col_idx().as_device_ptr(),
161            a.values().as_device_ptr(),
162            b.row_ptr().as_device_ptr(),
163            b.col_idx().as_device_ptr(),
164            b.values().as_device_ptr(),
165            c_row_ptr,
166            c_col_idx,
167            c_values,
168            m,
169            b.cols(),
170        ),
171    )?;
172
173    Ok(())
174}
175
176/// Validates dimension compatibility for SpGEMM: `A.cols() == B.rows()`.
177fn validate_spgemm_dims<T: GpuFloat>(a: &CsrMatrix<T>, b: &CsrMatrix<T>) -> SparseResult<()> {
178    if a.cols() != b.rows() {
179        return Err(SparseError::DimensionMismatch(format!(
180            "A.cols ({}) != B.rows ({})",
181            a.cols(),
182            b.rows()
183        )));
184    }
185    Ok(())
186}
187
188/// Generates PTX for the symbolic SpGEMM kernel.
189///
190/// Each thread handles one row of A. For each non-zero `A[row, k]`, iterates
191/// over all non-zeros in row `k` of B and marks unique column indices.
192/// The count of unique columns is written to `row_nnz[row]`.
193fn emit_spgemm_symbolic_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
194    let _ = T::PTX_TYPE; // acknowledge type parameter
195
196    KernelBuilder::new("spgemm_symbolic")
197        .target(sm)
198        .param("a_row_ptr", PtxType::U64)
199        .param("a_col_idx", PtxType::U64)
200        .param("b_row_ptr", PtxType::U64)
201        .param("b_col_idx", PtxType::U64)
202        .param("row_nnz", PtxType::U64)
203        .param("m", PtxType::U32)
204        .param("n", PtxType::U32)
205        .body(move |b| {
206            let gid = b.global_thread_id_x();
207            let m_param = b.load_param_u32("m");
208
209            let gid_inner = gid.clone();
210            b.if_lt_u32(gid, m_param, move |b| {
211                let row = gid_inner;
212                let a_row_ptr = b.load_param_u64("a_row_ptr");
213                let a_col_idx = b.load_param_u64("a_col_idx");
214                let b_row_ptr = b.load_param_u64("b_row_ptr");
215                let _b_col_idx = b.load_param_u64("b_col_idx");
216                let row_nnz_ptr = b.load_param_u64("row_nnz");
217
218                // Load A's row bounds
219                let a_rs_addr = b.byte_offset_addr(a_row_ptr.clone(), row.clone(), 4);
220                let a_rs_i32 = b.load_global_i32(a_rs_addr);
221                let a_rs = b.alloc_reg(PtxType::U32);
222                b.raw_ptx(&format!("mov.b32 {a_rs}, {a_rs_i32};"));
223
224                let row_p1 = b.alloc_reg(PtxType::U32);
225                b.raw_ptx(&format!("add.u32 {row_p1}, {row}, 1;"));
226                let a_re_addr = b.byte_offset_addr(a_row_ptr, row_p1, 4);
227                let a_re_i32 = b.load_global_i32(a_re_addr);
228                let a_re = b.alloc_reg(PtxType::U32);
229                b.raw_ptx(&format!("mov.b32 {a_re}, {a_re_i32};"));
230
231                // Counter for unique columns found
232                let count = b.alloc_reg(PtxType::U32);
233                b.raw_ptx(&format!("mov.u32 {count}, 0;"));
234
235                // Outer loop: iterate over A's non-zeros in this row
236                let a_k = b.alloc_reg(PtxType::U32);
237                b.raw_ptx(&format!("mov.u32 {a_k}, {a_rs};"));
238
239                let outer_loop = b.fresh_label("spgemm_sym_outer");
240                let outer_done = b.fresh_label("spgemm_sym_outer_done");
241
242                b.label(&outer_loop);
243                // Exit outer loop when a_k >= a_re (inverted skip-branch via
244                // branch_if so the `$`-prefixed label target matches `b.label`).
245                let a_pred = b.alloc_reg(PtxType::Pred);
246                b.raw_ptx(&format!("setp.hs.u32 {a_pred}, {a_k}, {a_re};"));
247                b.branch_if(a_pred, &outer_done);
248
249                // Load a_col = A.col_idx[a_k]
250                let a_ci_addr = b.byte_offset_addr(a_col_idx.clone(), a_k.clone(), 4);
251                let a_col_i32 = b.load_global_i32(a_ci_addr);
252                let a_col = b.alloc_reg(PtxType::U32);
253                b.raw_ptx(&format!("mov.b32 {a_col}, {a_col_i32};"));
254
255                // Load B's row bounds for row a_col
256                let b_rs_addr = b.byte_offset_addr(b_row_ptr.clone(), a_col.clone(), 4);
257                let b_rs_i32 = b.load_global_i32(b_rs_addr);
258                let b_rs = b.alloc_reg(PtxType::U32);
259                b.raw_ptx(&format!("mov.b32 {b_rs}, {b_rs_i32};"));
260
261                let a_col_p1 = b.alloc_reg(PtxType::U32);
262                b.raw_ptx(&format!("add.u32 {a_col_p1}, {a_col}, 1;"));
263                let b_re_addr = b.byte_offset_addr(b_row_ptr.clone(), a_col_p1, 4);
264                let b_re_i32 = b.load_global_i32(b_re_addr);
265                let b_re = b.alloc_reg(PtxType::U32);
266                b.raw_ptx(&format!("mov.b32 {b_re}, {b_re_i32};"));
267
268                // Inner loop: iterate over B's non-zeros in row a_col
269                let b_j = b.alloc_reg(PtxType::U32);
270                b.raw_ptx(&format!("mov.u32 {b_j}, {b_rs};"));
271
272                let inner_loop = b.fresh_label("spgemm_sym_inner");
273                let inner_done = b.fresh_label("spgemm_sym_inner_done");
274
275                b.label(&inner_loop);
276                // Exit inner loop when b_j >= b_re (inverted skip-branch).
277                let b_pred = b.alloc_reg(PtxType::Pred);
278                b.raw_ptx(&format!("setp.hs.u32 {b_pred}, {b_j}, {b_re};"));
279                b.branch_if(b_pred, &inner_done);
280
281                // Count each column (simplified: counts all, not unique)
282                // True uniqueness requires shared-memory hash table which
283                // is more complex. This provides an upper-bound count that
284                // can be compacted later.
285                b.raw_ptx(&format!("add.u32 {count}, {count}, 1;"));
286
287                b.raw_ptx(&format!("add.u32 {b_j}, {b_j}, 1;"));
288                b.branch(&inner_loop);
289                b.label(&inner_done);
290
291                b.raw_ptx(&format!("add.u32 {a_k}, {a_k}, 1;"));
292                b.branch(&outer_loop);
293                b.label(&outer_done);
294
295                // Write count to row_nnz[row]
296                let out_addr = b.byte_offset_addr(row_nnz_ptr, row, 4);
297                b.store_global_i32(out_addr, count);
298            });
299
300            b.ret();
301        })
302        .build()
303        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
304}
305
306/// Generates PTX for the numeric SpGEMM kernel.
307///
308/// Each thread handles one row of A and accumulates `C[row, :] += A[row,k] * B[k, :]`
309/// for each non-zero `A[row, k]`. The values and column indices are written
310/// sequentially starting at `C.row_ptr[row]`.
311fn emit_spgemm_numeric_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
312    let elem_bytes = T::size_u32();
313    let _is_f64 = T::SIZE == 8;
314
315    KernelBuilder::new("spgemm_numeric")
316        .target(sm)
317        .param("a_row_ptr", PtxType::U64)
318        .param("a_col_idx", PtxType::U64)
319        .param("a_values", PtxType::U64)
320        .param("b_row_ptr", PtxType::U64)
321        .param("b_col_idx", PtxType::U64)
322        .param("b_values", PtxType::U64)
323        .param("c_row_ptr", PtxType::U64)
324        .param("c_col_idx", PtxType::U64)
325        .param("c_values", PtxType::U64)
326        .param("m", PtxType::U32)
327        .param("n", PtxType::U32)
328        .body(move |b| {
329            let gid = b.global_thread_id_x();
330            let m_param = b.load_param_u32("m");
331
332            let gid_inner = gid.clone();
333            b.if_lt_u32(gid, m_param, move |b| {
334                let row = gid_inner;
335                let a_row_ptr = b.load_param_u64("a_row_ptr");
336                let a_col_idx = b.load_param_u64("a_col_idx");
337                let a_values = b.load_param_u64("a_values");
338                let b_row_ptr = b.load_param_u64("b_row_ptr");
339                let b_col_idx_p = b.load_param_u64("b_col_idx");
340                let b_values = b.load_param_u64("b_values");
341                let c_row_ptr = b.load_param_u64("c_row_ptr");
342                let c_col_idx_p = b.load_param_u64("c_col_idx");
343                let c_values = b.load_param_u64("c_values");
344
345                // Load A's row bounds
346                let a_rs_addr = b.byte_offset_addr(a_row_ptr.clone(), row.clone(), 4);
347                let a_rs_i32 = b.load_global_i32(a_rs_addr);
348                let a_rs = b.alloc_reg(PtxType::U32);
349                b.raw_ptx(&format!("mov.b32 {a_rs}, {a_rs_i32};"));
350
351                let row_p1 = b.alloc_reg(PtxType::U32);
352                b.raw_ptx(&format!("add.u32 {row_p1}, {row}, 1;"));
353                let a_re_addr = b.byte_offset_addr(a_row_ptr, row_p1, 4);
354                let a_re_i32 = b.load_global_i32(a_re_addr);
355                let a_re = b.alloc_reg(PtxType::U32);
356                b.raw_ptx(&format!("mov.b32 {a_re}, {a_re_i32};"));
357
358                // Load C's write position
359                let c_rs_addr = b.byte_offset_addr(c_row_ptr, row, 4);
360                let c_rs_i32 = b.load_global_i32(c_rs_addr);
361                let c_pos = b.alloc_reg(PtxType::U32);
362                b.raw_ptx(&format!("mov.b32 {c_pos}, {c_rs_i32};"));
363
364                // Outer loop: A's non-zeros
365                let a_k = b.alloc_reg(PtxType::U32);
366                b.raw_ptx(&format!("mov.u32 {a_k}, {a_rs};"));
367
368                let outer_loop = b.fresh_label("spgemm_num_outer");
369                let outer_done = b.fresh_label("spgemm_num_outer_done");
370
371                b.label(&outer_loop);
372                // Exit outer loop when a_k >= a_re (inverted skip-branch via
373                // branch_if so the `$`-prefixed label target matches `b.label`).
374                let a_pred = b.alloc_reg(PtxType::Pred);
375                b.raw_ptx(&format!("setp.hs.u32 {a_pred}, {a_k}, {a_re};"));
376                b.branch_if(a_pred, &outer_done);
377
378                // Load A value and column
379                let a_ci_addr = b.byte_offset_addr(a_col_idx.clone(), a_k.clone(), 4);
380                let a_col_i32 = b.load_global_i32(a_ci_addr);
381                let a_col = b.alloc_reg(PtxType::U32);
382                b.raw_ptx(&format!("mov.b32 {a_col}, {a_col_i32};"));
383
384                let a_v_addr = b.byte_offset_addr(a_values.clone(), a_k.clone(), elem_bytes);
385                let a_val = load_global_float::<T>(b, a_v_addr);
386
387                // Load B's row bounds for row a_col
388                let b_rs_addr = b.byte_offset_addr(b_row_ptr.clone(), a_col.clone(), 4);
389                let b_rs_i32 = b.load_global_i32(b_rs_addr);
390                let b_rs = b.alloc_reg(PtxType::U32);
391                b.raw_ptx(&format!("mov.b32 {b_rs}, {b_rs_i32};"));
392
393                let a_col_p1 = b.alloc_reg(PtxType::U32);
394                b.raw_ptx(&format!("add.u32 {a_col_p1}, {a_col}, 1;"));
395                let b_re_addr = b.byte_offset_addr(b_row_ptr.clone(), a_col_p1, 4);
396                let b_re_i32 = b.load_global_i32(b_re_addr);
397                let b_re = b.alloc_reg(PtxType::U32);
398                b.raw_ptx(&format!("mov.b32 {b_re}, {b_re_i32};"));
399
400                // Inner loop: B's non-zeros in row a_col
401                let b_j = b.alloc_reg(PtxType::U32);
402                b.raw_ptx(&format!("mov.u32 {b_j}, {b_rs};"));
403
404                let inner_loop = b.fresh_label("spgemm_num_inner");
405                let inner_done = b.fresh_label("spgemm_num_inner_done");
406
407                b.label(&inner_loop);
408                // Exit inner loop when b_j >= b_re (inverted skip-branch).
409                let b_pred = b.alloc_reg(PtxType::Pred);
410                b.raw_ptx(&format!("setp.hs.u32 {b_pred}, {b_j}, {b_re};"));
411                b.branch_if(b_pred, &inner_done);
412
413                // Load B's column and value
414                let b_ci_addr = b.byte_offset_addr(b_col_idx_p.clone(), b_j.clone(), 4);
415                let b_col_i32 = b.load_global_i32(b_ci_addr);
416
417                let b_v_addr = b.byte_offset_addr(b_values.clone(), b_j.clone(), elem_bytes);
418                let b_val = load_global_float::<T>(b, b_v_addr);
419
420                // C_val = A_val * B_val
421                let zero = load_float_imm::<T>(b, 0.0);
422                let c_val = fma_float::<T>(b, a_val.clone(), b_val, zero);
423
424                // Store C.col_idx[c_pos] = b_col
425                let c_ci_addr = b.byte_offset_addr(c_col_idx_p.clone(), c_pos.clone(), 4);
426                b.store_global_i32(c_ci_addr, b_col_i32);
427
428                // Store C.values[c_pos] = c_val
429                let c_v_addr = b.byte_offset_addr(c_values.clone(), c_pos.clone(), elem_bytes);
430                store_global_float::<T>(b, c_v_addr, c_val);
431
432                // c_pos++
433                b.raw_ptx(&format!("add.u32 {c_pos}, {c_pos}, 1;"));
434
435                b.raw_ptx(&format!("add.u32 {b_j}, {b_j}, 1;"));
436                b.branch(&inner_loop);
437                b.label(&inner_done);
438
439                b.raw_ptx(&format!("add.u32 {a_k}, {a_k}, 1;"));
440                b.branch(&outer_loop);
441                b.label(&outer_done);
442            });
443
444            b.ret();
445        })
446        .build()
447        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::ptx_helpers::test_support::assert_assembles_and_clean;
454    use oxicuda_ptx::arch::SmVersion;
455
456    /// Both SpGEMM kernels (symbolic + numeric) must assemble for sm_86 in both
457    /// precisions with `$`-prefixed branch targets and no `.b64` shuffle.
458    #[test]
459    fn spgemm_symbolic_numeric_f32_f64_assemble_sm86() {
460        let sym_f32 = emit_spgemm_symbolic_kernel::<f32>(SmVersion::Sm86).expect("sym f32");
461        assert_assembles_and_clean("spgemm_symbolic_f32", &sym_f32);
462        let sym_f64 = emit_spgemm_symbolic_kernel::<f64>(SmVersion::Sm86).expect("sym f64");
463        assert_assembles_and_clean("spgemm_symbolic_f64", &sym_f64);
464
465        let num_f32 = emit_spgemm_numeric_kernel::<f32>(SmVersion::Sm86).expect("num f32");
466        assert_assembles_and_clean("spgemm_numeric_f32", &num_f32);
467        let num_f64 = emit_spgemm_numeric_kernel::<f64>(SmVersion::Sm86).expect("num f64");
468        assert_assembles_and_clean("spgemm_numeric_f64", &num_f64);
469        assert!(
470            !num_f64.contains("0F00000000"),
471            "f64 SpGEMM numeric kernel must not materialize an f32 0.0 immediate:\n{num_f64}"
472        );
473    }
474
475    #[test]
476    fn spgemm_symbolic_ptx_generates_f32() {
477        let ptx = emit_spgemm_symbolic_kernel::<f32>(SmVersion::Sm80);
478        assert!(ptx.is_ok());
479        let ptx_str = ptx.expect("test: PTX gen should succeed");
480        assert!(ptx_str.contains(".entry spgemm_symbolic"));
481    }
482
483    #[test]
484    fn spgemm_symbolic_ptx_generates_f64() {
485        let ptx = emit_spgemm_symbolic_kernel::<f64>(SmVersion::Sm80);
486        assert!(ptx.is_ok());
487    }
488
489    #[test]
490    fn spgemm_numeric_ptx_generates_f32() {
491        let ptx = emit_spgemm_numeric_kernel::<f32>(SmVersion::Sm80);
492        assert!(ptx.is_ok());
493        let ptx_str = ptx.expect("test: PTX gen should succeed");
494        assert!(ptx_str.contains(".entry spgemm_numeric"));
495    }
496
497    #[test]
498    fn spgemm_numeric_ptx_generates_f64() {
499        let ptx = emit_spgemm_numeric_kernel::<f64>(SmVersion::Sm80);
500        assert!(ptx.is_ok());
501    }
502
503    #[test]
504    fn validate_dims_mismatch() {
505        // Cannot construct CsrMatrix without GPU, but we can test the error type
506        let err = SparseError::DimensionMismatch("A.cols (3) != B.rows (4)".to_string());
507        assert!(err.to_string().contains("A.cols"));
508    }
509}
510
511// ---------------------------------------------------------------------------
512// On-device numeric validation (feature = "gpu-tests")
513// ---------------------------------------------------------------------------
514
515#[cfg(all(test, feature = "gpu-tests"))]
516mod gpu_device_tests {
517    use super::*;
518    use crate::gpu_test_support::gpu_handle;
519    use crate::host_csr::{f64_to_gpu, gpu_to_f64};
520    use oxicuda_memory::DeviceBuffer;
521
522    /// CPU oracle reproducing the *exact* expansion the production kernels emit:
523    /// the symbolic phase counts every (A nnz x B-row nnz) product (no merging),
524    /// and the numeric phase writes them in CSR-iteration order. Returns the
525    /// expanded `(row_ptr, col_idx, values)` of `C = A * B`.
526    fn cpu_spgemm_expanded(
527        a_rows: usize,
528        a_row_ptr: &[i32],
529        a_col_idx: &[i32],
530        a_values: &[f64],
531        b_row_ptr: &[i32],
532        b_col_idx: &[i32],
533        b_values: &[f64],
534    ) -> (Vec<i32>, Vec<i32>, Vec<f64>) {
535        let mut row_ptr = vec![0i32];
536        let mut col_idx = Vec::new();
537        let mut values = Vec::new();
538        for row in 0..a_rows {
539            for ak in a_row_ptr[row] as usize..a_row_ptr[row + 1] as usize {
540                let a_col = a_col_idx[ak] as usize;
541                let a_val = a_values[ak];
542                for bj in b_row_ptr[a_col] as usize..b_row_ptr[a_col + 1] as usize {
543                    col_idx.push(b_col_idx[bj]);
544                    values.push(a_val * b_values[bj]);
545                }
546            }
547            row_ptr.push(col_idx.len() as i32);
548        }
549        (row_ptr, col_idx, values)
550    }
551
552    #[allow(clippy::too_many_arguments)]
553    fn run_spgemm<T: GpuFloat>(
554        a_rows: u32,
555        a_cols: u32,
556        a_row_ptr: &[i32],
557        a_col_idx: &[i32],
558        a_values: &[f64],
559        b_rows: u32,
560        b_cols: u32,
561        b_row_ptr: &[i32],
562        b_col_idx: &[i32],
563        b_values: &[f64],
564        tol: f64,
565        tag: &str,
566    ) {
567        let Some(handle) = gpu_handle() else {
568            return;
569        };
570        let a_dev: Vec<T> = a_values.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
571        let b_dev: Vec<T> = b_values.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
572        let a = CsrMatrix::<T>::from_host(a_rows, a_cols, a_row_ptr, a_col_idx, &a_dev)
573            .expect("test: build A");
574        let b = CsrMatrix::<T>::from_host(b_rows, b_cols, b_row_ptr, b_col_idx, &b_dev)
575            .expect("test: build B");
576
577        // Symbolic phase: produces the (expanded) C row_ptr.
578        let c_row_ptr = spgemm_symbolic::<T>(&handle, &a, &b).expect("test: symbolic");
579
580        let (want_rp, want_ci, want_vals) = cpu_spgemm_expanded(
581            a_rows as usize,
582            a_row_ptr,
583            a_col_idx,
584            a_values,
585            b_row_ptr,
586            b_col_idx,
587            b_values,
588        );
589        assert_eq!(c_row_ptr, want_rp, "{tag}: symbolic row_ptr mismatch");
590
591        let nnz_c = *c_row_ptr.last().expect("test: row_ptr non-empty") as usize;
592        let c_row_ptr_buf = DeviceBuffer::from_host(&c_row_ptr).expect("test: upload C row_ptr");
593        let c_col_idx_buf =
594            DeviceBuffer::from_host(&vec![0i32; nnz_c]).expect("test: alloc C col_idx");
595        let c_values_buf =
596            DeviceBuffer::from_host(&vec![T::gpu_zero(); nnz_c]).expect("test: alloc C values");
597
598        spgemm_numeric::<T>(
599            &handle,
600            &a,
601            &b,
602            c_row_ptr_buf.as_device_ptr(),
603            c_col_idx_buf.as_device_ptr(),
604            c_values_buf.as_device_ptr(),
605        )
606        .expect("test: numeric");
607        handle.stream().synchronize().expect("test: sync");
608
609        let mut got_ci = vec![0i32; nnz_c];
610        c_col_idx_buf
611            .copy_to_host(&mut got_ci)
612            .expect("test: download col_idx");
613        let mut got_vals_t = vec![T::gpu_zero(); nnz_c];
614        c_values_buf
615            .copy_to_host(&mut got_vals_t)
616            .expect("test: download values");
617        let got_vals: Vec<f64> = got_vals_t.iter().map(|&v| gpu_to_f64(v)).collect();
618
619        assert_eq!(got_ci, want_ci, "{tag}: numeric col_idx mismatch");
620        assert_eq!(got_vals.len(), want_vals.len(), "{tag}: values length");
621        for (i, (g, w)) in got_vals.iter().zip(want_vals.iter()).enumerate() {
622            let diff = (g - w).abs();
623            let scale = w.abs().max(1.0);
624            assert!(
625                diff <= tol * scale,
626                "{tag}: value {i}: got {g}, want {w} (|diff| {diff})"
627            );
628        }
629    }
630
631    #[test]
632    fn spgemm_2x3_times_3x2_f64() {
633        // A (2x3):           B (3x2):
634        // [1 0 2]            [10 20]
635        // [0 3 0]            [30  0]
636        //                    [ 0 40]
637        let a_rp = vec![0, 2, 3];
638        let a_ci = vec![0, 2, 1];
639        let a_v = vec![1.0, 2.0, 3.0];
640        let b_rp = vec![0, 2, 3, 4];
641        let b_ci = vec![0, 1, 0, 1];
642        let b_v = vec![10.0, 20.0, 30.0, 40.0];
643        run_spgemm::<f64>(
644            2,
645            3,
646            &a_rp,
647            &a_ci,
648            &a_v,
649            3,
650            2,
651            &b_rp,
652            &b_ci,
653            &b_v,
654            1e-10,
655            "spgemm_f64",
656        );
657    }
658
659    #[test]
660    fn spgemm_2x3_times_3x2_f32() {
661        let a_rp = vec![0, 2, 3];
662        let a_ci = vec![0, 2, 1];
663        let a_v = vec![1.5, -2.0, 3.25];
664        let b_rp = vec![0, 2, 3, 4];
665        let b_ci = vec![0, 1, 0, 1];
666        let b_v = vec![10.0, -20.0, 30.0, 40.0];
667        run_spgemm::<f32>(
668            2,
669            3,
670            &a_rp,
671            &a_ci,
672            &a_v,
673            3,
674            2,
675            &b_rp,
676            &b_ci,
677            &b_v,
678            1e-4,
679            "spgemm_f32",
680        );
681    }
682
683    #[test]
684    fn spgemm_identity_left_f64() {
685        // I (3x3) * B (3x3) = B, expanded form equals B exactly.
686        let i_rp = vec![0, 1, 2, 3];
687        let i_ci = vec![0, 1, 2];
688        let i_v = vec![1.0, 1.0, 1.0];
689        let b_rp = vec![0, 2, 3, 5];
690        let b_ci = vec![0, 2, 1, 0, 2];
691        let b_v = vec![7.0, 8.0, 9.0, 11.0, 13.0];
692        run_spgemm::<f64>(
693            3,
694            3,
695            &i_rp,
696            &i_ci,
697            &i_v,
698            3,
699            3,
700            &b_rp,
701            &b_ci,
702            &b_v,
703            1e-10,
704            "spgemm_identity",
705        );
706    }
707}