outram_foam_basic_lib/krylov/preconditioner.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//! Preconditioners for the asymmetric Krylov solvers.
20//!
21//! A preconditioner supplies an approximate inverse `M^{-1} ≈ A^{-1}` that is
22//! cheap to apply. Applying it to a residual `r` produces a "preconditioned
23//! residual" `z = M^{-1} r`; a good `M` clusters the eigenvalues of `A M^{-1}`
24//! (right) or `M^{-1} A` (left) and so cuts the Krylov iteration count.
25//!
26//! Dispatch is by the [`Preconditioner`] enum (defined in the parent module),
27//! never by trait objects, per the workspace design rules. This file provides
28//! the two concrete data-carrying variants:
29//!
30//! - [`JacobiPreconditioner`] — diagonal (reciprocal-diagonal) scaling. Rock-solid
31//! and always applicable; the guaranteed fallback.
32//! - [`Ilu0Preconditioner`] — incomplete LU with zero fill-in, ILU(0). A genuine
33//! incomplete factorisation on a CSR view of `A` built internally from the LDU
34//! coefficients, with a small-pivot guard so it never divides by (near-)zero.
35//!
36//! All quantities are dimensionless `f64`: the preconditioner acts on the raw
37//! algebraic residual, not on a `uom`-typed physical field.
38
39use crate::compute::ComputeBackend;
40use crate::ldu_matrix::parallel::vecop_backend_for;
41use crate::ldu_matrix::LduMatrix;
42
43#[cfg(feature = "parallel")]
44use crate::ldu_matrix::parallel::CELL_BLOCK;
45#[cfg(feature = "parallel")]
46use rayon::prelude::*;
47
48/// Smallest pivot magnitude the ILU(0) factorisation will divide by.
49///
50/// If a computed pivot falls below this in magnitude it is replaced (preserving
51/// sign) so that the triangular solves stay finite. This trades a small amount of
52/// factorisation accuracy for guaranteed robustness — the solver still converges,
53/// just with a slightly weaker preconditioner on that row.
54const ILU_PIVOT_FLOOR: f64 = 1.0e-300;
55
56/// Diagonal (Jacobi) preconditioner: `M = diag(A)`, so `M^{-1} r = r / diag`.
57///
58/// The cheapest possible preconditioner. It is always applicable and cannot break
59/// down: any exactly-zero diagonal entry is treated as `1.0` (identity on that
60/// row) so the reciprocal is finite. Stores the precomputed reciprocal diagonal,
61/// length `n_cells`, each entry dimensionless.
62pub struct JacobiPreconditioner {
63 /// `inv_diag[i] = 1 / diag[i]`, with a zero diagonal mapped to `1.0`.
64 inv_diag: Vec<f64>,
65}
66
67impl JacobiPreconditioner {
68 /// Build the reciprocal diagonal from `a`.
69 ///
70 /// A diagonal entry that is exactly `0.0` (or non-finite) is replaced by a
71 /// unit reciprocal so `apply` never produces `inf`/`NaN`.
72 pub fn new(a: &LduMatrix) -> Self {
73 let inv_diag = a
74 .diag
75 .iter()
76 .map(|&d| {
77 if d != 0.0 && d.is_finite() {
78 1.0 / d
79 } else {
80 1.0
81 }
82 })
83 .collect();
84 Self { inv_diag }
85 }
86
87 /// Apply `z = M^{-1} r` on the chosen [`ComputeBackend`].
88 ///
89 /// # Why this one parallelises and ILU(0) does not
90 ///
91 /// Jacobi is the *only* preconditioner in this module that is embarrassingly
92 /// parallel: `z_i` depends on `r_i` and `inv_diag[i]` alone, so the loop has
93 /// no loop-carried dependence at all. Contrast
94 /// [`Ilu0Preconditioner::apply_on`], whose triangular solves are inherently
95 /// sequential.
96 ///
97 /// # Determinism
98 ///
99 /// Bitwise identical on every backend and at any thread count. Each output
100 /// element is one independent multiplication, so — as for
101 /// [`crate::ldu_matrix::parallel::axpy`] and
102 /// [`crate::ldu_matrix::parallel::scale`] — there is no reduction to
103 /// reassociate. It is also bitwise identical to the serial loop this replaced,
104 /// so switching backend cannot change a solver's iteration count or residual
105 /// history through this path.
106 ///
107 /// # Arguments
108 ///
109 /// - `r` — residual to precondition, length `n_cells`, dimensionless.
110 /// - `z` — output buffer, length `n_cells`. Fully overwritten.
111 /// - `backend` — requested execution backend. Below
112 /// [`crate::ldu_matrix::parallel::VECOP_MIN_ELEMENTS`] elements a
113 /// [`ComputeBackend::CpuMulti`] request runs serially, because this kernel
114 /// is memory-bandwidth bound at one multiply per element and threading it
115 /// on a small vector costs more than it saves — the same floor and the same
116 /// reasoning as the vector operations it sits beside.
117 pub fn apply_on(&self, r: &[f64], z: &mut [f64], backend: ComputeBackend) {
118 debug_assert_eq!(r.len(), self.inv_diag.len());
119 debug_assert_eq!(z.len(), self.inv_diag.len());
120 match vecop_backend_for(backend, z.len()) {
121 #[cfg(feature = "parallel")]
122 ComputeBackend::CpuMulti => {
123 z.par_chunks_mut(CELL_BLOCK)
124 .zip(r.par_chunks(CELL_BLOCK))
125 .zip(self.inv_diag.par_chunks(CELL_BLOCK))
126 .for_each(|((zc, rc), dc)| {
127 for ((zi, ri), di) in zc.iter_mut().zip(rc.iter()).zip(dc.iter()) {
128 *zi = ri * di;
129 }
130 });
131 }
132 _ => {
133 for i in 0..z.len() {
134 z[i] = r[i] * self.inv_diag[i];
135 }
136 }
137 }
138 }
139}
140
141/// ILU(0) preconditioner: incomplete LU factorisation with **no fill-in**.
142///
143/// The factors `L` (unit lower-triangular) and `U` (upper-triangular) are computed
144/// with the standard IKJ Gaussian-elimination algorithm but every update that
145/// would land outside the sparsity pattern of `A` is discarded — so `L\U` occupy
146/// exactly the same nonzero positions as `A`. Applying `M^{-1} = (LU)^{-1}` is one
147/// forward substitution (`L y = r`) followed by one back substitution (`U z = y`).
148///
149/// # This is a genuine ILU(0), not a Jacobi fallback
150///
151/// The factorisation is real: for a symmetric problem it reduces to an incomplete
152/// Cholesky-like factor and typically beats Jacobi substantially; for a
153/// nonsymmetric problem it captures both off-diagonal directions. The only
154/// safeguard is a small-pivot floor (`ILU_PIVOT_FLOOR`): a pivot smaller than
155/// that in magnitude is bumped up (keeping its sign) so the triangular solves stay
156/// finite. In the degenerate limit where *every* off-diagonal contribution
157/// vanishes, ILU(0) coincides exactly with Jacobi — that is a property of the
158/// method, not a substitution of one for the other.
159///
160/// # Storage (CSR, built internally from the LDU coefficients)
161///
162/// The LDU (face-addressed) matrix is expanded once, at construction, into a
163/// compressed-sparse-row (CSR) view: for each row `i` the diagonal `diag[i]`, plus
164/// `upper[f]` at column `neighbour[f]` for every face owned by `i`, plus
165/// `lower[f]` at column `owner[f]` for every face neighboured by `i`. Columns
166/// within a row are stored in ascending order. The factorisation overwrites those
167/// values with the combined `L\U` (unit `L` diagonal is implicit and not stored).
168pub struct Ilu0Preconditioner {
169 /// Number of rows / unknowns.
170 n: usize,
171 /// CSR row offsets, length `n + 1`.
172 row_ptr: Vec<usize>,
173 /// CSR column indices, ascending within each row.
174 col_idx: Vec<usize>,
175 /// CSR values, overwritten with the `L\U` factors.
176 lu: Vec<f64>,
177 /// For each row, the index into `col_idx`/`lu` of its diagonal entry.
178 diag_ptr: Vec<usize>,
179}
180
181impl Ilu0Preconditioner {
182 /// Build the ILU(0) factorisation of `a`.
183 ///
184 /// Constructs the CSR view then runs the in-place IKJ incomplete-LU
185 /// elimination restricted to existing nonzeros. Runs in roughly
186 /// `O(nnz · avg_row)` time. Pivots below `ILU_PIVOT_FLOOR` are floored to
187 /// keep the factor invertible.
188 pub fn new(a: &LduMatrix) -> Self {
189 let n = a.n_cells;
190
191 // --- Count nonzeros per row: 1 diagonal + off-diagonals from faces. ---
192 let mut row_count = vec![1usize; n]; // diagonal always present
193 for f in 0..a.n_internal_faces {
194 let o = a.owner[f];
195 let nb = a.neighbour[f];
196 // upper[f] contributes to row o (column nb); lower[f] to row nb (column o).
197 row_count[o] += 1;
198 row_count[nb] += 1;
199 }
200
201 // --- Row offsets (prefix sum). ---
202 let mut row_ptr = vec![0usize; n + 1];
203 for i in 0..n {
204 row_ptr[i + 1] = row_ptr[i] + row_count[i];
205 }
206 let nnz = row_ptr[n];
207
208 // --- Scatter (column, value) pairs into each row, then sort per row. ---
209 // Temporarily collect per-row entries, then flatten in ascending column
210 // order so binary search on a row is valid.
211 let mut rows: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
212 for i in 0..n {
213 rows[i].push((i, a.diag[i])); // diagonal
214 }
215 for f in 0..a.n_internal_faces {
216 let o = a.owner[f];
217 let nb = a.neighbour[f];
218 rows[o].push((nb, a.upper[f]));
219 rows[nb].push((o, a.lower[f]));
220 }
221
222 let mut col_idx = Vec::with_capacity(nnz);
223 let mut lu = Vec::with_capacity(nnz);
224 let mut diag_ptr = vec![0usize; n];
225 for i in 0..n {
226 rows[i].sort_by_key(|&(c, _)| c);
227 for &(c, v) in &rows[i] {
228 if c == i {
229 diag_ptr[i] = col_idx.len();
230 }
231 col_idx.push(c);
232 lu.push(v);
233 }
234 }
235
236 let mut me = Self {
237 n,
238 row_ptr,
239 col_idx,
240 lu,
241 diag_ptr,
242 };
243 me.factor();
244 me
245 }
246
247 /// Locate the value at `(row, col)` within the CSR structure, if present.
248 ///
249 /// Binary search over the ascending column indices of `row`. Returns the flat
250 /// index into `lu`/`col_idx`, or `None` if `col` is not a stored nonzero.
251 fn find(&self, row: usize, col: usize) -> Option<usize> {
252 let lo = self.row_ptr[row];
253 let hi = self.row_ptr[row + 1];
254 let slice = &self.col_idx[lo..hi];
255 match slice.binary_search(&col) {
256 Ok(rel) => Some(lo + rel),
257 Err(_) => None,
258 }
259 }
260
261 /// In-place IKJ incomplete-LU elimination restricted to the sparsity pattern.
262 ///
263 /// After this call `lu` holds the combined factors: strictly-lower entries are
264 /// the multipliers of unit-lower `L`, the diagonal and strictly-upper entries
265 /// are `U`.
266 fn factor(&mut self) {
267 for i in 0..self.n {
268 let ri = self.row_ptr[i];
269 let ri_end = self.row_ptr[i + 1];
270 // Walk the entries of row i in ascending column order.
271 for p in ri..ri_end {
272 let k = self.col_idx[p];
273 if k >= i {
274 break; // reached the diagonal / upper part: L updates done
275 }
276 // a_ik := a_ik / a_kk (multiplier for row k)
277 let mut akk = self.lu[self.diag_ptr[k]];
278 if akk.abs() < ILU_PIVOT_FLOOR {
279 akk = if akk < 0.0 {
280 -ILU_PIVOT_FLOOR
281 } else {
282 ILU_PIVOT_FLOOR
283 };
284 }
285 let aik = self.lu[p] / akk;
286 self.lu[p] = aik;
287 // For every j > k in row i, subtract a_ik * a_kj (if a_kj exists).
288 for q in (p + 1)..ri_end {
289 let j = self.col_idx[q];
290 if let Some(kj) = self.find(k, j) {
291 let akj = self.lu[kj];
292 self.lu[q] -= aik * akj;
293 }
294 }
295 }
296 // Floor the diagonal pivot for the eventual back-substitution divide.
297 let dptr = self.diag_ptr[i];
298 if self.lu[dptr].abs() < ILU_PIVOT_FLOOR {
299 self.lu[dptr] = if self.lu[dptr] < 0.0 {
300 -ILU_PIVOT_FLOOR
301 } else {
302 ILU_PIVOT_FLOOR
303 };
304 }
305 }
306 }
307
308 /// Apply `z = M^{-1} r` — **always sequentially, on every backend**.
309 ///
310 /// # This kernel does not parallelise, and the code does not pretend it does
311 ///
312 /// ILU(0) is sequential by construction in both of its phases:
313 ///
314 /// - **The factorisation** ([`Self::factor`]) has a loop-carried dependence:
315 /// eliminating row `i` reads the already-eliminated rows `k < i` through
316 /// `self.lu[self.diag_ptr[k]]` and `find(k, j)`, so row `i` cannot start
317 /// until every row it touches has finished.
318 /// - **The triangular solves** ([`Self::apply`]) have the same shape: the
319 /// forward solve reads `z[j]` for `j < i` while computing `z[i]`, and the
320 /// back solve reads `z[j]` for `j > i`.
321 ///
322 /// There are parallel variants in the literature — level-scheduling, which
323 /// exposes only as much parallelism as the sparsity graph's critical path
324 /// allows, and block-Jacobi-over-subdomains, which is *a different
325 /// preconditioner*: it drops the couplings between subdomains, so it changes
326 /// the iteration count and needs its own validation. Bead `op-yvj.4.4`
327 /// names that explicitly and says not to substitute one silently for the
328 /// other, so this implementation does neither. It keeps the exact standard
329 /// ILU(0) and runs it on one thread.
330 ///
331 /// The practical consequence is Amdahl's law: in a solve preconditioned with
332 /// ILU(0), the fraction of time spent here is a hard serial floor on the
333 /// achievable speed-up. That fraction is measured, not assumed: on an
334 /// asymmetric 7-point-stencil system, 4 logical cores, release,
335 /// `--features parallel`, 2026-08-13, these triangular solves were **29-46%
336 /// of the serial solve time** (rising with problem size), which caps an
337 /// ILU(0)-preconditioned speed-up at **1.7-2.1x on 4 cores** and 2.2-3.4x on
338 /// infinitely many. The measured end-to-end figure is ~1.5x. Full derivation,
339 /// all four repeat runs and the limitations are on the
340 /// `ilu0_serial_fraction_benchmark` in `crate::krylov::hybrid_tests`.
341 ///
342 /// The **factorisation** ([`Self::new`]) is a further sequential cost on top:
343 /// at 262 144 cells it measured 124-151 ms against a 522-664 ms serial solve,
344 /// i.e. roughly a quarter of one solve — 23.8% in the fourth run, the only one
345 /// whose factorisation and solve figures are quoted here as a matched pair —
346 /// paid once per coefficient update rather than once per iteration.
347 ///
348 /// # Arguments
349 ///
350 /// - `r` — residual to precondition, length `n`, dimensionless.
351 /// - `z` — output buffer, length `n`. Fully overwritten.
352 /// - `_backend` — accepted so this matches
353 /// [`JacobiPreconditioner::apply_on`] and the enum can dispatch uniformly.
354 /// **It is ignored**; the kernel runs serially whatever is requested. That
355 /// also means the result is trivially bitwise identical across backends.
356 pub fn apply_on(&self, r: &[f64], z: &mut [f64], _backend: ComputeBackend) {
357 self.apply(r, z);
358 }
359
360 /// Apply `z = M^{-1} r = U^{-1} L^{-1} r` via forward then back substitution.
361 ///
362 /// `L` is unit lower-triangular (implicit unit diagonal), `U` upper-triangular
363 /// with the stored diagonal. `r` and `z` must both have length `n`.
364 ///
365 /// Sequential; see [`Self::apply_on`] for why it cannot be otherwise.
366 fn apply(&self, r: &[f64], z: &mut [f64]) {
367 debug_assert_eq!(r.len(), self.n);
368 debug_assert_eq!(z.len(), self.n);
369
370 // Forward solve L y = r (store y in z). Unit diagonal on L.
371 for i in 0..self.n {
372 let mut sum = r[i];
373 let ri = self.row_ptr[i];
374 let ri_end = self.row_ptr[i + 1];
375 for p in ri..ri_end {
376 let j = self.col_idx[p];
377 if j >= i {
378 break; // L part only (strictly lower)
379 }
380 sum -= self.lu[p] * z[j];
381 }
382 z[i] = sum;
383 }
384
385 // Back solve U z = y (in place on z). Divide by stored diagonal.
386 for i in (0..self.n).rev() {
387 let mut sum = z[i];
388 let ri_end = self.row_ptr[i + 1];
389 let dptr = self.diag_ptr[i];
390 for p in (dptr + 1)..ri_end {
391 let j = self.col_idx[p];
392 sum -= self.lu[p] * z[j];
393 }
394 z[i] = sum / self.lu[dptr];
395 }
396 }
397}
398
399// --- Dispatch surface -------------------------------------------------------
400//
401// The `Preconditioner` enum itself lives in the parent module (`mod.rs`) so it
402// sits alongside `KrylovSettings`/`KrylovResult`, but its `apply` delegates to
403// the free helpers below to keep those variant-specific routines next to the
404// data they act on.
405
406/// Apply a [`JacobiPreconditioner`]: `z = r / diag`, on `backend`.
407pub(crate) fn jacobi_apply(
408 p: &JacobiPreconditioner,
409 r: &[f64],
410 z: &mut [f64],
411 backend: ComputeBackend,
412) {
413 p.apply_on(r, z, backend);
414}
415
416/// Apply an [`Ilu0Preconditioner`]: `z = (LU)^{-1} r`. Always serial; `backend`
417/// is accepted for uniform dispatch and ignored — see
418/// [`Ilu0Preconditioner::apply_on`].
419pub(crate) fn ilu0_apply(
420 p: &Ilu0Preconditioner,
421 r: &[f64],
422 z: &mut [f64],
423 backend: ComputeBackend,
424) {
425 p.apply_on(r, z, backend);
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::ldu_matrix::LduMatrix;
432
433 /// Build a symmetric tridiagonal LduMatrix with diag `d` and off-diag `e`.
434 fn tridiag(n: usize, d: f64, e: f64) -> LduMatrix {
435 let mut owner = Vec::new();
436 let mut neigh = Vec::new();
437 for i in 0..n - 1 {
438 owner.push(i);
439 neigh.push(i + 1);
440 }
441 let mut a = LduMatrix::new(n, owner, neigh);
442 for i in 0..n {
443 a.diag[i] = d;
444 }
445 for f in 0..a.n_internal_faces {
446 a.lower[f] = e;
447 a.upper[f] = e;
448 }
449 a
450 }
451
452 #[test]
453 fn jacobi_reciprocal_diag() {
454 let a = tridiag(4, 2.0, -1.0);
455 let p = JacobiPreconditioner::new(&a);
456 let r = [2.0, 4.0, 6.0, 8.0];
457 let mut z = [0.0; 4];
458 p.apply_on(&r, &mut z, ComputeBackend::Serial);
459 // z = r / 2
460 assert_eq!(z, [1.0, 2.0, 3.0, 4.0]);
461 }
462
463 #[test]
464 fn jacobi_zero_diag_guard() {
465 let mut a = tridiag(2, 0.0, -1.0);
466 a.diag[0] = 0.0;
467 let p = JacobiPreconditioner::new(&a);
468 let r = [5.0, 3.0];
469 let mut z = [0.0; 2];
470 p.apply_on(&r, &mut z, ComputeBackend::Serial);
471 assert_eq!(z[0], 5.0); // zero diag -> identity on that row
472 }
473
474 #[test]
475 fn ilu0_exact_for_tridiagonal() {
476 // ILU(0) is an EXACT LU factorisation for a tridiagonal matrix (no fill
477 // is ever discarded), so M^{-1} r must equal A^{-1} r to round-off.
478 let n = 5;
479 let a = tridiag(n, 4.0, -1.0);
480 let ilu = Ilu0Preconditioner::new(&a);
481
482 // Pick a known x, form b = A x, then check ILU-apply(b) == x.
483 let x_true: Vec<f64> = (0..n).map(|i| (i as f64 + 1.0) * 0.5).collect();
484 let b = a.multiply(&x_true);
485 let mut z = vec![0.0; n];
486 ilu.apply(&b, &mut z);
487 for i in 0..n {
488 assert!(
489 (z[i] - x_true[i]).abs() < 1e-10,
490 "row {i}: {} vs {}",
491 z[i],
492 x_true[i]
493 );
494 }
495 }
496
497 #[test]
498 fn ilu0_nonsymmetric_finite() {
499 // Nonsymmetric: lower != upper. Just require a finite, sane apply.
500 let mut a = tridiag(4, 5.0, 0.0);
501 for f in 0..a.n_internal_faces {
502 a.lower[f] = -1.0;
503 a.upper[f] = -2.0;
504 }
505 let ilu = Ilu0Preconditioner::new(&a);
506 let r = [1.0, 2.0, 3.0, 4.0];
507 let mut z = [0.0; 4];
508 ilu.apply(&r, &mut z);
509 assert!(z.iter().all(|v| v.is_finite()));
510 }
511}