Skip to main content

oxicuda_sparse/preconditioner/
iluk.rs

1//! ILU(k) -- Incomplete LU factorization with k levels of fill-in.
2//!
3//! ILU(0) keeps only original nonzero positions. ILU(k) allows fill elements
4//! up to k steps from an original nonzero, producing a more accurate (but
5//! denser) preconditioner.
6//!
7//! ## Algorithm
8//!
9//! Two phases:
10//! 1. **Symbolic phase**: Determine the fill pattern using level-of-fill arrays.
11//!    For each entry `(i, j)` in the original matrix, level is 0. During
12//!    elimination, if row `i` has entry at column `k` with level `lev_ik` and
13//!    row `k` has entry at column `j` with level `lev_kj`, a fill-in entry
14//!    `(i, j)` is generated with level `lev_ik + lev_kj + 1`. Entries with
15//!    level `<= fill_level` are kept.
16//! 2. **Numeric phase**: Compute the actual ILU factorization values on the
17//!    fill pattern determined in step 1.
18#![allow(dead_code)]
19
20use oxicuda_blas::GpuFloat;
21
22use crate::error::{SparseError, SparseResult};
23use crate::format::CsrMatrix;
24use crate::handle::SparseHandle;
25
26// ---------------------------------------------------------------------------
27// GpuFloat <-> f64 conversion helpers
28// ---------------------------------------------------------------------------
29
30fn to_f64<T: GpuFloat>(val: T) -> f64 {
31    if T::SIZE == 4 {
32        f32::from_bits(val.to_bits_u64() as u32) as f64
33    } else {
34        f64::from_bits(val.to_bits_u64())
35    }
36}
37
38fn from_f64<T: GpuFloat>(val: f64) -> T {
39    if T::SIZE == 4 {
40        T::from_bits_u64(u64::from((val as f32).to_bits()))
41    } else {
42        T::from_bits_u64(val.to_bits())
43    }
44}
45
46fn div_gpu_float<T: GpuFloat>(a: T, b: T) -> T {
47    from_f64::<T>(to_f64(a) / to_f64(b))
48}
49
50fn mul_gpu_float<T: GpuFloat>(a: T, b: T) -> T {
51    from_f64::<T>(to_f64(a) * to_f64(b))
52}
53
54fn sub_gpu_float<T: GpuFloat>(a: T, b: T) -> T {
55    from_f64::<T>(to_f64(a) - to_f64(b))
56}
57
58// ---------------------------------------------------------------------------
59// ILU(k) configuration
60// ---------------------------------------------------------------------------
61
62/// ILU(k) preconditioner configuration.
63#[derive(Default)]
64pub struct IlukConfig {
65    /// Level of fill: 0 = ILU(0), 1 = ILU(1), etc.
66    pub fill_level: usize,
67}
68
69// ---------------------------------------------------------------------------
70// ILU(k) factorization result
71// ---------------------------------------------------------------------------
72
73/// ILU(k) factorization result stored as combined L\U in CSR.
74///
75/// `L` is unit lower triangular (diagonal = 1, stored implicitly) and `U` is
76/// upper triangular (diagonal stored explicitly). Both share the CSR storage
77/// `lu`.
78pub struct IlukFactorization<T: GpuFloat> {
79    /// Combined L\U factors in CSR format.
80    /// Lower triangle contains L (without unit diagonal).
81    /// Upper triangle (including diagonal) contains U.
82    pub lu: CsrMatrix<T>,
83    /// Inverse of the diagonal of U, for efficient forward/backward solve.
84    pub diag_inv: Vec<T>,
85    /// The fill level used to compute this factorization.
86    pub fill_level: usize,
87}
88
89// ---------------------------------------------------------------------------
90// Symbolic phase: determine fill pattern
91// ---------------------------------------------------------------------------
92
93/// Entry in the symbolic ILU(k) structure.
94struct SymbolicEntry {
95    col: usize,
96    level: usize,
97}
98
99/// Symbolic ILU(k): determine which entries (including fill-in) to keep.
100///
101/// Returns `(row_ptr, col_idx, levels)` where `levels[nz]` is the level-of-fill
102/// for each nonzero position.
103fn iluk_symbolic(
104    row_ptr: &[i32],
105    col_idx: &[i32],
106    n: usize,
107    fill_level: usize,
108) -> SparseResult<(Vec<i32>, Vec<i32>, Vec<usize>)> {
109    // For each row, maintain a sorted list of (column, level) pairs.
110    let mut rows: Vec<Vec<SymbolicEntry>> = Vec::with_capacity(n);
111
112    for i in 0..n {
113        let start = row_ptr[i] as usize;
114        let end = row_ptr[i + 1] as usize;
115        let mut row_entries: Vec<SymbolicEntry> = Vec::with_capacity(end - start);
116        for &cj in &col_idx[start..end] {
117            row_entries.push(SymbolicEntry {
118                col: cj as usize,
119                level: 0,
120            });
121        }
122        // Sort by column
123        row_entries.sort_by_key(|e| e.col);
124        rows.push(row_entries);
125    }
126
127    // Symbolic factorization: process rows in order
128    for i in 0..n {
129        // Find all lower-triangular entries in row i
130        let mut k_idx = 0;
131        loop {
132            if k_idx >= rows[i].len() {
133                break;
134            }
135            let k = rows[i][k_idx].col;
136            if k >= i {
137                break;
138            }
139            let lev_ik = rows[i][k_idx].level;
140
141            // Find diagonal of row k
142            let diag_pos = rows[k].iter().position(|e| e.col == k);
143            if diag_pos.is_none() {
144                k_idx += 1;
145                continue;
146            }
147
148            // For each entry in row k with col j > k
149            let row_k_entries: Vec<(usize, usize)> = rows[k]
150                .iter()
151                .filter(|e| e.col > k)
152                .map(|e| (e.col, e.level))
153                .collect();
154
155            for (j, lev_kj) in row_k_entries {
156                let new_level = lev_ik + lev_kj + 1;
157                if new_level > fill_level {
158                    continue;
159                }
160
161                // Check if column j already exists in row i
162                let existing = rows[i].iter().position(|e| e.col == j);
163                match existing {
164                    Some(pos) => {
165                        // Update level to minimum
166                        if new_level < rows[i][pos].level {
167                            rows[i][pos].level = new_level;
168                        }
169                    }
170                    None => {
171                        // Insert new fill-in entry
172                        let insert_pos = rows[i]
173                            .iter()
174                            .position(|e| e.col > j)
175                            .unwrap_or(rows[i].len());
176                        rows[i].insert(
177                            insert_pos,
178                            SymbolicEntry {
179                                col: j,
180                                level: new_level,
181                            },
182                        );
183                    }
184                }
185            }
186
187            k_idx += 1;
188        }
189    }
190
191    // Build CSR arrays from the symbolic structure
192    let mut out_row_ptr = vec![0i32; n + 1];
193    let mut out_col_idx = Vec::new();
194    let mut out_levels = Vec::new();
195
196    for (i, row_entries) in rows.iter().enumerate() {
197        for entry in row_entries {
198            out_col_idx.push(entry.col as i32);
199            out_levels.push(entry.level);
200        }
201        out_row_ptr[i + 1] = out_col_idx.len() as i32;
202    }
203
204    Ok((out_row_ptr, out_col_idx, out_levels))
205}
206
207// ---------------------------------------------------------------------------
208// Numeric phase
209// ---------------------------------------------------------------------------
210
211/// Performs numeric ILU(k) factorization on the given fill pattern.
212///
213/// `sym_row_ptr`, `sym_col_idx` define the sparsity pattern (from symbolic phase).
214/// `orig_row_ptr`, `orig_col_idx`, `orig_values` are the original matrix data.
215///
216/// Returns the factored values array (same length as `sym_col_idx`).
217fn iluk_numeric<T: GpuFloat>(
218    sym_row_ptr: &[i32],
219    sym_col_idx: &[i32],
220    orig_row_ptr: &[i32],
221    orig_col_idx: &[i32],
222    orig_values: &[T],
223    n: usize,
224) -> SparseResult<Vec<T>> {
225    let nnz = sym_col_idx.len();
226    let mut values = vec![T::gpu_zero(); nnz];
227
228    // Copy original values into the new pattern
229    for i in 0..n {
230        let orig_start = orig_row_ptr[i] as usize;
231        let orig_end = orig_row_ptr[i + 1] as usize;
232        let sym_start = sym_row_ptr[i] as usize;
233        let sym_end = sym_row_ptr[i + 1] as usize;
234
235        let mut sym_k = sym_start;
236        for orig_k in orig_start..orig_end {
237            let col = orig_col_idx[orig_k];
238            // Find col in symbolic row (it must exist since original entries have level 0)
239            while sym_k < sym_end && sym_col_idx[sym_k] < col {
240                sym_k += 1;
241            }
242            if sym_k < sym_end && sym_col_idx[sym_k] == col {
243                values[sym_k] = orig_values[orig_k];
244            }
245        }
246    }
247
248    // Perform ILU factorization on the filled pattern
249    for i in 0..n {
250        let row_start = sym_row_ptr[i] as usize;
251        let row_end = sym_row_ptr[i + 1] as usize;
252
253        // Process lower-triangular entries in row i
254        for nz in row_start..row_end {
255            let k = sym_col_idx[nz] as usize;
256            if k >= i {
257                break;
258            }
259
260            // Find diagonal of row k
261            let k_start = sym_row_ptr[k] as usize;
262            let k_end = sym_row_ptr[k + 1] as usize;
263            let diag_pos = find_col_in_row(&sym_col_idx[k_start..k_end], k as i32);
264            let diag_pos = match diag_pos {
265                Some(pos) => k_start + pos,
266                None => return Err(SparseError::SingularMatrix),
267            };
268
269            let a_kk = values[diag_pos];
270            if a_kk == T::gpu_zero() {
271                return Err(SparseError::SingularMatrix);
272            }
273
274            // a_ik /= a_kk
275            let ratio = div_gpu_float(values[nz], a_kk);
276            values[nz] = ratio;
277
278            // For each j in row k with j > k, update a_ij -= ratio * a_kj
279            for k_nz in (diag_pos + 1)..k_end {
280                let j = sym_col_idx[k_nz];
281                if let Some(ij_off) = find_col_in_row(&sym_col_idx[row_start..row_end], j) {
282                    let ij_pos = row_start + ij_off;
283                    let update = mul_gpu_float(ratio, values[k_nz]);
284                    values[ij_pos] = sub_gpu_float(values[ij_pos], update);
285                }
286            }
287        }
288    }
289
290    Ok(values)
291}
292
293// ---------------------------------------------------------------------------
294// Public API
295// ---------------------------------------------------------------------------
296
297impl<T: GpuFloat> IlukFactorization<T> {
298    /// Compute ILU(k) from a CSR matrix.
299    ///
300    /// # Arguments
301    ///
302    /// * `_handle` -- Sparse handle (reserved for GPU path).
303    /// * `matrix` -- Square CSR matrix to factor.
304    /// * `config` -- ILU(k) configuration (fill level).
305    ///
306    /// # Errors
307    ///
308    /// Returns [`SparseError::DimensionMismatch`] if the matrix is not square.
309    /// Returns [`SparseError::SingularMatrix`] if a zero pivot is encountered.
310    pub fn compute(
311        _handle: &SparseHandle,
312        matrix: &CsrMatrix<T>,
313        config: &IlukConfig,
314    ) -> SparseResult<Self> {
315        if matrix.rows() != matrix.cols() {
316            return Err(SparseError::DimensionMismatch(format!(
317                "ILU(k) requires square matrix, got {}x{}",
318                matrix.rows(),
319                matrix.cols()
320            )));
321        }
322
323        let n = matrix.rows() as usize;
324        if n == 0 {
325            return Err(SparseError::InvalidArgument(
326                "cannot factor an empty matrix".to_string(),
327            ));
328        }
329
330        let (h_row_ptr, h_col_idx, h_values) = matrix.to_host()?;
331
332        // Symbolic phase: determine fill pattern
333        let (sym_row_ptr, sym_col_idx, _levels) =
334            iluk_symbolic(&h_row_ptr, &h_col_idx, n, config.fill_level)?;
335
336        // Numeric phase: compute values
337        let factored_values = iluk_numeric::<T>(
338            &sym_row_ptr,
339            &sym_col_idx,
340            &h_row_ptr,
341            &h_col_idx,
342            &h_values,
343            n,
344        )?;
345
346        // Extract diagonal inverses
347        let mut diag_inv = vec![T::gpu_zero(); n];
348        for i in 0..n {
349            let start = sym_row_ptr[i] as usize;
350            let end = sym_row_ptr[i + 1] as usize;
351            let diag_pos = find_col_in_row(&sym_col_idx[start..end], i as i32);
352            match diag_pos {
353                Some(pos) => {
354                    let diag_val = factored_values[start + pos];
355                    if diag_val == T::gpu_zero() {
356                        return Err(SparseError::SingularMatrix);
357                    }
358                    diag_inv[i] = div_gpu_float(T::gpu_one(), diag_val);
359                }
360                None => return Err(SparseError::SingularMatrix),
361            }
362        }
363
364        let nnz = sym_col_idx.len() as u32;
365        if nnz == 0 {
366            return Err(SparseError::ZeroNnz);
367        }
368
369        let lu = CsrMatrix::from_host(
370            matrix.rows(),
371            matrix.cols(),
372            &sym_row_ptr,
373            &sym_col_idx,
374            &factored_values,
375        )?;
376
377        Ok(Self {
378            lu,
379            diag_inv,
380            fill_level: config.fill_level,
381        })
382    }
383
384    /// Apply as preconditioner: solve `(L*U)*z = r`.
385    ///
386    /// Forward solve `L*y = r` then backward solve `U*z = y`.
387    ///
388    /// # Arguments
389    ///
390    /// * `r` -- Right-hand side vector (host, length n).
391    /// * `z` -- Output vector (host, length n).
392    ///
393    /// # Errors
394    ///
395    /// Returns [`SparseError::DimensionMismatch`] if vector lengths are wrong.
396    pub fn apply(&self, r: &[T], z: &mut [T]) -> SparseResult<()> {
397        let n = self.lu.rows() as usize;
398        if r.len() != n || z.len() != n {
399            return Err(SparseError::DimensionMismatch(format!(
400                "vector length mismatch: r={}, z={}, expected {}",
401                r.len(),
402                z.len(),
403                n
404            )));
405        }
406
407        let (h_row_ptr, h_col_idx, h_values) = self.lu.to_host()?;
408
409        // Forward solve: L*y = r
410        // L has unit diagonal (implicit), lower triangle stored in lu
411        let mut y = vec![T::gpu_zero(); n];
412        for i in 0..n {
413            let start = h_row_ptr[i] as usize;
414            let end = h_row_ptr[i + 1] as usize;
415            let mut sum = r[i];
416
417            for nz in start..end {
418                let j = h_col_idx[nz] as usize;
419                if j >= i {
420                    break;
421                }
422                let update = mul_gpu_float(h_values[nz], y[j]);
423                sum = sub_gpu_float(sum, update);
424            }
425            y[i] = sum;
426        }
427
428        // Backward solve: U*z = y
429        // U has explicit diagonal, upper triangle stored in lu
430        for i in (0..n).rev() {
431            let start = h_row_ptr[i] as usize;
432            let end = h_row_ptr[i + 1] as usize;
433            let mut sum = y[i];
434
435            for nz in start..end {
436                let j = h_col_idx[nz] as usize;
437                if j <= i {
438                    continue;
439                }
440                let update = mul_gpu_float(h_values[nz], z[j]);
441                sum = sub_gpu_float(sum, update);
442            }
443            z[i] = mul_gpu_float(sum, self.diag_inv[i]);
444        }
445
446        Ok(())
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Helpers
452// ---------------------------------------------------------------------------
453
454fn find_col_in_row(col_slice: &[i32], target_col: i32) -> Option<usize> {
455    col_slice.iter().position(|&c| c == target_col)
456}
457
458// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn iluk_config_default() {
468        let cfg = IlukConfig::default();
469        assert_eq!(cfg.fill_level, 0);
470    }
471
472    #[test]
473    fn symbolic_identity_no_fill() {
474        // Identity matrix: no fill at any level
475        let row_ptr = vec![0, 1, 2, 3];
476        let col_idx = vec![0, 1, 2];
477        let (sym_rp, sym_ci, levels) =
478            iluk_symbolic(&row_ptr, &col_idx, 3, 5).expect("test: symbolic should succeed");
479        assert_eq!(sym_rp, row_ptr);
480        assert_eq!(sym_ci, col_idx);
481        assert!(levels.iter().all(|&l| l == 0));
482    }
483
484    #[test]
485    fn symbolic_tridiagonal_fill_level_0() {
486        // Tridiagonal 3x3:
487        // [1 2 0]
488        // [3 4 5]
489        // [0 6 7]
490        let row_ptr = vec![0, 2, 5, 7];
491        let col_idx = vec![0, 1, 0, 1, 2, 1, 2];
492        let (sym_rp, sym_ci, _) =
493            iluk_symbolic(&row_ptr, &col_idx, 3, 0).expect("test: symbolic should succeed");
494        // ILU(0) keeps the same pattern
495        assert_eq!(sym_rp, row_ptr);
496        assert_eq!(sym_ci, col_idx);
497    }
498
499    #[test]
500    fn symbolic_tridiagonal_fill_level_1() {
501        // Tridiagonal 4x4, ILU(1) may produce fill-in at (0,2) and (2,0)
502        // Row 0: [0,1], Row 1: [0,1,2], Row 2: [1,2,3], Row 3: [2,3]
503        let row_ptr = vec![0, 2, 5, 8, 10];
504        let col_idx = vec![0, 1, 0, 1, 2, 1, 2, 3, 2, 3];
505        let (sym_rp, sym_ci, _) =
506            iluk_symbolic(&row_ptr, &col_idx, 4, 1).expect("test: symbolic should succeed");
507        // With level 1, fill is allowed. Check nnz increased or stayed same.
508        let orig_nnz = col_idx.len();
509        let sym_nnz = sym_ci.len();
510        assert!(sym_nnz >= orig_nnz);
511        // Verify row_ptr is consistent
512        assert_eq!(sym_rp.len(), 5);
513        assert_eq!(sym_rp[0], 0);
514        assert_eq!(sym_rp[4], sym_nnz as i32);
515    }
516
517    #[test]
518    fn numeric_identity() {
519        // Identity 3x3: ILU(0) should give L=I, U=I
520        let row_ptr = vec![0, 1, 2, 3];
521        let col_idx = vec![0, 1, 2];
522        let values: Vec<f64> = vec![2.0, 3.0, 4.0];
523        let result = iluk_numeric::<f64>(&row_ptr, &col_idx, &row_ptr, &col_idx, &values, 3);
524        assert!(result.is_ok());
525        let vals = result.expect("test: numeric should succeed");
526        assert!((vals[0] - 2.0).abs() < 1e-12);
527        assert!((vals[1] - 3.0).abs() < 1e-12);
528        assert!((vals[2] - 4.0).abs() < 1e-12);
529    }
530
531    #[test]
532    fn numeric_singular_detection() {
533        // Matrix with zero diagonal
534        let row_ptr = vec![0, 2, 4];
535        let col_idx = vec![0, 1, 0, 1];
536        let values: Vec<f64> = vec![0.0, 1.0, 1.0, 2.0];
537        let result = iluk_numeric::<f64>(&row_ptr, &col_idx, &row_ptr, &col_idx, &values, 2);
538        assert!(matches!(result, Err(SparseError::SingularMatrix)));
539    }
540
541    #[test]
542    fn numeric_tridiagonal_f32() {
543        // 3x3 tridiagonal: well-conditioned
544        // [4 -1  0]
545        // [-1 4 -1]
546        // [0 -1  4]
547        let row_ptr = vec![0, 2, 5, 7];
548        let col_idx = vec![0, 1, 0, 1, 2, 1, 2];
549        let values: Vec<f32> = vec![4.0, -1.0, -1.0, 4.0, -1.0, -1.0, 4.0];
550        let result = iluk_numeric::<f32>(&row_ptr, &col_idx, &row_ptr, &col_idx, &values, 3);
551        assert!(result.is_ok());
552        let vals = result.expect("test: numeric should succeed");
553        // Check diagonal is nonzero
554        assert!(to_f64(vals[0]).abs() > 1e-6);
555        // Check L factor: a_10 / a_00 = -1/4 = -0.25
556        assert!((to_f64(vals[2]) - (-0.25)).abs() < 1e-5);
557    }
558
559    #[test]
560    fn find_col_works() {
561        let cols = [0, 2, 5, 7];
562        assert_eq!(find_col_in_row(&cols, 2), Some(1));
563        assert_eq!(find_col_in_row(&cols, 3), None);
564        assert_eq!(find_col_in_row(&cols, 7), Some(3));
565    }
566
567    #[test]
568    fn symbolic_empty_row() {
569        // Matrix with varying row lengths
570        // Row 0: [0], Row 1: [0,1], Row 2: [2]
571        let row_ptr = vec![0, 1, 3, 4];
572        let col_idx = vec![0, 0, 1, 2];
573        let (sym_rp, sym_ci, _) =
574            iluk_symbolic(&row_ptr, &col_idx, 3, 0).expect("test: symbolic should succeed");
575        assert_eq!(sym_rp.len(), 4);
576        assert_eq!(sym_ci.len() as i32, sym_rp[3]);
577    }
578}