Skip to main content

faer_precond/ic0/
mod.rs

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