Skip to main content

faer_precond/ilu0/
mod.rs

1//! Zero-fill incomplete LU preconditioner.
2//!
3//! ILU(0) is the general-purpose workhorse for nonsymmetric sparse systems
4//! solved with GMRES or BiCGSTAB. It approximates `A` by an LU factorisation
5//! constrained to `A`'s own sparsity pattern: no fill-in is allowed, so the
6//! factors take no more memory than `A` and are cheap to build. That
7//! approximation is enough to cut Krylov iteration counts substantially on most
8//! discretised PDE operators. (For symmetric positive-definite `A`, use
9//! [`crate::Ic0`] instead — it is the cheaper symmetric specialisation.)
10//!
11//! Concretely, [`Ilu0::try_new`] computes the unique factors `L` (unit lower)
12//! and `U` (upper) such that:
13//!
14//! - `pattern(L) ∪ pattern(U) = pattern(A)` (no fill-in),
15//! - `L * U` agrees with `A` at every entry in `pattern(A)`.
16//!
17//! # Repeated factorisation
18//!
19//! Krylov methods inside nonlinear solvers typically refactorise the
20//! preconditioner whenever the operator's values change but the sparsity
21//! pattern stays the same. For that use-case, build [`SymbolicIlu0`] once,
22//! allocate an [`Ilu0`] via [`Ilu0::new_with_symbolic`], and call
23//! [`Ilu0::refactorize`] in the hot loop — no allocation occurs.
24//!
25//! # Example
26//!
27//! ```
28//! use dyn_stack::MemStack;
29//! use faer::sparse::{SparseColMat, Triplet};
30//! use faer::{mat, Par};
31//! use faer::matrix_free::Precond;
32//! use faer_precond::Ilu0;
33//!
34//! // 5x5 tridiagonal: diag 4, off-diagonals -1.
35//! let mut triplets = Vec::new();
36//! for i in 0..5 {
37//!     triplets.push(Triplet::new(i, i, 4.0_f64));
38//!     if i > 0 {
39//!         triplets.push(Triplet::new(i, i - 1, -1.0));
40//!         triplets.push(Triplet::new(i - 1, i, -1.0));
41//!     }
42//! }
43//! let a = SparseColMat::<usize, f64>::try_new_from_triplets(5, 5, &triplets).unwrap();
44//!
45//! let pc = Ilu0::try_new(a.as_ref()).expect("non-singular pattern");
46//!
47//! // Apply M^{-1} to a right-hand side in place.
48//! let mut b = mat![[1.0_f64], [0.0], [0.0], [0.0], [0.0]];
49//! pc.apply_in_place(b.as_mut(), Par::Seq, MemStack::new(&mut []));
50//! ```
51//!
52//! # Storage
53//!
54//! The factors are stored in column-compressed (CSC) form following faer's
55//! sparse triangular-solve conventions — `L`'s unit diagonal stored *first* in
56//! each column and `U`'s diagonal stored *last*. Apply uses
57//! [`faer::sparse::linalg::triangular_solve`] directly and allocates no heap
58//! memory.
59
60use core::fmt::Debug;
61
62use dyn_stack::{MemStack, StackReq};
63use faer::matrix_free::{BiLinOp, BiPrecond, LinOp, Precond};
64use faer::{Conj, MatMut, MatRef, Par};
65use faer_traits::{ComplexField, Index};
66
67pub mod apply;
68pub mod numeric;
69pub mod symbolic;
70
71pub use numeric::Ilu0;
72pub use symbolic::SymbolicIlu0;
73
74/// Error returned by ILU(0) construction or refactorisation.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Ilu0Error {
77    /// The source matrix was not square.
78    NonSquareMatrix { nrows: usize, ncols: usize },
79    /// Column `col` of the source matrix does not contain its diagonal entry.
80    MissingDiagonal { col: usize },
81    /// Row indices in column `col` are not sorted ascending.
82    UnsortedRowIndices { col: usize },
83    /// A refactorisation was attempted with a matrix whose pattern does not
84    /// match the symbolic factor.
85    PatternMismatch,
86    /// A zero pivot was encountered while eliminating column `col`.
87    ZeroPivot { col: usize },
88}
89
90impl core::fmt::Display for Ilu0Error {
91    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92        match self {
93            Self::NonSquareMatrix { nrows, ncols } => {
94                write!(f, "matrix must be square but is {nrows}x{ncols}")
95            }
96            Self::MissingDiagonal { col } => {
97                write!(f, "column {col} is missing its diagonal entry")
98            }
99            Self::UnsortedRowIndices { col } => {
100                write!(f, "column {col} has unsorted row indices")
101            }
102            Self::PatternMismatch => f.write_str("refactorisation pattern does not match symbolic"),
103            Self::ZeroPivot { col } => write!(f, "encountered a zero pivot at column {col}"),
104        }
105    }
106}
107
108impl core::error::Error for Ilu0Error {}
109
110impl<I, T> LinOp<T> for Ilu0<I, T>
111where
112    I: Index,
113    T: ComplexField + Debug + Sync,
114{
115    fn apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
116        StackReq::EMPTY
117    }
118
119    fn nrows(&self) -> usize {
120        self.dim()
121    }
122
123    fn ncols(&self) -> usize {
124        self.dim()
125    }
126
127    fn apply(&self, mut out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, _stack: &mut MemStack) {
128        out.copy_from(rhs);
129        apply::solve_in_place(self, Conj::No, out, par);
130    }
131
132    fn conj_apply(
133        &self,
134        mut out: MatMut<'_, T>,
135        rhs: MatRef<'_, T>,
136        par: Par,
137        _stack: &mut MemStack,
138    ) {
139        out.copy_from(rhs);
140        apply::solve_in_place(self, Conj::Yes, out, par);
141    }
142}
143
144impl<I, T> Precond<T> for Ilu0<I, T>
145where
146    I: Index,
147    T: ComplexField + Debug + Sync,
148{
149    fn apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
150        StackReq::EMPTY
151    }
152
153    fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
154        apply::solve_in_place(self, Conj::No, rhs, par);
155    }
156
157    fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
158        apply::solve_in_place(self, Conj::Yes, rhs, par);
159    }
160}
161
162impl<I, T> BiLinOp<T> for Ilu0<I, T>
163where
164    I: Index,
165    T: ComplexField + Debug + Sync,
166{
167    fn transpose_apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
168        StackReq::EMPTY
169    }
170
171    fn transpose_apply(
172        &self,
173        mut out: MatMut<'_, T>,
174        rhs: MatRef<'_, T>,
175        par: Par,
176        _stack: &mut MemStack,
177    ) {
178        out.copy_from(rhs);
179        apply::solve_transpose_in_place(self, Conj::No, out, par);
180    }
181
182    fn adjoint_apply(
183        &self,
184        mut out: MatMut<'_, T>,
185        rhs: MatRef<'_, T>,
186        par: Par,
187        _stack: &mut MemStack,
188    ) {
189        out.copy_from(rhs);
190        apply::solve_transpose_in_place(self, Conj::Yes, out, par);
191    }
192}
193
194impl<I, T> BiPrecond<T> for Ilu0<I, T>
195where
196    I: Index,
197    T: ComplexField + Debug + Sync,
198{
199    fn transpose_apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
200        StackReq::EMPTY
201    }
202
203    fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
204        apply::solve_transpose_in_place(self, Conj::No, rhs, par);
205    }
206
207    fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
208        apply::solve_transpose_in_place(self, Conj::Yes, rhs, par);
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use faer::sparse::{SparseColMat, Triplet};
216    use faer::{Mat, MatRef, mat};
217
218    fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
219        assert_eq!(lhs.nrows(), rhs.nrows());
220        assert_eq!(lhs.ncols(), rhs.ncols());
221        for j in 0..lhs.ncols() {
222            for i in 0..lhs.nrows() {
223                let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
224                assert!(
225                    diff <= tol,
226                    "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
227                    *lhs.get(i, j),
228                    *rhs.get(i, j),
229                );
230            }
231        }
232    }
233
234    /// 5-point Laplacian stencil on a 4x4 grid (n=16). Symmetric, diagonally
235    /// dominant, banded. ILU(0) is exact-modulo-fill on this stencil.
236    fn laplacian_2d(grid: usize) -> SparseColMat<usize, f64> {
237        let n = grid * grid;
238        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
239        for gy in 0..grid {
240            for gx in 0..grid {
241                let idx = gy * grid + gx;
242                triplets.push(Triplet::new(idx, idx, 4.0));
243                if gx > 0 {
244                    triplets.push(Triplet::new(idx, idx - 1, -1.0));
245                }
246                if gx + 1 < grid {
247                    triplets.push(Triplet::new(idx, idx + 1, -1.0));
248                }
249                if gy > 0 {
250                    triplets.push(Triplet::new(idx, idx - grid, -1.0));
251                }
252                if gy + 1 < grid {
253                    triplets.push(Triplet::new(idx, idx + grid, -1.0));
254                }
255            }
256        }
257        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
258    }
259
260    /// Convert a sparse matrix to dense for verification.
261    fn to_dense(a: &SparseColMat<usize, f64>) -> Mat<f64> {
262        let n = a.nrows();
263        let mut out = Mat::<f64>::zeros(n, a.ncols());
264        let a_ref = a.as_ref();
265        for j in 0..a.ncols() {
266            let rows = a_ref.symbolic().row_idx_of_col_raw(j);
267            let vals = a_ref.val_of_col(j);
268            for (r, v) in rows.iter().zip(vals.iter()) {
269                *out.as_mut().get_mut(*r, j) = *v;
270            }
271        }
272        out
273    }
274
275    fn tridiagonal_csc(n: usize, diag: f64, off: f64) -> SparseColMat<usize, f64> {
276        let mut triplets = Vec::new();
277        for i in 0..n {
278            triplets.push(Triplet::new(i, i, diag));
279            if i > 0 {
280                triplets.push(Triplet::new(i, i - 1, off));
281                triplets.push(Triplet::new(i - 1, i, off));
282            }
283        }
284        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
285    }
286
287    #[test]
288    fn ilu0_tridiagonal_matches_exact_inverse() {
289        // For a tridiagonal SPD matrix, ILU(0) coincides with the exact LU
290        // factorisation (no fill is introduced anyway). Thus M^{-1} A = I.
291        let a = tridiagonal_csc(5, 4.0, -1.0);
292        let pc = Ilu0::try_new(a.as_ref()).unwrap();
293
294        let a_dense = to_dense(&a);
295        let x_true = mat![[1.0], [-2.0], [3.0], [-1.0], [0.5_f64]];
296        let mut rhs = (&a_dense * &x_true).to_owned();
297
298        pc.apply_in_place(rhs.as_mut(), Par::Seq, MemStack::new(&mut []));
299        assert_close(rhs.as_ref(), x_true.as_ref(), 1e-12);
300    }
301
302    fn sparse_view_to_dense(a: faer::sparse::SparseColMatRef<'_, usize, f64>) -> Mat<f64> {
303        let mut dense = Mat::<f64>::zeros(a.nrows(), a.ncols());
304        for j in 0..a.ncols() {
305            let rows = a.symbolic().row_idx_of_col_raw(j);
306            let vals = a.val_of_col(j);
307            for (r, v) in rows.iter().zip(vals.iter()) {
308                *dense.as_mut().get_mut(*r, j) = *v;
309            }
310        }
311        dense
312    }
313
314    #[test]
315    fn ilu0_factor_satisfies_pattern_equation() {
316        // For ILU(0) the relation L*U == A holds at every position in pattern(A).
317        let a = laplacian_2d(4);
318        let pc = Ilu0::try_new(a.as_ref()).unwrap();
319
320        let l_dense = sparse_view_to_dense(pc.l_view());
321        let u_dense = sparse_view_to_dense(pc.u_view());
322        let lu_dense = &l_dense * &u_dense;
323        let a_dense = to_dense(&a);
324
325        let a_ref = a.as_ref();
326        for j in 0..a.ncols() {
327            for r in a_ref.symbolic().row_idx_of_col_raw(j) {
328                let i = *r;
329                let diff = (*lu_dense.as_ref().get(i, j) - *a_dense.as_ref().get(i, j)).abs();
330                assert!(diff <= 1e-12, "L*U disagrees with A at ({i},{j}): {diff}");
331            }
332        }
333    }
334
335    #[test]
336    fn ilu0_reduces_residual_significantly() {
337        // For diagonally-dominant matrices, ILU(0) should at minimum produce a
338        // very small residual ||A x - b|| for a single right-preconditioned solve.
339        let a = laplacian_2d(8);
340        let n = a.nrows();
341        let pc = Ilu0::try_new(a.as_ref()).unwrap();
342        let a_dense = to_dense(&a);
343
344        let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
345        let mut x = b.clone();
346        pc.apply_in_place(x.as_mut(), Par::Seq, MemStack::new(&mut []));
347
348        let residual = &a_dense * &x - &b;
349        let b_norm: f64 = b.as_ref().col(0).iter().map(|v| v * v).sum::<f64>().sqrt();
350        let r_norm: f64 = residual
351            .as_ref()
352            .col(0)
353            .iter()
354            .map(|v| v * v)
355            .sum::<f64>()
356            .sqrt();
357        // ILU(0) for the 5-point Laplacian gives a much smaller residual than 1.
358        assert!(
359            r_norm / b_norm < 0.5,
360            "ILU(0) residual ratio {r_norm}/{b_norm} too large"
361        );
362    }
363
364    #[test]
365    fn refactorize_matches_fresh_construction() {
366        let a1 = tridiagonal_csc(7, 4.0, -1.0);
367        let a2 = tridiagonal_csc(7, 5.0, -2.0);
368
369        let pc_fresh = Ilu0::try_new(a2.as_ref()).unwrap();
370
371        let mut pc_reused = Ilu0::try_new(a1.as_ref()).unwrap();
372        pc_reused.refactorize(a2.as_ref()).unwrap();
373
374        assert_eq!(pc_fresh.l_values.len(), pc_reused.l_values.len());
375        assert_eq!(pc_fresh.u_values.len(), pc_reused.u_values.len());
376        for (a, b) in pc_fresh.l_values.iter().zip(pc_reused.l_values.iter()) {
377            assert!((a - b).abs() < 1e-14);
378        }
379        for (a, b) in pc_fresh.u_values.iter().zip(pc_reused.u_values.iter()) {
380            assert!((a - b).abs() < 1e-14);
381        }
382    }
383
384    #[test]
385    fn rejects_non_square() {
386        let mut triplets = Vec::new();
387        for i in 0..3 {
388            triplets.push(Triplet::new(i, i, 1.0));
389        }
390        let a = SparseColMat::<usize, f64>::try_new_from_triplets(3, 4, &triplets).unwrap();
391        let err = Ilu0::try_new(a.as_ref()).unwrap_err();
392        assert_eq!(err, Ilu0Error::NonSquareMatrix { nrows: 3, ncols: 4 });
393    }
394
395    #[test]
396    fn rejects_missing_diagonal() {
397        // 3x3 with no diagonal in column 1.
398        let triplets = vec![
399            Triplet::new(0, 0, 1.0),
400            Triplet::new(0, 1, 2.0),
401            Triplet::new(2, 1, 3.0),
402            Triplet::new(2, 2, 4.0_f64),
403        ];
404        let a = SparseColMat::<usize, f64>::try_new_from_triplets(3, 3, &triplets).unwrap();
405        let err = Ilu0::try_new(a.as_ref()).unwrap_err();
406        assert_eq!(err, Ilu0Error::MissingDiagonal { col: 1 });
407    }
408
409    #[test]
410    fn rejects_pattern_mismatch_on_refactorize() {
411        let a1 = tridiagonal_csc(5, 4.0, -1.0);
412        let a2 = tridiagonal_csc(6, 4.0, -1.0);
413        let mut pc = Ilu0::try_new(a1.as_ref()).unwrap();
414        let err = pc.refactorize(a2.as_ref()).unwrap_err();
415        assert_eq!(err, Ilu0Error::PatternMismatch);
416    }
417
418    #[test]
419    fn transpose_apply_inverts_transposed_system() {
420        let a = tridiagonal_csc(6, 4.0, -1.0);
421        let pc = Ilu0::try_new(a.as_ref()).unwrap();
422        let a_dense = to_dense(&a);
423
424        let x_true = mat![[1.0], [-2.0], [3.0], [-1.0], [0.5], [2.0_f64]];
425        let rhs = a_dense.transpose() * &x_true;
426
427        let mut out = rhs.clone();
428        pc.transpose_apply_in_place(out.as_mut(), Par::Seq, MemStack::new(&mut []));
429        // Tridiagonal => ILU(0) exact => M^{-T} A^T x = x.
430        assert_close(out.as_ref(), x_true.as_ref(), 1e-12);
431    }
432}