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}