Skip to main content

outram_foam_basic_lib/krylov/
mod.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
18
19//! Asymmetric Krylov iterative solvers and preconditioners for sparse `A x = b`.
20//!
21//! This module complements the crate's existing SPD-only machinery (DIC-PCG and
22//! GAMG in [`crate::ldu_matrix::solvers`]) with the **nonsymmetric** iterative
23//! solvers a Newton–Krylov subsurface-flow solver needs, where the Jacobian is
24//! not symmetric:
25//!
26//! - [`bicgstab`](crate::krylov::bicgstab()) — preconditioned BiCGStab: fixed work/storage per iteration,
27//!   breakdown-guarded.
28//! - [`gmres`](crate::krylov::gmres()) — restarted, right-preconditioned GMRES(m): residual-minimising,
29//!   robust, `O(m)` storage.
30//!
31//! and three preconditioners dispatched by the [`Preconditioner`](crate::krylov::Preconditioner) enum (never
32//! trait objects, per the workspace design rules):
33//!
34//! - [`Preconditioner::identity`](crate::krylov::Preconditioner::identity) — no preconditioning (`M = I`).
35//! - [`Preconditioner::jacobi`](crate::krylov::Preconditioner::jacobi) — diagonal scaling; always applicable.
36//! - [`Preconditioner::ilu0`](crate::krylov::Preconditioner::ilu0) — genuine ILU(0) incomplete factorisation.
37//!
38//! # Matrix representation and conventions
39//!
40//! All solvers act on [`crate::ldu_matrix::LduMatrix`], the crate's face-addressed
41//! sparse matrix, and use only its `multiply` (SpMV) and `residual` kernels. The
42//! system size `n` is `LduMatrix::n_cells`; all right-hand-side, guess, and
43//! solution slices have length `n`. Every quantity here is a dimensionless `f64`:
44//! a Krylov subspace mixes residuals, search directions and increments that share
45//! no single physical dimension, so no `uom` typing is applied — apply units at
46//! the field/equation layer that assembles the matrix.
47//!
48//! # Execution backend
49//!
50//! Both solvers run on the hybrid [`ComputeBackend`](crate::compute::ComputeBackend), driving the kernels in
51//! [`crate::ldu_matrix::parallel`]. Each has **one** implementation with the
52//! backend as a parameter — [`bicgstab_prepared`](crate::krylov::bicgstab_prepared()) and [`gmres_prepared`](crate::krylov::gmres_prepared()), which
53//! take a [`HybridLdu`](crate::ldu_matrix::parallel::HybridLdu) so the
54//! cell-gather index is built once per mesh rather than once per solve — plus a
55//! convenience adapter ([`bicgstab`](crate::krylov::bicgstab()), [`gmres`](crate::krylov::gmres())) for a caller holding a bare
56//! [`LduMatrix`](crate::ldu_matrix::LduMatrix), which builds the index and runs on
57//! [`ComputeBackend::Serial`](crate::compute::ComputeBackend::Serial). They are not a serial/parallel pair: they differ
58//! in who owns the index, and the backend is a parameter of both.
59//!
60//! Whole-solve wall clock on 4 logical cores, release, `--features parallel`,
61//! 512 000 cells, measured 2026-08-13 over five independent runs: **2.4-2.7x**
62//! with Jacobi preconditioning, **~1.5x** with ILU(0) — the gap being ILU(0)'s
63//! inherently sequential triangular solves, measured at 29-46% of the *serial*
64//! solve, which caps it at 1.7-2.1x on 4 cores. Below roughly 13 000 cells the
65//! parallel path **loses** (0.6-0.8x at 4 096 cells). Full tables, methodology
66//! and limitations are on the benchmarks in `hybrid_tests` and summarised on
67//! [`bicgstab_prepared`](crate::krylov::bicgstab_prepared()).
68//!
69//! **Backend parity is bitwise**, not tolerance-based: `Serial` and `CpuMulti`
70//! produce identical iterates, identical residual histories and identical
71//! iteration counts at any thread count, because every kernel underneath carries
72//! that guarantee individually.
73//!
74//! What is *not* bitwise is the comparison against the solvers as they stood
75//! **before** they took the hybrid path, because
76//! [`crate::ldu_matrix::parallel::dot`] sums in blocks of 1 024 where
77//! [`crate::krylov::vecops::dot`] sums flat, and floating-point addition is not
78//! associative. Below 1 024 elements the two are identical; above it they differ
79//! in the last bits, which in principle can move an iteration count by one when a
80//! residual crosses the tolerance within those bits. Measured rather than assumed:
81//! over a 216-solve sweep of 3 mesh sizes x 3 diagonal dominances x 3 right-hand
82//! sides x 4 tolerances (1e-6 to 1e-12) x both solvers, the iteration count
83//! changed **zero times**, for GMRES — the more exposed of the two — as well as
84//! for BiCGStab. Converged residuals differ by up to 2.2e-4 *relative*, which at
85//! the tightest tolerance swept is an absolute difference of ~1.9e-16 on a
86//! residual of 8.6e-13. See `hybrid_tests` for the full grid and its limitations.
87//!
88//! # Convergence
89//!
90//! The stopping test for both solvers is the **relative** residual
91//! `||b − A x||₂ / ||b||₂ ≤ tolerance`. The reported `final_residual` is always the
92//! *true* residual of the returned iterate (recomputed from `A` and `b`), not an
93//! internal estimate. A right-hand side that is exactly zero returns `x = 0`,
94//! `converged = true`, `0` iterations.
95//!
96//! # Example
97//!
98//! ```rust
99//! use outram_foam_basic_lib::ldu_matrix::LduMatrix;
100//! use outram_foam_basic_lib::krylov::{bicgstab, Preconditioner, KrylovSettings};
101//!
102//! // 3-cell chain: cells 0-1 and 1-2 share a face each.
103//! let mut a = LduMatrix::new(3, vec![0, 1], vec![1, 2]);
104//! a.diag = vec![4.0, 4.0, 4.0];
105//! a.lower = vec![-1.0, -1.0];
106//! a.upper = vec![-1.0, -1.0];
107//! let b = vec![1.0, 2.0, 3.0];
108//!
109//! let precond = Preconditioner::jacobi(&a);
110//! let settings = KrylovSettings::default();
111//! let (x, result) = bicgstab(&a, &b, None, &precond, &settings);
112//!
113//! assert!(result.converged);
114//! // Verify: A x ≈ b.
115//! let ax = a.multiply(&x);
116//! for i in 0..3 {
117//!     assert!((ax[i] - b[i]).abs() < 1e-6);
118//! }
119//! ```
120
121mod bicgstab;
122mod gmres;
123mod preconditioner;
124pub mod vecops;
125
126#[cfg(test)]
127mod hybrid_tests;
128
129pub use bicgstab::{bicgstab, bicgstab_prepared};
130pub use gmres::{gmres, gmres_prepared};
131pub use preconditioner::{Ilu0Preconditioner, JacobiPreconditioner};
132
133use crate::compute::ComputeBackend;
134use crate::ldu_matrix::LduMatrix;
135
136/// Iteration controls shared by [`bicgstab`](crate::krylov::bicgstab()) and [`gmres`](crate::krylov::gmres()).
137///
138/// All fields are plain scalars with no units.
139#[derive(Debug, Clone, Copy)]
140pub struct KrylovSettings {
141    /// Relative convergence tolerance on `||b − A x||₂ / ||b||₂`. Must be `> 0`;
142    /// typical range `1e-12 .. 1e-4`. Default `1e-8`.
143    pub tolerance: f64,
144    /// Maximum total number of iterations (matrix–vector products) before the
145    /// solver returns unconverged. Default `1000`.
146    pub max_iter: usize,
147    /// GMRES restart length `m` — the Krylov subspace dimension per outer cycle,
148    /// trading memory (`O(m·n)`) against convergence robustness. Ignored by
149    /// BiCGStab. `0` means "no restart" (`m = max_iter`). Default `30`.
150    pub restart: usize,
151}
152
153impl Default for KrylovSettings {
154    /// Defaults: `tolerance = 1e-8`, `max_iter = 1000`, `restart = 30`.
155    fn default() -> Self {
156        Self {
157            tolerance: 1.0e-8,
158            max_iter: 1000,
159            restart: 30,
160        }
161    }
162}
163
164/// Outcome of a Krylov solve.
165///
166/// All fields are plain scalars with no units.
167#[derive(Debug, Clone, Copy)]
168pub struct KrylovResult {
169    /// Number of iterations (matrix–vector products) actually performed.
170    pub n_iterations: usize,
171    /// The **true** relative residual `||b − A x||₂ / ||b||₂` of the returned
172    /// iterate, recomputed from `A` and `b` (dimensionless, `>= 0`).
173    pub final_residual: f64,
174    /// `true` iff `final_residual <= settings.tolerance`.
175    pub converged: bool,
176}
177
178/// Preconditioner `M^{-1} ≈ A^{-1}`, dispatched by enum (no trait objects).
179///
180/// A preconditioner turns a residual `r` into `z = M^{-1} r`, an approximate
181/// error, which the Krylov solvers use to accelerate convergence. Construct one
182/// from the system matrix with [`Preconditioner::jacobi`] or
183/// [`Preconditioner::ilu0`], or use [`Preconditioner::identity`] for none.
184pub enum Preconditioner {
185    /// No preconditioning: `M = I`, so `z = r`.
186    Identity,
187    /// Diagonal (Jacobi) scaling: `z = r / diag(A)`.
188    Jacobi(JacobiPreconditioner),
189    /// ILU(0) incomplete factorisation: `z = (LU)^{-1} r`.
190    Ilu0(Ilu0Preconditioner),
191}
192
193impl Preconditioner {
194    /// Identity preconditioner (`M = I`) — equivalent to no preconditioning.
195    pub fn identity() -> Self {
196        Preconditioner::Identity
197    }
198
199    /// Build a Jacobi (reciprocal-diagonal) preconditioner from `a`.
200    ///
201    /// Cannot break down: a zero diagonal entry is treated as `1.0`. The
202    /// guaranteed-robust choice when ILU(0) is not warranted.
203    pub fn jacobi(a: &LduMatrix) -> Self {
204        Preconditioner::Jacobi(JacobiPreconditioner::new(a))
205    }
206
207    /// Build an ILU(0) preconditioner from `a` (same sparsity pattern as `A`).
208    ///
209    /// A genuine incomplete LU factorisation with a small-pivot guard; see
210    /// [`Ilu0Preconditioner`]. Typically far stronger than Jacobi on
211    /// diagonally-dominant / mildly ill-conditioned systems.
212    pub fn ilu0(a: &LduMatrix) -> Self {
213        Preconditioner::Ilu0(Ilu0Preconditioner::new(a))
214    }
215
216    /// Apply the preconditioner serially: write `z = M^{-1} r`.
217    ///
218    /// `r` and `z` must both have length `n_cells`. For [`Preconditioner::Identity`]
219    /// this copies `r` into `z`. Equivalent to [`Self::apply_on`] with
220    /// [`ComputeBackend::Serial`](crate::compute::ComputeBackend::Serial).
221    pub fn apply(&self, r: &[f64], z: &mut [f64]) {
222        self.apply_on(r, z, ComputeBackend::Serial);
223    }
224
225    /// Apply the preconditioner on the chosen [`ComputeBackend`]: write
226    /// `z = M^{-1} r`.
227    ///
228    /// # How much each variant actually parallelises
229    ///
230    /// | Variant | Backend honoured? | Why |
231    /// |---|---|---|
232    /// | [`Identity`](Self::Identity) | no — `copy_from_slice` | already a single memcpy; there is nothing to thread |
233    /// | [`Jacobi`](Self::Jacobi) | **yes** | one independent multiply per cell — embarrassingly parallel |
234    /// | [`Ilu0`](Self::Ilu0) | no — always serial | the triangular solves have a loop-carried dependence; see [`Ilu0Preconditioner::apply_on`] |
235    ///
236    /// This table is the honest answer to "did the preconditioners move onto the
237    /// backend?": one of the three did, and the reason the other two did not is
238    /// structural rather than unfinished work.
239    ///
240    /// # Determinism
241    ///
242    /// All three variants are bitwise identical across backends and thread
243    /// counts, and bitwise identical to [`Self::apply`]. Two are serial anyway,
244    /// and Jacobi is element-wise, so no summation is reassociated. A caller may
245    /// therefore switch backend without perturbing a solver's iteration count or
246    /// residual history through the preconditioner.
247    ///
248    /// # Arguments
249    ///
250    /// - `r` — residual to precondition, length `n_cells`.
251    /// - `z` — output buffer, length `n_cells`. Fully overwritten.
252    /// - `backend` — requested execution backend.
253    pub fn apply_on(&self, r: &[f64], z: &mut [f64], backend: ComputeBackend) {
254        debug_assert_eq!(r.len(), z.len(), "Preconditioner::apply length mismatch");
255        match self {
256            Preconditioner::Identity => z.copy_from_slice(r),
257            Preconditioner::Jacobi(p) => preconditioner::jacobi_apply(p, r, z, backend),
258            Preconditioner::Ilu0(p) => preconditioner::ilu0_apply(p, r, z, backend),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use petir::mathf::RealMath;
266    use super::*;
267    use crate::matrix::SquareMatrix;
268
269    /// Materialise an LduMatrix as a dense SquareMatrix (for cross-checking).
270    fn dense_of(a: &LduMatrix) -> SquareMatrix {
271        let mut m = SquareMatrix::new(a.n_cells);
272        for i in 0..a.n_cells {
273            m.set(i, i, a.diag[i]);
274        }
275        for f in 0..a.n_internal_faces {
276            let o = a.owner[f];
277            let n = a.neighbour[f];
278            m.set(o, n, a.upper[f]);
279            m.set(n, o, a.lower[f]);
280        }
281        m
282    }
283
284    /// Symmetric SPD tridiagonal: diag d, off-diag e (lower==upper).
285    fn spd_tridiag(n: usize, d: f64, e: f64) -> LduMatrix {
286        let mut owner = Vec::new();
287        let mut neigh = Vec::new();
288        for i in 0..n - 1 {
289            owner.push(i);
290            neigh.push(i + 1);
291        }
292        let mut a = LduMatrix::new(n, owner, neigh);
293        for i in 0..n {
294            a.diag[i] = d;
295        }
296        for f in 0..a.n_internal_faces {
297            a.lower[f] = e;
298            a.upper[f] = e;
299        }
300        a
301    }
302
303    /// Nonsymmetric tridiagonal: distinct lower and upper off-diagonals.
304    fn nonsym_tridiag(n: usize, d: f64, lo: f64, up: f64) -> LduMatrix {
305        let mut owner = Vec::new();
306        let mut neigh = Vec::new();
307        for i in 0..n - 1 {
308            owner.push(i);
309            neigh.push(i + 1);
310        }
311        let mut a = LduMatrix::new(n, owner, neigh);
312        for i in 0..n {
313            a.diag[i] = d;
314        }
315        for f in 0..a.n_internal_faces {
316            a.lower[f] = lo;
317            a.upper[f] = up;
318        }
319        a
320    }
321
322    fn rel_err(x: &[f64], y: &[f64]) -> f64 {
323        let mut num = 0.0;
324        let mut den = 0.0;
325        for i in 0..x.len() {
326            num += (x[i] - y[i]).r_powi(2);
327            den += y[i].r_powi(2);
328        }
329        (num / den).sqrt()
330    }
331
332    // (1) Small SPD system, known solution, both solvers.
333    #[test]
334    fn spd_known_solution() {
335        let n = 4;
336        let a = spd_tridiag(n, 2.0, -1.0);
337        // Choose x_true, form b = A x.
338        let x_true = vec![1.0, 2.0, 3.0, 4.0];
339        let b = a.multiply(&x_true);
340        let settings = KrylovSettings {
341            tolerance: 1e-10,
342            max_iter: 200,
343            restart: 30,
344        };
345
346        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
347        assert!(rb.converged, "bicgstab did not converge: {:?}", rb);
348        assert!(rel_err(&xb, &x_true) < 1e-7);
349
350        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
351        assert!(rg.converged, "gmres did not converge: {:?}", rg);
352        assert!(rel_err(&xg, &x_true) < 1e-7);
353    }
354
355    // (2) Nonsymmetric system solved by both, cross-checked vs dense LU.
356    #[test]
357    fn nonsymmetric_vs_dense_lu() {
358        let n = 6;
359        let a = nonsym_tridiag(n, 4.0, -1.0, -2.0); // lower != upper
360        let b: Vec<f64> = (0..n).map(|i| (i as f64) - 2.5).collect();
361
362        // Dense reference.
363        let dense = dense_of(&a);
364        let x_ref = dense.solve(&b).expect("dense LU failed");
365
366        let settings = KrylovSettings {
367            tolerance: 1e-8,
368            max_iter: 500,
369            restart: 30,
370        };
371
372        for precond in [
373            Preconditioner::identity(),
374            Preconditioner::jacobi(&a),
375            Preconditioner::ilu0(&a),
376        ] {
377            let (xb, rb) = bicgstab(&a, &b, None, &precond, &settings);
378            assert!(rb.converged, "bicgstab unconverged: {:?}", rb);
379            assert!(
380                rel_err(&xb, &x_ref) < 1e-6,
381                "bicgstab mismatch vs dense LU: {}",
382                rel_err(&xb, &x_ref)
383            );
384
385            let (xg, rg) = gmres(&a, &b, None, &precond, &settings);
386            assert!(rg.converged, "gmres unconverged: {:?}", rg);
387            assert!(
388                rel_err(&xg, &x_ref) < 1e-6,
389                "gmres mismatch vs dense LU: {}",
390                rel_err(&xg, &x_ref)
391            );
392        }
393    }
394
395    // (3) Preconditioning helps (or at least still converges) on an
396    //     ill-conditioned-ish system: ILU(0) should not need MORE iterations
397    //     than Identity, and usually far fewer.
398    #[test]
399    fn preconditioning_reduces_iterations() {
400        let n = 40;
401        // Mildly ill-conditioned SPD-ish, diagonally dominant tridiagonal.
402        let a = nonsym_tridiag(n, 2.05, -1.0, -1.0);
403        let b = vec![1.0; n];
404        let settings = KrylovSettings {
405            tolerance: 1e-8,
406            max_iter: 2000,
407            restart: 50,
408        };
409
410        let (_, r_id) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
411        let (_, r_jac) = bicgstab(&a, &b, None, &Preconditioner::jacobi(&a), &settings);
412        let (_, r_ilu) = bicgstab(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
413
414        assert!(r_id.converged && r_jac.converged && r_ilu.converged);
415        // ILU(0) must not be worse than identity, and should help here.
416        assert!(
417            r_ilu.n_iterations <= r_id.n_iterations,
418            "ILU(0) {} vs Identity {} iters",
419            r_ilu.n_iterations,
420            r_id.n_iterations
421        );
422
423        // Same check for GMRES.
424        let (_, g_id) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
425        let (_, g_ilu) = gmres(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
426        assert!(g_id.converged && g_ilu.converged);
427        assert!(g_ilu.n_iterations <= g_id.n_iterations);
428    }
429
430    // (4) Zero RHS shortcut.
431    #[test]
432    fn zero_rhs() {
433        let a = spd_tridiag(5, 3.0, -1.0);
434        let b = vec![0.0; 5];
435        let s = KrylovSettings::default();
436        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &s);
437        assert!(rb.converged && rb.n_iterations == 0 && xb.iter().all(|&v| v == 0.0));
438        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &s);
439        assert!(rg.converged && rg.n_iterations == 0 && xg.iter().all(|&v| v == 0.0));
440    }
441
442    // (5) Nonzero initial guess is honoured.
443    #[test]
444    fn initial_guess() {
445        let a = spd_tridiag(4, 2.0, -1.0);
446        let x_true = vec![0.5, 1.5, -2.0, 3.0];
447        let b = a.multiply(&x_true);
448        let s = KrylovSettings {
449            tolerance: 1e-10,
450            max_iter: 200,
451            restart: 30,
452        };
453        let guess = x_true.clone();
454        let (x, r) = gmres(&a, &b, Some(&guess), &Preconditioner::ilu0(&a), &s);
455        assert!(r.converged);
456        assert!(rel_err(&x, &x_true) < 1e-7);
457    }
458}