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 super::*;
266    use crate::matrix::SquareMatrix;
267
268    /// Materialise an LduMatrix as a dense SquareMatrix (for cross-checking).
269    fn dense_of(a: &LduMatrix) -> SquareMatrix {
270        let mut m = SquareMatrix::new(a.n_cells);
271        for i in 0..a.n_cells {
272            m.set(i, i, a.diag[i]);
273        }
274        for f in 0..a.n_internal_faces {
275            let o = a.owner[f];
276            let n = a.neighbour[f];
277            m.set(o, n, a.upper[f]);
278            m.set(n, o, a.lower[f]);
279        }
280        m
281    }
282
283    /// Symmetric SPD tridiagonal: diag d, off-diag e (lower==upper).
284    fn spd_tridiag(n: usize, d: f64, e: f64) -> LduMatrix {
285        let mut owner = Vec::new();
286        let mut neigh = Vec::new();
287        for i in 0..n - 1 {
288            owner.push(i);
289            neigh.push(i + 1);
290        }
291        let mut a = LduMatrix::new(n, owner, neigh);
292        for i in 0..n {
293            a.diag[i] = d;
294        }
295        for f in 0..a.n_internal_faces {
296            a.lower[f] = e;
297            a.upper[f] = e;
298        }
299        a
300    }
301
302    /// Nonsymmetric tridiagonal: distinct lower and upper off-diagonals.
303    fn nonsym_tridiag(n: usize, d: f64, lo: f64, up: f64) -> LduMatrix {
304        let mut owner = Vec::new();
305        let mut neigh = Vec::new();
306        for i in 0..n - 1 {
307            owner.push(i);
308            neigh.push(i + 1);
309        }
310        let mut a = LduMatrix::new(n, owner, neigh);
311        for i in 0..n {
312            a.diag[i] = d;
313        }
314        for f in 0..a.n_internal_faces {
315            a.lower[f] = lo;
316            a.upper[f] = up;
317        }
318        a
319    }
320
321    fn rel_err(x: &[f64], y: &[f64]) -> f64 {
322        let mut num = 0.0;
323        let mut den = 0.0;
324        for i in 0..x.len() {
325            num += (x[i] - y[i]).powi(2);
326            den += y[i].powi(2);
327        }
328        (num / den).sqrt()
329    }
330
331    // (1) Small SPD system, known solution, both solvers.
332    #[test]
333    fn spd_known_solution() {
334        let n = 4;
335        let a = spd_tridiag(n, 2.0, -1.0);
336        // Choose x_true, form b = A x.
337        let x_true = vec![1.0, 2.0, 3.0, 4.0];
338        let b = a.multiply(&x_true);
339        let settings = KrylovSettings {
340            tolerance: 1e-10,
341            max_iter: 200,
342            restart: 30,
343        };
344
345        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
346        assert!(rb.converged, "bicgstab did not converge: {:?}", rb);
347        assert!(rel_err(&xb, &x_true) < 1e-7);
348
349        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
350        assert!(rg.converged, "gmres did not converge: {:?}", rg);
351        assert!(rel_err(&xg, &x_true) < 1e-7);
352    }
353
354    // (2) Nonsymmetric system solved by both, cross-checked vs dense LU.
355    #[test]
356    fn nonsymmetric_vs_dense_lu() {
357        let n = 6;
358        let a = nonsym_tridiag(n, 4.0, -1.0, -2.0); // lower != upper
359        let b: Vec<f64> = (0..n).map(|i| (i as f64) - 2.5).collect();
360
361        // Dense reference.
362        let dense = dense_of(&a);
363        let x_ref = dense.solve(&b).expect("dense LU failed");
364
365        let settings = KrylovSettings {
366            tolerance: 1e-8,
367            max_iter: 500,
368            restart: 30,
369        };
370
371        for precond in [
372            Preconditioner::identity(),
373            Preconditioner::jacobi(&a),
374            Preconditioner::ilu0(&a),
375        ] {
376            let (xb, rb) = bicgstab(&a, &b, None, &precond, &settings);
377            assert!(rb.converged, "bicgstab unconverged: {:?}", rb);
378            assert!(
379                rel_err(&xb, &x_ref) < 1e-6,
380                "bicgstab mismatch vs dense LU: {}",
381                rel_err(&xb, &x_ref)
382            );
383
384            let (xg, rg) = gmres(&a, &b, None, &precond, &settings);
385            assert!(rg.converged, "gmres unconverged: {:?}", rg);
386            assert!(
387                rel_err(&xg, &x_ref) < 1e-6,
388                "gmres mismatch vs dense LU: {}",
389                rel_err(&xg, &x_ref)
390            );
391        }
392    }
393
394    // (3) Preconditioning helps (or at least still converges) on an
395    //     ill-conditioned-ish system: ILU(0) should not need MORE iterations
396    //     than Identity, and usually far fewer.
397    #[test]
398    fn preconditioning_reduces_iterations() {
399        let n = 40;
400        // Mildly ill-conditioned SPD-ish, diagonally dominant tridiagonal.
401        let a = nonsym_tridiag(n, 2.05, -1.0, -1.0);
402        let b = vec![1.0; n];
403        let settings = KrylovSettings {
404            tolerance: 1e-8,
405            max_iter: 2000,
406            restart: 50,
407        };
408
409        let (_, r_id) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
410        let (_, r_jac) = bicgstab(&a, &b, None, &Preconditioner::jacobi(&a), &settings);
411        let (_, r_ilu) = bicgstab(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
412
413        assert!(r_id.converged && r_jac.converged && r_ilu.converged);
414        // ILU(0) must not be worse than identity, and should help here.
415        assert!(
416            r_ilu.n_iterations <= r_id.n_iterations,
417            "ILU(0) {} vs Identity {} iters",
418            r_ilu.n_iterations,
419            r_id.n_iterations
420        );
421
422        // Same check for GMRES.
423        let (_, g_id) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
424        let (_, g_ilu) = gmres(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
425        assert!(g_id.converged && g_ilu.converged);
426        assert!(g_ilu.n_iterations <= g_id.n_iterations);
427    }
428
429    // (4) Zero RHS shortcut.
430    #[test]
431    fn zero_rhs() {
432        let a = spd_tridiag(5, 3.0, -1.0);
433        let b = vec![0.0; 5];
434        let s = KrylovSettings::default();
435        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &s);
436        assert!(rb.converged && rb.n_iterations == 0 && xb.iter().all(|&v| v == 0.0));
437        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &s);
438        assert!(rg.converged && rg.n_iterations == 0 && xg.iter().all(|&v| v == 0.0));
439    }
440
441    // (5) Nonzero initial guess is honoured.
442    #[test]
443    fn initial_guess() {
444        let a = spd_tridiag(4, 2.0, -1.0);
445        let x_true = vec![0.5, 1.5, -2.0, 3.0];
446        let b = a.multiply(&x_true);
447        let s = KrylovSettings {
448            tolerance: 1e-10,
449            max_iter: 200,
450            restart: 30,
451        };
452        let guess = x_true.clone();
453        let (x, r) = gmres(&a, &b, Some(&guess), &Preconditioner::ilu0(&a), &s);
454        assert!(r.converged);
455        assert!(rel_err(&x, &x_true) < 1e-7);
456    }
457}