Skip to main content

oxicuda_sparse/format/
ell.rs

1//! ELLPACK (ELL) sparse matrix format.
2//!
3//! The ELLPACK format stores at most `max_nnz_per_row` entries per row,
4//! using a padded column index array and a padded values array, each of
5//! shape `(rows, max_nnz_per_row)` stored in column-major order.
6//!
7//! Unused entries are padded with a sentinel column index of -1 and a
8//! zero value. This format is highly efficient for matrices with regular
9//! sparsity patterns (similar nnz per row) since it avoids indirect
10//! indexing and enables coalesced memory access on GPUs.
11
12use oxicuda_blas::GpuFloat;
13use oxicuda_memory::DeviceBuffer;
14
15use crate::error::{SparseError, SparseResult};
16
17/// Sentinel value for unused ELLPACK entries.
18pub const ELL_SENTINEL: i32 = -1;
19
20/// A sparse matrix in ELLPACK (ELL) format, stored on GPU.
21///
22/// Both `col_idx` and `values` have length `rows * max_nnz_per_row`,
23/// stored in column-major order: element `(i, k)` is at index
24/// `k * rows + i`.
25pub struct EllMatrix<T: GpuFloat> {
26    /// Number of rows.
27    rows: u32,
28    /// Number of columns.
29    cols: u32,
30    /// Maximum non-zeros per row (determines the padding width).
31    max_nnz_per_row: u32,
32    /// Column indices: length `rows * max_nnz_per_row`. Unused entries are -1.
33    col_idx: DeviceBuffer<i32>,
34    /// Values: length `rows * max_nnz_per_row`. Unused entries are zero.
35    values: DeviceBuffer<T>,
36}
37
38impl<T: GpuFloat> EllMatrix<T> {
39    /// Creates an ELL matrix from host-side padded arrays, uploading to GPU.
40    ///
41    /// # Arguments
42    ///
43    /// * `rows` -- Number of rows.
44    /// * `cols` -- Number of columns.
45    /// * `max_nnz_per_row` -- Maximum entries stored per row.
46    /// * `col_idx` -- Padded column indices, length `rows * max_nnz_per_row`,
47    ///   column-major. Unused entries must be `ELL_SENTINEL` (-1).
48    /// * `values` -- Padded values, length `rows * max_nnz_per_row`,
49    ///   column-major. Unused entries should be zero.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`SparseError::InvalidFormat`] if array lengths are incorrect.
54    pub fn from_host(
55        rows: u32,
56        cols: u32,
57        max_nnz_per_row: u32,
58        col_idx: &[i32],
59        values: &[T],
60    ) -> SparseResult<Self> {
61        if rows == 0 || cols == 0 {
62            return Err(SparseError::InvalidFormat(
63                "rows and cols must be non-zero".to_string(),
64            ));
65        }
66        if max_nnz_per_row == 0 {
67            return Err(SparseError::ZeroNnz);
68        }
69
70        let total = rows as usize * max_nnz_per_row as usize;
71        if col_idx.len() != total {
72            return Err(SparseError::InvalidFormat(format!(
73                "col_idx length ({}) must be rows * max_nnz_per_row ({})",
74                col_idx.len(),
75                total
76            )));
77        }
78        if values.len() != total {
79            return Err(SparseError::InvalidFormat(format!(
80                "values length ({}) must be rows * max_nnz_per_row ({})",
81                values.len(),
82                total
83            )));
84        }
85
86        // Validate column indices are within [0, cols), or the padding
87        // sentinel `ELL_SENTINEL` (-1); any other out-of-range value
88        // would cause SpMV kernels to read device memory out of bounds.
89        for (k, &c) in col_idx.iter().enumerate() {
90            if c != ELL_SENTINEL && (c < 0 || c as u32 >= cols) {
91                return Err(SparseError::InvalidFormat(format!(
92                    "col_idx[{k}] = {c} out of range [0, {cols}) and not the sentinel ({ELL_SENTINEL})"
93                )));
94            }
95        }
96
97        let d_col_idx = DeviceBuffer::from_host(col_idx)?;
98        let d_values = DeviceBuffer::from_host(values)?;
99
100        Ok(Self {
101            rows,
102            cols,
103            max_nnz_per_row,
104            col_idx: d_col_idx,
105            values: d_values,
106        })
107    }
108
109    /// Creates an ELL matrix from a CSR matrix on the host.
110    ///
111    /// Determines `max_nnz_per_row` from the CSR structure, then pads
112    /// each row to that width.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`SparseError::Cuda`] on transfer failure.
117    pub fn from_csr(csr: &super::CsrMatrix<T>) -> SparseResult<Self> {
118        let (h_row_ptr, h_col_idx, h_values) = csr.to_host()?;
119        let rows = csr.rows();
120        let cols = csr.cols();
121
122        // Find max nnz per row
123        let mut max_nnz: u32 = 0;
124        for i in 0..rows as usize {
125            let row_nnz = (h_row_ptr[i + 1] - h_row_ptr[i]) as u32;
126            if row_nnz > max_nnz {
127                max_nnz = row_nnz;
128            }
129        }
130
131        if max_nnz == 0 {
132            return Err(SparseError::ZeroNnz);
133        }
134
135        // Build padded ELL arrays (column-major: element (i, k) at k * rows + i)
136        let total = rows as usize * max_nnz as usize;
137        let mut ell_col_idx = vec![ELL_SENTINEL; total];
138        let mut ell_values = vec![T::gpu_zero(); total];
139
140        for i in 0..rows as usize {
141            let start = h_row_ptr[i] as usize;
142            let end = h_row_ptr[i + 1] as usize;
143            for (k, j) in (start..end).enumerate() {
144                let idx = k * rows as usize + i;
145                ell_col_idx[idx] = h_col_idx[j];
146                ell_values[idx] = h_values[j];
147            }
148        }
149
150        Self::from_host(rows, cols, max_nnz, &ell_col_idx, &ell_values)
151    }
152
153    /// Downloads the ELL arrays from GPU to host memory.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`SparseError::Cuda`] on transfer failure.
158    pub fn to_host(&self) -> SparseResult<(Vec<i32>, Vec<T>)> {
159        let mut h_col_idx = vec![0i32; self.col_idx.len()];
160        let mut h_values = vec![T::gpu_zero(); self.values.len()];
161
162        self.col_idx.copy_to_host(&mut h_col_idx)?;
163        self.values.copy_to_host(&mut h_values)?;
164
165        Ok((h_col_idx, h_values))
166    }
167
168    /// Returns the number of rows.
169    #[inline]
170    pub fn rows(&self) -> u32 {
171        self.rows
172    }
173
174    /// Returns the number of columns.
175    #[inline]
176    pub fn cols(&self) -> u32 {
177        self.cols
178    }
179
180    /// Returns the maximum non-zeros per row.
181    #[inline]
182    pub fn max_nnz_per_row(&self) -> u32 {
183        self.max_nnz_per_row
184    }
185
186    /// Returns a reference to the column index device buffer.
187    #[inline]
188    pub fn col_idx(&self) -> &DeviceBuffer<i32> {
189        &self.col_idx
190    }
191
192    /// Returns a reference to the values device buffer.
193    #[inline]
194    pub fn values(&self) -> &DeviceBuffer<T> {
195        &self.values
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn ell_validation_array_lengths() {
205        // 3 rows, max 2 per row => total 6 entries
206        let result = EllMatrix::<f32>::from_host(
207            3,
208            3,
209            2,
210            &[0, 1, 2, -1, -1], // length 5, should be 6
211            &[1.0; 5],
212        );
213        assert!(result.is_err());
214    }
215
216    #[test]
217    fn ell_sentinel_value() {
218        assert_eq!(ELL_SENTINEL, -1);
219    }
220
221    #[test]
222    fn ell_validation_col_idx_out_of_range() {
223        // cols = 3, so col index 3 is out of range (and is not the sentinel)
224        let result = EllMatrix::<f32>::from_host(1, 3, 2, &[3, ELL_SENTINEL], &[1.0, 0.0]);
225        assert!(matches!(result, Err(SparseError::InvalidFormat(_))));
226    }
227}
228
229// ---------------------------------------------------------------------------
230// On-device validation that the padding sentinel is still accepted
231// (feature = "gpu-tests")
232// ---------------------------------------------------------------------------
233
234#[cfg(all(test, feature = "gpu-tests"))]
235mod gpu_device_tests {
236    use super::*;
237    use crate::gpu_test_support::gpu_handle;
238
239    #[test]
240    fn ell_validation_sentinel_accepted() {
241        // Keep the handle (and the CUDA context it holds current) alive for
242        // the whole test; dropping it immediately would tear down the
243        // context before `EllMatrix::from_host` uploads to the device.
244        let Some(_handle) = gpu_handle() else {
245            return;
246        };
247        // The padding sentinel (-1) must remain valid regardless of `cols`.
248        let result = EllMatrix::<f32>::from_host(1, 3, 2, &[0, ELL_SENTINEL], &[1.0, 0.0]);
249        assert!(result.is_ok());
250    }
251}