Skip to main content

oxicuda_sparse/ops/
batched.rs

1//! Batched sparse operations.
2//!
3//! This module provides routines for executing many small sparse operations
4//! in a single logical batch, amortising kernel launch overhead and enabling
5//! fusion when beneficial.
6//!
7//! ## Provided types
8//!
9//! - [`BatchedSpMV`] -- batched sparse matrix-vector multiply (host-side loop).
10//! - [`BatchedSpMVPlan`] -- precomputed plan with concatenated CSR arrays.
11//! - [`UniformBatchedSpMV`] -- optimised path when all matrices share structure.
12//! - [`BatchedSpGEMM`] -- batched sparse-sparse matrix multiply.
13//! - [`BatchedTriSolve`] -- batched sparse triangular solve.
14//! - [`BatchScheduler`] -- heuristic-based execution strategy selector.
15
16use std::ops::{Add, AddAssign, Mul};
17
18use oxicuda_blas::GpuFloat;
19
20use crate::error::{SparseError, SparseResult};
21use crate::format::CsrMatrix;
22
23/// Result type for a single SpGEMM: (row_ptr, col_idx, values, rows, cols).
24type SpGEMMResultU32<T> = (Vec<i32>, Vec<i32>, Vec<T>, u32, u32);
25
26/// Result type for a single SpGEMM from host arrays: (row_ptr, col_idx, values, rows, cols).
27type SpGEMMResultUsize<T> = (Vec<i32>, Vec<i32>, Vec<T>, usize, usize);
28
29// ---------------------------------------------------------------------------
30// BatchScheduler -- strategy selection
31// ---------------------------------------------------------------------------
32
33/// Execution strategy chosen by [`BatchScheduler`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Strategy {
36    /// Process each operation sequentially on a single stream.
37    Sequential,
38    /// Issue operations on `num_streams` concurrent CUDA streams.
39    Concurrent(usize),
40    /// Fuse all operations into a single kernel launch.
41    Fused,
42}
43
44/// Heuristic-based scheduler that decides how to execute a batch.
45///
46/// The strategy depends on batch size and average matrix complexity:
47///
48/// | Condition | Strategy |
49/// |-----------|----------|
50/// | batch <= 4 *and* avg\_nnz >= 10 000 | [`Sequential`](Strategy::Sequential) |
51/// | batch >= 64 *and* avg\_nnz < 256 | [`Fused`](Strategy::Fused) |
52/// | otherwise | [`Concurrent`](Strategy::Concurrent) with up to 8 streams |
53#[derive(Debug, Clone)]
54pub struct BatchScheduler {
55    _private: (),
56}
57
58impl BatchScheduler {
59    /// Create a new scheduler.
60    #[inline]
61    pub fn new() -> Self {
62        Self { _private: () }
63    }
64
65    /// Select an execution strategy for the given batch characteristics.
66    ///
67    /// # Arguments
68    ///
69    /// * `batch_size` -- Number of independent operations in the batch.
70    /// * `avg_nnz` -- Average number of non-zeros per matrix in the batch.
71    pub fn select_strategy(&self, batch_size: usize, avg_nnz: usize) -> Strategy {
72        Self::select_strategy_static(batch_size, avg_nnz)
73    }
74
75    /// Static version of [`select_strategy`](Self::select_strategy) for use
76    /// without constructing a scheduler instance.
77    pub fn select_strategy_static(batch_size: usize, avg_nnz: usize) -> Strategy {
78        // Small batches of large matrices -> sequential is fine
79        if batch_size <= 4 && avg_nnz >= 10_000 {
80            return Strategy::Sequential;
81        }
82        // Many tiny matrices -> fuse into single kernel
83        if batch_size >= 64 && avg_nnz < 256 {
84            return Strategy::Fused;
85        }
86        // Default: concurrent streams (capped at 8)
87        let streams = batch_size.clamp(1, 8);
88        Strategy::Concurrent(streams)
89    }
90}
91
92impl Default for BatchScheduler {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98// ---------------------------------------------------------------------------
99// BatchedSpMVPlan -- precomputed concatenated arrays
100// ---------------------------------------------------------------------------
101
102/// Precomputed plan for batched SpMV execution.
103///
104/// Concatenates all CSR arrays (row\_ptr, col\_indices, values) from multiple
105/// matrices into contiguous host-side arrays with offset tables, enabling
106/// efficient data transfer and potential kernel fusion.
107///
108/// # Type parameters
109///
110/// * `T` -- Element type (must satisfy [`GpuFloat`]).
111#[derive(Debug, Clone)]
112pub struct BatchedSpMVPlan<T> {
113    /// Concatenated row pointer array across all matrices.
114    pub concat_row_ptr: Vec<i32>,
115    /// Concatenated column index array across all matrices.
116    pub concat_col_idx: Vec<i32>,
117    /// Concatenated values array across all matrices.
118    pub concat_values: Vec<T>,
119    /// Start offset of each matrix's row\_ptr in `concat_row_ptr`.
120    pub batch_offsets_row_ptr: Vec<usize>,
121    /// Start offset of each matrix's col\_idx / values in the concatenated arrays.
122    pub batch_offsets_nnz: Vec<usize>,
123    /// Number of rows in each matrix.
124    pub row_counts: Vec<usize>,
125    /// Number of columns in each matrix.
126    pub col_counts: Vec<usize>,
127    /// Number of non-zeros in each matrix.
128    pub nnz_counts: Vec<usize>,
129    /// Total number of matrices in the batch.
130    pub batch_size: usize,
131    /// Recommended execution strategy.
132    pub strategy: Strategy,
133}
134
135impl<T: GpuFloat> BatchedSpMVPlan<T> {
136    /// Build a plan from a slice of CSR matrices.
137    ///
138    /// Downloads each matrix to host memory, concatenates the arrays, and
139    /// records offset metadata.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`SparseError::InvalidArgument`] if `matrices` is empty.
144    /// Returns [`SparseError::Cuda`] on download failure.
145    pub fn from_matrices(matrices: &[CsrMatrix<T>]) -> SparseResult<Self> {
146        if matrices.is_empty() {
147            return Err(SparseError::InvalidArgument(
148                "batch must contain at least one matrix".to_string(),
149            ));
150        }
151
152        let batch_size = matrices.len();
153        let mut concat_row_ptr = Vec::new();
154        let mut concat_col_idx = Vec::new();
155        let mut concat_values: Vec<T> = Vec::new();
156        let mut batch_offsets_row_ptr = Vec::with_capacity(batch_size);
157        let mut batch_offsets_nnz = Vec::with_capacity(batch_size);
158        let mut row_counts = Vec::with_capacity(batch_size);
159        let mut col_counts = Vec::with_capacity(batch_size);
160        let mut nnz_counts = Vec::with_capacity(batch_size);
161
162        for mat in matrices {
163            let (h_rp, h_ci, h_vals) = mat.to_host()?;
164
165            batch_offsets_row_ptr.push(concat_row_ptr.len());
166            batch_offsets_nnz.push(concat_col_idx.len());
167            row_counts.push(mat.rows() as usize);
168            col_counts.push(mat.cols() as usize);
169            nnz_counts.push(mat.nnz() as usize);
170
171            concat_row_ptr.extend_from_slice(&h_rp);
172            concat_col_idx.extend_from_slice(&h_ci);
173            concat_values.extend_from_slice(&h_vals);
174        }
175
176        let total_nnz = nnz_counts.iter().copied().sum::<usize>();
177        let avg_nnz = total_nnz.checked_div(batch_size).unwrap_or(0);
178        let strategy = BatchScheduler::select_strategy_static(batch_size, avg_nnz);
179
180        Ok(Self {
181            concat_row_ptr,
182            concat_col_idx,
183            concat_values,
184            batch_offsets_row_ptr,
185            batch_offsets_nnz,
186            row_counts,
187            col_counts,
188            nnz_counts,
189            batch_size,
190            strategy,
191        })
192    }
193
194    /// Build a plan from host-side arrays directly (no GPU download required).
195    ///
196    /// Each entry in the parallel arrays describes one matrix in the batch.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`SparseError::InvalidArgument`] if any of the slices are empty
201    /// or if their lengths do not match.
202    pub fn from_host_arrays(
203        row_ptrs: &[Vec<i32>],
204        col_indices: &[Vec<i32>],
205        values: &[Vec<T>],
206        rows: &[usize],
207        cols: &[usize],
208    ) -> SparseResult<Self> {
209        let batch_size = row_ptrs.len();
210        if batch_size == 0 {
211            return Err(SparseError::InvalidArgument(
212                "batch must contain at least one matrix".to_string(),
213            ));
214        }
215        if col_indices.len() != batch_size
216            || values.len() != batch_size
217            || rows.len() != batch_size
218            || cols.len() != batch_size
219        {
220            return Err(SparseError::InvalidArgument(
221                "all input slices must have the same length".to_string(),
222            ));
223        }
224
225        let mut concat_row_ptr = Vec::new();
226        let mut concat_col_idx = Vec::new();
227        let mut concat_values: Vec<T> = Vec::new();
228        let mut batch_offsets_row_ptr = Vec::with_capacity(batch_size);
229        let mut batch_offsets_nnz = Vec::with_capacity(batch_size);
230        let mut row_counts = Vec::with_capacity(batch_size);
231        let mut col_counts = Vec::with_capacity(batch_size);
232        let mut nnz_counts = Vec::with_capacity(batch_size);
233
234        for i in 0..batch_size {
235            batch_offsets_row_ptr.push(concat_row_ptr.len());
236            batch_offsets_nnz.push(concat_col_idx.len());
237            row_counts.push(rows[i]);
238            col_counts.push(cols[i]);
239            nnz_counts.push(values[i].len());
240
241            concat_row_ptr.extend_from_slice(&row_ptrs[i]);
242            concat_col_idx.extend_from_slice(&col_indices[i]);
243            concat_values.extend_from_slice(&values[i]);
244        }
245
246        let total_nnz = nnz_counts.iter().copied().sum::<usize>();
247        let avg_nnz = total_nnz.checked_div(batch_size).unwrap_or(0);
248        let strategy = BatchScheduler::select_strategy_static(batch_size, avg_nnz);
249
250        Ok(Self {
251            concat_row_ptr,
252            concat_col_idx,
253            concat_values,
254            batch_offsets_row_ptr,
255            batch_offsets_nnz,
256            row_counts,
257            col_counts,
258            nnz_counts,
259            batch_size,
260            strategy,
261        })
262    }
263
264    /// Returns the total number of non-zeros across the entire batch.
265    #[inline]
266    pub fn total_nnz(&self) -> usize {
267        self.nnz_counts.iter().copied().sum()
268    }
269
270    /// Returns the total number of rows across the entire batch.
271    #[inline]
272    pub fn total_rows(&self) -> usize {
273        self.row_counts.iter().copied().sum()
274    }
275
276    /// Returns the average non-zeros per matrix.
277    #[inline]
278    pub fn avg_nnz(&self) -> usize {
279        if self.batch_size == 0 {
280            return 0;
281        }
282        self.total_nnz() / self.batch_size
283    }
284}
285
286// ---------------------------------------------------------------------------
287// BatchedSpMV -- batched sparse matrix-vector multiply
288// ---------------------------------------------------------------------------
289
290/// Batched sparse matrix-vector multiplication.
291///
292/// Computes `y_i = alpha * A_i * x_i + beta * y_i` for a batch of sparse
293/// matrices `A_i`, dense vectors `x_i`, and output vectors `y_i`.
294///
295/// The baseline implementation iterates sequentially on the host. For GPU
296/// execution, use [`generate_batched_spmv_ptx`] to create a single fused
297/// kernel that processes all matrices via offset arrays.
298#[derive(Debug)]
299pub struct BatchedSpMV<T: GpuFloat> {
300    /// The batch of sparse matrices (stored as host-side CSR data).
301    matrices: Vec<HostCsr<T>>,
302}
303
304/// Host-side CSR representation for batched operations.
305#[derive(Debug, Clone)]
306struct HostCsr<T> {
307    rows: usize,
308    cols: usize,
309    row_ptr: Vec<i32>,
310    col_idx: Vec<i32>,
311    values: Vec<T>,
312}
313
314impl<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign> BatchedSpMV<T> {
315    /// Create a `BatchedSpMV` by downloading matrix data from device memory.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`SparseError::InvalidArgument`] if `matrices` is empty.
320    /// Returns [`SparseError::Cuda`] on download failure.
321    pub fn from_device(matrices: &[CsrMatrix<T>]) -> SparseResult<Self> {
322        if matrices.is_empty() {
323            return Err(SparseError::InvalidArgument(
324                "batch must contain at least one matrix".to_string(),
325            ));
326        }
327
328        let mut host_mats = Vec::with_capacity(matrices.len());
329        for mat in matrices {
330            let (rp, ci, vals) = mat.to_host()?;
331            host_mats.push(HostCsr {
332                rows: mat.rows() as usize,
333                cols: mat.cols() as usize,
334                row_ptr: rp,
335                col_idx: ci,
336                values: vals,
337            });
338        }
339
340        Ok(Self {
341            matrices: host_mats,
342        })
343    }
344
345    /// Create a `BatchedSpMV` from host-side CSR arrays.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`SparseError::InvalidArgument`] on dimension mismatch.
350    pub fn from_host(
351        row_ptrs: Vec<Vec<i32>>,
352        col_indices: Vec<Vec<i32>>,
353        values: Vec<Vec<T>>,
354        rows: Vec<usize>,
355        cols: Vec<usize>,
356    ) -> SparseResult<Self> {
357        let n = row_ptrs.len();
358        if n == 0 {
359            return Err(SparseError::InvalidArgument(
360                "batch must contain at least one matrix".to_string(),
361            ));
362        }
363        if col_indices.len() != n || values.len() != n || rows.len() != n || cols.len() != n {
364            return Err(SparseError::InvalidArgument(
365                "all input vectors must have the same length".to_string(),
366            ));
367        }
368
369        let mut host_mats = Vec::with_capacity(n);
370        for i in 0..n {
371            host_mats.push(HostCsr {
372                rows: rows[i],
373                cols: cols[i],
374                row_ptr: row_ptrs[i].clone(),
375                col_idx: col_indices[i].clone(),
376                values: values[i].clone(),
377            });
378        }
379
380        Ok(Self {
381            matrices: host_mats,
382        })
383    }
384
385    /// Returns the number of matrices in the batch.
386    #[inline]
387    pub fn batch_size(&self) -> usize {
388        self.matrices.len()
389    }
390
391    /// Execute the batched SpMV on the host (sequential baseline).
392    ///
393    /// For each matrix `A_i`:
394    ///   `y_i[r] = alpha * sum(A_i[r,c] * x_i[c]) + beta * y_i[r]`
395    ///
396    /// # Arguments
397    ///
398    /// * `xs` -- Slice of input vectors, one per matrix.
399    /// * `ys` -- Slice of output vectors, one per matrix (modified in place).
400    /// * `alpha` -- Scalar multiplier for `A * x`.
401    /// * `beta` -- Scalar multiplier for existing `y`.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`SparseError::DimensionMismatch`] if slice lengths do not match
406    /// the batch size, or if individual vector dimensions are wrong.
407    pub fn execute(&self, xs: &[Vec<T>], ys: &mut [Vec<T>], alpha: T, beta: T) -> SparseResult<()> {
408        let n = self.matrices.len();
409        if xs.len() != n || ys.len() != n {
410            return Err(SparseError::DimensionMismatch(format!(
411                "expected {} vectors, got xs={}, ys={}",
412                n,
413                xs.len(),
414                ys.len()
415            )));
416        }
417
418        for (i, mat) in self.matrices.iter().enumerate() {
419            if xs[i].len() < mat.cols {
420                return Err(SparseError::DimensionMismatch(format!(
421                    "matrix {} has {} cols but x has {} elements",
422                    i,
423                    mat.cols,
424                    xs[i].len()
425                )));
426            }
427            if ys[i].len() < mat.rows {
428                return Err(SparseError::DimensionMismatch(format!(
429                    "matrix {} has {} rows but y has {} elements",
430                    i,
431                    mat.rows,
432                    ys[i].len()
433                )));
434            }
435
436            host_csr_spmv(mat, &xs[i], &mut ys[i], alpha, beta);
437        }
438
439        Ok(())
440    }
441}
442
443/// Perform a single CSR SpMV on the host: y = alpha*A*x + beta*y.
444fn host_csr_spmv<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
445    mat: &HostCsr<T>,
446    x: &[T],
447    y: &mut [T],
448    alpha: T,
449    beta: T,
450) {
451    for (row, y_row) in y.iter_mut().enumerate().take(mat.rows) {
452        let start = mat.row_ptr[row] as usize;
453        let end = mat.row_ptr[row + 1] as usize;
454
455        let mut acc = T::gpu_zero();
456        for j in start..end {
457            let col = mat.col_idx[j] as usize;
458            acc += mat.values[j] * x[col];
459        }
460
461        *y_row = alpha * acc + beta * *y_row;
462    }
463}
464
465// ---------------------------------------------------------------------------
466// UniformBatchedSpMV -- shared structure, only values differ
467// ---------------------------------------------------------------------------
468
469/// Optimised batched SpMV when all matrices share the same sparsity pattern.
470///
471/// Only the non-zero values differ between matrices; the row pointers and
472/// column indices are shared. This enables more efficient memory layout and
473/// potential vectorisation across the batch dimension.
474#[derive(Debug, Clone)]
475pub struct UniformBatchedSpMV<T> {
476    /// Number of rows in every matrix.
477    rows: usize,
478    /// Number of columns in every matrix.
479    cols: usize,
480    /// Shared row pointer array (length `rows + 1`).
481    row_ptr: Vec<i32>,
482    /// Shared column index array (length `nnz`).
483    col_idx: Vec<i32>,
484    /// Per-matrix values. `batch_values[i]` has length `nnz`.
485    batch_values: Vec<Vec<T>>,
486}
487
488impl<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign> UniformBatchedSpMV<T> {
489    /// Create a uniform batch from a pattern matrix and per-batch values.
490    ///
491    /// The pattern matrix supplies the shared structure (row\_ptr, col\_idx);
492    /// `batch_values[i]` supplies the non-zero values for the i-th matrix.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`SparseError::InvalidArgument`] if `batch_values` is empty or
497    /// if any entry has the wrong length.
498    /// Returns [`SparseError::Cuda`] on download failure.
499    pub fn from_pattern(pattern: &CsrMatrix<T>, batch_values: Vec<Vec<T>>) -> SparseResult<Self> {
500        if batch_values.is_empty() {
501            return Err(SparseError::InvalidArgument(
502                "batch_values must not be empty".to_string(),
503            ));
504        }
505
506        let nnz = pattern.nnz() as usize;
507        for (i, vals) in batch_values.iter().enumerate() {
508            if vals.len() != nnz {
509                return Err(SparseError::InvalidArgument(format!(
510                    "batch_values[{}] has length {} but pattern nnz is {}",
511                    i,
512                    vals.len(),
513                    nnz
514                )));
515            }
516        }
517
518        let (rp, ci, _) = pattern.to_host()?;
519
520        Ok(Self {
521            rows: pattern.rows() as usize,
522            cols: pattern.cols() as usize,
523            row_ptr: rp,
524            col_idx: ci,
525            batch_values,
526        })
527    }
528
529    /// Create a uniform batch from host-side arrays (no GPU required).
530    ///
531    /// # Errors
532    ///
533    /// Returns [`SparseError::InvalidArgument`] on dimension / length mismatch.
534    pub fn from_host_arrays(
535        rows: usize,
536        cols: usize,
537        row_ptr: Vec<i32>,
538        col_idx: Vec<i32>,
539        batch_values: Vec<Vec<T>>,
540    ) -> SparseResult<Self> {
541        if batch_values.is_empty() {
542            return Err(SparseError::InvalidArgument(
543                "batch_values must not be empty".to_string(),
544            ));
545        }
546        if row_ptr.len() != rows + 1 {
547            return Err(SparseError::InvalidArgument(format!(
548                "row_ptr length {} != rows + 1 ({})",
549                row_ptr.len(),
550                rows + 1
551            )));
552        }
553        let nnz = col_idx.len();
554        for (i, vals) in batch_values.iter().enumerate() {
555            if vals.len() != nnz {
556                return Err(SparseError::InvalidArgument(format!(
557                    "batch_values[{}] length {} != nnz {}",
558                    i,
559                    vals.len(),
560                    nnz
561                )));
562            }
563        }
564
565        Ok(Self {
566            rows,
567            cols,
568            row_ptr,
569            col_idx,
570            batch_values,
571        })
572    }
573
574    /// Number of matrices in the batch.
575    #[inline]
576    pub fn batch_size(&self) -> usize {
577        self.batch_values.len()
578    }
579
580    /// Execute batched SpMV on the host.
581    ///
582    /// # Errors
583    ///
584    /// Returns [`SparseError::DimensionMismatch`] on vector size mismatch.
585    pub fn execute(&self, xs: &[Vec<T>], ys: &mut [Vec<T>], alpha: T, beta: T) -> SparseResult<()> {
586        let n = self.batch_values.len();
587        if xs.len() != n || ys.len() != n {
588            return Err(SparseError::DimensionMismatch(format!(
589                "expected {} vectors, got xs={}, ys={}",
590                n,
591                xs.len(),
592                ys.len()
593            )));
594        }
595
596        for i in 0..n {
597            if xs[i].len() < self.cols {
598                return Err(SparseError::DimensionMismatch(format!(
599                    "x[{}] length {} < cols {}",
600                    i,
601                    xs[i].len(),
602                    self.cols
603                )));
604            }
605            if ys[i].len() < self.rows {
606                return Err(SparseError::DimensionMismatch(format!(
607                    "y[{}] length {} < rows {}",
608                    i,
609                    ys[i].len(),
610                    self.rows
611                )));
612            }
613
614            let mat = HostCsr {
615                rows: self.rows,
616                cols: self.cols,
617                row_ptr: self.row_ptr.clone(),
618                col_idx: self.col_idx.clone(),
619                values: self.batch_values[i].clone(),
620            };
621            host_csr_spmv(&mat, &xs[i], &mut ys[i], alpha, beta);
622        }
623
624        Ok(())
625    }
626}
627
628// ---------------------------------------------------------------------------
629// BatchedSpGEMM -- batched sparse-sparse matrix multiply
630// ---------------------------------------------------------------------------
631
632/// Batched sparse-sparse matrix multiplication.
633///
634/// Computes `C_i = A_i * B_i` for a batch of sparse matrix pairs.
635/// The baseline runs each SpGEMM independently on the host using a
636/// hash-table accumulation approach.
637#[derive(Debug)]
638pub struct BatchedSpGEMM {
639    _private: (),
640}
641
642impl BatchedSpGEMM {
643    /// Create a new batched SpGEMM executor.
644    #[inline]
645    pub fn new() -> Self {
646        Self { _private: () }
647    }
648
649    /// Execute batched SpGEMM on the host.
650    ///
651    /// For each pair `(A_i, B_i)`, computes `C_i = A_i * B_i` using a
652    /// row-by-row hash-table accumulation.
653    ///
654    /// Returns host-side CSR triples `(row_ptr, col_idx, values)` for each
655    /// result matrix.
656    ///
657    /// # Errors
658    ///
659    /// Returns [`SparseError::InvalidArgument`] if batch sizes differ or are empty.
660    /// Returns [`SparseError::DimensionMismatch`] if dimensions are incompatible.
661    /// Returns [`SparseError::Cuda`] on download failure.
662    pub fn execute<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
663        a_batch: &[CsrMatrix<T>],
664        b_batch: &[CsrMatrix<T>],
665    ) -> SparseResult<Vec<SpGEMMResultU32<T>>> {
666        if a_batch.is_empty() {
667            return Err(SparseError::InvalidArgument(
668                "batch must not be empty".to_string(),
669            ));
670        }
671        if a_batch.len() != b_batch.len() {
672            return Err(SparseError::InvalidArgument(format!(
673                "a_batch length {} != b_batch length {}",
674                a_batch.len(),
675                b_batch.len()
676            )));
677        }
678
679        let mut results = Vec::with_capacity(a_batch.len());
680
681        for (i, (a, b)) in a_batch.iter().zip(b_batch.iter()).enumerate() {
682            if a.cols() != b.rows() {
683                return Err(SparseError::DimensionMismatch(format!(
684                    "pair {}: A.cols ({}) != B.rows ({})",
685                    i,
686                    a.cols(),
687                    b.rows()
688                )));
689            }
690
691            let (a_rp, a_ci, a_vals) = a.to_host()?;
692            let (b_rp, b_ci, b_vals) = b.to_host()?;
693
694            let (c_rp, c_ci, c_vals) = host_spgemm(
695                &a_rp,
696                &a_ci,
697                &a_vals,
698                a.rows() as usize,
699                &b_rp,
700                &b_ci,
701                &b_vals,
702                b.cols() as usize,
703            );
704
705            results.push((c_rp, c_ci, c_vals, a.rows(), b.cols()));
706        }
707
708        Ok(results)
709    }
710
711    /// Execute batched SpGEMM from host-side CSR arrays (no GPU required).
712    ///
713    /// # Errors
714    ///
715    /// Returns [`SparseError::InvalidArgument`] on length mismatch.
716    /// Returns [`SparseError::DimensionMismatch`] on dimension mismatch.
717    #[allow(clippy::too_many_arguments)]
718    pub fn execute_host<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
719        a_row_ptrs: &[Vec<i32>],
720        a_col_indices: &[Vec<i32>],
721        a_values: &[Vec<T>],
722        a_rows: &[usize],
723        a_cols: &[usize],
724        b_row_ptrs: &[Vec<i32>],
725        b_col_indices: &[Vec<i32>],
726        b_values: &[Vec<T>],
727        b_cols: &[usize],
728    ) -> SparseResult<Vec<SpGEMMResultUsize<T>>> {
729        let n = a_row_ptrs.len();
730        if n == 0 {
731            return Err(SparseError::InvalidArgument(
732                "batch must not be empty".to_string(),
733            ));
734        }
735        if b_row_ptrs.len() != n
736            || a_col_indices.len() != n
737            || a_values.len() != n
738            || a_rows.len() != n
739            || a_cols.len() != n
740            || b_col_indices.len() != n
741            || b_values.len() != n
742            || b_cols.len() != n
743        {
744            return Err(SparseError::InvalidArgument(
745                "all input slices must have the same length".to_string(),
746            ));
747        }
748
749        let mut results = Vec::with_capacity(n);
750        for i in 0..n {
751            let b_rows_i = a_cols[i]; // A.cols == B.rows
752            let (c_rp, c_ci, c_vals) = host_spgemm(
753                &a_row_ptrs[i],
754                &a_col_indices[i],
755                &a_values[i],
756                a_rows[i],
757                &b_row_ptrs[i],
758                &b_col_indices[i],
759                &b_values[i],
760                b_cols[i],
761            );
762            let _ = b_rows_i; // validated by caller
763            results.push((c_rp, c_ci, c_vals, a_rows[i], b_cols[i]));
764        }
765
766        Ok(results)
767    }
768}
769
770impl Default for BatchedSpGEMM {
771    fn default() -> Self {
772        Self::new()
773    }
774}
775
776/// Host-side SpGEMM for a single pair: C = A * B using hash accumulation.
777#[allow(clippy::too_many_arguments)]
778fn host_spgemm<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
779    a_rp: &[i32],
780    a_ci: &[i32],
781    a_vals: &[T],
782    a_rows: usize,
783    b_rp: &[i32],
784    b_ci: &[i32],
785    b_vals: &[T],
786    b_cols: usize,
787) -> (Vec<i32>, Vec<i32>, Vec<T>) {
788    use std::collections::BTreeMap;
789
790    let mut c_row_ptr = vec![0i32; a_rows + 1];
791    let mut c_col_idx = Vec::new();
792    let mut c_values: Vec<T> = Vec::new();
793
794    for row in 0..a_rows {
795        let a_start = a_rp[row] as usize;
796        let a_end = a_rp[row + 1] as usize;
797
798        // BTreeMap gives sorted column indices automatically
799        let mut acc: BTreeMap<usize, T> = BTreeMap::new();
800
801        for ja in a_start..a_end {
802            let a_col = a_ci[ja] as usize;
803            let a_val = a_vals[ja];
804
805            let b_start = b_rp[a_col] as usize;
806            let b_end = b_rp[a_col + 1] as usize;
807
808            for jb in b_start..b_end {
809                let b_col = b_ci[jb] as usize;
810                if b_col < b_cols {
811                    let product = a_val * b_vals[jb];
812                    acc.entry(b_col)
813                        .and_modify(|v| *v += product)
814                        .or_insert(product);
815                }
816            }
817        }
818
819        for (&col, &val) in &acc {
820            c_col_idx.push(col as i32);
821            c_values.push(val);
822        }
823        c_row_ptr[row + 1] = c_col_idx.len() as i32;
824    }
825
826    (c_row_ptr, c_col_idx, c_values)
827}
828
829// ---------------------------------------------------------------------------
830// BatchedTriSolve -- batched sparse triangular solve
831// ---------------------------------------------------------------------------
832
833/// Batched sparse triangular solve.
834///
835/// Solves `L_i * x_i = b_i` for each lower-triangular CSR matrix `L_i`
836/// and right-hand side `b_i` in the batch.
837#[derive(Debug)]
838pub struct BatchedTriSolve {
839    _private: (),
840}
841
842impl BatchedTriSolve {
843    /// Create a new batched triangular solve executor.
844    #[inline]
845    pub fn new() -> Self {
846        Self { _private: () }
847    }
848
849    /// Execute batched forward-substitution on the host.
850    ///
851    /// Each `L_i` must be square and lower-triangular with non-zero diagonal.
852    ///
853    /// # Errors
854    ///
855    /// Returns [`SparseError::InvalidArgument`] on batch size mismatch.
856    /// Returns [`SparseError::DimensionMismatch`] if matrices are not square.
857    /// Returns [`SparseError::SingularMatrix`] if a zero diagonal is encountered.
858    /// Returns [`SparseError::Cuda`] on download failure.
859    pub fn execute<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
860        l_batch: &[CsrMatrix<T>],
861        b_batch: &[Vec<T>],
862    ) -> SparseResult<Vec<Vec<T>>> {
863        if l_batch.is_empty() {
864            return Err(SparseError::InvalidArgument(
865                "batch must not be empty".to_string(),
866            ));
867        }
868        if l_batch.len() != b_batch.len() {
869            return Err(SparseError::InvalidArgument(format!(
870                "l_batch length {} != b_batch length {}",
871                l_batch.len(),
872                b_batch.len()
873            )));
874        }
875
876        let mut results = Vec::with_capacity(l_batch.len());
877
878        for (i, (l, b)) in l_batch.iter().zip(b_batch.iter()).enumerate() {
879            if l.rows() != l.cols() {
880                return Err(SparseError::DimensionMismatch(format!(
881                    "matrix {} is not square: {}x{}",
882                    i,
883                    l.rows(),
884                    l.cols()
885                )));
886            }
887            if b.len() < l.rows() as usize {
888                return Err(SparseError::DimensionMismatch(format!(
889                    "matrix {} has {} rows but rhs has {} elements",
890                    i,
891                    l.rows(),
892                    b.len()
893                )));
894            }
895
896            let (rp, ci, vals) = l.to_host()?;
897            let x = host_forward_solve(&rp, &ci, &vals, l.rows() as usize, b)?;
898            results.push(x);
899        }
900
901        Ok(results)
902    }
903
904    /// Execute batched forward-substitution from host-side arrays.
905    ///
906    /// # Errors
907    ///
908    /// Returns [`SparseError::InvalidArgument`] on length mismatch.
909    /// Returns [`SparseError::SingularMatrix`] on zero diagonal.
910    pub fn execute_host<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
911        row_ptrs: &[Vec<i32>],
912        col_indices: &[Vec<i32>],
913        values: &[Vec<T>],
914        sizes: &[usize],
915        rhs: &[Vec<T>],
916    ) -> SparseResult<Vec<Vec<T>>> {
917        let n = row_ptrs.len();
918        if n == 0 {
919            return Err(SparseError::InvalidArgument(
920                "batch must not be empty".to_string(),
921            ));
922        }
923        if col_indices.len() != n || values.len() != n || sizes.len() != n || rhs.len() != n {
924            return Err(SparseError::InvalidArgument(
925                "all input slices must have the same length".to_string(),
926            ));
927        }
928
929        let mut results = Vec::with_capacity(n);
930        for i in 0..n {
931            let x =
932                host_forward_solve(&row_ptrs[i], &col_indices[i], &values[i], sizes[i], &rhs[i])?;
933            results.push(x);
934        }
935
936        Ok(results)
937    }
938}
939
940impl Default for BatchedTriSolve {
941    fn default() -> Self {
942        Self::new()
943    }
944}
945
946/// Host-side lower-triangular forward substitution: L * x = b.
947fn host_forward_solve<T: GpuFloat + Add<Output = T> + Mul<Output = T> + AddAssign>(
948    row_ptr: &[i32],
949    col_idx: &[i32],
950    values: &[T],
951    n: usize,
952    b: &[T],
953) -> SparseResult<Vec<T>> {
954    let mut x = vec![T::gpu_zero(); n];
955
956    for row in 0..n {
957        let start = row_ptr[row] as usize;
958        let end = row_ptr[row + 1] as usize;
959
960        // Accumulate off-diagonal contributions and find diagonal entry.
961        // x[row] = (b[row] - sum(L[row,j]*x[j] for j<row)) / L[row,row]
962        let mut off_diag_sum = T::gpu_zero();
963        let mut diag = T::gpu_zero();
964
965        for j in start..end {
966            let col = col_idx[j] as usize;
967            if col < row {
968                off_diag_sum += values[j] * x[col];
969            } else if col == row {
970                diag = values[j];
971            }
972        }
973
974        // Check for zero diagonal
975        if diag == T::gpu_zero() {
976            return Err(SparseError::SingularMatrix);
977        }
978
979        // x[row] = (b[row] - off_diag_sum) / diag
980        // We cannot subtract directly without Sub trait. Use bit manipulation
981        // to negate for f32/f64.
982        let neg_sum = negate_float(off_diag_sum);
983        let numerator = b[row] + neg_sum;
984        x[row] = divide_float(numerator, diag);
985    }
986
987    Ok(x)
988}
989
990/// Negate a GpuFloat value by flipping the sign bit.
991#[inline]
992fn negate_float<T: GpuFloat>(val: T) -> T {
993    let bits = val.to_bits_u64();
994    if T::SIZE == 4 {
995        // f32: flip bit 31
996        T::from_bits_u64(bits ^ (1u64 << 31))
997    } else {
998        // f64: flip bit 63
999        T::from_bits_u64(bits ^ (1u64 << 63))
1000    }
1001}
1002
1003/// Divide two GpuFloat values by converting through f64.
1004#[inline]
1005fn divide_float<T: GpuFloat>(num: T, den: T) -> T {
1006    // Convert to f64, divide, convert back. Works for f32 and f64.
1007    let n_bits = num.to_bits_u64();
1008    let d_bits = den.to_bits_u64();
1009    if T::SIZE == 4 {
1010        let n = f32::from_bits(n_bits as u32) as f64;
1011        let d = f32::from_bits(d_bits as u32) as f64;
1012        let result = (n / d) as f32;
1013        T::from_bits_u64(u64::from(result.to_bits()))
1014    } else {
1015        let n = f64::from_bits(n_bits);
1016        let d = f64::from_bits(d_bits);
1017        let result = n / d;
1018        T::from_bits_u64(result.to_bits())
1019    }
1020}
1021
1022// ---------------------------------------------------------------------------
1023// PTX generation stub for batched SpMV
1024// ---------------------------------------------------------------------------
1025
1026/// Generate a batched SpMV PTX kernel that processes all matrices via offset
1027/// arrays in a single launch.
1028///
1029/// The generated kernel expects the following arguments:
1030/// - Concatenated row\_ptr, col\_idx, values arrays
1031/// - batch\_offsets\_row\_ptr, batch\_offsets\_nnz arrays
1032/// - row\_counts array
1033/// - x pointers array, y pointers array
1034/// - alpha, beta scalars
1035/// - batch\_size
1036///
1037/// Each thread block processes one matrix in the batch; within a block,
1038/// threads cooperate on rows using the scalar SpMV strategy.
1039///
1040/// # Note
1041///
1042/// This generates valid PTX source but actual GPU execution requires a
1043/// CUDA-capable device.
1044pub fn generate_batched_spmv_ptx<T: GpuFloat>() -> String {
1045    let type_name = T::NAME;
1046    let ptx_type = match T::SIZE {
1047        4 => ".f32",
1048        8 => ".f64",
1049        _ => ".f32",
1050    };
1051    let elem_size = match T::SIZE {
1052        8 => 8,
1053        _ => 4,
1054    };
1055    let zero_literal = match T::SIZE {
1056        8 => "0d0000000000000000",
1057        _ => "0f00000000",
1058    };
1059
1060    format!(
1061        r#"//
1062// Batched SpMV kernel for {type_name}
1063// Generated by oxicuda-sparse batched module
1064//
1065.version 7.0
1066.target sm_70
1067.address_size 64
1068
1069.visible .entry batched_spmv_{type_name}(
1070    .param .u64 concat_row_ptr,
1071    .param .u64 concat_col_idx,
1072    .param .u64 concat_values,
1073    .param .u64 batch_offsets_rp,
1074    .param .u64 batch_offsets_nnz,
1075    .param .u64 row_counts,
1076    .param .u64 x_ptrs,
1077    .param .u64 y_ptrs,
1078    .param {ptx_type} alpha,
1079    .param {ptx_type} beta,
1080    .param .u32 batch_size
1081)
1082{{
1083    .reg .u32 %r<16>;
1084    .reg .u64 %rd<32>;
1085    .reg {ptx_type} %f<8>;
1086    .reg .pred %p<4>;
1087
1088    // blockIdx.x = matrix index in batch
1089    mov.u32 %r0, %ctaid.x;
1090    // Early exit if blockIdx >= batch_size
1091    ld.param.u32 %r1, [batch_size];
1092    setp.ge.u32 %p0, %r0, %r1;
1093    @%p0 ret;
1094
1095    // threadIdx.x = local row within this matrix
1096    mov.u32 %r2, %tid.x;
1097
1098    // Load row_count for this matrix
1099    ld.param.u64 %rd0, [row_counts];
1100    cvt.u64.u32 %rd1, %r0;
1101    mad.wide.u32 %rd2, %r0, 4, %rd0;
1102    ld.global.u32 %r3, [%rd2];
1103
1104    // Early exit if tid >= row_count
1105    setp.ge.u32 %p1, %r2, %r3;
1106    @%p1 ret;
1107
1108    // Load row_ptr offset for this matrix
1109    ld.param.u64 %rd3, [batch_offsets_rp];
1110    mad.wide.u32 %rd4, %r0, 4, %rd3;
1111    ld.global.u32 %r4, [%rd4];
1112
1113    // row_start = concat_row_ptr[rp_offset + tid], row_end = next entry
1114    ld.param.u64 %rd5, [concat_row_ptr];
1115    add.u32 %r11, %r4, %r2;
1116    mul.wide.u32 %rd6, %r11, 4;
1117    add.u64 %rd5, %rd5, %rd6;
1118    ld.global.u32 %r5, [%rd5];
1119    add.u64 %rd6, %rd5, 4;
1120    ld.global.u32 %r6, [%rd6];
1121
1122    // Load nnz offset for this matrix
1123    ld.param.u64 %rd7, [batch_offsets_nnz];
1124    mad.wide.u32 %rd8, %r0, 4, %rd7;
1125    ld.global.u32 %r7, [%rd8];
1126
1127    // Load x and y pointers for this matrix
1128    ld.param.u64 %rd9, [x_ptrs];
1129    mad.wide.u32 %rd10, %r0, 8, %rd9;
1130    ld.global.u64 %rd10, [%rd10];
1131    ld.param.u64 %rd11, [y_ptrs];
1132    mad.wide.u32 %rd12, %r0, 8, %rd11;
1133    ld.global.u64 %rd11, [%rd12];
1134
1135    // Load concatenated col_idx / values bases and alpha / beta scalars
1136    ld.param.u64 %rd12, [concat_col_idx];
1137    ld.param.u64 %rd13, [concat_values];
1138    ld.param{ptx_type} %f6, [alpha];
1139    ld.param{ptx_type} %f7, [beta];
1140
1141    // acc = 0; iterate row_start .. row_end
1142    mov{ptx_type} %f0, {zero_literal};
1143    mov.u32 %r8, %r5;
1144
1145$ROW_LOOP:
1146    setp.lt.u32 %p2, %r8, %r6;
1147    @!%p2 bra $ROW_DONE;
1148
1149    // k = nnz_offset + absolute row nnz index
1150    add.u32 %r10, %r7, %r8;
1151
1152    // col = concat_col_idx[k]
1153    mul.wide.u32 %rd14, %r10, 4;
1154    add.u64 %rd15, %rd12, %rd14;
1155    ld.global.u32 %r9, [%rd15];
1156
1157    // val = concat_values[k]
1158    mul.wide.u32 %rd16, %r10, {elem_size};
1159    add.u64 %rd17, %rd13, %rd16;
1160    ld.global{ptx_type} %f1, [%rd17];
1161
1162    // x_val = x[col]
1163    mul.wide.u32 %rd18, %r9, {elem_size};
1164    add.u64 %rd19, %rd10, %rd18;
1165    ld.global{ptx_type} %f2, [%rd19];
1166
1167    // acc += val * x_val
1168    fma.rn{ptx_type} %f0, %f1, %f2, %f0;
1169
1170    add.u32 %r8, %r8, 1;
1171    bra $ROW_LOOP;
1172
1173$ROW_DONE:
1174    // y[tid] = alpha * acc + beta * y[tid]
1175    mul.wide.u32 %rd20, %r2, {elem_size};
1176    add.u64 %rd21, %rd11, %rd20;
1177    ld.global{ptx_type} %f3, [%rd21];
1178    mul.rn{ptx_type} %f4, %f6, %f0;
1179    mul.rn{ptx_type} %f5, %f7, %f3;
1180    add.rn{ptx_type} %f4, %f4, %f5;
1181    st.global{ptx_type} [%rd21], %f4;
1182
1183    ret;
1184}}
1185"#
1186    )
1187}
1188
1189// ---------------------------------------------------------------------------
1190// CPU reference: batched SpMV for multiple right-hand sides
1191// ---------------------------------------------------------------------------
1192
1193/// Batched SpMV (CPU reference): compute `y_i = A * x_i` for all `b` RHS vectors.
1194///
1195/// The shared sparse matrix `A` is given in CSR format. `x_batch` and `y_batch`
1196/// are stored in **column-major** order: element `(row_or_col, b)` is at index
1197/// `row_or_col * batch_size + b`.
1198///
1199/// # Arguments
1200///
1201/// * `n_rows` -- Number of rows in `A`.
1202/// * `n_cols` -- Number of columns in `A` (used for bounds; not strictly needed here).
1203/// * `row_ptr` -- CSR row pointer of length `n_rows + 1`.
1204/// * `col_idx` -- CSR column indices of length `nnz`.
1205/// * `values` -- CSR values of length `nnz`.
1206/// * `x_batch` -- Column-major matrix of shape `[n_cols × batch_size]`.
1207/// * `batch_size` -- Number of simultaneous right-hand sides.
1208///
1209/// # Returns
1210///
1211/// Output matrix `y` of shape `[n_rows × batch_size]` in column-major order.
1212pub fn batched_spmv_cpu(
1213    n_rows: usize,
1214    _n_cols: usize,
1215    row_ptr: &[u32],
1216    col_idx: &[u32],
1217    values: &[f32],
1218    x_batch: &[f32],
1219    batch_size: usize,
1220) -> Vec<f32> {
1221    let mut y = vec![0.0f32; n_rows * batch_size];
1222    for row in 0..n_rows {
1223        let start = row_ptr[row] as usize;
1224        let end = row_ptr[row + 1] as usize;
1225        for idx in start..end {
1226            let col = col_idx[idx] as usize;
1227            let val = values[idx];
1228            for b in 0..batch_size {
1229                y[row * batch_size + b] += val * x_batch[col * batch_size + b];
1230            }
1231        }
1232    }
1233    y
1234}
1235
1236// ---------------------------------------------------------------------------
1237// CPU reference: mixed-precision SpMV (FP16 storage, FP64 accumulation -> FP32)
1238// ---------------------------------------------------------------------------
1239
1240/// Mixed-precision SpMV (CPU reference): matrix values stored in FP16 range,
1241/// accumulation performed in FP64, result cast to FP32.
1242///
1243/// On actual GPU hardware, values would be loaded as FP16 (2 bytes each) and
1244/// accumulated in FP32 registers. This CPU reference simulates that by treating
1245/// `values_fp16` as FP32 scalars already quantised to FP16 precision, and
1246/// accumulating in FP64 to avoid catastrophic cancellation in the reference.
1247///
1248/// # Arguments
1249///
1250/// * `n_rows` -- Number of rows.
1251/// * `row_ptr` -- CSR row pointer of length `n_rows + 1`.
1252/// * `col_idx` -- CSR column indices of length `nnz`.
1253/// * `values_fp16` -- Per-entry matrix values (FP16-range, stored as f32).
1254/// * `x` -- Dense input vector of length `n_cols`.
1255///
1256/// # Returns
1257///
1258/// Output vector `y` of length `n_rows` as FP32.
1259pub fn mixed_precision_spmv_cpu(
1260    n_rows: usize,
1261    row_ptr: &[u32],
1262    col_idx: &[u32],
1263    values_fp16: &[f32],
1264    x: &[f32],
1265) -> Vec<f32> {
1266    let mut y = vec![0.0f32; n_rows];
1267    for row in 0..n_rows {
1268        let start = row_ptr[row] as usize;
1269        let end = row_ptr[row + 1] as usize;
1270        // Accumulate in f64 for numerical stability of the reference
1271        let mut acc = 0.0f64;
1272        for idx in start..end {
1273            let col = col_idx[idx] as usize;
1274            let val = values_fp16[idx] as f64;
1275            acc += val * (x[col] as f64);
1276        }
1277        y[row] = acc as f32;
1278    }
1279    y
1280}
1281
1282// ---------------------------------------------------------------------------
1283// Tests
1284// ---------------------------------------------------------------------------
1285
1286#[cfg(test)]
1287mod tests;