Skip to main content

oxicuda_sparse/ops/
spgemm_symbolic.rs

1//! Symbolic SpGEMM (Gustavson) -- the sparsity pattern of `C = A * B`.
2//!
3//! This module computes **only** the output sparsity structure (the `row_ptr`
4//! array and the sorted column indices of each row) of the sparse-sparse
5//! product `C = A * B`, without evaluating any numeric values. Knowing the
6//! pattern ahead of time lets a caller pre-allocate the result and run the
7//! numeric phase without reallocation -- the standard two-phase SpGEMM strategy
8//! and the foundation of fixed-pattern iterative schemes (repeated Galerkin
9//! products, polynomial preconditioners, graph powers).
10//!
11//! ## Algorithm
12//!
13//! The implementation follows Gustavson's 1978 row-wise formulation. For each
14//! row `i` of `A`, every non-zero `A[i, p]` selects row `p` of `B`; the union
15//! of the column indices over all such `B` rows is exactly the set of
16//! structurally non-zero columns of `C[i, :]`. Uniqueness within the row is
17//! tracked by a single integer **mask** array indexed by column: `mask[c]`
18//! holds the index of the row that most recently inserted column `c`, so a
19//! membership test and insertion are both `O(1)` with no hashing and no
20//! per-row reinitialisation (the row index itself is the generation marker).
21//! Each completed row is sorted ascending to match the crate's CSR convention.
22//!
23//! The result is the **structural** pattern. It equals the numeric non-zero
24//! pattern of `A * B` exactly whenever no numeric cancellation occurs (e.g. for
25//! sign-definite operands); with cancellation the structural pattern is a
26//! superset, as is true of every symbolic SpGEMM.
27
28use crate::error::{SparseError, SparseResult};
29use crate::host_csr::HostCsr;
30
31/// The sparsity pattern (structure) of a sparse matrix in CSR layout.
32///
33/// Carries no numeric values -- only the row pointers and the column indices.
34/// Column indices are sorted ascending within each row, matching [`HostCsr`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SymbolicPattern {
37    /// Number of rows.
38    pub nrows: usize,
39    /// Number of columns.
40    pub ncols: usize,
41    /// Row pointer array of length `nrows + 1`, monotone non-decreasing, with
42    /// `row_ptr[0] == 0` and `row_ptr[nrows] == nnz`.
43    pub row_ptr: Vec<usize>,
44    /// Column indices of length `nnz`, sorted ascending within each row and all
45    /// in the range `[0, ncols)`.
46    pub col_indices: Vec<usize>,
47}
48
49impl SymbolicPattern {
50    /// Number of structural non-zeros (`row_ptr[nrows]`).
51    #[inline]
52    pub fn nnz(&self) -> usize {
53        self.col_indices.len()
54    }
55
56    /// Returns the sorted column indices of structural row `row`.
57    ///
58    /// # Panics
59    ///
60    /// Panics if `row >= nrows` (the caller is responsible for the bound, as
61    /// with ordinary slice indexing).
62    #[inline]
63    pub fn row(&self, row: usize) -> &[usize] {
64        &self.col_indices[self.row_ptr[row]..self.row_ptr[row + 1]]
65    }
66}
67
68/// Computes the structural sparsity pattern of `C = A * B` via Gustavson's
69/// row-wise symbolic algorithm.
70///
71/// Only the structure of `A` and `B` is inspected; their stored values are
72/// never read. The returned [`SymbolicPattern`] has `A.nrows` rows and
73/// `B.ncols` columns, with each row's column indices sorted ascending.
74///
75/// # Errors
76///
77/// Returns [`SparseError::DimensionMismatch`] if `A.ncols != B.nrows`.
78pub fn spgemm_symbolic_pattern(a: &HostCsr, b: &HostCsr) -> SparseResult<SymbolicPattern> {
79    if a.ncols != b.nrows {
80        return Err(SparseError::DimensionMismatch(format!(
81            "A.ncols ({}) != B.nrows ({})",
82            a.ncols, b.nrows
83        )));
84    }
85
86    let out_cols = b.ncols;
87    let mut row_ptr = vec![0usize; a.nrows + 1];
88    let mut col_indices: Vec<usize> = Vec::new();
89
90    // Gustavson generation mask: `mask[c]` records the row that last inserted
91    // column `c`. The sentinel `usize::MAX` marks a column never yet touched,
92    // which cannot collide with any real row index in practice.
93    let mut mask = vec![usize::MAX; out_cols];
94    let mut row_cols: Vec<usize> = Vec::new();
95
96    for i in 0..a.nrows {
97        row_cols.clear();
98        let a_start = a.row_ptr[i];
99        let a_end = a.row_ptr[i + 1];
100        for &a_col in &a.col_indices[a_start..a_end] {
101            let b_start = b.row_ptr[a_col];
102            let b_end = b.row_ptr[a_col + 1];
103            for &b_col in &b.col_indices[b_start..b_end] {
104                if mask[b_col] != i {
105                    mask[b_col] = i;
106                    row_cols.push(b_col);
107                }
108            }
109        }
110        row_cols.sort_unstable();
111        col_indices.extend_from_slice(&row_cols);
112        row_ptr[i + 1] = col_indices.len();
113    }
114
115    Ok(SymbolicPattern {
116        nrows: a.nrows,
117        ncols: out_cols,
118        row_ptr,
119        col_indices,
120    })
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    /// Deterministic LCG for building reproducible random test matrices.
128    struct Lcg {
129        state: u64,
130    }
131
132    impl Lcg {
133        fn new(seed: u64) -> Self {
134            Self {
135                state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
136            }
137        }
138        fn next_u32(&mut self) -> u32 {
139            self.state = self
140                .state
141                .wrapping_mul(6_364_136_223_846_793_005)
142                .wrapping_add(1_442_695_040_888_963_407);
143            (self.state >> 32) as u32
144        }
145        /// Strictly positive value in `(0, 4]` so products never cancel.
146        fn next_pos(&mut self) -> f64 {
147            1.0 + (self.next_u32() as f64 / u32::MAX as f64) * 3.0
148        }
149        fn next_bool(&mut self, num: u32, den: u32) -> bool {
150            self.next_u32() % den < num
151        }
152    }
153
154    /// Builds a random `nrows x ncols` matrix with strictly positive entries
155    /// (so the numeric product has no cancellation) at the given fill density.
156    fn random_positive(nrows: usize, ncols: usize, num: u32, den: u32, seed: u64) -> HostCsr {
157        let mut rng = Lcg::new(seed);
158        let mut row_ptr = vec![0usize; nrows + 1];
159        let mut col_indices = Vec::new();
160        let mut values = Vec::new();
161        for i in 0..nrows {
162            for j in 0..ncols {
163                if rng.next_bool(num, den) {
164                    col_indices.push(j);
165                    values.push(rng.next_pos());
166                }
167            }
168            row_ptr[i + 1] = col_indices.len();
169        }
170        HostCsr::new(nrows, ncols, row_ptr, col_indices, values).expect("valid random csr")
171    }
172
173    /// Extracts the numeric non-zero pattern of a host matrix as per-row sets.
174    fn numeric_pattern(m: &HostCsr) -> Vec<Vec<usize>> {
175        (0..m.nrows)
176            .map(|i| m.col_indices[m.row_ptr[i]..m.row_ptr[i + 1]].to_vec())
177            .collect()
178    }
179
180    /// Asserts the symbolic pattern matches the numeric product exactly.
181    fn assert_matches_numeric(a: &HostCsr, b: &HostCsr) {
182        let sym = spgemm_symbolic_pattern(a, b).expect("symbolic");
183        let numeric = a.matmul(b).expect("numeric");
184
185        // row_ptr must agree exactly (so nnz per row and total nnz agree).
186        assert_eq!(sym.row_ptr, numeric.row_ptr, "row_ptr mismatch");
187        assert_eq!(sym.nnz(), numeric.nnz(), "nnz mismatch");
188
189        // Per-row column sets must agree (both are sorted ascending).
190        let num_sets = numeric_pattern(&numeric);
191        for (i, num_row) in num_sets.iter().enumerate() {
192            assert_eq!(sym.row(i), num_row.as_slice(), "row {i} columns differ");
193        }
194
195        // Structural invariants.
196        assert_eq!(sym.row_ptr[0], 0);
197        assert_eq!(*sym.row_ptr.last().expect("nonempty"), sym.nnz());
198        for w in sym.row_ptr.windows(2) {
199            assert!(w[1] >= w[0], "row_ptr not monotone");
200        }
201        for &c in &sym.col_indices {
202            assert!(c < sym.ncols, "column {c} out of range");
203        }
204    }
205
206    #[test]
207    fn matches_dense_handbuilt() {
208        // A = [[1,2,0],[0,3,4],[5,0,6]] ; B = [[7,0],[0,8],[9,0]]  (all positive)
209        let a = HostCsr::new(
210            3,
211            3,
212            vec![0, 2, 4, 6],
213            vec![0, 1, 1, 2, 0, 2],
214            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
215        )
216        .expect("a");
217        let b =
218            HostCsr::new(3, 2, vec![0, 1, 2, 3], vec![0, 1, 0], vec![7.0, 8.0, 9.0]).expect("b");
219        assert_matches_numeric(&a, &b);
220
221        // Spot-check the exact expected pattern.
222        let sym = spgemm_symbolic_pattern(&a, &b).expect("sym");
223        assert_eq!(sym.row_ptr, vec![0, 2, 4, 5]);
224        assert_eq!(sym.row(0), &[0, 1]);
225        assert_eq!(sym.row(1), &[0, 1]);
226        assert_eq!(sym.row(2), &[0]); // row 2 -> only column 0 is structurally hit
227    }
228
229    #[test]
230    fn identity_preserves_pattern() {
231        let a = random_positive(6, 6, 1, 3, 0xabc);
232        let eye = HostCsr::new(
233            6,
234            6,
235            vec![0, 1, 2, 3, 4, 5, 6],
236            vec![0, 1, 2, 3, 4, 5],
237            vec![1.0; 6],
238        )
239        .expect("eye");
240        // A * I and I * A both reproduce A's structure.
241        assert_matches_numeric(&a, &eye);
242        assert_matches_numeric(&eye, &a);
243    }
244
245    #[test]
246    fn handles_empty_rows() {
247        // Row 1 of A is empty -> row 1 of C must be empty.
248        let a = HostCsr::new(3, 3, vec![0, 1, 1, 2], vec![2, 0], vec![1.0, 1.0]).expect("a");
249        let b = random_positive(3, 4, 1, 2, 0x55);
250        let sym = spgemm_symbolic_pattern(&a, &b).expect("sym");
251        assert_eq!(sym.row_ptr[1], sym.row_ptr[2], "empty A row -> empty C row");
252        assert_matches_numeric(&a, &b);
253    }
254
255    #[test]
256    fn empty_b_column_block() {
257        // B has a fully empty row 0; columns reached only via A[*,0] vanish.
258        let a = random_positive(4, 3, 2, 3, 0x99);
259        // B rows: 0 empty, 1 -> col 2, 2 -> cols 0,1.
260        let b =
261            HostCsr::new(3, 3, vec![0, 0, 1, 3], vec![2, 0, 1], vec![1.0, 1.0, 1.0]).expect("b");
262        assert_matches_numeric(&a, &b);
263    }
264
265    #[test]
266    fn random_small_matrices() {
267        // Several independent random pairs of compatible dimensions.
268        let cases = [
269            (5usize, 4usize, 6usize, 1u32, 2u32, 0x1u64),
270            (7, 7, 7, 1, 3, 0x2),
271            (3, 8, 5, 2, 3, 0x3),
272            (10, 6, 9, 1, 4, 0x4),
273            (6, 6, 6, 1, 1, 0x5), // dense-ish
274        ];
275        for (m, k, n, num, den, seed) in cases {
276            let a = random_positive(m, k, num, den, seed);
277            let b = random_positive(k, n, num, den, seed ^ 0xdead);
278            assert_matches_numeric(&a, &b);
279        }
280    }
281
282    #[test]
283    fn dimension_mismatch_errors() {
284        let a = random_positive(3, 4, 1, 2, 1);
285        let b = random_positive(5, 2, 1, 2, 2); // 5 != 4
286        assert!(spgemm_symbolic_pattern(&a, &b).is_err());
287    }
288
289    #[test]
290    fn nnz_and_row_accessor() {
291        let a = random_positive(4, 4, 1, 2, 7);
292        let b = random_positive(4, 4, 1, 2, 8);
293        let sym = spgemm_symbolic_pattern(&a, &b).expect("sym");
294        let total: usize = (0..sym.nrows).map(|i| sym.row(i).len()).sum();
295        assert_eq!(total, sym.nnz());
296    }
297}