Skip to main content

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}