Skip to main content

faer_precond/ssor/
mod.rs

1//! SSOR / symmetric Gauss-Seidel preconditioner.
2//!
3//! SSOR is a *stationary* preconditioner: it asks for no factorisation and adds
4//! no fill. It is built straight from `A`'s own splitting `A = D + L + U` (the
5//! diagonal, strict-lower and strict-upper parts) and a relaxation factor
6//! `w in (0, 2)`:
7//!
8//! ```text
9//! M = 1 / (w (2 - w)) * (D + w L) D^{-1} (D + w U)
10//! ```
11//!
12//! With `w = 1` this is the symmetric Gauss-Seidel (SGS) preconditioner
13//! `(D + L) D^{-1} (D + U)`. Applying `M^{-1}` is one lower triangular solve,
14//! a diagonal scaling, and one upper triangular solve — all against `A`'s own
15//! entries, with no heap allocation.
16//!
17//! # When to use it
18//!
19//! SSOR is the natural step up from [`crate::JacobiPrecond`] when you want
20//! something stronger than diagonal scaling but cheaper to build than an
21//! incomplete factorisation: there is nothing to store beyond a scaled copy of
22//! `A`'s triangles, and refactorisation against new values is allocation-free.
23//! It is most effective on diagonally-dominant and symmetric positive-definite
24//! systems, where `M` is itself SPD and pairs with conjugate gradient. A good
25//! `w` is problem-dependent (often between 1.0 and 1.9); `w = 1` (SGS) is a
26//! robust default.
27//!
28//! It is a weaker approximation than [`crate::Ic0`] / [`crate::Ilu0`] for the
29//! same apply cost on most PDE operators, so reach for those when you can. SSOR
30//! shines when you want a parameter-light, fill-free preconditioner or a smoother.
31//!
32//! # Storage
33//!
34//! The two factors `(D + w L)` and `(D + w U)` are stored CSC following faer's
35//! triangular-solve conventions — `(D + w L)`'s diagonal *first* in each column,
36//! `(D + w U)`'s diagonal *last*. The `w (2 - w)` scalar is folded into a
37//! precomputed `scaled_diag` so apply needs no scratch.
38
39use core::fmt::Debug;
40
41use dyn_stack::{MemStack, StackReq};
42use faer::matrix_free::{BiLinOp, BiPrecond, LinOp, Precond};
43use faer::{MatMut, MatRef, Par};
44use faer_traits::{ComplexField, Index};
45
46mod apply;
47mod build;
48
49/// Tuning parameters for [`Ssor`].
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct SsorParams {
52    /// Relaxation factor `w`, required to lie in `(0, 2)`. `w = 1` gives
53    /// symmetric Gauss-Seidel. Default `1.0`.
54    pub omega: f64,
55}
56
57impl Default for SsorParams {
58    fn default() -> Self {
59        Self { omega: 1.0 }
60    }
61}
62
63/// Error returned by SSOR construction or refactorisation.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum SsorError {
66    /// The source matrix was not square.
67    NonSquareMatrix { nrows: usize, ncols: usize },
68    /// Column `col` of the source matrix does not contain its diagonal entry.
69    MissingDiagonal { col: usize },
70    /// Row indices in column `col` are not sorted ascending.
71    UnsortedRowIndices { col: usize },
72    /// Diagonal entry `col` was zero, so the triangular factors are singular.
73    ZeroDiagonal { col: usize },
74    /// `omega` was not in the open interval `(0, 2)`.
75    InvalidOmega,
76    /// A refactorisation was attempted with a matrix whose pattern does not
77    /// match the stored factor.
78    PatternMismatch,
79}
80
81impl core::fmt::Display for SsorError {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        match self {
84            Self::NonSquareMatrix { nrows, ncols } => {
85                write!(f, "matrix must be square but is {nrows}x{ncols}")
86            }
87            Self::MissingDiagonal { col } => write!(f, "column {col} is missing its diagonal entry"),
88            Self::UnsortedRowIndices { col } => write!(f, "column {col} has unsorted row indices"),
89            Self::ZeroDiagonal { col } => write!(f, "diagonal entry {col} is zero"),
90            Self::InvalidOmega => f.write_str("omega must lie in the open interval (0, 2)"),
91            Self::PatternMismatch => f.write_str("refactorisation pattern does not match"),
92        }
93    }
94}
95
96impl core::error::Error for SsorError {}
97
98/// SSOR / symmetric Gauss-Seidel preconditioner.
99///
100/// Stores the scaled triangular factors `(D + w L)` and `(D + w U)` of `A` plus
101/// the folded diagonal scaling. Apply is two sparse triangular solves and one
102/// diagonal multiply, allocating nothing. See the [module documentation](self)
103/// for when this is the right choice.
104#[derive(Debug, Clone)]
105pub struct Ssor<I, T> {
106    pub(crate) dim: usize,
107    pub(crate) omega: f64,
108    /// `w (2 - w) * A[i,i]`, the folded middle diagonal scaling.
109    pub(crate) scaled_diag: Vec<T>,
110    /// `(D + w L)` CSC: column pointers, row indices (diagonal first), values.
111    pub(crate) l_col_ptr: Vec<I>,
112    pub(crate) l_row_idx: Vec<I>,
113    pub(crate) l_values: Vec<T>,
114    /// `(D + w U)` CSC: column pointers, row indices (diagonal last), values.
115    pub(crate) u_col_ptr: Vec<I>,
116    pub(crate) u_row_idx: Vec<I>,
117    pub(crate) u_values: Vec<T>,
118    pub(crate) diag_pos: Vec<usize>,
119}
120
121impl<I, T> Ssor<I, T> {
122    /// Dimension `n` of the preconditioner.
123    #[inline]
124    pub fn dim(&self) -> usize {
125        self.dim
126    }
127
128    /// The relaxation factor `w` this preconditioner was built with.
129    #[inline]
130    pub fn omega(&self) -> f64 {
131        self.omega
132    }
133}
134
135impl<I, T> LinOp<T> for Ssor<I, T>
136where
137    I: Index,
138    T: ComplexField + Debug + Sync,
139{
140    fn apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
141        StackReq::EMPTY
142    }
143
144    fn nrows(&self) -> usize {
145        self.dim
146    }
147
148    fn ncols(&self) -> usize {
149        self.dim
150    }
151
152    fn apply(&self, mut out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, _stack: &mut MemStack) {
153        out.copy_from(rhs);
154        apply::solve_in_place(self, false, false, out, par);
155    }
156
157    fn conj_apply(
158        &self,
159        mut out: MatMut<'_, T>,
160        rhs: MatRef<'_, T>,
161        par: Par,
162        _stack: &mut MemStack,
163    ) {
164        out.copy_from(rhs);
165        apply::solve_in_place(self, false, true, out, par);
166    }
167}
168
169impl<I, T> Precond<T> for Ssor<I, T>
170where
171    I: Index,
172    T: ComplexField + Debug + Sync,
173{
174    fn apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
175        StackReq::EMPTY
176    }
177
178    fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
179        apply::solve_in_place(self, false, false, rhs, par);
180    }
181
182    fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
183        apply::solve_in_place(self, false, true, rhs, par);
184    }
185}
186
187impl<I, T> BiLinOp<T> for Ssor<I, T>
188where
189    I: Index,
190    T: ComplexField + Debug + Sync,
191{
192    fn transpose_apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
193        StackReq::EMPTY
194    }
195
196    fn transpose_apply(
197        &self,
198        mut out: MatMut<'_, T>,
199        rhs: MatRef<'_, T>,
200        par: Par,
201        _stack: &mut MemStack,
202    ) {
203        out.copy_from(rhs);
204        apply::solve_in_place(self, true, false, out, par);
205    }
206
207    fn adjoint_apply(
208        &self,
209        mut out: MatMut<'_, T>,
210        rhs: MatRef<'_, T>,
211        par: Par,
212        _stack: &mut MemStack,
213    ) {
214        out.copy_from(rhs);
215        apply::solve_in_place(self, true, true, out, par);
216    }
217}
218
219impl<I, T> BiPrecond<T> for Ssor<I, T>
220where
221    I: Index,
222    T: ComplexField + Debug + Sync,
223{
224    fn transpose_apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
225        StackReq::EMPTY
226    }
227
228    fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
229        apply::solve_in_place(self, true, false, rhs, par);
230    }
231
232    fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, _stack: &mut MemStack) {
233        apply::solve_in_place(self, true, true, rhs, par);
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use faer::sparse::{SparseColMat, Triplet};
241    use faer::{Mat, MatRef, mat};
242
243    fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
244        assert_eq!(lhs.nrows(), rhs.nrows());
245        assert_eq!(lhs.ncols(), rhs.ncols());
246        for j in 0..lhs.ncols() {
247            for i in 0..lhs.nrows() {
248                let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
249                assert!(
250                    diff <= tol,
251                    "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
252                    *lhs.get(i, j),
253                    *rhs.get(i, j),
254                );
255            }
256        }
257    }
258
259    fn to_dense(a: &SparseColMat<usize, f64>) -> Mat<f64> {
260        let n = a.nrows();
261        let mut out = Mat::<f64>::zeros(n, a.ncols());
262        let a_ref = a.as_ref();
263        for j in 0..a.ncols() {
264            let rows = a_ref.symbolic().row_idx_of_col_raw(j);
265            let vals = a_ref.val_of_col(j);
266            for (r, v) in rows.iter().zip(vals.iter()) {
267                *out.as_mut().get_mut(*r, j) = *v;
268            }
269        }
270        out
271    }
272
273    fn diagonal(diag: &[f64]) -> SparseColMat<usize, f64> {
274        let mut triplets = Vec::new();
275        for (i, &v) in diag.iter().enumerate() {
276            triplets.push(Triplet::new(i, i, v));
277        }
278        SparseColMat::try_new_from_triplets(diag.len(), diag.len(), &triplets).unwrap()
279    }
280
281    fn tridiagonal(n: usize, diag: f64, sub: f64, sup: f64) -> SparseColMat<usize, f64> {
282        let mut triplets = Vec::new();
283        for i in 0..n {
284            triplets.push(Triplet::new(i, i, diag));
285            if i > 0 {
286                triplets.push(Triplet::new(i, i - 1, sub));
287                triplets.push(Triplet::new(i - 1, i, sup));
288            }
289        }
290        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
291    }
292
293    fn laplacian_2d(grid: usize) -> SparseColMat<usize, f64> {
294        let n = grid * grid;
295        let mut triplets = Vec::new();
296        for gy in 0..grid {
297            for gx in 0..grid {
298                let idx = gy * grid + gx;
299                triplets.push(Triplet::new(idx, idx, 4.0));
300                if gx > 0 {
301                    triplets.push(Triplet::new(idx, idx - 1, -1.0));
302                }
303                if gx + 1 < grid {
304                    triplets.push(Triplet::new(idx, idx + 1, -1.0));
305                }
306                if gy > 0 {
307                    triplets.push(Triplet::new(idx, idx - grid, -1.0));
308                }
309                if gy + 1 < grid {
310                    triplets.push(Triplet::new(idx, idx + grid, -1.0));
311                }
312            }
313        }
314        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
315    }
316
317    fn residual_ratio(a: &SparseColMat<usize, f64>, pc: &Ssor<usize, f64>, b: &Mat<f64>) -> f64 {
318        let a_dense = to_dense(a);
319        let mut x = b.clone();
320        pc.apply_in_place(x.as_mut(), Par::Seq, MemStack::new(&mut []));
321        let residual = &a_dense * &x - b;
322        let b_norm: f64 = b.as_ref().col(0).iter().map(|v| v * v).sum::<f64>().sqrt();
323        let r_norm: f64 = residual
324            .as_ref()
325            .col(0)
326            .iter()
327            .map(|v| v * v)
328            .sum::<f64>()
329            .sqrt();
330        r_norm / b_norm
331    }
332
333    #[test]
334    fn sgs_on_diagonal_is_exact_inverse() {
335        // For a diagonal A and w = 1, SGS reduces to M^{-1} = D^{-1}.
336        let a = diagonal(&[2.0, 4.0, 8.0]);
337        let pc = Ssor::try_new(a.as_ref(), SsorParams::default()).unwrap();
338
339        let mut x = mat![[2.0_f64], [8.0], [16.0]];
340        pc.apply_in_place(x.as_mut(), Par::Seq, MemStack::new(&mut []));
341        let expected = mat![[1.0_f64], [2.0], [2.0]];
342        assert_close(x.as_ref(), expected.as_ref(), 1e-12);
343    }
344
345    #[test]
346    fn symmetric_input_makes_transpose_equal_apply() {
347        // For symmetric A and real w, M is symmetric, so M^{-T} == M^{-1}.
348        let a = tridiagonal(6, 4.0, -1.0, -1.0);
349        let pc = Ssor::try_new(a.as_ref(), SsorParams { omega: 1.3 }).unwrap();
350        let rhs = mat![[1.0_f64], [-2.0], [3.0], [0.5], [-1.0], [2.0]];
351
352        let mut fwd = rhs.clone();
353        pc.apply_in_place(fwd.as_mut(), Par::Seq, MemStack::new(&mut []));
354        let mut tr = rhs.clone();
355        pc.transpose_apply_in_place(tr.as_mut(), Par::Seq, MemStack::new(&mut []));
356        assert_close(fwd.as_ref(), tr.as_ref(), 1e-12);
357    }
358
359    #[test]
360    fn sgs_reduces_residual_on_laplacian() {
361        let a = laplacian_2d(8);
362        let n = a.nrows();
363        let pc = Ssor::try_new(a.as_ref(), SsorParams::default()).unwrap();
364        let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
365        let ratio = residual_ratio(&a, &pc, &b);
366        assert!(ratio < 0.7, "SGS residual ratio {ratio} too large");
367    }
368
369    #[test]
370    fn out_of_place_matches_in_place() {
371        let a = tridiagonal(7, 4.0, -2.0, -1.0);
372        let pc = Ssor::try_new(a.as_ref(), SsorParams { omega: 1.2 }).unwrap();
373        let rhs = Mat::<f64>::from_fn(7, 2, |i, j| ((i + 2 * j) % 5) as f64 - 2.0);
374
375        let mut out = Mat::<f64>::zeros(7, 2);
376        pc.apply(out.as_mut(), rhs.as_ref(), Par::Seq, MemStack::new(&mut []));
377        let mut inplace = rhs.clone();
378        pc.apply_in_place(inplace.as_mut(), Par::Seq, MemStack::new(&mut []));
379        assert_close(out.as_ref(), inplace.as_ref(), 1e-12);
380    }
381
382    #[test]
383    fn refactorize_matches_fresh_construction() {
384        let a1 = tridiagonal(7, 4.0, -1.0, -1.0);
385        let a2 = tridiagonal(7, 5.0, -2.0, -1.5);
386        let params = SsorParams { omega: 1.4 };
387
388        let fresh = Ssor::try_new(a2.as_ref(), params).unwrap();
389        let mut reused = Ssor::try_new(a1.as_ref(), params).unwrap();
390        reused.refactorize(a2.as_ref()).unwrap();
391
392        assert_eq!(fresh.l_values.len(), reused.l_values.len());
393        for (a, b) in fresh.l_values.iter().zip(reused.l_values.iter()) {
394            assert!((a - b).abs() < 1e-14);
395        }
396        for (a, b) in fresh.u_values.iter().zip(reused.u_values.iter()) {
397            assert!((a - b).abs() < 1e-14);
398        }
399        for (a, b) in fresh.scaled_diag.iter().zip(reused.scaled_diag.iter()) {
400            assert!((a - b).abs() < 1e-14);
401        }
402    }
403
404    #[test]
405    fn rejects_invalid_omega() {
406        let a = tridiagonal(3, 4.0, -1.0, -1.0);
407        assert_eq!(
408            Ssor::try_new(a.as_ref(), SsorParams { omega: 0.0 }).unwrap_err(),
409            SsorError::InvalidOmega
410        );
411        assert_eq!(
412            Ssor::try_new(a.as_ref(), SsorParams { omega: 2.0 }).unwrap_err(),
413            SsorError::InvalidOmega
414        );
415    }
416
417    #[test]
418    fn rejects_zero_diagonal() {
419        let a = diagonal(&[1.0, 0.0, 1.0]);
420        assert_eq!(
421            Ssor::try_new(a.as_ref(), SsorParams::default()).unwrap_err(),
422            SsorError::ZeroDiagonal { col: 1 }
423        );
424    }
425
426    #[test]
427    fn rejects_non_square() {
428        let mut triplets = Vec::new();
429        for i in 0..3 {
430            triplets.push(Triplet::new(i, i, 1.0));
431        }
432        let a = SparseColMat::<usize, f64>::try_new_from_triplets(3, 4, &triplets).unwrap();
433        assert_eq!(
434            Ssor::try_new(a.as_ref(), SsorParams::default()).unwrap_err(),
435            SsorError::NonSquareMatrix { nrows: 3, ncols: 4 }
436        );
437    }
438}