Skip to main content

single_svdlib/matrix/
kernels.rs

1//! Parallel sparse × dense kernels.
2//!
3//! # Why there are two kernels
4//!
5//! A compressed matrix can only be walked along its outer dimension. For a CSR matrix
6//! that is rows, so:
7//!
8//! - `A · D` writes output row `i` from sparse row `i`. Threads own disjoint output
9//!   rows, so this needs **no scratch and no reduction** — [`gather_mul`].
10//! - `Aᵀ · D` reads sparse row `i` and scatters into output rows `j` for every column
11//!   `j` present in that row. Threads collide, so accumulation is needed —
12//!   [`scatter_mul`].
13//!
14//! `transpose_view()` does not escape this: it relabels a CSR matrix as a CSC view of
15//! the transpose, but the traversable dimension is unchanged. What it *does* buy is
16//! that a CSC-stored matrix gets the disjoint kernel for `Aᵀ · D` for free, so callers
17//! holding CSC pay nothing for the transposed direction.
18//!
19//! # Scratch budgeting
20//!
21//! The 1.x code allocated one full `n × k` buffer **per chunk**, with chunk count
22//! driven by matrix size — 64 chunks on a 200k-row matrix, so ~922 MiB of scratch for
23//! a single 30000 × 60 product. Here the accumulator count is the *thread* count, and
24//! if `threads × n × k` still exceeds [`DEFAULT_SCRATCH_BUDGET`] the dense columns are
25//! processed in blocks so the bound always holds.
26
27// Numeric kernels index several arrays in step from one loop variable, and
28// offset arithmetic is load-bearing; iterator rewrites obscure which array an
29// index belongs to.
30#![allow(clippy::needless_range_loop)]
31
32use crate::types::SvdFloat;
33use ndarray::{s, Array2, ArrayView2, ArrayViewMut2, Axis};
34use rayon::prelude::*;
35use sprs::{CsMatViewI, SpIndex};
36
37/// Upper bound on transient scratch for scatter-direction products, in bytes.
38///
39/// Exceeding this trades an extra pass over the sparse indices for a smaller
40/// footprint. 64 MiB keeps the accumulators comfortably inside last-level cache
41/// pressure on typical machines while still amortising index reads over many columns.
42pub const DEFAULT_SCRATCH_BUDGET: usize = 64 << 20;
43
44/// Below this many output elements, threading costs more than it saves.
45const SERIAL_ELEMS: usize = 8 << 10;
46
47#[inline]
48fn threads() -> usize {
49    rayon::current_num_threads().max(1)
50}
51
52/// `y += alpha * x`, over contiguous slices so LLVM can vectorise it.
53#[inline]
54fn axpy<T: SvdFloat>(alpha: T, x: &[T], y: &mut [T]) {
55    debug_assert_eq!(x.len(), y.len());
56    for (yi, &xi) in y.iter_mut().zip(x.iter()) {
57        *yi += alpha * xi;
58    }
59}
60
61/// Split `[0, outer)` into `p` contiguous ranges holding roughly equal non-zeros.
62///
63/// Row counts are a poor proxy for work when the non-zero distribution is skewed,
64/// which it reliably is for count matrices (a few rows carry a large share of the
65/// mass). Returns `p + 1` boundaries.
66fn nnz_balanced_split<N, I: SpIndex, Iptr: SpIndex>(
67    m: &CsMatViewI<N, I, Iptr>,
68    p: usize,
69) -> Vec<usize> {
70    let outer = m.outer_dims();
71    let total = m.nnz();
72    let mut bounds = Vec::with_capacity(p + 1);
73    bounds.push(0);
74    if p <= 1 || outer == 0 || total == 0 {
75        bounds.push(outer);
76        while bounds.len() < p + 1 {
77            bounds.push(outer);
78        }
79        return bounds;
80    }
81    let mut acc = 0usize;
82    let mut next = 1usize;
83    for i in 0..outer {
84        acc += m.outer_view(i).map_or(0, |v| v.nnz());
85        // Advance past every boundary this row crosses, so a single heavy row cannot
86        // leave later partitions unassigned.
87        while next < p && acc * p >= total * next {
88            bounds.push(i + 1);
89            next += 1;
90        }
91    }
92    while bounds.len() < p + 1 {
93        bounds.push(outer);
94    }
95    bounds
96}
97
98/// `out = lhs · rhs` where `lhs` is CSR. Write-disjoint: no scratch, no reduction.
99///
100/// `out` is fully overwritten.
101pub fn gather_mul<T, I, Iptr>(
102    lhs: CsMatViewI<T, I, Iptr>,
103    rhs: ArrayView2<T>,
104    mut out: ArrayViewMut2<T>,
105) where
106    T: SvdFloat,
107    I: SpIndex,
108    Iptr: SpIndex,
109{
110    assert!(lhs.is_csr(), "gather_mul requires CSR storage");
111    assert_eq!(lhs.cols(), rhs.nrows(), "gather_mul: lhs.cols != rhs.rows");
112    assert_eq!(lhs.rows(), out.nrows(), "gather_mul: lhs.rows != out.rows");
113    assert_eq!(rhs.ncols(), out.ncols(), "gather_mul: rhs.cols != out.cols");
114
115    let k = rhs.ncols();
116    let m = lhs.rows();
117    if m == 0 || k == 0 {
118        out.fill(T::zero());
119        return;
120    }
121
122    let row_op = |i: usize, orow: &mut [T], rhs: &ArrayView2<T>| {
123        orow.fill(T::zero());
124        let Some(row) = lhs.outer_view(i) else { return };
125        for (j, &v) in row.indices().iter().zip(row.data().iter()) {
126            let rrow = rhs.row(j.index());
127            // rhs is built row-major by this crate; fall back if a caller passes a view.
128            match rrow.as_slice() {
129                Some(sl) => axpy(v, sl, orow),
130                None => {
131                    for (o, &r) in orow.iter_mut().zip(rrow.iter()) {
132                        *o += v * r;
133                    }
134                }
135            }
136        }
137    };
138
139    if m * k <= SERIAL_ELEMS {
140        for i in 0..m {
141            let mut orow = out.row_mut(i);
142            match orow.as_slice_mut() {
143                Some(sl) => row_op(i, sl, &rhs),
144                None => {
145                    let mut tmp = vec![T::zero(); k];
146                    row_op(i, &mut tmp, &rhs);
147                    for (o, t) in orow.iter_mut().zip(tmp) {
148                        *o = t;
149                    }
150                }
151            }
152        }
153        return;
154    }
155
156    // 4 chunks per thread lets rayon steal work when rows are unevenly filled.
157    let chunk = m.div_ceil(threads() * 4).max(1);
158    out.axis_chunks_iter_mut(Axis(0), chunk)
159        .into_par_iter()
160        .enumerate()
161        .for_each(|(ci, mut block)| {
162            let base = ci * chunk;
163            for (local, mut orow) in block.rows_mut().into_iter().enumerate() {
164                let i = base + local;
165                match orow.as_slice_mut() {
166                    Some(sl) => row_op(i, sl, &rhs),
167                    None => {
168                        let mut tmp = vec![T::zero(); k];
169                        row_op(i, &mut tmp, &rhs);
170                        for (o, t) in orow.iter_mut().zip(tmp) {
171                            *o = t;
172                        }
173                    }
174                }
175            }
176        });
177}
178
179/// `out = lhsᵀ · rhs` where `lhs` is CSR. Scatter direction: uses one accumulator per
180/// thread, blocking over the columns of `rhs` to keep scratch under `budget`.
181///
182/// `out` is fully overwritten.
183pub fn scatter_mul<T, I, Iptr>(
184    lhs: CsMatViewI<T, I, Iptr>,
185    rhs: ArrayView2<T>,
186    mut out: ArrayViewMut2<T>,
187    budget: usize,
188) where
189    T: SvdFloat,
190    I: SpIndex,
191    Iptr: SpIndex,
192{
193    assert!(lhs.is_csr(), "scatter_mul requires CSR storage");
194    assert_eq!(lhs.rows(), rhs.nrows(), "scatter_mul: lhs.rows != rhs.rows");
195    assert_eq!(lhs.cols(), out.nrows(), "scatter_mul: lhs.cols != out.rows");
196    assert_eq!(
197        rhs.ncols(),
198        out.ncols(),
199        "scatter_mul: rhs.cols != out.cols"
200    );
201
202    let (m, n, k) = (lhs.rows(), lhs.cols(), rhs.ncols());
203    out.fill(T::zero());
204    if m == 0 || n == 0 || k == 0 {
205        return;
206    }
207
208    // Serial path: accumulate straight into `out`, no scratch at all.
209    let p = threads();
210    if p == 1 || n * k <= SERIAL_ELEMS {
211        for i in 0..m {
212            let Some(row) = lhs.outer_view(i) else {
213                continue;
214            };
215            let rrow = rhs.row(i);
216            let rslice = rrow.as_slice();
217            for (j, &v) in row.indices().iter().zip(row.data().iter()) {
218                let mut orow = out.row_mut(j.index());
219                match (orow.as_slice_mut(), rslice) {
220                    (Some(o), Some(r)) => axpy(v, r, o),
221                    _ => {
222                        for (o, &r) in orow.iter_mut().zip(rrow.iter()) {
223                            *o += v * r;
224                        }
225                    }
226                }
227            }
228        }
229        return;
230    }
231
232    // Widest column block whose p accumulators fit the budget.
233    let bytes_per_col = p.saturating_mul(n).saturating_mul(std::mem::size_of::<T>());
234    let kb = budget
235        .checked_div(bytes_per_col)
236        .map_or(k, |wide| wide.clamp(1, k));
237
238    let bounds = nnz_balanced_split(&lhs, p);
239
240    for cstart in (0..k).step_by(kb) {
241        let cend = (cstart + kb).min(k);
242        let width = cend - cstart;
243        let rhs_blk = rhs.slice(s![.., cstart..cend]);
244
245        let partials: Vec<Array2<T>> = (0..p)
246            .into_par_iter()
247            .map(|t| {
248                let (lo, hi) = (bounds[t], bounds[t + 1]);
249                let mut acc = Array2::<T>::zeros((n, width));
250                for i in lo..hi {
251                    let Some(row) = lhs.outer_view(i) else {
252                        continue;
253                    };
254                    let rrow = rhs_blk.row(i);
255                    // rhs_blk is a column slice, so its rows stay contiguous only when
256                    // the block spans every column; handle both.
257                    let rslice = rrow.as_slice();
258                    for (j, &v) in row.indices().iter().zip(row.data().iter()) {
259                        let jj = j.index();
260                        let mut arow = acc.row_mut(jj);
261                        let aslice = arow.as_slice_mut().expect("owned array is contiguous");
262                        match rslice {
263                            Some(r) => axpy(v, r, aslice),
264                            None => {
265                                for (a, &r) in aslice.iter_mut().zip(rrow.iter()) {
266                                    *a += v * r;
267                                }
268                            }
269                        }
270                    }
271                }
272                acc
273            })
274            .collect();
275
276        // Reduce, parallel over disjoint output rows.
277        let mut out_blk = out.slice_mut(s![.., cstart..cend]);
278        let rchunk = n.div_ceil(p * 4).max(1);
279        out_blk
280            .axis_chunks_iter_mut(Axis(0), rchunk)
281            .into_par_iter()
282            .enumerate()
283            .for_each(|(ci, mut block)| {
284                let base = ci * rchunk;
285                for (local, mut orow) in block.rows_mut().into_iter().enumerate() {
286                    let gi = base + local;
287                    for acc in &partials {
288                        let arow = acc.row(gi);
289                        for (o, &a) in orow.iter_mut().zip(arow.iter()) {
290                            *o += a;
291                        }
292                    }
293                }
294            });
295    }
296}
297
298/// `y = lhs · x` where `lhs` is CSR. Write-disjoint.
299pub fn gather_mul_vec<T, I, Iptr>(lhs: CsMatViewI<T, I, Iptr>, x: &[T], y: &mut [T])
300where
301    T: SvdFloat,
302    I: SpIndex,
303    Iptr: SpIndex,
304{
305    assert!(lhs.is_csr(), "gather_mul_vec requires CSR storage");
306    assert_eq!(lhs.cols(), x.len(), "gather_mul_vec: lhs.cols != x.len");
307    assert_eq!(lhs.rows(), y.len(), "gather_mul_vec: lhs.rows != y.len");
308
309    let dot = |i: usize| -> T {
310        let Some(row) = lhs.outer_view(i) else {
311            return T::zero();
312        };
313        let mut sum = T::zero();
314        for (j, &v) in row.indices().iter().zip(row.data().iter()) {
315            sum += v * x[j.index()];
316        }
317        sum
318    };
319
320    if y.len() <= SERIAL_ELEMS {
321        for (i, yi) in y.iter_mut().enumerate() {
322            *yi = dot(i);
323        }
324        return;
325    }
326    let chunk = y.len().div_ceil(threads() * 4).max(1);
327    y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| {
328        let base = ci * chunk;
329        for (local, yi) in blk.iter_mut().enumerate() {
330            *yi = dot(base + local);
331        }
332    });
333}
334
335/// `y = lhsᵀ · x` where `lhs` is CSR. Scatter direction, one accumulator per thread.
336pub fn scatter_mul_vec<T, I, Iptr>(lhs: CsMatViewI<T, I, Iptr>, x: &[T], y: &mut [T])
337where
338    T: SvdFloat,
339    I: SpIndex,
340    Iptr: SpIndex,
341{
342    assert!(lhs.is_csr(), "scatter_mul_vec requires CSR storage");
343    assert_eq!(lhs.rows(), x.len(), "scatter_mul_vec: lhs.rows != x.len");
344    assert_eq!(lhs.cols(), y.len(), "scatter_mul_vec: lhs.cols != y.len");
345
346    let (m, n) = (lhs.rows(), lhs.cols());
347    y.fill(T::zero());
348    if m == 0 || n == 0 {
349        return;
350    }
351
352    let p = threads();
353    // A single vector's worth of accumulator is p * n scalars; only worth splitting
354    // when there is enough work to pay for the reduction.
355    if p == 1 || lhs.nnz() <= SERIAL_ELEMS {
356        for i in 0..m {
357            let Some(row) = lhs.outer_view(i) else {
358                continue;
359            };
360            let xi = x[i];
361            if xi.is_zero() {
362                continue;
363            }
364            for (j, &v) in row.indices().iter().zip(row.data().iter()) {
365                y[j.index()] += v * xi;
366            }
367        }
368        return;
369    }
370
371    let bounds = nnz_balanced_split(&lhs, p);
372    let partials: Vec<Vec<T>> = (0..p)
373        .into_par_iter()
374        .map(|t| {
375            let (lo, hi) = (bounds[t], bounds[t + 1]);
376            let mut acc = vec![T::zero(); n];
377            for i in lo..hi {
378                let Some(row) = lhs.outer_view(i) else {
379                    continue;
380                };
381                let xi = x[i];
382                if xi.is_zero() {
383                    continue;
384                }
385                for (j, &v) in row.indices().iter().zip(row.data().iter()) {
386                    acc[j.index()] += v * xi;
387                }
388            }
389            acc
390        })
391        .collect();
392
393    let chunk = n.div_ceil(p * 4).max(1);
394    y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| {
395        let base = ci * chunk;
396        for (local, yi) in blk.iter_mut().enumerate() {
397            let gi = base + local;
398            for acc in &partials {
399                *yi += acc[gi];
400            }
401        }
402    });
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use ndarray::{arr2, Array2};
409    use sprs::{CsMatI, TriMatI};
410
411    fn tiny() -> CsMatI<f64, u32, u64> {
412        // [ 1 0 2 ]
413        // [ 0 3 0 ]
414        // [ 4 0 5 ]
415        // [ 0 6 0 ]
416        let mut t = TriMatI::<f64, u32>::new((4, 3));
417        t.add_triplet(0, 0, 1.0);
418        t.add_triplet(0, 2, 2.0);
419        t.add_triplet(1, 1, 3.0);
420        t.add_triplet(2, 0, 4.0);
421        t.add_triplet(2, 2, 5.0);
422        t.add_triplet(3, 1, 6.0);
423        t.to_csr::<u64>()
424    }
425
426    use crate::testing::dense_of;
427
428    #[test]
429    fn gather_matches_dense() {
430        let a = tiny();
431        let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]);
432        let mut out = Array2::zeros((4, 2));
433        gather_mul(a.view(), rhs.view(), out.view_mut());
434        let expect = dense_of(&a).dot(&rhs);
435        assert_eq!(out, expect);
436    }
437
438    #[test]
439    fn scatter_matches_dense() {
440        let a = tiny();
441        let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]);
442        let mut out = Array2::zeros((3, 2));
443        scatter_mul(a.view(), rhs.view(), out.view_mut(), DEFAULT_SCRATCH_BUDGET);
444        let expect = dense_of(&a).t().dot(&rhs);
445        assert_eq!(out, expect);
446    }
447
448    /// A budget of 0 forces `kb == 1`, exercising the multi-pass path.
449    #[test]
450    fn scatter_column_blocking_matches_single_pass() {
451        let a = tiny();
452        let rhs = arr2(&[
453            [1.0, 2.0, 9.0],
454            [3.0, 4.0, 8.0],
455            [5.0, 6.0, 7.0],
456            [7.0, 8.0, 6.0],
457        ]);
458        let mut wide = Array2::zeros((3, 3));
459        let mut narrow = Array2::zeros((3, 3));
460        scatter_mul(a.view(), rhs.view(), wide.view_mut(), usize::MAX);
461        scatter_mul(a.view(), rhs.view(), narrow.view_mut(), 0);
462        assert_eq!(wide, narrow);
463        assert_eq!(wide, dense_of(&a).t().dot(&rhs));
464    }
465
466    #[test]
467    fn matvecs_match_dense() {
468        let a = tiny();
469        let d = dense_of(&a);
470        let x = vec![1.0, 2.0, 3.0];
471        let mut y = vec![0.0; 4];
472        gather_mul_vec(a.view(), &x, &mut y);
473        assert_eq!(y, d.dot(&ndarray::arr1(&x)).to_vec());
474
475        let xt = vec![1.0, 2.0, 3.0, 4.0];
476        let mut yt = vec![0.0; 3];
477        scatter_mul_vec(a.view(), &xt, &mut yt);
478        assert_eq!(yt, d.t().dot(&ndarray::arr1(&xt)).to_vec());
479    }
480
481    #[test]
482    fn nnz_split_covers_all_rows_and_is_monotone() {
483        let a = tiny();
484        for p in 1..=8 {
485            let b = nnz_balanced_split(&a.view(), p);
486            assert_eq!(b.len(), p + 1);
487            assert_eq!(b[0], 0);
488            assert_eq!(*b.last().unwrap(), a.rows());
489            assert!(b.windows(2).all(|w| w[0] <= w[1]), "not monotone: {b:?}");
490        }
491    }
492
493    /// A matrix whose non-zeros are concentrated in one row: row-count splitting
494    /// would put all the work on a single thread.
495    #[test]
496    fn nnz_split_handles_skew() {
497        let mut t = TriMatI::<f64, u32>::new((100, 50));
498        for j in 0..50 {
499            t.add_triplet(0, j, 1.0);
500        }
501        for i in 1..100 {
502            t.add_triplet(i, 0, 1.0);
503        }
504        let a: CsMatI<f64, u32, u64> = t.to_csr();
505        let b = nnz_balanced_split(&a.view(), 4);
506        assert_eq!(b[0], 0);
507        assert_eq!(*b.last().unwrap(), 100);
508        assert!(b.windows(2).all(|w| w[0] <= w[1]));
509        // The heavy first row must be isolated into the first partition.
510        assert_eq!(
511            b[1], 1,
512            "expected the 50-nnz row to form its own partition: {b:?}"
513        );
514    }
515}