Skip to main content

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}