Skip to main content

hermes_simd_core/sparse/
spmv.rs

1//! Sparse matrix-vector multiplication (SpMV) kernels.
2//!
3//! # Safety
4//!
5//! Every kernel call below is `#[target_feature]`-gated and is therefore sound
6//! only on a host implementing `Arch`. That holds by construction rather than by
7//! inspection: [`SimdView::new`](crate::view::SimdView::new) returns `None` for
8//! an architecture the host cannot execute, and the sparse and copy-on-write
9//! constructors assert the same condition, so possessing one of these
10//! arch-parameterized values *is* the proof. Per-site `SAFETY` comments record
11//! only the obligations that go beyond it — pointer provenance, bounds, and
12//! alignment.
13
14use super::{BlockedCoo, Csr, DenseWithMask, SellP, SellPData, SparseView, Validated};
15use crate::arch::SimdArch;
16use crate::kernel::SimdKernel;
17use crate::scalar::Scalar;
18
19/// Unified trait for sparse matrix-vector multiplication.
20pub trait SparseSpMv<T> {
21    /// Perform matrix-vector multiplication: `y += A * x`.
22    ///
23    /// # Panics
24    /// Panics if the dimensions of `x` or `y` are incompatible with the matrix.
25    fn spmv(&self, x: &[T], y: &mut [T]);
26}
27
28/// Build an `Arch::IndexVector` from a slice of `i32` column indices.
29///
30/// # Safety
31/// All implementations of `SimdKernel` in this workspace define `IndexVector`
32/// with the layout `[i32; LANE_COUNT]`. This function reads `&[i32]` of length
33/// `>= LANE_COUNT` as one `Arch::IndexVector` via an unaligned read (so element
34/// alignment, not vector alignment, is the only requirement). The size half of
35/// the layout invariant is enforced at compile time per backend by the
36/// `const` assert below; the length contract is enforced at runtime.
37#[inline(always)]
38pub(crate) unsafe fn build_index_vector<T: Scalar, Arch: SimdKernel<T>>(
39    cols: &[i32],
40) -> Arch::IndexVector {
41    // Compile-time guard binding the soundness condition the SAFETY note relies
42    // on: a backend whose `IndexVector` is not `LANE_COUNT` packed `i32`s fails
43    // to build rather than reading out of bounds / forming an invalid value.
44    const {
45        assert!(
46            core::mem::size_of::<Arch::IndexVector>()
47                == Arch::LANE_COUNT * core::mem::size_of::<i32>(),
48            "IndexVector size must equal LANE_COUNT * size_of::<i32>()"
49        )
50    };
51    assert!(
52        cols.len() >= Arch::LANE_COUNT,
53        "cols slice length {} is less than LANE_COUNT {}",
54        cols.len(),
55        Arch::LANE_COUNT
56    );
57    let ptr = cols.as_ptr() as *const Arch::IndexVector;
58    core::ptr::read_unaligned(ptr)
59}
60
61#[inline(never)]
62fn validate_spmv_sizes(x_len: usize, y_len: usize, ncols: usize, nrows: usize, format_name: &str) {
63    assert!(
64        x_len >= ncols,
65        "x too short for {} ncols (got {}, expected >= {})",
66        format_name,
67        x_len,
68        ncols
69    );
70    assert!(
71        y_len >= nrows,
72        "y too short for {} nrows (got {}, expected >= {})",
73        format_name,
74        y_len,
75        nrows
76    );
77}
78
79impl<'a, T, Arch> SparseSpMv<T> for SparseView<'a, T, Validated<Csr>, Arch>
80where
81    T: Scalar,
82    Arch: SimdArch + SimdKernel<T>,
83{
84    #[inline]
85    fn spmv(&self, x: &[T], y: &mut [T]) {
86        let data = self.data.storage();
87        validate_spmv_sizes(x.len(), y.len(), data.ncols, data.nrows, "CSR");
88
89        let lane_count = Arch::LANE_COUNT;
90
91        for r in 0..data.nrows {
92            let start = data.row_ptr[r] as usize;
93            let end = data.row_ptr[r + 1] as usize;
94            let row_nnz = end - start;
95
96            if row_nnz == 0 {
97                continue;
98            }
99
100            let vals = &data.values[start..end];
101            let cols = &data.col_indices[start..end];
102
103            let simd_len = (row_nnz / lane_count) * lane_count;
104
105            // Accumulate in vector registers first to avoid horizontal reductions in the inner loop.
106            // SAFETY: `Arch::*` are the target-feature kernels covered by the
107            // module invariant. Each lane-block below reads a `LANE_COUNT` window
108            // of `vals`/`cols` at offset `j < simd_len <= row_nnz`, and both
109            // slices have `row_nnz` elements, so every `load_unaligned` and every
110            // `build_index_vector` (which requires `>= LANE_COUNT` inputs) stays
111            // in bounds. `Validated<Csr>` proves every gathered `cols[k] < ncols`
112            // and `validate_spmv_sizes` asserted `x.len() >= ncols`, so each
113            // `Arch::gather` reads a live element of `x`.
114            let acc_vec = unsafe {
115                let mut acc_vec0 = Arch::zero();
116                let mut acc_vec1 = Arch::zero();
117                let mut acc_vec2 = Arch::zero();
118                let mut acc_vec3 = Arch::zero();
119
120                let unroll_len = (row_nnz / (lane_count * 4)) * (lane_count * 4);
121                let mut j = 0usize;
122                while j < unroll_len {
123                    let idx0 = build_index_vector::<T, Arch>(&cols[j..j + lane_count]);
124                    acc_vec0 = Arch::fmadd(
125                        Arch::gather(x.as_ptr(), idx0),
126                        Arch::load_unaligned(vals[j..].as_ptr()),
127                        acc_vec0,
128                    );
129
130                    let idx1 =
131                        build_index_vector::<T, Arch>(&cols[j + lane_count..j + lane_count * 2]);
132                    acc_vec1 = Arch::fmadd(
133                        Arch::gather(x.as_ptr(), idx1),
134                        Arch::load_unaligned(vals[j + lane_count..].as_ptr()),
135                        acc_vec1,
136                    );
137
138                    let idx2 = build_index_vector::<T, Arch>(
139                        &cols[j + lane_count * 2..j + lane_count * 3],
140                    );
141                    acc_vec2 = Arch::fmadd(
142                        Arch::gather(x.as_ptr(), idx2),
143                        Arch::load_unaligned(vals[j + lane_count * 2..].as_ptr()),
144                        acc_vec2,
145                    );
146
147                    let idx3 = build_index_vector::<T, Arch>(
148                        &cols[j + lane_count * 3..j + lane_count * 4],
149                    );
150                    acc_vec3 = Arch::fmadd(
151                        Arch::gather(x.as_ptr(), idx3),
152                        Arch::load_unaligned(vals[j + lane_count * 3..].as_ptr()),
153                        acc_vec3,
154                    );
155
156                    j += lane_count * 4;
157                }
158
159                let mut acc_vec =
160                    Arch::add(Arch::add(acc_vec0, acc_vec1), Arch::add(acc_vec2, acc_vec3));
161
162                while j < simd_len {
163                    let idx = build_index_vector::<T, Arch>(&cols[j..j + lane_count]);
164                    acc_vec = Arch::fmadd(
165                        Arch::gather(x.as_ptr(), idx),
166                        Arch::load_unaligned(vals[j..].as_ptr()),
167                        acc_vec,
168                    );
169                    j += lane_count;
170                }
171                acc_vec
172            };
173
174            // SAFETY: target-feature kernel, covered by the module invariant.
175            let mut acc = unsafe { Arch::sum_reduce(acc_vec) };
176
177            let mut j = simd_len;
178            while j < row_nnz {
179                // SAFETY: `Validated<Csr>` proves every `col_indices[k] < ncols`,
180                // and `validate_spmv_sizes` asserted `x.len() >= ncols`, so
181                // `cols[j] < x.len()`. This is the same invariant the SIMD
182                // `Arch::gather` above (and the SellP vectorized path) already
183                // relies on; the scalar tail — the entire row when
184                // `row_nnz < LANE_COUNT` — was inconsistently keeping a per-nonzero
185                // bounds-check + panic branch on the gather.
186                acc += vals[j] * unsafe { *x.get_unchecked(cols[j] as usize) };
187                j += 1;
188            }
189
190            y[r] += acc;
191        }
192    }
193}
194
195impl<'a, T, Arch> SparseSpMv<T> for SparseView<'a, T, DenseWithMask, Arch>
196where
197    T: Scalar,
198    Arch: SimdArch + SimdKernel<T>,
199{
200    #[inline]
201    fn spmv(&self, x: &[T], y: &mut [T]) {
202        let data = &self.data;
203        validate_spmv_sizes(x.len(), y.len(), data.ncols, data.nrows, "DenseWithMask");
204
205        let lane_count = Arch::LANE_COUNT;
206
207        for r in 0..data.nrows {
208            let row_offset = r * data.ncols;
209            let vals = &data.values[row_offset..row_offset + data.ncols];
210            let mask_bits = &data.mask[row_offset..row_offset + data.ncols];
211
212            let simd_len = (data.ncols / lane_count) * lane_count;
213
214            // SAFETY: `Arch::*` are the target-feature kernels covered by the
215            // module invariant. Every windowed access below reads a `LANE_COUNT`
216            // span of `vals`/`mask_bits`/`x` at offset `j < simd_len`; `vals` and
217            // `mask_bits` hold `ncols` elements and `x.len() >= ncols` was
218            // asserted, so `simd_len <= ncols` keeps each `load`, `mask_from_bools`,
219            // and `masked_load_unaligned` in bounds. `zero_vec` — the masked-off
220            // fill — is loop-invariant and hoisted here.
221            let acc_vec = unsafe {
222                let zero_vec = Arch::zero();
223                let mut acc_vec0 = zero_vec;
224                let mut acc_vec1 = zero_vec;
225                let mut acc_vec2 = zero_vec;
226                let mut acc_vec3 = zero_vec;
227
228                let unroll_len = (data.ncols / (lane_count * 4)) * (lane_count * 4);
229                let mut j = 0usize;
230                while j < unroll_len {
231                    let msk0 = Arch::mask_from_bools(&mask_bits[j..j + lane_count]);
232                    acc_vec0 = Arch::masked_fmadd(
233                        Arch::masked_load_unaligned(vals[j..].as_ptr(), msk0, zero_vec),
234                        Arch::load_unaligned(x[j..].as_ptr()),
235                        acc_vec0,
236                        msk0,
237                    );
238
239                    let msk1 =
240                        Arch::mask_from_bools(&mask_bits[j + lane_count..j + lane_count * 2]);
241                    acc_vec1 = Arch::masked_fmadd(
242                        Arch::masked_load_unaligned(
243                            vals[j + lane_count..].as_ptr(),
244                            msk1,
245                            zero_vec,
246                        ),
247                        Arch::load_unaligned(x[j + lane_count..].as_ptr()),
248                        acc_vec1,
249                        msk1,
250                    );
251
252                    let msk2 =
253                        Arch::mask_from_bools(&mask_bits[j + lane_count * 2..j + lane_count * 3]);
254                    acc_vec2 = Arch::masked_fmadd(
255                        Arch::masked_load_unaligned(
256                            vals[j + lane_count * 2..].as_ptr(),
257                            msk2,
258                            zero_vec,
259                        ),
260                        Arch::load_unaligned(x[j + lane_count * 2..].as_ptr()),
261                        acc_vec2,
262                        msk2,
263                    );
264
265                    let msk3 =
266                        Arch::mask_from_bools(&mask_bits[j + lane_count * 3..j + lane_count * 4]);
267                    acc_vec3 = Arch::masked_fmadd(
268                        Arch::masked_load_unaligned(
269                            vals[j + lane_count * 3..].as_ptr(),
270                            msk3,
271                            zero_vec,
272                        ),
273                        Arch::load_unaligned(x[j + lane_count * 3..].as_ptr()),
274                        acc_vec3,
275                        msk3,
276                    );
277
278                    j += lane_count * 4;
279                }
280
281                let mut acc_vec =
282                    Arch::add(Arch::add(acc_vec0, acc_vec1), Arch::add(acc_vec2, acc_vec3));
283
284                while j < simd_len {
285                    let msk = Arch::mask_from_bools(&mask_bits[j..j + lane_count]);
286                    acc_vec = Arch::masked_fmadd(
287                        Arch::masked_load_unaligned(vals[j..].as_ptr(), msk, zero_vec),
288                        Arch::load_unaligned(x[j..].as_ptr()),
289                        acc_vec,
290                        msk,
291                    );
292                    j += lane_count;
293                }
294                acc_vec
295            };
296
297            // SAFETY: target-feature kernel, covered by the module invariant.
298            let mut acc = unsafe { Arch::sum_reduce(acc_vec) };
299
300            let mut j = simd_len;
301            while j < data.ncols {
302                if mask_bits[j] {
303                    acc += vals[j] * x[j];
304                }
305                j += 1;
306            }
307
308            y[r] += acc;
309        }
310    }
311}
312
313impl<'a, T, const BM: usize, const BN: usize, Arch> SparseSpMv<T>
314    for SparseView<'a, T, Validated<BlockedCoo<BM, BN>>, Arch>
315where
316    T: Scalar,
317    Arch: SimdArch + SimdKernel<T>,
318{
319    #[inline]
320    fn spmv(&self, x: &[T], y: &mut [T]) {
321        let data = self.data.storage();
322        validate_spmv_sizes(x.len(), y.len(), data.ncols, data.nrows, "BlockedCoo");
323
324        let block_size = BM * BN;
325        let lane_count = Arch::LANE_COUNT;
326
327        if BN == lane_count {
328            for b in 0..data.nblocks {
329                let br = data.block_row[b] as usize;
330                let bc = data.block_col[b] as usize;
331                let block = &data.blocks[b * block_size..(b + 1) * block_size];
332
333                // SAFETY: `Arch::*` are target-feature kernels (module invariant).
334                // `BN == LANE_COUNT`, so each `LANE_COUNT`-wide load reads exactly
335                // one block row `block[i*BN .. i*BN + BN]` (in bounds — `block` has
336                // `BM*BN` elements) or the column window `x[bc .. bc + BN]`. A
337                // `Validated<BlockedCoo>` guarantees `bc + BN <= ncols <= x.len()`.
338                unsafe {
339                    let x_vec = Arch::load_unaligned(x.as_ptr().add(bc));
340                    for i in 0..BM {
341                        let b_vec = Arch::load_unaligned(block.as_ptr().add(i * BN));
342                        y[br + i] += Arch::sum_reduce(Arch::mul(b_vec, x_vec));
343                    }
344                }
345            }
346        } else if BN == lane_count * 2 {
347            for b in 0..data.nblocks {
348                let br = data.block_row[b] as usize;
349                let bc = data.block_col[b] as usize;
350                let block = &data.blocks[b * block_size..(b + 1) * block_size];
351
352                // SAFETY: as above, with `BN == 2*LANE_COUNT` so each block row and
353                // each `x` column window spans two `LANE_COUNT` loads; both halves
354                // stay within `block` (`BM*BN` elements) and within
355                // `x[bc .. bc + BN]` (`bc + BN <= ncols <= x.len()`).
356                unsafe {
357                    let x_vec0 = Arch::load_unaligned(x.as_ptr().add(bc));
358                    let x_vec1 = Arch::load_unaligned(x.as_ptr().add(bc + lane_count));
359                    for i in 0..BM {
360                        let offset = i * BN;
361                        let prod0 =
362                            Arch::mul(Arch::load_unaligned(block.as_ptr().add(offset)), x_vec0);
363                        let prod1 = Arch::mul(
364                            Arch::load_unaligned(block.as_ptr().add(offset + lane_count)),
365                            x_vec1,
366                        );
367                        y[br + i] += Arch::sum_reduce(Arch::add(prod0, prod1));
368                    }
369                }
370            }
371        } else {
372            // SAFETY: `Validated<BlockedCoo>` guarantees all block coordinates
373            // are in bounds; `validate_spmv_sizes` at function entry asserted
374            // `x.len() >= ncols` and `y.len() >= nrows`. Raw pointers eliminate
375            // redundant bounds checks from sub-slicing.
376            unsafe {
377                let x_ptr = x.as_ptr();
378                let y_ptr = y.as_mut_ptr();
379                for b in 0..data.nblocks {
380                    let br = data.block_row[b] as usize;
381                    let bc = data.block_col[b] as usize;
382                    let block_ptr = data.blocks.as_ptr().add(b * block_size);
383
384                    for i in 0..BM {
385                        let row_ptr = block_ptr.add(i * BN);
386                        let mut s = T::ZERO;
387                        for k in 0..BN {
388                            s = s + *row_ptr.add(k) * *x_ptr.add(bc + k);
389                        }
390                        *y_ptr.add(br + i) += s;
391                    }
392                }
393            }
394        }
395    }
396}
397
398fn sellp_spmv_scalar<T, const C: usize>(data: &SellPData<'_, T, C>, x: &[T], y: &mut [T])
399where
400    T: Scalar,
401{
402    let nslices = data.nslices();
403    for s in 0..nslices {
404        let col_count = data.slice_col_count[s] as usize;
405        let start_offset = data.slice_ptr[s] as usize;
406
407        let mut row_acc = [T::ZERO; C];
408
409        for col in 0..col_count {
410            for row in 0..C {
411                let idx = start_offset + col * C + row;
412                let val = data.values[idx];
413                let c_idx = data.col_indices[idx] as usize;
414                // SAFETY: `Validated<SellP>` proves every `col_indices[k] < ncols`
415                // and `validate_spmv_sizes` asserted `x.len() >= ncols`, so
416                // `c_idx < x.len()`. The vectorized path gathers on exactly this
417                // invariant (see its SAFETY note). The removed `if c_idx < x.len()`
418                // guard was dead under that invariant — and had it ever been false
419                // it would have *silently dropped* the term rather than surfacing
420                // the violation, so this is also a correctness-honesty improvement.
421                row_acc[row] += val * unsafe { *x.get_unchecked(c_idx) };
422            }
423        }
424
425        for row in 0..C {
426            let r_idx = s * C + row;
427            if r_idx < y.len() {
428                y[r_idx] += row_acc[row];
429            }
430        }
431    }
432}
433
434/// Vectorized SELL-p SpMV for the case `Arch::LANE_COUNT == C`.
435///
436/// # Safety
437/// - The host must implement `Arch` (its kernels are `#[target_feature]`-gated).
438///   The caller establishes this by holding an arch-parameterized `SparseView`,
439///   whose constructor asserts host support.
440/// - `data` must be a `Validated<SellP<C>>` payload: every `col_indices[k]` is
441///   `< ncols`, and `x.len() >= ncols`, so each gathered `x[col]` is in bounds.
442///   Each slice `values[offset .. offset + C]` and `col_indices[offset ..
443///   offset + C]` must lie within its buffer, which the SELL-p slice layout
444///   guarantees for `offset = slice_ptr[s] + col*C`, `col < slice_col_count[s]`.
445unsafe fn sellp_spmv_vectorized<T, const C: usize, Arch>(
446    data: &SellPData<'_, T, C>,
447    x: &[T],
448    y: &mut [T],
449) where
450    T: Scalar,
451    Arch: SimdArch + SimdKernel<T>,
452{
453    assert_eq!(
454        Arch::LANE_COUNT,
455        C,
456        "sellp_spmv_vectorized requires Arch::LANE_COUNT == C"
457    );
458    let nslices = data.nslices();
459    for s in 0..nslices {
460        let col_count = data.slice_col_count[s] as usize;
461        let start_offset = data.slice_ptr[s] as usize;
462
463        let mut acc0 = Arch::zero();
464        let mut acc1 = Arch::zero();
465        let mut acc2 = Arch::zero();
466        let mut acc3 = Arch::zero();
467
468        let unroll = (col_count / 4) * 4;
469        let mut col = 0;
470        while col < unroll {
471            // Unroll 0
472            let offset = start_offset + col * C;
473            let val_vec = Arch::load_unaligned(data.values[offset..].as_ptr());
474            let idx_vec = build_index_vector::<T, Arch>(&data.col_indices[offset..offset + C]);
475            let x_vec = Arch::gather(x.as_ptr(), idx_vec);
476            acc0 = Arch::fmadd(val_vec, x_vec, acc0);
477
478            // Unroll 1
479            let offset = start_offset + (col + 1) * C;
480            let val_vec = Arch::load_unaligned(data.values[offset..].as_ptr());
481            let idx_vec = build_index_vector::<T, Arch>(&data.col_indices[offset..offset + C]);
482            let x_vec = Arch::gather(x.as_ptr(), idx_vec);
483            acc1 = Arch::fmadd(val_vec, x_vec, acc1);
484
485            // Unroll 2
486            let offset = start_offset + (col + 2) * C;
487            let val_vec = Arch::load_unaligned(data.values[offset..].as_ptr());
488            let idx_vec = build_index_vector::<T, Arch>(&data.col_indices[offset..offset + C]);
489            let x_vec = Arch::gather(x.as_ptr(), idx_vec);
490            acc2 = Arch::fmadd(val_vec, x_vec, acc2);
491
492            // Unroll 3
493            let offset = start_offset + (col + 3) * C;
494            let val_vec = Arch::load_unaligned(data.values[offset..].as_ptr());
495            let idx_vec = build_index_vector::<T, Arch>(&data.col_indices[offset..offset + C]);
496            let x_vec = Arch::gather(x.as_ptr(), idx_vec);
497            acc3 = Arch::fmadd(val_vec, x_vec, acc3);
498
499            col += 4;
500        }
501
502        let mut acc = Arch::add(Arch::add(acc0, acc1), Arch::add(acc2, acc3));
503
504        while col < col_count {
505            let offset = start_offset + col * C;
506            let val_vec = Arch::load_unaligned(data.values[offset..].as_ptr());
507            let idx_vec = build_index_vector::<T, Arch>(&data.col_indices[offset..offset + C]);
508            let x_vec = Arch::gather(x.as_ptr(), idx_vec);
509            acc = Arch::fmadd(val_vec, x_vec, acc);
510            col += 1;
511        }
512
513        let r_idx = s * C;
514        if r_idx + C <= y.len() {
515            let y_ptr = y.as_mut_ptr().add(r_idx);
516            let y_vec = Arch::load_unaligned(y_ptr);
517            let res_vec = Arch::add(y_vec, acc);
518            Arch::store_unaligned(y_ptr, res_vec);
519        } else {
520            let mut temp = [T::ZERO; C];
521            Arch::store_unaligned(temp.as_mut_ptr(), acc);
522            for row in 0..y.len() - r_idx {
523                y[r_idx + row] += temp[row];
524            }
525        }
526    }
527}
528
529impl<'a, T, const C: usize, Arch> SparseSpMv<T> for SparseView<'a, T, Validated<SellP<C>>, Arch>
530where
531    T: Scalar,
532    Arch: SimdArch + SimdKernel<T>,
533{
534    #[inline]
535    fn spmv(&self, x: &[T], y: &mut [T]) {
536        let data = self.data.storage();
537        validate_spmv_sizes(x.len(), y.len(), data.ncols, data.nrows, "SellP");
538
539        if Arch::LANE_COUNT == C {
540            // SAFETY: `ValidatedData` proves every `col_indices[k] < ncols`
541            // (so each gathered `x[col]` is in bounds given `x.len() >= ncols`)
542            // and every slice load `values[offset..offset + C]` stays within
543            // `values`, which are the unchecked-load preconditions.
544            unsafe { sellp_spmv_vectorized::<T, C, Arch>(data, x, y) };
545        } else {
546            sellp_spmv_scalar::<T, C>(data, x, y);
547        }
548    }
549}