Skip to main content

oxicuda_sparse/ops/
sddmm.rs

1//! Sampled Dense-Dense Matrix Multiply (SDDMM).
2//!
3//! Computes `C_ij = alpha * (A @ B)_ij * spy(S)_ij + beta * S_ij`
4//! where the result is only computed at positions where the sparse matrix `S`
5//! has non-zero entries. This is a key primitive in graph neural networks and
6//! sparse attention mechanisms.
7//!
8//! ## Strategy
9//!
10//! Each thread handles one non-zero entry of `S`. For that entry at position
11//! `(row, col)`, the thread computes the dot product `A[row, :] . B[:, col]`
12//! (inner dimension `K`), scales by `alpha`, and blends with the existing
13//! value via `beta`.
14#![allow(dead_code)]
15
16use std::sync::Arc;
17
18use oxicuda_blas::GpuFloat;
19use oxicuda_driver::Module;
20use oxicuda_driver::ffi::CUdeviceptr;
21use oxicuda_launch::{Kernel, LaunchParams, grid_size_for};
22use oxicuda_ptx::arch::SmVersion;
23use oxicuda_ptx::builder::KernelBuilder;
24use oxicuda_ptx::ir::PtxType;
25
26use crate::error::{SparseError, SparseResult};
27use crate::format::CsrMatrix;
28use crate::handle::SparseHandle;
29use crate::ptx_helpers::{
30    add_float, fma_float, load_float_imm, load_global_float, mul_float, reinterpret_bits_to_float,
31    store_global_float,
32};
33
34/// Default block size for SDDMM kernel.
35const SDDMM_BLOCK_SIZE: u32 = 256;
36
37/// Sampled Dense-Dense Matrix Multiply.
38///
39/// Computes `S_ij = alpha * dot(A[i,:], B[:,j]) + beta * S_ij` for each
40/// non-zero position `(i, j)` in the sparse matrix `S`.
41///
42/// # Arguments
43///
44/// * `handle` -- Sparse handle.
45/// * `alpha` -- Scalar multiplier for the dense product.
46/// * `a_ptr` -- Device pointer to dense matrix `A` (row-major, shape `m x k`).
47/// * `a_rows` -- Number of rows of `A`.
48/// * `a_cols` -- Number of columns of `A` (= inner dimension `K`).
49/// * `a_ld` -- Leading dimension (row stride) of `A`.
50/// * `b_ptr` -- Device pointer to dense matrix `B` (row-major, shape `k x n`).
51/// * `b_cols` -- Number of columns of `B`.
52/// * `b_ld` -- Leading dimension (row stride) of `B`.
53/// * `beta` -- Scalar multiplier for existing `S` values.
54/// * `s` -- Sparse CSR matrix `S`, updated in place.
55///
56/// # Errors
57///
58/// Returns [`SparseError::DimensionMismatch`] if dimensions are incompatible.
59/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
60#[allow(clippy::too_many_arguments)]
61pub fn sddmm<T: GpuFloat>(
62    handle: &SparseHandle,
63    alpha: T,
64    a_ptr: CUdeviceptr,
65    a_rows: u32,
66    a_cols: u32,
67    a_ld: u32,
68    b_ptr: CUdeviceptr,
69    b_cols: u32,
70    b_ld: u32,
71    beta: T,
72    s: &mut CsrMatrix<T>,
73) -> SparseResult<()> {
74    // Validate dimensions
75    if s.rows() != a_rows {
76        return Err(SparseError::DimensionMismatch(format!(
77            "S.rows ({}) != A.rows ({})",
78            s.rows(),
79            a_rows
80        )));
81    }
82    if s.cols() != b_cols {
83        return Err(SparseError::DimensionMismatch(format!(
84            "S.cols ({}) != B.cols ({})",
85            s.cols(),
86            b_cols
87        )));
88    }
89
90    if s.nnz() == 0 || a_cols == 0 {
91        return Ok(());
92    }
93
94    let ptx = emit_sddmm_kernel::<T>(handle.sm_version())?;
95    let module = Arc::new(Module::from_ptx(&ptx)?);
96    let kernel = Kernel::from_module(module, "sddmm")?;
97
98    let block_size = SDDMM_BLOCK_SIZE;
99    let grid_size = grid_size_for(s.nnz(), block_size);
100    let params = LaunchParams::new(grid_size, block_size);
101
102    kernel.launch(
103        &params,
104        handle.stream(),
105        &(
106            s.row_ptr().as_device_ptr(),
107            s.col_idx().as_device_ptr(),
108            s.values().as_device_ptr(),
109            a_ptr,
110            b_ptr,
111            alpha.to_bits_u64(),
112            beta.to_bits_u64(),
113            s.rows(),
114            a_cols,
115            a_ld,
116            b_ld,
117        ),
118    )?;
119
120    Ok(())
121}
122
123/// Generates PTX for the SDDMM kernel.
124///
125/// Each thread handles one non-zero of `S`. It identifies the row and column
126/// from the CSR structure, then computes the dot product of `A[row, :]` and
127/// `B[:, col]` over the inner dimension `K`.
128fn emit_sddmm_kernel<T: GpuFloat>(sm: SmVersion) -> SparseResult<String> {
129    let elem_bytes = T::size_u32();
130    let is_f64 = T::SIZE == 8;
131
132    KernelBuilder::new("sddmm")
133        .target(sm)
134        .param("row_ptr", PtxType::U64)
135        .param("col_idx", PtxType::U64)
136        .param("values", PtxType::U64)
137        .param("a_ptr", PtxType::U64)
138        .param("b_ptr", PtxType::U64)
139        .param("alpha_bits", PtxType::U64)
140        .param("beta_bits", PtxType::U64)
141        .param("m", PtxType::U32)
142        .param("k", PtxType::U32)
143        .param("a_ld", PtxType::U32)
144        .param("b_ld", PtxType::U32)
145        .body(move |b| {
146            let gid = b.global_thread_id_x();
147            let m_param = b.load_param_u32("m");
148            let mov_suffix = if is_f64 { "f64" } else { "f32" };
149
150            // We need to find which row this non-zero belongs to.
151            // Simple approach: binary search in row_ptr.
152            // But for PTX simplicity, we use a linear scan.
153            //
154            // Actually, we launch one thread per row and iterate over that row's nnz.
155            // This is simpler and avoids the row-finding problem.
156
157            let gid_inner = gid.clone();
158            b.if_lt_u32(gid, m_param, move |b| {
159                let row = gid_inner;
160                let row_ptr_base = b.load_param_u64("row_ptr");
161                let col_idx_base = b.load_param_u64("col_idx");
162                let values_base = b.load_param_u64("values");
163                let a_ptr = b.load_param_u64("a_ptr");
164                let b_ptr = b.load_param_u64("b_ptr");
165                let alpha_bits = b.load_param_u64("alpha_bits");
166                let beta_bits = b.load_param_u64("beta_bits");
167                let k_param = b.load_param_u32("k");
168                let a_ld = b.load_param_u32("a_ld");
169                let b_ld = b.load_param_u32("b_ld");
170
171                let alpha = reinterpret_bits_to_float::<T>(b, alpha_bits);
172                let beta = reinterpret_bits_to_float::<T>(b, beta_bits);
173
174                // Load row bounds
175                let rp_addr = b.byte_offset_addr(row_ptr_base.clone(), row.clone(), 4);
176                let rs_i32 = b.load_global_i32(rp_addr);
177                let rs = b.alloc_reg(PtxType::U32);
178                b.raw_ptx(&format!("mov.b32 {rs}, {rs_i32};"));
179
180                let row_p1 = b.alloc_reg(PtxType::U32);
181                b.raw_ptx(&format!("add.u32 {row_p1}, {row}, 1;"));
182                let re_addr = b.byte_offset_addr(row_ptr_base, row_p1, 4);
183                let re_i32 = b.load_global_i32(re_addr);
184                let re = b.alloc_reg(PtxType::U32);
185                b.raw_ptx(&format!("mov.b32 {re}, {re_i32};"));
186
187                // For each non-zero in this row
188                let nz_idx = b.alloc_reg(PtxType::U32);
189                b.raw_ptx(&format!("mov.u32 {nz_idx}, {rs};"));
190
191                let nz_loop = b.fresh_label("sddmm_nz_loop");
192                let nz_done = b.fresh_label("sddmm_nz_done");
193
194                b.label(&nz_loop);
195                // Exit when nz_idx >= row_end (inverted skip-branch via branch_if
196                // so the `$`-prefixed label target matches the `b.label` def).
197                let nz_pred = b.alloc_reg(PtxType::Pred);
198                b.raw_ptx(&format!("setp.hs.u32 {nz_pred}, {nz_idx}, {re};"));
199                b.branch_if(nz_pred, &nz_done);
200
201                // Load column index
202                let ci_addr = b.byte_offset_addr(col_idx_base.clone(), nz_idx.clone(), 4);
203                let col_i32 = b.load_global_i32(ci_addr);
204                let col = b.alloc_reg(PtxType::U32);
205                b.raw_ptx(&format!("mov.b32 {col}, {col_i32};"));
206
207                // Compute dot product: A[row, :] . B[:, col]
208                let dot = load_float_imm::<T>(b, 0.0);
209
210                let kk = b.alloc_reg(PtxType::U32);
211                b.raw_ptx(&format!("mov.u32 {kk}, 0;"));
212
213                let k_loop = b.fresh_label("sddmm_k_loop");
214                let k_done = b.fresh_label("sddmm_k_done");
215
216                b.label(&k_loop);
217                // Exit when kk >= k (inverted skip-branch via branch_if).
218                let k_pred = b.alloc_reg(PtxType::Pred);
219                b.raw_ptx(&format!("setp.hs.u32 {k_pred}, {kk}, {k_param};"));
220                b.branch_if(k_pred, &k_done);
221
222                // A[row, kk] = a_ptr + (row * a_ld + kk) * elem_bytes
223                let a_row_off = b.alloc_reg(PtxType::U32);
224                b.raw_ptx(&format!("mul.lo.u32 {a_row_off}, {row}, {a_ld};"));
225                let a_idx = b.alloc_reg(PtxType::U32);
226                b.raw_ptx(&format!("add.u32 {a_idx}, {a_row_off}, {kk};"));
227                let a_addr = b.byte_offset_addr(a_ptr.clone(), a_idx, elem_bytes);
228                let a_val = load_global_float::<T>(b, a_addr);
229
230                // B[kk, col] = b_ptr + (kk * b_ld + col) * elem_bytes
231                let b_row_off = b.alloc_reg(PtxType::U32);
232                b.raw_ptx(&format!("mul.lo.u32 {b_row_off}, {kk}, {b_ld};"));
233                let b_idx = b.alloc_reg(PtxType::U32);
234                b.raw_ptx(&format!("add.u32 {b_idx}, {b_row_off}, {col};"));
235                let b_addr = b.byte_offset_addr(b_ptr.clone(), b_idx, elem_bytes);
236                let b_val = load_global_float::<T>(b, b_addr);
237
238                // dot += a_val * b_val
239                let new_dot = fma_float::<T>(b, a_val, b_val, dot.clone());
240                b.raw_ptx(&format!("mov.{mov_suffix} {dot}, {new_dot};"));
241
242                b.raw_ptx(&format!("add.u32 {kk}, {kk}, 1;"));
243                b.branch(&k_loop);
244                b.label(&k_done);
245
246                // Load old S value
247                let s_v_addr = b.byte_offset_addr(values_base.clone(), nz_idx.clone(), elem_bytes);
248                let s_old = load_global_float::<T>(b, s_v_addr.clone());
249
250                // result = alpha * dot + beta * s_old
251                let alpha_dot = mul_float::<T>(b, alpha.clone(), dot);
252                let beta_s = mul_float::<T>(b, beta.clone(), s_old);
253                let result = add_float::<T>(b, alpha_dot, beta_s);
254
255                store_global_float::<T>(b, s_v_addr, result);
256
257                b.raw_ptx(&format!("add.u32 {nz_idx}, {nz_idx}, 1;"));
258                b.branch(&nz_loop);
259                b.label(&nz_done);
260            });
261
262            b.ret();
263        })
264        .build()
265        .map_err(|e| SparseError::PtxGeneration(e.to_string()))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::ptx_helpers::test_support::assert_assembles_and_clean;
272
273    /// The SDDMM kernel must assemble for sm_86 in both precisions with
274    /// `$`-prefixed branch targets and no `.b64` shuffle.
275    #[test]
276    fn sddmm_f32_f64_assemble_sm86() {
277        let f32_ptx = emit_sddmm_kernel::<f32>(SmVersion::Sm86).expect("f32 SDDMM PTX");
278        assert_assembles_and_clean("sddmm_f32", &f32_ptx);
279
280        let f64_ptx = emit_sddmm_kernel::<f64>(SmVersion::Sm86).expect("f64 SDDMM PTX");
281        assert_assembles_and_clean("sddmm_f64", &f64_ptx);
282        assert!(
283            !f64_ptx.contains("0F00000000"),
284            "f64 SDDMM kernel must not materialize an f32 0.0 immediate:\n{f64_ptx}"
285        );
286    }
287    use oxicuda_ptx::arch::SmVersion;
288
289    #[test]
290    fn sddmm_ptx_generates_f32() {
291        let ptx = emit_sddmm_kernel::<f32>(SmVersion::Sm80);
292        assert!(ptx.is_ok());
293        let ptx_str = ptx.expect("test: PTX gen should succeed");
294        assert!(ptx_str.contains(".entry sddmm"));
295    }
296
297    #[test]
298    fn sddmm_ptx_generates_f64() {
299        let ptx = emit_sddmm_kernel::<f64>(SmVersion::Sm80);
300        assert!(ptx.is_ok());
301    }
302
303    #[test]
304    fn sddmm_ptx_has_correct_target() {
305        let ptx = emit_sddmm_kernel::<f32>(SmVersion::Sm75);
306        assert!(ptx.is_ok());
307        let ptx_str = ptx.expect("test: PTX gen should succeed");
308        assert!(ptx_str.contains(".target sm_75"));
309    }
310}
311
312// ---------------------------------------------------------------------------
313// On-device numeric validation (feature = "gpu-tests")
314// ---------------------------------------------------------------------------
315
316#[cfg(all(test, feature = "gpu-tests"))]
317mod gpu_device_tests {
318    use super::*;
319    use crate::gpu_test_support::{assert_close, gpu_handle};
320    use crate::host_csr::{f64_to_gpu, gpu_to_f64};
321    use oxicuda_memory::DeviceBuffer;
322
323    /// CPU oracle for SDDMM: for each non-zero `(row, col)` of `S`,
324    /// `S = alpha * sum_k A[row,k]*B[k,col] + beta * S_old`.
325    /// `A` is m x k row-major, `B` is k x n row-major (contiguous, no padding).
326    #[allow(clippy::too_many_arguments)]
327    fn cpu_sddmm(
328        m: usize,
329        k: usize,
330        n: usize,
331        row_ptr: &[i32],
332        col_idx: &[i32],
333        s_old: &[f64],
334        a: &[f64],
335        b: &[f64],
336        alpha: f64,
337        beta: f64,
338    ) -> Vec<f64> {
339        let mut s = s_old.to_vec();
340        for row in 0..m {
341            for nz in row_ptr[row] as usize..row_ptr[row + 1] as usize {
342                let col = col_idx[nz] as usize;
343                let mut dot = 0.0_f64;
344                for kk in 0..k {
345                    // A is m x k row-major; B is k x n row-major.
346                    dot += a[row * k + kk] * b[kk * n + col];
347                }
348                s[nz] = alpha * dot + beta * s_old[nz];
349            }
350        }
351        s
352    }
353
354    /// Drive the production `sddmm` op and compare to the CPU oracle.
355    #[allow(clippy::too_many_arguments)]
356    fn run_sddmm<T: GpuFloat>(
357        m: u32,
358        k: u32,
359        n: u32,
360        row_ptr: &[i32],
361        col_idx: &[i32],
362        s_old: &[f64],
363        a: &[f64],
364        b: &[f64],
365        alpha: f64,
366        beta: f64,
367        tol: f64,
368        tag: &str,
369    ) {
370        let Some(handle) = gpu_handle() else {
371            return;
372        };
373        let dev_s: Vec<T> = s_old.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
374        let mut s =
375            CsrMatrix::<T>::from_host(m, n, row_ptr, col_idx, &dev_s).expect("test: build CSR S");
376
377        let dev_a: Vec<T> = a.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
378        let dev_b: Vec<T> = b.iter().map(|&v| f64_to_gpu::<T>(v)).collect();
379        let a_buf = DeviceBuffer::from_host(&dev_a).expect("test: upload A");
380        let b_buf = DeviceBuffer::from_host(&dev_b).expect("test: upload B");
381
382        sddmm::<T>(
383            &handle,
384            f64_to_gpu::<T>(alpha),
385            a_buf.as_device_ptr(),
386            m,
387            k,
388            k, // a_ld (row-major, contiguous)
389            b_buf.as_device_ptr(),
390            n,
391            n, // b_ld (row-major, contiguous)
392            f64_to_gpu::<T>(beta),
393            &mut s,
394        )
395        .expect("test: sddmm launch");
396        handle.stream().synchronize().expect("test: sync");
397
398        let (_rp, _ci, out_vals) = s.to_host().expect("test: download S");
399        let got: Vec<f64> = out_vals.iter().map(|&v| gpu_to_f64(v)).collect();
400        let want = cpu_sddmm(
401            m as usize, k as usize, n as usize, row_ptr, col_idx, s_old, a, b, alpha, beta,
402        );
403        assert_close(&got, &want, tol, tag);
404    }
405
406    /// Sparse mask S (3x4):
407    /// nonzeros at (0,0),(0,2),(1,1),(1,3),(2,0),(2,3)
408    fn mask_3x4() -> (u32, Vec<i32>, Vec<i32>, Vec<f64>) {
409        let row_ptr = vec![0, 2, 4, 6];
410        let col_idx = vec![0, 2, 1, 3, 0, 3];
411        // pre-existing S values (used by the beta term)
412        let s_old = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
413        (4, row_ptr, col_idx, s_old)
414    }
415
416    fn dense(rows: usize, cols: usize, base: f64) -> Vec<f64> {
417        (0..rows * cols)
418            .map(|idx| base + 0.25 * (idx as f64) - 0.05 * ((idx % 5) as f64))
419            .collect()
420    }
421
422    #[test]
423    fn sddmm_f64_alpha_beta() {
424        let m = 3usize;
425        let k = 5usize;
426        let (n, rp, ci, s_old) = mask_3x4();
427        let a = dense(m, k, 1.0);
428        let b = dense(k, n as usize, -0.5);
429        run_sddmm::<f64>(
430            m as u32,
431            k as u32,
432            n,
433            &rp,
434            &ci,
435            &s_old,
436            &a,
437            &b,
438            1.5,
439            -0.75,
440            1e-10,
441            "sddmm_f64",
442        );
443    }
444
445    #[test]
446    fn sddmm_f32_alpha_beta() {
447        let m = 3usize;
448        let k = 4usize;
449        let (n, rp, ci, s_old) = mask_3x4();
450        let a = dense(m, k, 0.5);
451        let b = dense(k, n as usize, 1.0);
452        run_sddmm::<f32>(
453            m as u32,
454            k as u32,
455            n,
456            &rp,
457            &ci,
458            &s_old,
459            &a,
460            &b,
461            2.0,
462            0.5,
463            1e-4,
464            "sddmm_f32",
465        );
466    }
467
468    #[test]
469    fn sddmm_f64_beta_zero() {
470        // beta = 0: result depends only on the dense product, not S_old.
471        let m = 3usize;
472        let k = 6usize;
473        let (n, rp, ci, _s_old) = mask_3x4();
474        let s_old = vec![1e7; 6];
475        let a = dense(m, k, 2.0);
476        let b = dense(k, n as usize, 0.3);
477        run_sddmm::<f64>(
478            m as u32,
479            k as u32,
480            n,
481            &rp,
482            &ci,
483            &s_old,
484            &a,
485            &b,
486            1.0,
487            0.0,
488            1e-10,
489            "sddmm_beta0",
490        );
491    }
492}