outram_foam_basic_lib/ldu_matrix/parallel.rs
1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
21
22//! The sparse LDU matrix-vector product and its companion vector operations, on
23//! the hybrid execution backend — the hot path of every implicit finite-volume
24//! solve.
25//!
26//! A Krylov solver (conjugate gradient, BiCGStab, GMRES) spends most of its wall
27//! clock inside a handful of operations. This module provides each of them as
28//! **one** public entry point that takes a
29//! [`ComputeBackend`](crate::compute::ComputeBackend) parameter, in the shape the
30//! hybrid-backend epic mandates: dispatch, not a `foo_parallel()` sibling beside
31//! `foo()`.
32//!
33//! | Operation | Cost | Entry point |
34//! |---|---|---|
35//! | sparse product `y = A x` | `O(n_cells + n_faces)` | [`HybridLdu::spmv`] / [`HybridLdu::spmv_into`] |
36//! | residual `r = b - A x` | `O(n_cells + n_faces)` | [`HybridLdu::residual`] / [`HybridLdu::residual_into`] |
37//! | scaled residual norm | `O(n_cells + n_faces)` | [`HybridLdu::normalised_residual`] |
38//! | diagonal reciprocal `1 / diag` | `O(n_cells)` | [`HybridLdu::diagonal_reciprocal`] |
39//! | inner product `a . b` | `O(n)` | [`dot`] |
40//! | `y := alpha x + y` | `O(n)` | [`axpy`] |
41//! | `x := alpha x` | `O(n)` | [`scale`] |
42//! | `sqrt(sum x_i^2)` / `sum abs(x_i)` | `O(n)` | [`norm_l2`] / [`norm_l1`] |
43//!
44//! The solvers that consume them live in [`crate::krylov`] (BiCGStab, GMRES) and
45//! [`crate::ldu_matrix::solvers`]; see [`crate::krylov::bicgstab_prepared`] for
46//! the entry point that drives this module's kernels end to end.
47//!
48//! # The correctness problem this module solves
49//!
50//! [`LduMatrix`] stores its off-diagonal coefficients **per internal face**, so
51//! the textbook product is a face-based *scatter*:
52//!
53//! ```text
54//! for each face f: y[owner[f]] += upper[f] * x[neighbour[f]]
55//! y[neighbour[f]] += lower[f] * x[owner[f]]
56//! ```
57//!
58//! Two faces generally share a cell, so parallelising that face loop directly is
59//! a **data race**: two threads read-modify-write the same `y[c]`. Where such a
60//! thing can be written at all (atomics, unchecked split borrows) it produces
61//! silently wrong, run-varying answers.
62//!
63//! This module uses the **cell-gather reformulation**. A one-off index build
64//! ([`LduTopology`]) inverts the face addressing into a per-cell list of incident
65//! faces, turning the product into
66//!
67//! ```text
68//! for each cell c: y[c] = diag[c] * x[c]
69//! + sum over faces f incident on c of
70//! (c is owner ? upper[f] * x[neighbour[f]]
71//! : lower[f] * x[owner[f]])
72//! ```
73//!
74//! The loop is now over **cells**, and every output element `y[c]` is written by
75//! exactly one thread. There is no race, no atomic, and no per-thread scratch
76//! buffer.
77//!
78//! The two alternatives were considered and rejected. *Per-thread partial
79//! accumulation* costs `threads * n_cells` extra memory and needs a reduction
80//! pass whose association order varies with the schedule, so it is not
81//! reproducible. *Face colouring* needs a graph colouring at build time, still
82//! writes each cell once per colour, and its result depends on the colouring
83//! produced. The cell-gather index costs one `O(n_cells + n_faces)` build that is
84//! amortised over the thousands of products a solve performs, and it buys exact
85//! reproducibility (below).
86//!
87//! # Determinism
88//!
89//! **Every kernel in this module returns bit-for-bit identical output on
90//! [`ComputeBackend::Serial`](crate::compute::ComputeBackend::Serial) and
91//! [`ComputeBackend::CpuMulti`](crate::compute::ComputeBackend::CpuMulti), at any
92//! thread count, on every run.** That is stronger than the usual
93//! parallel-reduction contract and it is deliberate, because
94//! `ComputeBackend::Serial` is this workspace's documented deterministic oracle.
95//!
96//! Two separate mechanisms deliver it:
97//!
98//! - **Products and element-wise kernels** ([`HybridLdu::spmv`],
99//! [`HybridLdu::residual`], [`HybridLdu::diagonal_reciprocal`], [`axpy`],
100//! [`scale`]) are
101//! bitwise identical *also to the pre-existing serial reference*
102//! [`LduMatrix::multiply`] / [`LduMatrix::residual`]. [`LduTopology`] lists
103//! each cell's incident faces in **ascending face index**, which is exactly the
104//! order in which the serial scatter reaches that cell, so each `y[c]`
105//! accumulates the same additions in the same sequence.
106//! - **Reductions** ([`dot`], [`norm_l1`], [`norm_l2`],
107//! [`HybridLdu::normalised_residual`]) sum in fixed-size blocks of
108//! [`REDUCTION_BLOCK`] elements and then combine the block partials in
109//! ascending block order. The association is a function of the array length and
110//! [`REDUCTION_BLOCK`] alone — never of the thread count or the work-stealing
111//! schedule — so a 1-thread run agrees bit for bit with a 64-thread run.
112//!
113//! The one thing a reduction here is **not** is bitwise equal to a flat
114//! left-to-right sum such as [`crate::krylov::vecops::dot`] or
115//! [`LduMatrix::normalised_residual`]. Blocked summation reassociates, and
116//! floating-point addition is not associative. That difference is small,
117//! bounded, and *measured* rather than asserted — see the "Measured deviation"
118//! sections on [`dot`] and [`HybridLdu::normalised_residual`]. Blocked summation
119//! is in fact the more accurate of the two (it is a two-level pairwise-style
120//! sum), so this is not a loss of accuracy relative to the flat reference.
121//!
122//! # When multi-CPU is actually faster
123//!
124//! Threading is not free. Below [`SPMV_MIN_CELLS`] cells (products) or
125//! [`VECOP_MIN_ELEMENTS`] elements (vector operations), a `CpuMulti` request runs
126//! the serial kernel on the calling thread instead. Because the two paths are
127//! bitwise identical, that size dispatch changes **no** number a caller can
128//! observe — only the wall clock. Both constants were measured on this
129//! workspace's development machine; the tables are on the constants themselves.
130//!
131//! For scale, the sparse product on a 262 144-cell / 774 144-face matrix,
132//! measured 2026-08-12 on 4 logical cores (`spmv_thread_scaling_benchmark`,
133//! best of 7 samples, time per call):
134//!
135//! | Worker threads | Time per product | Speed-up |
136//! |---|---|---|
137//! | 1 | 6487.26 us | 1.00x |
138//! | 2 | 3481.42 us | 1.86x |
139//! | 4 | 2322.10 us | 2.79x |
140//! | 8 | 1830.46 us | 3.54x |
141//!
142//! The output was asserted bitwise identical at every one of those thread
143//! counts, which is the determinism claim above measured rather than argued.
144//! Eight workers on four cores still help, which is the signature of a
145//! memory-latency-bound gather: extra threads buy more outstanding loads rather
146//! than more arithmetic. **This is one machine and one mesh; it is not a scaling
147//! study**, and nothing here has been measured on Android hardware or on a
148//! many-core server.
149//!
150//! # Cargo features
151//!
152//! The `rayon` code paths sit behind the crate's `parallel` feature, which is
153//! **off by default**. With the feature off this module still compiles and every
154//! entry point still works: `ComputeBackend::CpuMulti` resolves down to
155//! `ComputeBackend::Serial` via
156//! [`ComputeBackend::resolve`](crate::compute::ComputeBackend::resolve) and the
157//! answer is unchanged. There is no `Gpu` kernel here yet, so a `Gpu` request
158//! also degrades to the best available CPU path.
159//!
160//! # Portability
161//!
162//! `rayon` is pure Rust with no system component, so everything here compiles and
163//! runs on `aarch64-linux-android` / Termux exactly as on desktop. Nothing in
164//! this module is target-gated.
165//!
166//! # Units
167//!
168//! All slices are dimensionless `f64` in cell order: element `c` is the value at
169//! cell `c`, in whatever units the assembled equation carries. No `uom` typing is
170//! applied at this layer, for the same reason [`crate::krylov::vecops`] applies
171//! none: a Krylov subspace mixes residuals, search directions and solution
172//! increments that share no single physical dimension. Units belong on the field
173//! and equation layer that assembles the matrix, and are not stripped there.
174//!
175//! # Example
176//!
177//! ```rust
178//! use std::sync::Arc;
179//! use outram_foam_basic_lib::compute::ComputeBackend;
180//! use outram_foam_basic_lib::ldu_matrix::LduMatrix;
181//! use outram_foam_basic_lib::ldu_matrix::parallel::HybridLdu;
182//!
183//! // 3-cell symmetric tridiagonal [[2,-1,0],[-1,2,-1],[0,-1,2]]
184//! let mut m = LduMatrix::new(3, vec![0, 1], vec![1, 2]);
185//! m.diag = vec![2.0, 2.0, 2.0];
186//! m.upper = vec![-1.0, -1.0];
187//! m.lower = vec![-1.0, -1.0];
188//! let m = Arc::new(m);
189//!
190//! let ldu = HybridLdu::new(Arc::clone(&m));
191//! let x = vec![1.0, 1.0, 1.0];
192//!
193//! assert_eq!(ldu.spmv(&x, ComputeBackend::Serial), vec![1.0, 0.0, 1.0]);
194//!
195//! // Asking for multi-CPU gives a bit-for-bit identical answer, whether or not
196//! // the `parallel` feature is compiled in.
197//! assert_eq!(
198//! ldu.spmv(&x, ComputeBackend::CpuMulti),
199//! ldu.spmv(&x, ComputeBackend::Serial),
200//! );
201//! ```
202
203use std::sync::Arc;
204
205#[cfg(feature = "parallel")]
206use rayon::prelude::*;
207
208use crate::compute::{select_backend, ComputeBackend};
209use crate::ldu_matrix::ldu_matrix::LduMatrix;
210
211/// The WGSL kernels mirroring this module's CPU paths.
212///
213/// Gated exactly as `wgpu` itself is. Each function here returns `Option`, so
214/// a GPU failure degrades to the CPU path rather than propagating. See its
215/// module docs for the `f32` precision caveat and why these kernels are
216/// **off** the auto-select policy.
217#[cfg(all(feature = "gpu", not(target_os = "android")))]
218pub mod gpu;
219
220#[cfg(test)]
221mod tests;
222
223// ── Tuning constants ──────────────────────────────────────────────────────────
224
225/// Number of cells in one block of the cell-parallel kernels.
226///
227/// `rayon` splits a chunked parallel iterator adaptively, so this is a *lower*
228/// bound on task granularity rather than a fixed task size: it stops the
229/// scheduler subdividing below a block that is too small to pay for itself. 1024
230/// cells is about 8 KiB of output per block, comfortably inside an L1 data cache,
231/// while still leaving hundreds of blocks on any mesh worth threading.
232///
233/// This constant affects wall time only. It cannot affect the value of any
234/// kernel that uses it, because those kernels compute each cell independently.
235///
236/// # Units
237///
238/// A count of cells, dimensionless.
239pub const CELL_BLOCK: usize = 1024;
240
241/// Number of elements summed serially inside one block of a reduction, before
242/// block partials are combined.
243///
244/// This constant **does** affect the last bits of every reduction in this module
245/// ([`dot`], [`norm_l1`], [`norm_l2`], [`HybridLdu::normalised_residual`]),
246/// because floating-point addition is not associative — but it does so
247/// *reproducibly*. The summation tree is a function of the array length and this
248/// constant alone, never of the thread count or the scheduler, which is exactly
249/// what makes those kernels bitwise reproducible across backends and runs.
250///
251/// Treat it as part of the numerical contract rather than a free tuning knob:
252/// changing it would perturb converged residual histories in the last few digits.
253///
254/// # Units
255///
256/// A count of vector elements, dimensionless.
257pub const REDUCTION_BLOCK: usize = 1024;
258
259/// Cell count below which a [`ComputeBackend::CpuMulti`] request runs the serial
260/// product kernel instead.
261///
262/// # Why a threshold exists
263///
264/// Dispatching a `rayon` parallel iterator costs on the order of a microsecond of
265/// scheduling and synchronisation. A sparse product on a small mesh finishes in
266/// less than that, so threading it is a straight loss. Because the serial and
267/// parallel kernels are **bitwise identical** (see the module documentation,
268/// "Determinism"), dispatching on size changes no number a caller can observe.
269///
270/// # Measured crossover
271///
272/// Measured 2026-08-12 on this workspace's development machine
273/// (`std::thread::available_parallelism()` = **4**, release build, `--features
274/// parallel`, rayon's global pool), on a structured 7-point-stencil LDU matrix
275/// with pseudorandom coefficients. Each figure is the best of 7 samples, each
276/// sample timing enough back-to-back calls to total about 8 million cell
277/// updates, reported as time per [`HybridLdu::spmv_into`] call. Produced by the
278/// `#[ignore]`d `spmv_crossover_benchmark` test in `parallel/tests.rs` and
279/// transcribed from its printed output.
280///
281/// | Cells | Faces | Serial | CpuMulti | Speed-up | Speed-up, repeat run |
282/// |---|---|---|---|---|---|
283/// | 512 | 1 344 | 9.76 us | 9.78 us | 1.00x | 1.00x |
284/// | 1 000 | 2 700 | 20.24 us | 20.08 us | 1.01x | 1.00x |
285/// | 1 728 | 4 752 | 34.55 us | 63.28 us | 0.55x | 0.65x |
286/// | 2 744 | 7 644 | 55.67 us | 69.32 us | 0.80x | 1.21x |
287/// | 4 096 | 11 520 | 83.67 us | 80.61 us | 1.04x | 1.02x |
288/// | 5 832 | 16 524 | 125.50 us | 122.35 us | 1.03x | 1.52x |
289/// | 8 000 | 22 800 | 165.60 us | 114.06 us | 1.45x | 2.02x |
290/// | 15 625 | 45 000 | 330.59 us | 175.99 us | 1.88x | 2.03x |
291/// | 32 768 | 95 232 | 690.40 us | 277.86 us | 2.48x | 2.03x |
292/// | 64 000 | 187 200 | 1448.41 us | 481.12 us | 3.01x | 3.11x |
293/// | 132 651 | 390 150 | 3279.72 us | 1139.65 us | 2.88x | 3.48x |
294/// | 262 144 | 774 144 | 6640.67 us | 2213.45 us | 3.00x | 3.00x |
295/// | 512 000 | 1 516 800 | 13338.47 us | 4822.19 us | 2.77x | 3.48x |
296///
297/// The serial column is highly reproducible between the two runs (within 4%);
298/// the multi-CPU column is not, which is why the second run's speed-ups are
299/// carried alongside rather than averaged away.
300///
301/// Reading the table: at and below 1 000 cells the two paths are *identical*,
302/// because [`CELL_BLOCK`] is 1 024 so there is only one block and rayon never
303/// splits. Between roughly 1 700 and 2 700 cells the parallel path genuinely
304/// loses (0.55x — 1.21x). At 4 096 it breaks even in both runs (1.04x, 1.02x)
305/// and from about 5 800 cells upward it wins in both. This constant is therefore
306/// set to **4 096**, the smallest measured size at which the parallel path did
307/// not lose in either run.
308///
309/// Speed-ups above 3x on 4 cores are superlinear and were reproducible. The
310/// likely cause is cache: the gather reads `x` at scattered indices across the
311/// whole vector, and splitting the cells narrows each worker's working set, so
312/// four threads share more than four times the effective cache. This has not
313/// been confirmed with hardware counters and is stated as a hypothesis.
314///
315/// # Relationship to [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`]
316///
317/// That crate-wide constant is documented as a placeholder awaiting measurement
318/// and is currently 4 096. **For this kernel the measurement supports it**: 4 096
319/// cells is exactly where the parallel path stops losing. No change to the
320/// crate-wide constant is proposed on the strength of the sparse product alone —
321/// but note that the vector operations in this same module cross over two orders
322/// of magnitude later (see [`VECOP_MIN_ELEMENTS`]), so 4 096 is *not* a good
323/// single number for every kernel, and a crate-wide revision belongs with bead
324/// `op-yvj.4.7` rather than here.
325///
326/// # Limitations
327///
328/// One threshold cannot be right for every machine: a 64-core server pays more
329/// dispatch cost and a 2-core phone less. This value was measured on exactly one
330/// machine, with 4 logical cores, and has **not** been checked on any other — in
331/// particular not on Android/Termux hardware and not on a many-core server. It
332/// was also measured on an otherwise idle machine; on a loaded machine an
333/// earlier, contended run of the same benchmark put 4 096 cells at 0.72x, so the
334/// break-even point moves right under contention.
335///
336/// # Units
337///
338/// A count of cells, dimensionless.
339pub const SPMV_MIN_CELLS: usize = 4_096;
340
341/// Element count below which a [`ComputeBackend::CpuMulti`] request runs the
342/// serial kernel instead, for the vector operations [`dot`], [`axpy`],
343/// [`norm_l1`] and [`norm_l2`].
344///
345/// # Why this is larger than [`SPMV_MIN_CELLS`]
346///
347/// A vector operation does one or two floating-point operations per element
348/// loaded, so it is limited by memory bandwidth rather than by arithmetic. Extra
349/// cores do not add bandwidth, so there is much less to win and the fixed
350/// dispatch cost is amortised much more slowly than it is for the sparse product,
351/// which does roughly seven operations per cell.
352///
353/// # Measured crossover
354///
355/// Measured 2026-08-12 on the same machine and under the same conditions as
356/// [`SPMV_MIN_CELLS`] (4 logical cores, release, `--features parallel`, idle
357/// machine), best of 7 samples, per call. Produced by the `#[ignore]`d
358/// `vecop_crossover_benchmark` test in `parallel/tests.rs` and transcribed from
359/// its printed output. The last column of each pair is the speed-up from a
360/// second, independent run of the same benchmark.
361///
362/// | Elements | `dot` serial | `dot` multi | Speed-up | (repeat) | `axpy` serial | `axpy` multi | Speed-up | (repeat) |
363/// |---|---|---|---|---|---|---|---|---|
364/// | 1 024 | 1.27 us | 1.30 us | 0.98x | 0.97x | 0.34 us | 0.34 us | 0.98x | 0.98x |
365/// | 2 048 | 2.51 us | 14.88 us | 0.17x | 0.11x | 0.63 us | 15.32 us | 0.04x | 0.02x |
366/// | 4 096 | 4.97 us | 12.48 us | 0.40x | 0.21x | 1.59 us | 23.71 us | 0.07x | 0.05x |
367/// | 8 192 | 10.02 us | 31.58 us | 0.32x | 0.24x | 3.14 us | 37.23 us | 0.08x | 0.09x |
368/// | 16 384 | 19.87 us | 22.05 us | 0.90x | 0.69x | 5.55 us | 16.33 us | 0.34x | 0.09x |
369/// | 32 768 | 40.42 us | 26.88 us | 1.50x | 0.96x | 11.69 us | 26.78 us | 0.44x | 0.74x |
370/// | 65 536 | 80.10 us | 53.41 us | 1.50x | 1.85x | 26.13 us | 26.73 us | 0.98x | 0.60x |
371/// | 131 072 | 160.35 us | 84.69 us | 1.89x | 2.23x | 77.26 us | 45.57 us | 1.70x | 0.74x |
372/// | 262 144 | 322.69 us | 106.86 us | 3.02x | 3.06x | 132.15 us | 70.70 us | 1.87x | 1.00x |
373/// | 524 288 | 662.60 us | 187.03 us | 3.54x | 2.13x | 314.93 us | 129.20 us | 2.44x | 1.28x |
374/// | 1 048 576 | 1712.59 us | 547.58 us | 3.13x | 3.35x | 916.51 us | 306.37 us | 2.99x | 2.98x |
375/// | 2 097 152 | 3883.47 us | 1209.26 us | 3.21x | 3.69x | 3248.92 us | 1000.85 us | 3.25x | 3.70x |
376/// | 4 194 304 | 8115.34 us | 2306.65 us | 3.52x | 3.47x | 7447.22 us | 2235.37 us | 3.33x | 3.78x |
377///
378/// The two operations cross over at very different sizes. `dot` is reliably
379/// ahead from 65 536 elements; `axpy` is still break-even at 262 144 (1.87x in
380/// one run, 1.00x in the other) and only reliably ahead from 1 048 576. The
381/// constant is set to **262 144** — the smallest size at which *neither*
382/// operation lost in either run — so the single floor is safe for all four
383/// vector operations. The cost of one shared floor is that `dot` forgoes a
384/// 1.5x-2.2x win between 65 536 and 262 144 elements; a per-operation floor is
385/// left as a follow-up rather than shipped on two runs of evidence.
386///
387/// Note the 1 024-element row: the two paths are identical there because
388/// [`REDUCTION_BLOCK`] and [`CELL_BLOCK`] are both 1 024, so there is a single
389/// block and rayon never splits. The worst region is 2 048 — 16 384 elements,
390/// where waking workers costs 15-37 us against a kernel that takes 0.6-20 us.
391///
392/// # Limitations
393///
394/// Measured on one 4-core machine only, idle; see [`SPMV_MIN_CELLS`] for the
395/// same caveats, which apply unchanged. `norm_l1` and `norm_l2` were **not**
396/// separately benchmarked — they share `dot`'s blocked-reduction structure and
397/// inherit its floor, which is an assumption, not a measurement.
398///
399/// # Units
400///
401/// A count of vector elements, dimensionless.
402pub const VECOP_MIN_ELEMENTS: usize = 262_144;
403
404// ── Backend dispatch ──────────────────────────────────────────────────────────
405
406/// Resolve a requested backend to the one this module will actually run, given
407/// how much work there is.
408///
409/// Three reductions happen here, in order:
410///
411/// 1. [`ComputeBackend::resolve`] degrades anything whose feature is off or whose
412/// hardware is absent.
413/// 2. `Gpu` degrades further, because this module has no GPU kernel yet.
414/// 3. `CpuMulti` degrades to `Serial` below `min_work_items`, where thread
415/// dispatch costs more than the work.
416///
417/// The result is only ever `Serial` or `CpuMulti`.
418fn effective_backend(
419 requested: ComputeBackend,
420 work_items: usize,
421 min_work_items: usize,
422) -> ComputeBackend {
423 // No GPU kernel exists in this module, so a resolved `Gpu` is re-resolved
424 // down the CPU ladder rather than silently claiming to have run on a GPU.
425 let cpu = match requested.resolve() {
426 ComputeBackend::Gpu => ComputeBackend::CpuMulti.resolve(),
427 other => other,
428 };
429 match cpu {
430 ComputeBackend::CpuMulti if work_items >= min_work_items => ComputeBackend::CpuMulti,
431 _ => ComputeBackend::Serial,
432 }
433}
434
435/// The [`ComputeBackend`] this module would actually use for a sparse product
436/// over `n_cells` cells if asked for `requested` — without running anything.
437///
438/// Useful for logging and for benchmark harnesses that need to report which path
439/// a call took. It applies exactly the same three-step reduction the kernels do
440/// (feature availability, no-GPU-kernel-here, and the [`SPMV_MIN_CELLS`] size
441/// floor), so what it reports is what would run.
442///
443/// # Arguments
444///
445/// - `requested` — the backend a caller would pass to [`HybridLdu::spmv`].
446/// - `n_cells` — the matrix size, dimensionless.
447///
448/// # Returns
449///
450/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`]; never
451/// [`ComputeBackend::Gpu`], because no GPU kernel exists here yet.
452///
453/// # Example
454///
455/// ```rust
456/// use outram_foam_basic_lib::compute::ComputeBackend;
457/// use outram_foam_basic_lib::ldu_matrix::parallel::{spmv_backend_for, SPMV_MIN_CELLS};
458///
459/// // Too small to thread, whatever was asked for.
460/// assert_eq!(spmv_backend_for(ComputeBackend::CpuMulti, 100), ComputeBackend::Serial);
461///
462/// // Big enough; the answer now depends only on whether `parallel` is compiled in.
463/// let picked = spmv_backend_for(ComputeBackend::CpuMulti, SPMV_MIN_CELLS);
464/// assert!(picked.is_available());
465/// ```
466#[must_use]
467pub fn spmv_backend_for(requested: ComputeBackend, n_cells: usize) -> ComputeBackend {
468 effective_backend(requested, n_cells, SPMV_MIN_CELLS)
469}
470
471/// The [`ComputeBackend`] this module would actually use for a vector operation
472/// over `n` elements if asked for `requested` — without running anything.
473///
474/// The vector-operation counterpart of [`spmv_backend_for`], differing only in
475/// using the [`VECOP_MIN_ELEMENTS`] size floor.
476///
477/// # Arguments
478///
479/// - `requested` — the backend a caller would pass to [`dot`], [`axpy`],
480/// [`norm_l1`] or [`norm_l2`].
481/// - `n` — the vector length, dimensionless.
482///
483/// # Returns
484///
485/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`].
486///
487/// # Example
488///
489/// ```rust
490/// use outram_foam_basic_lib::compute::ComputeBackend;
491/// use outram_foam_basic_lib::ldu_matrix::parallel::vecop_backend_for;
492///
493/// assert_eq!(vecop_backend_for(ComputeBackend::CpuMulti, 64), ComputeBackend::Serial);
494/// assert_eq!(vecop_backend_for(ComputeBackend::Serial, 1 << 24), ComputeBackend::Serial);
495/// ```
496#[must_use]
497pub fn vecop_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
498 effective_backend(requested, n, VECOP_MIN_ELEMENTS)
499}
500
501// ── Cell-gather index ─────────────────────────────────────────────────────────
502
503/// The face addressing of an [`LduMatrix`], inverted into per-cell incident-face
504/// lists so the matrix-vector product can be parallelised over cells.
505///
506/// This is a pure topology object: it depends only on `n_cells`, `owner` and
507/// `neighbour`, **not** on any coefficient value. A finite-volume solver
508/// reassembles coefficients every outer iteration while the mesh addressing stays
509/// fixed, so build this once and reuse it — see [`HybridLdu::with_matrix`].
510///
511/// # Layout
512///
513/// Compressed-row: `row_start[c] .. row_start[c + 1]` selects cell `c`'s entries
514/// out of the flat `entry_*` arrays. There are exactly `2 * n_internal_faces`
515/// entries, because every internal face is incident on exactly two cells.
516///
517/// Each cell's entries are stored in **ascending internal-face index**. That is
518/// not cosmetic: it is the property that makes the cell-gather product bitwise
519/// reproduce the serial face-scatter of [`LduMatrix::multiply`], because it is
520/// the order in which the scatter reaches that cell.
521///
522/// # Units
523///
524/// Pure indices and counts; dimensionless.
525///
526/// # Example
527///
528/// ```rust
529/// use outram_foam_basic_lib::ldu_matrix::LduMatrix;
530/// use outram_foam_basic_lib::ldu_matrix::parallel::LduTopology;
531///
532/// // 3 cells, 2 internal faces: (0,1) and (1,2).
533/// let m = LduMatrix::new(3, vec![0, 1], vec![1, 2]);
534/// let topo = LduTopology::from_matrix(&m);
535///
536/// assert_eq!(topo.n_cells(), 3);
537/// assert_eq!(topo.n_internal_faces(), 2);
538/// // The middle cell touches both faces; the end cells touch one each.
539/// assert_eq!(topo.incident_face_count(0), 1);
540/// assert_eq!(topo.incident_face_count(1), 2);
541/// assert_eq!(topo.incident_face_count(2), 1);
542/// ```
543#[derive(Debug, Clone, PartialEq, Eq)]
544pub struct LduTopology {
545 /// Number of cells the index was built for; matches `LduMatrix::n_cells`.
546 n_cells: usize,
547 /// Number of internal faces the index was built for.
548 n_internal_faces: usize,
549 /// Start offset of each cell's entry run, length `n_cells + 1`.
550 row_start: Vec<usize>,
551 /// Internal-face index of each entry, length `2 * n_internal_faces`.
552 entry_face: Vec<usize>,
553 /// The *other* cell across that face — the column index of the coefficient.
554 entry_other: Vec<usize>,
555 /// `true` when this cell is the face's owner, so the coefficient comes from
556 /// `upper`; `false` when it is the neighbour, so it comes from `lower`.
557 entry_uses_upper: Vec<bool>,
558}
559
560impl LduTopology {
561 /// Build the cell-gather index from a matrix's face addressing.
562 ///
563 /// Cost is `O(n_cells + n_internal_faces)` in both time and memory (see
564 /// [`Self::index_bytes`]). It is a one-off per mesh, not a per-iteration cost.
565 ///
566 /// Only `n_cells`, `owner` and `neighbour` are read. Coefficient values are
567 /// ignored, so an index built from a freshly allocated matrix stays valid for
568 /// every later reassembly of the same mesh.
569 ///
570 /// # Arguments
571 ///
572 /// - `matrix` — any [`LduMatrix`] with consistent addressing. Its
573 /// coefficients may be all zero.
574 ///
575 /// # Panics
576 ///
577 /// Panics if `owner` and `neighbour` have different lengths, or if any entry
578 /// of either is `>= n_cells`. Both indicate corrupt addressing that would
579 /// otherwise surface as an out-of-bounds access deep inside a kernel, or —
580 /// worse — as a silently wrong answer.
581 #[must_use]
582 pub fn from_matrix(matrix: &LduMatrix) -> Self {
583 let n_cells = matrix.n_cells;
584 let n_faces = matrix.owner.len();
585 assert_eq!(
586 matrix.neighbour.len(),
587 n_faces,
588 "LduTopology::from_matrix: owner has {} entries but neighbour has {}; \
589 there must be exactly one of each per internal face",
590 n_faces,
591 matrix.neighbour.len()
592 );
593
594 // Pass 1 — count how many faces touch each cell.
595 let mut row_start = vec![0_usize; n_cells + 1];
596 for f in 0..n_faces {
597 let o = matrix.owner[f];
598 let n = matrix.neighbour[f];
599 assert!(
600 o < n_cells && n < n_cells,
601 "LduTopology::from_matrix: internal face {f} addresses cells ({o}, {n}) \
602 but the matrix has only {n_cells} cells"
603 );
604 row_start[o + 1] += 1;
605 row_start[n + 1] += 1;
606 }
607
608 // Prefix sum turns per-cell counts into run offsets.
609 for c in 0..n_cells {
610 row_start[c + 1] += row_start[c];
611 }
612
613 // Pass 2 — scatter each face into both of its cells' runs. Faces are
614 // visited in ascending order, so each cell's run ends up sorted by face
615 // index: the property the bitwise-reproducibility guarantee rests on.
616 let n_entries = 2 * n_faces;
617 let mut entry_face = vec![0_usize; n_entries];
618 let mut entry_other = vec![0_usize; n_entries];
619 let mut entry_uses_upper = vec![false; n_entries];
620 let mut cursor = row_start.clone();
621
622 for f in 0..n_faces {
623 let o = matrix.owner[f];
624 let n = matrix.neighbour[f];
625
626 // Owner side: y[owner] += upper[f] * x[neighbour].
627 let e = cursor[o];
628 entry_face[e] = f;
629 entry_other[e] = n;
630 entry_uses_upper[e] = true;
631 cursor[o] += 1;
632
633 // Neighbour side: y[neighbour] += lower[f] * x[owner].
634 let e = cursor[n];
635 entry_face[e] = f;
636 entry_other[e] = o;
637 entry_uses_upper[e] = false;
638 cursor[n] += 1;
639 }
640
641 Self {
642 n_cells,
643 n_internal_faces: n_faces,
644 row_start,
645 entry_face,
646 entry_other,
647 entry_uses_upper,
648 }
649 }
650
651 /// Number of cells this index was built for.
652 #[must_use]
653 pub fn n_cells(&self) -> usize {
654 self.n_cells
655 }
656
657 /// Number of internal faces this index was built for.
658 #[must_use]
659 pub fn n_internal_faces(&self) -> usize {
660 self.n_internal_faces
661 }
662
663 /// How many internal faces are incident on cell `c` — its off-diagonal count.
664 ///
665 /// # Panics
666 ///
667 /// Panics if `c >= n_cells()`.
668 #[must_use]
669 pub fn incident_face_count(&self, c: usize) -> usize {
670 self.row_start[c + 1] - self.row_start[c]
671 }
672
673 /// Approximate heap footprint of the index, in bytes.
674 ///
675 /// Useful for judging whether the one-off build is affordable for a given
676 /// mesh. It is roughly `8 * (n_cells + 1) + 17 * 2 * n_internal_faces` bytes
677 /// on a 64-bit target, i.e. about 34 bytes per internal face. A
678 /// 1-million-cell hexahedral mesh has roughly 3 million internal faces, so
679 /// about 100 MB — substantial, and the reason the index is shared through an
680 /// [`Arc`] rather than rebuilt per matrix.
681 ///
682 /// # Units
683 ///
684 /// Bytes, dimensionless.
685 #[must_use]
686 pub fn index_bytes(&self) -> usize {
687 let usize_b = std::mem::size_of::<usize>();
688 self.row_start.len() * usize_b
689 + self.entry_face.len() * usize_b
690 + self.entry_other.len() * usize_b
691 + self.entry_uses_upper.len()
692 }
693
694 /// Whether this index describes `matrix`'s addressing exactly.
695 ///
696 /// Checks every entry against the matrix's own `owner`/`neighbour` arrays:
697 /// for each cell `c` and each of its entries, the stored face must actually
698 /// connect `c` to the stored other-cell, on the stored side. That is a
699 /// complete verification, not a heuristic, and costs
700 /// `O(n_cells + n_internal_faces)` — far less than rebuilding.
701 ///
702 /// Used by [`HybridLdu::with_matrix`] so a reassembled matrix over the same
703 /// mesh can reuse an index instead of rebuilding it.
704 ///
705 /// # Returns
706 ///
707 /// `true` only if using this index with `matrix` would give exactly the same
708 /// answer as an index freshly built from it.
709 #[must_use]
710 pub fn matches(&self, matrix: &LduMatrix) -> bool {
711 if self.n_cells != matrix.n_cells
712 || self.n_internal_faces != matrix.owner.len()
713 || self.n_internal_faces != matrix.neighbour.len()
714 {
715 return false;
716 }
717 for c in 0..self.n_cells {
718 for e in self.row_start[c]..self.row_start[c + 1] {
719 let f = self.entry_face[e];
720 if f >= self.n_internal_faces {
721 return false;
722 }
723 let (this_side, other_side) = if self.entry_uses_upper[e] {
724 (matrix.owner[f], matrix.neighbour[f])
725 } else {
726 (matrix.neighbour[f], matrix.owner[f])
727 };
728 if this_side != c || other_side != self.entry_other[e] {
729 return false;
730 }
731 }
732 }
733 true
734 }
735
736 /// Gather row `c` of `A x` — that is, the single value `y[c]`.
737 ///
738 /// This is the one kernel both backends run; the only difference between them
739 /// is which thread calls it. `#[inline]` so the serial and parallel block
740 /// loops compile to the same inner code, which is part of why they agree
741 /// bitwise.
742 #[inline]
743 fn gather_row(&self, matrix: &LduMatrix, x: &[f64], c: usize) -> f64 {
744 // Start from 0.0 and accumulate, matching `LduMatrix::multiply`'s
745 // `y = vec![0.0; n]; y[c] += diag[c] * x[c]` exactly.
746 let mut acc = 0.0_f64;
747 acc += matrix.diag[c] * x[c];
748 for e in self.row_start[c]..self.row_start[c + 1] {
749 let f = self.entry_face[e];
750 let coef = if self.entry_uses_upper[e] {
751 matrix.upper[f]
752 } else {
753 matrix.lower[f]
754 };
755 acc += coef * x[self.entry_other[e]];
756 }
757 acc
758 }
759}
760
761// ── The hybrid-backend matrix ─────────────────────────────────────────────────
762
763/// An [`LduMatrix`] bundled with its cell-gather index, exposing the
764/// per-iteration kernels a Krylov solver needs on any [`ComputeBackend`].
765///
766/// Construct once per assembled matrix and call the kernels many times: the
767/// `O(n_cells + n_faces)` index build happens in [`Self::new`], and every kernel
768/// call after that is index-free. Both the matrix and the index are held behind
769/// [`Arc`], so cloning is cheap and the value can be shared across threads.
770///
771/// # Which backend runs
772///
773/// Every kernel takes the backend as a parameter — there is no `_parallel`
774/// sibling API. What actually runs is [`spmv_backend_for`] /
775/// [`vecop_backend_for`] applied to the request: an unavailable backend degrades,
776/// `Gpu` degrades (no GPU kernel here yet), and a problem below the measured
777/// size floor runs serially. None of those degradations changes the answer.
778/// [`Self::auto_backend`] applies the crate-wide policy
779/// [`crate::compute::select_backend`] if you would rather not choose.
780///
781/// # Thread pool
782///
783/// The `parallel` kernels use `rayon`'s ambient pool — the global one by default.
784/// No pool is built here, and none is built per call. A caller that wants a
785/// specific worker count builds its own `rayon::ThreadPool` and calls these
786/// kernels inside its `install(...)` scope; the parallel iterators then run on
787/// that pool. Because every kernel is bitwise deterministic at any thread count,
788/// that choice affects wall time only.
789///
790/// # Units
791///
792/// All vectors are dimensionless `f64` in cell order; see the module
793/// documentation.
794///
795/// # Example
796///
797/// ```rust
798/// use std::sync::Arc;
799/// use outram_foam_basic_lib::compute::ComputeBackend;
800/// use outram_foam_basic_lib::ldu_matrix::LduMatrix;
801/// use outram_foam_basic_lib::ldu_matrix::parallel::HybridLdu;
802///
803/// let mut m = LduMatrix::new(4, vec![0, 1, 2], vec![1, 2, 3]);
804/// m.diag = vec![4.0, 4.0, 4.0, 4.0];
805/// m.upper = vec![-1.0, -1.0, -1.0];
806/// m.lower = vec![-1.0, -1.0, -1.0];
807/// let m = Arc::new(m);
808///
809/// let ldu = HybridLdu::new(Arc::clone(&m));
810/// let x = vec![1.0, 2.0, 3.0, 4.0];
811///
812/// // Bit-for-bit agreement with the pre-existing serial reference kernel.
813/// assert_eq!(ldu.spmv(&x, ComputeBackend::CpuMulti), m.multiply(&x));
814///
815/// // Reassembling the same mesh reuses the index instead of rebuilding it.
816/// let mut m2 = (*m).clone();
817/// m2.diag = vec![8.0, 8.0, 8.0, 8.0];
818/// let ldu2 = ldu.with_matrix(Arc::new(m2)).expect("same addressing");
819/// assert_eq!(ldu2.matrix().diag[0], 8.0);
820/// ```
821#[derive(Debug, Clone)]
822pub struct HybridLdu {
823 matrix: Arc<LduMatrix>,
824 topology: Arc<LduTopology>,
825}
826
827impl HybridLdu {
828 /// Build the cell-gather index for `matrix` and wrap it for hybrid execution.
829 ///
830 /// The `O(n_cells + n_internal_faces)` index build happens here, once.
831 ///
832 /// # Arguments
833 ///
834 /// - `matrix` — the assembled sparse system, shared by [`Arc`] so the caller
835 /// may keep its own handle.
836 ///
837 /// # Panics
838 ///
839 /// Panics if the matrix addressing is inconsistent — see
840 /// [`LduTopology::from_matrix`].
841 #[must_use]
842 pub fn new(matrix: Arc<LduMatrix>) -> Self {
843 let topology = Arc::new(LduTopology::from_matrix(&matrix));
844 Self { matrix, topology }
845 }
846
847 /// Reuse this index with a **reassembled** matrix over the same mesh.
848 ///
849 /// A finite-volume solver rebuilds coefficients every outer iteration while
850 /// the mesh addressing is unchanged. This returns a `HybridLdu` over the new
851 /// coefficients that shares the existing index — an
852 /// `O(n_cells + n_internal_faces)` addressing check instead of a full rebuild.
853 ///
854 /// # Returns
855 ///
856 /// `None` if `matrix` does not have exactly the same `owner`/`neighbour`
857 /// addressing (see [`LduTopology::matches`]), in which case the caller must
858 /// go through [`Self::new`]. Returning `None` rather than reusing a
859 /// mismatched index is deliberate: a stale index would produce a wrong
860 /// answer, not a crash.
861 #[must_use]
862 pub fn with_matrix(&self, matrix: Arc<LduMatrix>) -> Option<Self> {
863 if !self.topology.matches(&matrix) {
864 return None;
865 }
866 Some(Self {
867 matrix,
868 topology: Arc::clone(&self.topology),
869 })
870 }
871
872 /// The matrix these kernels operate on.
873 #[must_use]
874 pub fn matrix(&self) -> &Arc<LduMatrix> {
875 &self.matrix
876 }
877
878 /// The cell-gather index, shared behind an [`Arc`].
879 #[must_use]
880 pub fn topology(&self) -> &Arc<LduTopology> {
881 &self.topology
882 }
883
884 /// The backend the crate-wide policy [`crate::compute::select_backend`] picks
885 /// for a product on this matrix.
886 ///
887 /// Passes `n_cells` as the work-item count, because the cell-gather kernel's
888 /// independent work items are cells. The returned backend is guaranteed
889 /// available, so it can be handed straight to [`Self::spmv`].
890 ///
891 /// Note this is the *crate-wide* policy, whose `CpuMulti` threshold is
892 /// documented as a placeholder; this module's own measured threshold
893 /// [`SPMV_MIN_CELLS`] is applied afterwards by the kernel itself, so a
894 /// too-eager `CpuMulti` from here still runs serially below that size.
895 #[must_use]
896 pub fn auto_backend(&self) -> ComputeBackend {
897 select_backend(self.matrix.n_cells)
898 }
899
900 // ── Sparse matrix-vector product ─────────────────────────────────────────
901
902 /// Sparse matrix-vector product `y = A x`, writing into a caller-owned buffer.
903 ///
904 /// This is the kernel a Krylov solver calls once (BiCGStab: twice) per
905 /// iteration and where it spends most of its time. Prefer it over
906 /// [`Self::spmv`] inside a solver loop — it reuses `y` instead of allocating
907 /// a fresh `Vec` every iteration.
908 ///
909 /// # Arguments
910 ///
911 /// - `x` — input vector in cell order, exactly `n_cells` long, dimensionless.
912 /// - `y` — output buffer, exactly `n_cells` long. Fully overwritten; its prior
913 /// contents are irrelevant. Aliasing with `x` is impossible because `y` is a
914 /// unique borrow.
915 /// - `backend` — requested execution backend; see [`spmv_backend_for`] for
916 /// what will actually run.
917 ///
918 /// # Determinism
919 ///
920 /// Bitwise identical to [`LduMatrix::multiply`] on every backend and at any
921 /// thread count. See the module documentation, "Determinism".
922 ///
923 /// # Panics
924 ///
925 /// Panics if `x.len()` or `y.len()` differs from the matrix's `n_cells`.
926 pub fn spmv_into(&self, x: &[f64], y: &mut [f64], backend: ComputeBackend) {
927 self.spmv_into_min(x, y, backend, SPMV_MIN_CELLS);
928 }
929
930 /// [`Self::spmv_into`] with the size floor supplied by the caller.
931 ///
932 /// Exists so the crossover benchmark can measure the multi-CPU path *below*
933 /// [`SPMV_MIN_CELLS`] — which is the only way to find where the crossover
934 /// actually is — and so the cross-backend bitwise tests are not vacuous on
935 /// small matrices. Not public: production callers get the measured floor.
936 pub(crate) fn spmv_into_min(
937 &self,
938 x: &[f64],
939 y: &mut [f64],
940 backend: ComputeBackend,
941 min_work_items: usize,
942 ) {
943 let n = self.matrix.n_cells;
944 assert_eq!(
945 x.len(),
946 n,
947 "HybridLdu::spmv_into: x has {} entries, expected n_cells = {n}",
948 x.len()
949 );
950 assert_eq!(
951 y.len(),
952 n,
953 "HybridLdu::spmv_into: y has {} entries, expected n_cells = {n}",
954 y.len()
955 );
956
957 match effective_backend(backend, n, min_work_items) {
958 #[cfg(feature = "parallel")]
959 ComputeBackend::CpuMulti => {
960 let (topology, matrix) = (&self.topology, &self.matrix);
961 y.par_chunks_mut(CELL_BLOCK)
962 .enumerate()
963 .for_each(|(block, out)| {
964 let base = block * CELL_BLOCK;
965 for (k, slot) in out.iter_mut().enumerate() {
966 *slot = topology.gather_row(matrix, x, base + k);
967 }
968 });
969 }
970 _ => {
971 for (c, slot) in y.iter_mut().enumerate() {
972 *slot = self.topology.gather_row(&self.matrix, x, c);
973 }
974 }
975 }
976 }
977
978 /// Sparse matrix-vector product `y = A x`, allocating the result.
979 ///
980 /// Convenience wrapper over [`Self::spmv_into`] with identical semantics and
981 /// the same bitwise guarantee. Inside a solver loop prefer
982 /// [`Self::spmv_into`], which does not allocate.
983 ///
984 /// # Arguments
985 ///
986 /// - `x` — input vector in cell order, exactly `n_cells` long.
987 /// - `backend` — requested execution backend.
988 ///
989 /// # Returns
990 ///
991 /// A fresh `Vec<f64>` of length `n_cells`.
992 ///
993 /// # Panics
994 ///
995 /// Panics if `x.len()` differs from the matrix's `n_cells`.
996 #[must_use]
997 pub fn spmv(&self, x: &[f64], backend: ComputeBackend) -> Vec<f64> {
998 let mut y = vec![0.0_f64; self.matrix.n_cells];
999 self.spmv_into(x, &mut y, backend);
1000 y
1001 }
1002
1003 // ── Residual ─────────────────────────────────────────────────────────────
1004
1005 /// Residual `r = b - A x`, writing into a caller-owned buffer.
1006 ///
1007 /// # Arguments
1008 ///
1009 /// - `x` — current solution estimate, `n_cells` long.
1010 /// - `b` — the equation's source vector, `n_cells` long.
1011 /// - `r` — output buffer, `n_cells` long. Fully overwritten.
1012 /// - `backend` — requested execution backend.
1013 ///
1014 /// # Determinism
1015 ///
1016 /// Bitwise identical to [`LduMatrix::residual`] on every backend and at any
1017 /// thread count: the product is bitwise exact and the subtraction is
1018 /// element-wise, so no reassociation can occur.
1019 ///
1020 /// # Panics
1021 ///
1022 /// Panics if any of `x`, `b`, `r` has a length other than `n_cells`.
1023 pub fn residual_into(&self, x: &[f64], b: &[f64], r: &mut [f64], backend: ComputeBackend) {
1024 self.residual_into_min(x, b, r, backend, SPMV_MIN_CELLS);
1025 }
1026
1027 /// [`Self::residual_into`] with a caller-supplied size floor; see
1028 /// [`Self::spmv_into_min`] for why this exists.
1029 pub(crate) fn residual_into_min(
1030 &self,
1031 x: &[f64],
1032 b: &[f64],
1033 r: &mut [f64],
1034 backend: ComputeBackend,
1035 min_work_items: usize,
1036 ) {
1037 let n = self.matrix.n_cells;
1038 assert_eq!(
1039 b.len(),
1040 n,
1041 "HybridLdu::residual_into: b has {} entries, expected n_cells = {n}",
1042 b.len()
1043 );
1044 assert_eq!(
1045 r.len(),
1046 n,
1047 "HybridLdu::residual_into: r has {} entries, expected n_cells = {n}",
1048 r.len()
1049 );
1050
1051 // r <- A x, then r <- b - r, in place.
1052 self.spmv_into_min(x, r, backend, min_work_items);
1053
1054 match effective_backend(backend, n, min_work_items) {
1055 #[cfg(feature = "parallel")]
1056 ComputeBackend::CpuMulti => {
1057 r.par_chunks_mut(CELL_BLOCK)
1058 .zip(b.par_chunks(CELL_BLOCK))
1059 .for_each(|(rc, bc)| {
1060 for (ri, bi) in rc.iter_mut().zip(bc.iter()) {
1061 *ri = bi - *ri;
1062 }
1063 });
1064 }
1065 _ => {
1066 for (ri, bi) in r.iter_mut().zip(b.iter()) {
1067 *ri = bi - *ri;
1068 }
1069 }
1070 }
1071 }
1072
1073 /// Residual `r = b - A x`, allocating the result.
1074 ///
1075 /// Convenience wrapper over [`Self::residual_into`]; identical semantics and
1076 /// the same bitwise guarantee against [`LduMatrix::residual`].
1077 ///
1078 /// # Panics
1079 ///
1080 /// Panics if `x.len()` or `b.len()` differs from `n_cells`.
1081 #[must_use]
1082 pub fn residual(&self, x: &[f64], b: &[f64], backend: ComputeBackend) -> Vec<f64> {
1083 let mut r = vec![0.0_f64; self.matrix.n_cells];
1084 self.residual_into(x, b, &mut r, backend);
1085 r
1086 }
1087
1088 /// OpenFOAM-style scaled residual norm,
1089 /// `||b - A x||_1 / (0.5 * (||A x||_1 + ||b||_1))`.
1090 ///
1091 /// This is the convergence measure the solvers in
1092 /// [`crate::ldu_matrix::solvers`] test against a tolerance, so it is evaluated
1093 /// once per iteration. The scaling makes the number dimensionless and roughly
1094 /// comparable across equations of very different magnitude. When the
1095 /// denominator underflows below [`f64::EPSILON`] the unscaled `||b - A x||_1`
1096 /// is returned instead, matching [`LduMatrix::normalised_residual`].
1097 ///
1098 /// # Arguments
1099 ///
1100 /// - `x` — current solution estimate, `n_cells` long.
1101 /// - `b` — source vector, `n_cells` long.
1102 /// - `backend` — requested execution backend.
1103 ///
1104 /// # Returns
1105 ///
1106 /// A dimensionless non-negative scalar.
1107 ///
1108 /// # Determinism, and how this differs from the serial reference
1109 ///
1110 /// This is a **reduction**, so unlike the product it cannot be bitwise
1111 /// identical to a flat left-to-right sum. It sums in blocks of
1112 /// [`REDUCTION_BLOCK`] elements and combines the block partials in ascending
1113 /// block order, an association fixed by `n_cells` alone. Therefore repeated
1114 /// runs agree bit for bit, and a 1-thread run agrees bit for bit with an
1115 /// N-thread run — but the value differs from
1116 /// [`LduMatrix::normalised_residual`] in the last bits.
1117 ///
1118 /// **Measured deviation.** *Methodology:* a fixed-seed xorshift64\*
1119 /// pseudorandom, diagonally dominant symmetric 7-point-stencil matrix on a
1120 /// 32x32x32 mesh (32 768 cells, 95 232 internal faces), with pseudorandom `x`
1121 /// and `b` uniform on `[-1, 1)`; compare this function against
1122 /// [`LduMatrix::normalised_residual`] on the same inputs. *Pass criterion:*
1123 /// relative difference `<= 1e-13`, plus bitwise equality between the two
1124 /// backends.
1125 ///
1126 /// *Result, measured 2026-08-12 by the test
1127 /// `normalised_residual_matches_flat_reference` in `parallel/tests.rs`:*
1128 /// blocked `1.74107987267472608e0`, flat `1.74107987267473940e0`, relative
1129 /// difference **7.6520e-15** — about 34 units in the last place, an order of
1130 /// magnitude inside the gate — and bitwise equality across backends held.
1131 ///
1132 /// *Interpretation:* the convergence measure a solver tests against a
1133 /// tolerance is unchanged for any practical tolerance. Solver tolerances in
1134 /// this crate are 1e-6 to 1e-12 on a quantity whose two summation orders
1135 /// differ in the fifteenth digit, so switching backend cannot change an
1136 /// iteration count.
1137 ///
1138 /// # Panics
1139 ///
1140 /// Panics if `x.len()` or `b.len()` differs from `n_cells`.
1141 #[must_use]
1142 pub fn normalised_residual(&self, x: &[f64], b: &[f64], backend: ComputeBackend) -> f64 {
1143 self.normalised_residual_min(x, b, backend, SPMV_MIN_CELLS)
1144 }
1145
1146 /// [`Self::normalised_residual`] with a caller-supplied size floor; see
1147 /// [`Self::spmv_into_min`] for why this exists.
1148 pub(crate) fn normalised_residual_min(
1149 &self,
1150 x: &[f64],
1151 b: &[f64],
1152 backend: ComputeBackend,
1153 min_work_items: usize,
1154 ) -> f64 {
1155 let n = self.matrix.n_cells;
1156 assert_eq!(
1157 b.len(),
1158 n,
1159 "HybridLdu::normalised_residual: b has {} entries, expected n_cells = {n}",
1160 b.len()
1161 );
1162
1163 let mut ax = vec![0.0_f64; n];
1164 self.spmv_into_min(x, &mut ax, backend, min_work_items);
1165
1166 let partials: Vec<NormBlock> = match effective_backend(backend, n, min_work_items) {
1167 #[cfg(feature = "parallel")]
1168 ComputeBackend::CpuMulti => ax
1169 .par_chunks(REDUCTION_BLOCK)
1170 .zip(b.par_chunks(REDUCTION_BLOCK))
1171 .map(|(axc, bc)| NormBlock::of(axc, bc))
1172 .collect(),
1173 _ => ax
1174 .chunks(REDUCTION_BLOCK)
1175 .zip(b.chunks(REDUCTION_BLOCK))
1176 .map(|(axc, bc)| NormBlock::of(axc, bc))
1177 .collect(),
1178 };
1179 let combined = NormBlock::combine(&partials);
1180
1181 let denom = (combined.ax_norm + combined.b_norm) * 0.5;
1182 if denom < f64::EPSILON {
1183 combined.r_norm
1184 } else {
1185 combined.r_norm / denom
1186 }
1187 }
1188
1189 // ── Diagonal reciprocal ──────────────────────────────────────────────────
1190
1191 /// Element-wise reciprocal of the diagonal, `1 / diag[c]` for every cell.
1192 ///
1193 /// This is the Jacobi preconditioner, and the first step of the DIC/DILU
1194 /// preconditioner setups. It is recomputed whenever the matrix is
1195 /// reassembled, which for a transient solve is every timestep. `O(n_cells)`.
1196 ///
1197 /// A zero diagonal yields an infinity and a `NaN` diagonal propagates, exactly
1198 /// as the scalar expression `1.0 / diag[c]` would. No clamping or masking is
1199 /// applied, because silently substituting a finite value would hide a singular
1200 /// assembly. Callers needing a guard should test the result with
1201 /// [`f64::is_finite`].
1202 ///
1203 /// # Arguments
1204 ///
1205 /// - `backend` — requested execution backend.
1206 ///
1207 /// # Returns
1208 ///
1209 /// A fresh `Vec<f64>` of length `n_cells`, in cell order. Its units are the
1210 /// reciprocal of the diagonal's units.
1211 ///
1212 /// # Determinism
1213 ///
1214 /// Bitwise identical on every backend and at any thread count: each element is
1215 /// one independent division.
1216 ///
1217 /// # Example
1218 ///
1219 /// ```rust
1220 /// use std::sync::Arc;
1221 /// use outram_foam_basic_lib::compute::ComputeBackend;
1222 /// use outram_foam_basic_lib::ldu_matrix::LduMatrix;
1223 /// use outram_foam_basic_lib::ldu_matrix::parallel::HybridLdu;
1224 ///
1225 /// let mut m = LduMatrix::new(3, vec![], vec![]);
1226 /// m.diag = vec![2.0, 4.0, 0.0];
1227 /// let ldu = HybridLdu::new(Arc::new(m));
1228 ///
1229 /// let rd = ldu.diagonal_reciprocal(ComputeBackend::CpuMulti);
1230 /// assert_eq!(rd[0], 0.5);
1231 /// assert_eq!(rd[1], 0.25);
1232 /// assert!(rd[2].is_infinite()); // a singular row is reported, not hidden
1233 /// ```
1234 #[must_use]
1235 pub fn diagonal_reciprocal(&self, backend: ComputeBackend) -> Vec<f64> {
1236 let mut out = vec![0.0_f64; self.matrix.n_cells];
1237 self.diagonal_reciprocal_into(&mut out, backend);
1238 out
1239 }
1240
1241 /// Element-wise reciprocal of the diagonal, into a caller-owned buffer.
1242 ///
1243 /// Same semantics as [`Self::diagonal_reciprocal`] without the allocation.
1244 ///
1245 /// # Arguments
1246 ///
1247 /// - `out` — output buffer, exactly `n_cells` long. Fully overwritten.
1248 /// - `backend` — requested execution backend.
1249 ///
1250 /// # Panics
1251 ///
1252 /// Panics if `out.len()` differs from `n_cells`.
1253 pub fn diagonal_reciprocal_into(&self, out: &mut [f64], backend: ComputeBackend) {
1254 self.diagonal_reciprocal_into_min(out, backend, VECOP_MIN_ELEMENTS);
1255 }
1256
1257 /// [`Self::diagonal_reciprocal_into`] with a caller-supplied size floor; see
1258 /// [`Self::spmv_into_min`] for why this exists.
1259 pub(crate) fn diagonal_reciprocal_into_min(
1260 &self,
1261 out: &mut [f64],
1262 backend: ComputeBackend,
1263 min_work_items: usize,
1264 ) {
1265 let n = self.matrix.n_cells;
1266 assert_eq!(
1267 out.len(),
1268 n,
1269 "HybridLdu::diagonal_reciprocal_into: out has {} entries, expected n_cells = {n}",
1270 out.len()
1271 );
1272 let diag = &self.matrix.diag;
1273
1274 match effective_backend(backend, n, min_work_items) {
1275 #[cfg(feature = "parallel")]
1276 ComputeBackend::CpuMulti => {
1277 out.par_chunks_mut(CELL_BLOCK)
1278 .zip(diag.par_chunks(CELL_BLOCK))
1279 .for_each(|(oc, dc)| {
1280 for (o, d) in oc.iter_mut().zip(dc.iter()) {
1281 *o = 1.0 / d;
1282 }
1283 });
1284 }
1285 _ => {
1286 for (o, d) in out.iter_mut().zip(diag.iter()) {
1287 *o = 1.0 / d;
1288 }
1289 }
1290 }
1291 }
1292}
1293
1294// ── Vector operations ─────────────────────────────────────────────────────────
1295
1296/// Inner product `sum_i a_i * b_i`, on the chosen backend.
1297///
1298/// Krylov methods evaluate two or three of these per iteration (conjugate
1299/// gradient: `r.r` and `p.Ap`), so on a large mesh they are worth threading even
1300/// though the operation is memory-bandwidth bound rather than arithmetic bound.
1301///
1302/// # Arguments
1303///
1304/// - `a`, `b` — equal-length dimensionless vectors in cell order.
1305/// - `backend` — requested execution backend; see [`vecop_backend_for`] for what
1306/// will actually run.
1307///
1308/// # Returns
1309///
1310/// The dimensionless scalar product. Returns `0.0` for empty inputs.
1311///
1312/// # Determinism, and how this differs from [`crate::krylov::vecops::dot`]
1313///
1314/// Sums in fixed blocks of [`REDUCTION_BLOCK`] elements, combining block partials
1315/// in ascending block order. The result is therefore **bitwise identical between
1316/// backends and at any thread count**, and reproducible run to run. It differs
1317/// from the flat left-to-right sum in [`crate::krylov::vecops::dot`] only by
1318/// floating-point non-associativity.
1319///
1320/// **Measured deviation.** *Methodology:* two fixed-seed xorshift64\*
1321/// pseudorandom vectors with elements uniform on `[-1, 1)`, at lengths 1 024
1322/// through 4 194 304 in powers of four; compare against
1323/// [`crate::krylov::vecops::dot`] on the same inputs. Two measures are reported,
1324/// because the raw relative difference is taken against a heavily cancelled sum
1325/// (terms of order 1, total of order `sqrt(n)`) and so overstates the error:
1326/// the raw relative difference `|blocked - flat| / max(|blocked|, |flat|)`, and
1327/// the conditioning-aware `|blocked - flat| / sum |a_i b_i|`. *Pass criteria:*
1328/// worst raw `<= 1e-12` **and** worst conditioned `<= 1e-15`.
1329///
1330/// *Results, measured 2026-08-12 by the test `dot_matches_flat_reference` in
1331/// `parallel/tests.rs`:*
1332///
1333/// | n | blocked | flat | raw rel diff | vs sum abs terms |
1334/// |---|---|---|---|---|
1335/// | 1 024 | -1.07439412213983019e1 | -1.07439412213983019e1 | 0 | 0 |
1336/// | 4 096 | 2.27305615265860794e0 | 2.27305615265858707e0 | 9.1824e-15 | 2.0525e-17 |
1337/// | 16 384 | -4.10927502452607953e1 | -4.10927502452608877e1 | 2.2479e-15 | 2.2574e-17 |
1338/// | 65 536 | -1.48877508980721871e2 | -1.48877508980720563e2 | 8.7817e-15 | 7.9476e-17 |
1339/// | 262 144 | -2.57469059787491972e2 | -2.57469059787487652e2 | 1.6779e-14 | 6.6111e-17 |
1340/// | 1 048 576 | 2.06465247477648091e2 | 2.06465247477646983e2 | 5.3687e-15 | 4.2292e-18 |
1341/// | 4 194 304 | -1.50102185250877881e2 | -1.50102185250842240e2 | 2.3744e-13 | 3.3997e-17 |
1342///
1343/// Worst raw relative difference **2.3744e-13** at n = 4 194 304; worst
1344/// conditioned difference **7.9476e-17** at n = 65 536.
1345///
1346/// *Interpretation:* the difference is summation reassociation at rounding
1347/// level, not a defect — relative to the arithmetic actually performed it never
1348/// exceeds about a third of one `f64` epsilon. The raw figure grows with `n`
1349/// because the exact sum of `n` random signed terms grows only like `sqrt(n)`
1350/// while the rounding error grows faster, so the quotient inflates; that is a
1351/// property of the test vectors, not of either kernel. Blocked summation is the
1352/// two-level, more accurate form — a flat sum of `n` terms has error growing
1353/// like `n * eps`, a blocked sum like `(block + n / block) * eps` — so the
1354/// blocked result is if anything closer to the exact value.
1355///
1356/// # Panics
1357///
1358/// Panics if `a` and `b` have different lengths.
1359///
1360/// # Example
1361///
1362/// ```rust
1363/// use outram_foam_basic_lib::compute::ComputeBackend;
1364/// use outram_foam_basic_lib::ldu_matrix::parallel::dot;
1365///
1366/// let a = [1.0, 2.0, 3.0];
1367/// let b = [4.0, -5.0, 6.0];
1368/// // 1*4 + 2*(-5) + 3*6 = 12
1369/// assert_eq!(dot(&a, &b, ComputeBackend::Serial), 12.0);
1370/// // Bitwise identical, not merely close.
1371/// assert_eq!(
1372/// dot(&a, &b, ComputeBackend::CpuMulti),
1373/// dot(&a, &b, ComputeBackend::Serial),
1374/// );
1375/// ```
1376#[must_use]
1377pub fn dot(a: &[f64], b: &[f64], backend: ComputeBackend) -> f64 {
1378 dot_min(a, b, backend, VECOP_MIN_ELEMENTS)
1379}
1380
1381/// [`dot`] with a caller-supplied size floor; see [`HybridLdu::spmv_into_min`]
1382/// for why the `_min` variants exist.
1383pub(crate) fn dot_min(a: &[f64], b: &[f64], backend: ComputeBackend, min_work_items: usize) -> f64 {
1384 assert_eq!(
1385 a.len(),
1386 b.len(),
1387 "dot: length mismatch ({} vs {})",
1388 a.len(),
1389 b.len()
1390 );
1391
1392 let partials: Vec<f64> = match effective_backend(backend, a.len(), min_work_items) {
1393 #[cfg(feature = "parallel")]
1394 ComputeBackend::CpuMulti => a
1395 .par_chunks(REDUCTION_BLOCK)
1396 .zip(b.par_chunks(REDUCTION_BLOCK))
1397 .map(|(x, y)| block_dot(x, y))
1398 .collect(),
1399 _ => a
1400 .chunks(REDUCTION_BLOCK)
1401 .zip(b.chunks(REDUCTION_BLOCK))
1402 .map(|(x, y)| block_dot(x, y))
1403 .collect(),
1404 };
1405
1406 combine_in_order(&partials)
1407}
1408
1409/// L2 (Euclidean) norm `sqrt(sum_i x_i^2)`, on the chosen backend.
1410///
1411/// Computed as `dot(x, x, backend).sqrt()`, so it inherits that function's
1412/// determinism guarantee exactly: bitwise identical between backends and at any
1413/// thread count, and differing from [`crate::krylov::vecops::nrm2`] only by
1414/// summation reassociation.
1415///
1416/// # Arguments
1417///
1418/// - `x` — a dimensionless vector in cell order.
1419/// - `backend` — requested execution backend.
1420///
1421/// # Returns
1422///
1423/// A non-negative dimensionless scalar; `0.0` for an empty input. For very large
1424/// element magnitudes the intermediate sum of squares can overflow to infinity —
1425/// there is no scaling guard, matching [`crate::krylov::vecops::nrm2`], because
1426/// well-scaled linear systems stay far inside normal `f64` range.
1427///
1428/// # Example
1429///
1430/// ```rust
1431/// use outram_foam_basic_lib::compute::ComputeBackend;
1432/// use outram_foam_basic_lib::ldu_matrix::parallel::norm_l2;
1433///
1434/// assert_eq!(norm_l2(&[3.0, 4.0], ComputeBackend::CpuMulti), 5.0);
1435/// assert_eq!(norm_l2(&[], ComputeBackend::Serial), 0.0);
1436/// ```
1437#[must_use]
1438pub fn norm_l2(x: &[f64], backend: ComputeBackend) -> f64 {
1439 dot(x, x, backend).sqrt()
1440}
1441
1442/// L1 norm `sum_i abs(x_i)`, on the chosen backend.
1443///
1444/// This is the norm OpenFOAM's solver convergence test uses, and the one behind
1445/// [`HybridLdu::normalised_residual`].
1446///
1447/// # Arguments
1448///
1449/// - `x` — a dimensionless vector in cell order.
1450/// - `backend` — requested execution backend.
1451///
1452/// # Returns
1453///
1454/// A non-negative dimensionless scalar; `0.0` for an empty input.
1455///
1456/// # Determinism
1457///
1458/// Blocked summation exactly as in [`dot`]: bitwise identical between backends
1459/// and at any thread count, differing from a flat sum only by reassociation.
1460///
1461/// # Example
1462///
1463/// ```rust
1464/// use outram_foam_basic_lib::compute::ComputeBackend;
1465/// use outram_foam_basic_lib::ldu_matrix::parallel::norm_l1;
1466///
1467/// assert_eq!(norm_l1(&[1.0, -2.0, 3.0], ComputeBackend::CpuMulti), 6.0);
1468/// ```
1469#[must_use]
1470pub fn norm_l1(x: &[f64], backend: ComputeBackend) -> f64 {
1471 norm_l1_min(x, backend, VECOP_MIN_ELEMENTS)
1472}
1473
1474/// [`norm_l1`] with a caller-supplied size floor; see
1475/// [`HybridLdu::spmv_into_min`] for why the `_min` variants exist.
1476pub(crate) fn norm_l1_min(x: &[f64], backend: ComputeBackend, min_work_items: usize) -> f64 {
1477 let partials: Vec<f64> = match effective_backend(backend, x.len(), min_work_items) {
1478 #[cfg(feature = "parallel")]
1479 ComputeBackend::CpuMulti => x.par_chunks(REDUCTION_BLOCK).map(block_asum).collect(),
1480 _ => x.chunks(REDUCTION_BLOCK).map(block_asum).collect(),
1481 };
1482 combine_in_order(&partials)
1483}
1484
1485/// AXPY update `y := alpha * x + y`, in place, on the chosen backend.
1486///
1487/// The vector update every Krylov iteration performs several times (advancing the
1488/// solution, the residual and the search direction).
1489///
1490/// # Arguments
1491///
1492/// - `alpha` — dimensionless scalar multiplier.
1493/// - `x` — dimensionless input vector in cell order.
1494/// - `y` — dimensionless accumulator, same length as `x`, updated in place.
1495/// - `backend` — requested execution backend.
1496///
1497/// # Determinism
1498///
1499/// Bitwise identical between backends and at any thread count: each element is an
1500/// independent fused expression, so there is no reduction to reassociate. Unlike
1501/// the reductions, this also matches [`crate::krylov::vecops::axpy`] exactly.
1502///
1503/// # Panics
1504///
1505/// Panics if `x` and `y` have different lengths.
1506///
1507/// # Example
1508///
1509/// ```rust
1510/// use outram_foam_basic_lib::compute::ComputeBackend;
1511/// use outram_foam_basic_lib::ldu_matrix::parallel::axpy;
1512///
1513/// let x = [1.0, 1.0, 1.0];
1514/// let mut y = [10.0, 20.0, 30.0];
1515/// axpy(2.0, &x, &mut y, ComputeBackend::CpuMulti);
1516/// assert_eq!(y, [12.0, 22.0, 32.0]);
1517/// ```
1518pub fn axpy(alpha: f64, x: &[f64], y: &mut [f64], backend: ComputeBackend) {
1519 axpy_min(alpha, x, y, backend, VECOP_MIN_ELEMENTS);
1520}
1521
1522/// [`axpy`] with a caller-supplied size floor; see [`HybridLdu::spmv_into_min`]
1523/// for why the `_min` variants exist.
1524pub(crate) fn axpy_min(
1525 alpha: f64,
1526 x: &[f64],
1527 y: &mut [f64],
1528 backend: ComputeBackend,
1529 min_work_items: usize,
1530) {
1531 assert_eq!(
1532 x.len(),
1533 y.len(),
1534 "axpy: length mismatch ({} vs {})",
1535 x.len(),
1536 y.len()
1537 );
1538
1539 match effective_backend(backend, x.len(), min_work_items) {
1540 #[cfg(feature = "parallel")]
1541 ComputeBackend::CpuMulti => {
1542 y.par_chunks_mut(CELL_BLOCK)
1543 .zip(x.par_chunks(CELL_BLOCK))
1544 .for_each(|(yc, xc)| {
1545 for (yi, xi) in yc.iter_mut().zip(xc.iter()) {
1546 *yi += alpha * xi;
1547 }
1548 });
1549 }
1550 _ => {
1551 for (yi, xi) in y.iter_mut().zip(x.iter()) {
1552 *yi += alpha * xi;
1553 }
1554 }
1555 }
1556}
1557
1558/// Scale `x := alpha * x`, in place, on the chosen backend.
1559///
1560/// The third element-wise vector update a Krylov solver needs, alongside [`axpy`]
1561/// and the products. GMRES normalises every Arnoldi basis vector with it
1562/// (`v := v / ||v||`), twice per inner iteration, so on a large mesh it is on the
1563/// hot path; BiCGStab uses it to rescale search directions.
1564///
1565/// # Arguments
1566///
1567/// - `alpha` — dimensionless scalar multiplier.
1568/// - `x` — dimensionless vector in cell order, updated in place.
1569/// - `backend` — requested execution backend; see [`vecop_backend_for`] for what
1570/// will actually run.
1571///
1572/// # Determinism
1573///
1574/// Bitwise identical between backends and at any thread count, and bitwise
1575/// identical to [`crate::krylov::vecops::scal`]: every element is an independent
1576/// single multiplication, so — exactly as for [`axpy`] — there is no reduction to
1577/// reassociate and no summation order to depend on. This is the strongest of the
1578/// three determinism grades in this module and it holds unconditionally, for any
1579/// finite or non-finite `alpha` and any input.
1580///
1581/// # Example
1582///
1583/// ```rust
1584/// use outram_foam_basic_lib::compute::ComputeBackend;
1585/// use outram_foam_basic_lib::ldu_matrix::parallel::scale;
1586///
1587/// let mut x = [1.0, -2.0, 3.0];
1588/// scale(-2.0, &mut x, ComputeBackend::CpuMulti);
1589/// assert_eq!(x, [-2.0, 4.0, -6.0]);
1590/// ```
1591pub fn scale(alpha: f64, x: &mut [f64], backend: ComputeBackend) {
1592 scale_min(alpha, x, backend, VECOP_MIN_ELEMENTS);
1593}
1594
1595/// [`scale`] with a caller-supplied size floor; see [`HybridLdu::spmv_into_min`]
1596/// for why the `_min` variants exist.
1597pub(crate) fn scale_min(alpha: f64, x: &mut [f64], backend: ComputeBackend, min_work_items: usize) {
1598 match effective_backend(backend, x.len(), min_work_items) {
1599 #[cfg(feature = "parallel")]
1600 ComputeBackend::CpuMulti => {
1601 x.par_chunks_mut(CELL_BLOCK).for_each(|xc| {
1602 for xi in xc.iter_mut() {
1603 *xi *= alpha;
1604 }
1605 });
1606 }
1607 _ => {
1608 for xi in x.iter_mut() {
1609 *xi *= alpha;
1610 }
1611 }
1612 }
1613}
1614
1615// ── Internals ─────────────────────────────────────────────────────────────────
1616
1617/// Serial inner product over one reduction block.
1618#[inline]
1619fn block_dot(x: &[f64], y: &[f64]) -> f64 {
1620 let mut p = 0.0_f64;
1621 for (xi, yi) in x.iter().zip(y.iter()) {
1622 p += xi * yi;
1623 }
1624 p
1625}
1626
1627/// Serial sum of absolute values over one reduction block.
1628#[inline]
1629fn block_asum(x: &[f64]) -> f64 {
1630 let mut p = 0.0_f64;
1631 for xi in x {
1632 p += xi.abs();
1633 }
1634 p
1635}
1636
1637/// Combine block partials in ascending block order.
1638///
1639/// This one function is why the reductions in this module are reproducible: the
1640/// partials arrive in a `Vec` indexed by block, so the association is fixed by the
1641/// array length alone, never by which thread finished first.
1642#[inline]
1643fn combine_in_order(partials: &[f64]) -> f64 {
1644 let mut total = 0.0_f64;
1645 for p in partials {
1646 total += p;
1647 }
1648 total
1649}
1650
1651/// The three L1 partial sums one reduction block contributes to
1652/// [`HybridLdu::normalised_residual`].
1653///
1654/// A plain `Copy` struct so the parallel map can `collect()` it into an ordered
1655/// `Vec` and the combine step can walk that `Vec` in block order.
1656#[derive(Debug, Clone, Copy, Default)]
1657struct NormBlock {
1658 /// `sum abs(b - A x)` over the block.
1659 r_norm: f64,
1660 /// `sum abs(A x)` over the block.
1661 ax_norm: f64,
1662 /// `sum abs(b)` over the block.
1663 b_norm: f64,
1664}
1665
1666impl NormBlock {
1667 /// Sum one block of `A x` against the matching block of `b`.
1668 fn of(ax: &[f64], b: &[f64]) -> Self {
1669 let mut out = Self::default();
1670 for (a, bi) in ax.iter().zip(b.iter()) {
1671 out.r_norm += (bi - a).abs();
1672 out.ax_norm += a.abs();
1673 out.b_norm += bi.abs();
1674 }
1675 out
1676 }
1677
1678 /// Combine block partials in ascending block order.
1679 fn combine(blocks: &[NormBlock]) -> Self {
1680 let mut out = Self::default();
1681 for blk in blocks {
1682 out.r_norm += blk.r_norm;
1683 out.ax_norm += blk.ax_norm;
1684 out.b_norm += blk.b_norm;
1685 }
1686 out
1687 }
1688}