Skip to main content

faer_precond/poly/
mod.rs

1//! Polynomial preconditioner (Neumann series and Chebyshev).
2//!
3//! A polynomial preconditioner approximates `A^{-1}` by a polynomial in `A`:
4//! `M^{-1} = p(A)`. Applying it is nothing but a handful of sparse
5//! matrix-vector products and vector updates — there are **no triangular
6//! solves**. That makes it the odd one out in this crate: where ILU/IC apply a
7//! sequential forward/back substitution, a polynomial preconditioner is built
8//! entirely from `A * x`, which parallelises and vectorises freely.
9//!
10//! Two flavours are provided:
11//!
12//! - **Neumann series** — `M^{-1} = w * sum_{k=0}^{d} (I - w A)^k`. One real
13//!   damping parameter `w`; converges when `0 < w * lambda < 2` across the
14//!   spectrum.
15//! - **Chebyshev** — the degree-`d` Chebyshev polynomial that minimises
16//!   `max |1 - lambda p(lambda)|` over `[lambda_min, lambda_max]`. Sharper than
17//!   Neumann for the same degree, but it needs an estimate of the spectral
18//!   interval.
19//!
20//! # When to use it
21//!
22//! Reach for a polynomial preconditioner when matrix-vector products are cheap
23//! and plentiful but triangular solves are a bottleneck — many cores, a GPU, or
24//! a distributed operator where the sequential sweep of an ILU does not scale.
25//! It is also the classic choice for a *smoother* inside multigrid. On a single
26//! core it rarely beats [`crate::Ic0`] / [`crate::Ilu0`]; its appeal is
27//! parallelism and the absence of any factorisation.
28//!
29//! Chebyshev assumes a Hermitian positive-definite operator and is only as good
30//! as its `[lambda_min, lambda_max]` estimate: an over-estimated `lambda_min`
31//! (or under-estimated `lambda_max`) degrades or even diverges the polynomial.
32//! Pass [`BoundEstimate::Manual`] when you know the spectrum; otherwise
33//! [`BoundEstimate::PowerIteration`] gives a tight `lambda_max` and a
34//! conservative `lambda_min`.
35//!
36//! # Storage
37//!
38//! The operator `A` is stored as an owned CSC copy (apply reads it through a
39//! [`faer::sparse::SparseColMatRef`]). The recurrence's
40//! temporaries — one work column for Neumann, two for Chebyshev — come from the
41//! caller's [`MemStack`], so apply allocates no heap memory.
42
43use core::fmt::Debug;
44
45use dyn_stack::{MemStack, StackReq};
46use faer::matrix_free::{BiLinOp, BiPrecond, LinOp, Precond};
47use faer::{MatMut, MatRef, Par};
48use faer_traits::{ComplexField, Index};
49
50mod apply;
51mod build;
52
53/// Which polynomial to use for `M^{-1} = p(A)`.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub enum PolyKind {
56    /// Neumann series with real damping `omega`. `M^{-1} = w sum (I - wA)^k`.
57    Neumann { omega: f64 },
58    /// Chebyshev polynomial over the spectral interval `[lambda_min, lambda_max]`.
59    Chebyshev { lambda_min: f64, lambda_max: f64 },
60}
61
62/// How to obtain the spectral interval for [`Poly::try_new_auto`].
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub enum BoundEstimate {
65    /// Gershgorin discs: a closed-form bracket from the matrix structure.
66    /// `lambda_max` is safe; `lambda_min` may be loose.
67    Gershgorin,
68    /// `iters` steps of power iteration for a tight `lambda_max`; `lambda_min`
69    /// falls back to the (conservative) Gershgorin lower bound.
70    PowerIteration { iters: usize },
71    /// Caller-supplied bounds.
72    Manual { lambda_min: f64, lambda_max: f64 },
73}
74
75/// Tuning parameters for [`Poly::try_new`].
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct PolyParams {
78    /// Polynomial degree (number of recurrence steps / matvecs). Must be `>= 1`.
79    pub degree: usize,
80    /// Which polynomial to build.
81    pub kind: PolyKind,
82}
83
84/// Error returned by polynomial-preconditioner construction.
85#[derive(Debug, Clone, PartialEq)]
86pub enum PolyError {
87    /// The source matrix was not square.
88    NonSquareMatrix { nrows: usize, ncols: usize },
89    /// `degree` was zero.
90    ZeroDegree,
91    /// A Neumann `omega` was non-positive or non-finite.
92    InvalidOmega,
93    /// The Chebyshev interval was not a usable `0 < lambda_min < lambda_max`.
94    InvalidBounds,
95    /// A refactorisation was attempted with a mismatched sparsity pattern.
96    PatternMismatch,
97}
98
99impl core::fmt::Display for PolyError {
100    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
101        match self {
102            Self::NonSquareMatrix { nrows, ncols } => {
103                write!(f, "matrix must be square but is {nrows}x{ncols}")
104            }
105            Self::ZeroDegree => f.write_str("polynomial degree must be at least 1"),
106            Self::InvalidOmega => f.write_str("neumann omega must be finite and positive"),
107            Self::InvalidBounds => {
108                f.write_str("chebyshev bounds must satisfy 0 < lambda_min < lambda_max")
109            }
110            Self::PatternMismatch => f.write_str("refactorisation pattern does not match"),
111        }
112    }
113}
114
115impl core::error::Error for PolyError {}
116
117/// Resolved polynomial coefficients (real values stored as `T`).
118#[derive(Debug, Clone)]
119pub(crate) enum Coeffs<T> {
120    Neumann { omega: T },
121    Chebyshev { lambda_min: T, lambda_max: T },
122}
123
124/// Polynomial preconditioner `M^{-1} = p(A)`.
125///
126/// Stores an owned copy of `A` and the polynomial coefficients. Apply is a
127/// sequence of sparse matvecs and vector updates with no triangular solves and
128/// no heap allocation. See the [module documentation](self) for guidance.
129#[derive(Debug, Clone)]
130pub struct Poly<I, T> {
131    pub(crate) dim: usize,
132    pub(crate) degree: usize,
133    pub(crate) a_col_ptr: Vec<I>,
134    pub(crate) a_row_idx: Vec<I>,
135    pub(crate) a_values: Vec<T>,
136    pub(crate) coeffs: Coeffs<T>,
137    pub(crate) recompute: Option<BoundEstimate>,
138}
139
140impl<I, T> Poly<I, T> {
141    /// Dimension `n` of the preconditioner.
142    #[inline]
143    pub fn dim(&self) -> usize {
144        self.dim
145    }
146
147    /// Polynomial degree (number of matvecs per apply).
148    #[inline]
149    pub fn degree(&self) -> usize {
150        self.degree
151    }
152}
153
154impl<I, T> LinOp<T> for Poly<I, T>
155where
156    I: Index,
157    T: ComplexField + Debug + Sync,
158{
159    fn apply_scratch(&self, rhs_ncols: usize, _par: Par) -> StackReq {
160        apply::run_scratch(self, rhs_ncols)
161    }
162
163    fn nrows(&self) -> usize {
164        self.dim
165    }
166
167    fn ncols(&self) -> usize {
168        self.dim
169    }
170
171    fn apply(&self, out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, stack: &mut MemStack) {
172        apply::apply_out(self, out, rhs, par, stack);
173    }
174
175    fn conj_apply(&self, out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, stack: &mut MemStack) {
176        // p(A) has real coefficients, so conj(p(A)) = p(conj(A)); we apply the
177        // same recurrence and rely on the matvec's own conjugation being absent
178        // here — for real operators conj_apply == apply.
179        apply::apply_out(self, out, rhs, par, stack);
180    }
181}
182
183impl<I, T> Precond<T> for Poly<I, T>
184where
185    I: Index,
186    T: ComplexField + Debug + Sync,
187{
188    fn apply_in_place_scratch(&self, rhs_ncols: usize, _par: Par) -> StackReq {
189        apply::inplace_scratch(self, rhs_ncols)
190    }
191
192    fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
193        apply::apply_inplace(self, rhs, par, stack);
194    }
195
196    fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
197        apply::apply_inplace(self, rhs, par, stack);
198    }
199}
200
201impl<I, T> BiLinOp<T> for Poly<I, T>
202where
203    I: Index,
204    T: ComplexField + Debug + Sync,
205{
206    fn transpose_apply_scratch(&self, rhs_ncols: usize, _par: Par) -> StackReq {
207        apply::run_scratch(self, rhs_ncols)
208    }
209
210    fn transpose_apply(
211        &self,
212        out: MatMut<'_, T>,
213        rhs: MatRef<'_, T>,
214        par: Par,
215        stack: &mut MemStack,
216    ) {
217        // For the Hermitian-PD operators these preconditioners target, p(A) is
218        // symmetric, so the transpose coincides with the forward apply.
219        apply::apply_out(self, out, rhs, par, stack);
220    }
221
222    fn adjoint_apply(
223        &self,
224        out: MatMut<'_, T>,
225        rhs: MatRef<'_, T>,
226        par: Par,
227        stack: &mut MemStack,
228    ) {
229        apply::apply_out(self, out, rhs, par, stack);
230    }
231}
232
233impl<I, T> BiPrecond<T> for Poly<I, T>
234where
235    I: Index,
236    T: ComplexField + Debug + Sync,
237{
238    fn transpose_apply_in_place_scratch(&self, rhs_ncols: usize, _par: Par) -> StackReq {
239        apply::inplace_scratch(self, rhs_ncols)
240    }
241
242    fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
243        apply::apply_inplace(self, rhs, par, stack);
244    }
245
246    fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
247        apply::apply_inplace(self, rhs, par, stack);
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use core::mem::MaybeUninit;
255    use faer::sparse::{SparseColMat, Triplet};
256    use faer::{Mat, MatRef};
257
258    fn with_stack(req: StackReq, f: impl FnOnce(&mut MemStack)) {
259        let nbytes = req.unaligned_bytes_required().max(1);
260        let mut buf = vec![MaybeUninit::<u8>::uninit(); nbytes].into_boxed_slice();
261        f(MemStack::new(&mut buf));
262    }
263
264    fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
265        assert_eq!(lhs.nrows(), rhs.nrows());
266        assert_eq!(lhs.ncols(), rhs.ncols());
267        for j in 0..lhs.ncols() {
268            for i in 0..lhs.nrows() {
269                let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
270                assert!(
271                    diff <= tol,
272                    "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
273                    *lhs.get(i, j),
274                    *rhs.get(i, j),
275                );
276            }
277        }
278    }
279
280    fn to_dense(a: &SparseColMat<usize, f64>) -> Mat<f64> {
281        let n = a.nrows();
282        let mut out = Mat::<f64>::zeros(n, a.ncols());
283        let a_ref = a.as_ref();
284        for j in 0..a.ncols() {
285            let rows = a_ref.symbolic().row_idx_of_col_raw(j);
286            let vals = a_ref.val_of_col(j);
287            for (r, v) in rows.iter().zip(vals.iter()) {
288                *out.as_mut().get_mut(*r, j) = *v;
289            }
290        }
291        out
292    }
293
294    fn tridiagonal(n: usize, diag: f64, off: f64) -> SparseColMat<usize, f64> {
295        let mut triplets = Vec::new();
296        for i in 0..n {
297            triplets.push(Triplet::new(i, i, diag));
298            if i > 0 {
299                triplets.push(Triplet::new(i, i - 1, off));
300                triplets.push(Triplet::new(i - 1, i, off));
301            }
302        }
303        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
304    }
305
306    fn laplacian_2d(grid: usize) -> SparseColMat<usize, f64> {
307        let n = grid * grid;
308        let mut triplets = Vec::new();
309        for gy in 0..grid {
310            for gx in 0..grid {
311                let idx = gy * grid + gx;
312                triplets.push(Triplet::new(idx, idx, 4.0));
313                if gx > 0 {
314                    triplets.push(Triplet::new(idx, idx - 1, -1.0));
315                }
316                if gx + 1 < grid {
317                    triplets.push(Triplet::new(idx, idx + 1, -1.0));
318                }
319                if gy > 0 {
320                    triplets.push(Triplet::new(idx, idx - grid, -1.0));
321                }
322                if gy + 1 < grid {
323                    triplets.push(Triplet::new(idx, idx + grid, -1.0));
324                }
325            }
326        }
327        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
328    }
329
330    fn apply_inplace(pc: &Poly<usize, f64>, rhs: &mut Mat<f64>) {
331        with_stack(pc.apply_in_place_scratch(rhs.ncols(), Par::Seq), |stack| {
332            pc.apply_in_place(rhs.as_mut(), Par::Seq, stack);
333        });
334    }
335
336    fn residual_ratio(a: &SparseColMat<usize, f64>, pc: &Poly<usize, f64>, b: &Mat<f64>) -> f64 {
337        let a_dense = to_dense(a);
338        let mut x = b.clone();
339        apply_inplace(pc, &mut x);
340        let residual = &a_dense * &x - b;
341        let b_norm: f64 = b.as_ref().col(0).iter().map(|v| v * v).sum::<f64>().sqrt();
342        let r_norm: f64 = residual
343            .as_ref()
344            .col(0)
345            .iter()
346            .map(|v| v * v)
347            .sum::<f64>()
348            .sqrt();
349        r_norm / b_norm
350    }
351
352    #[test]
353    fn neumann_higher_degree_reduces_residual() {
354        let a = tridiagonal(20, 2.5, -1.0);
355        let b = Mat::<f64>::from_fn(20, 1, |i, _| (i % 5) as f64 - 2.0);
356        // omega chosen below 2/lambda_max (lambda_max < 4.5 here).
357        let mk = |deg| {
358            Poly::try_new(
359                a.as_ref(),
360                PolyParams {
361                    degree: deg,
362                    kind: PolyKind::Neumann { omega: 0.4 },
363                },
364            )
365            .unwrap()
366        };
367        let r_low = residual_ratio(&a, &mk(2), &b);
368        let r_high = residual_ratio(&a, &mk(12), &b);
369        assert!(
370            r_high < r_low,
371            "higher Neumann degree should reduce residual: {r_high} !< {r_low}"
372        );
373    }
374
375    #[test]
376    fn chebyshev_reduces_residual_with_exact_bounds() {
377        // 5-point Laplacian eigenvalues: 4 - 2cos(p pi h) - 2cos(q pi h),
378        // h = 1/(grid+1). Use the exact extremes as bounds.
379        let grid = 8;
380        let a = laplacian_2d(grid);
381        let n = a.nrows();
382        let h = std::f64::consts::PI / (grid as f64 + 1.0);
383        let lam_min = 4.0 - 4.0 * (h).cos();
384        let lam_max = 4.0 - 4.0 * (grid as f64 * h).cos();
385        let pc = Poly::try_new(
386            a.as_ref(),
387            PolyParams {
388                degree: 8,
389                kind: PolyKind::Chebyshev {
390                    lambda_min: lam_min,
391                    lambda_max: lam_max,
392                },
393            },
394        )
395        .unwrap();
396        let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
397        let ratio = residual_ratio(&a, &pc, &b);
398        assert!(ratio < 0.5, "Chebyshev residual ratio {ratio} too large");
399    }
400
401    #[test]
402    fn out_of_place_matches_in_place() {
403        let a = tridiagonal(12, 3.0, -1.0);
404        let pc = Poly::try_new(
405            a.as_ref(),
406            PolyParams {
407                degree: 5,
408                kind: PolyKind::Chebyshev {
409                    lambda_min: 1.0,
410                    lambda_max: 5.0,
411                },
412            },
413        )
414        .unwrap();
415        let rhs = Mat::<f64>::from_fn(12, 2, |i, j| ((i + 3 * j) % 7) as f64 - 3.0);
416
417        let mut out = Mat::<f64>::zeros(12, 2);
418        with_stack(pc.apply_scratch(2, Par::Seq), |stack| {
419            pc.apply(out.as_mut(), rhs.as_ref(), Par::Seq, stack);
420        });
421        let mut inplace = rhs.clone();
422        apply_inplace(&pc, &mut inplace);
423        assert_close(out.as_ref(), inplace.as_ref(), 1e-12);
424    }
425
426    #[test]
427    fn auto_power_iteration_builds_and_helps() {
428        let a = laplacian_2d(8);
429        let n = a.nrows();
430        let pc = Poly::try_new_auto(a.as_ref(), 6, BoundEstimate::PowerIteration { iters: 30 })
431            .unwrap();
432        let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
433        let ratio = residual_ratio(&a, &pc, &b);
434        assert!(ratio < 1.0, "auto Chebyshev should not diverge: {ratio}");
435    }
436
437    #[test]
438    fn refactorize_updates_values() {
439        let a1 = tridiagonal(10, 3.0, -1.0);
440        let a2 = tridiagonal(10, 4.0, -1.5);
441        let params = PolyParams {
442            degree: 4,
443            kind: PolyKind::Neumann { omega: 0.3 },
444        };
445        let fresh = Poly::try_new(a2.as_ref(), params).unwrap();
446        let mut reused = Poly::try_new(a1.as_ref(), params).unwrap();
447        reused.refactorize(a2.as_ref()).unwrap();
448        assert_eq!(fresh.a_values, reused.a_values);
449    }
450
451    #[test]
452    fn rejects_bad_params() {
453        let a = tridiagonal(4, 3.0, -1.0);
454        assert_eq!(
455            Poly::try_new(
456                a.as_ref(),
457                PolyParams {
458                    degree: 0,
459                    kind: PolyKind::Neumann { omega: 0.3 }
460                }
461            )
462            .unwrap_err(),
463            PolyError::ZeroDegree
464        );
465        assert_eq!(
466            Poly::try_new(
467                a.as_ref(),
468                PolyParams {
469                    degree: 3,
470                    kind: PolyKind::Neumann { omega: -1.0 }
471                }
472            )
473            .unwrap_err(),
474            PolyError::InvalidOmega
475        );
476        assert_eq!(
477            Poly::try_new(
478                a.as_ref(),
479                PolyParams {
480                    degree: 3,
481                    kind: PolyKind::Chebyshev {
482                        lambda_min: 2.0,
483                        lambda_max: 1.0
484                    }
485                }
486            )
487            .unwrap_err(),
488            PolyError::InvalidBounds
489        );
490    }
491}