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}