Skip to main content

outram_foam_basic_lib/
compute.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK. See `src/lib.rs` for the full licence
5// notice.
6
7//! Where a numerical kernel runs — serial, multi-CPU, or GPU.
8//!
9//! # What belongs in this module
10//!
11//! The **dispatch layer and nothing else**: the [`ComputeBackend`] enum, the
12//! worker-count policy [`ThreadCount`], the named backend-selection policy
13//! [`select_backend`], and the GPU adapter probe. It is deliberately free of
14//! numerical code so that every kernel in the crate answers the question
15//! "where do I run?" the same way.
16//!
17//! # What does NOT belong here
18//!
19//! Kernels. A root finder, a sparse matrix-vector product, a quadrature rule
20//! or an ODE ensemble lives in its own module (`polynomial/`, `ldu_matrix/`,
21//! `krylov/`, `math/`, `ode/`) and takes a [`ComputeBackend`] as a parameter.
22//! If you find yourself writing physics or arithmetic in this file, it is in
23//! the wrong file.
24//!
25//! # Hybrid means dispatch, not two APIs
26//!
27//! A kernel exposes **one** public entry point with the backend as a
28//! parameter. It does not grow a `foo_parallel()` sibling beside `foo()`.
29//! Callers that do not care let [`select_backend`] resolve one for them;
30//! callers that do care name the backend explicitly and get exactly it, or a
31//! documented fallback. There is no `Auto` variant — resolution is a function,
32//! not a backend.
33//!
34//! # The serial path is the oracle
35//!
36//! [`ComputeBackend::Serial`] is always compiled, on every target, under every
37//! feature combination, and is the `#[default]`. It is the reference
38//! implementation that the other two are checked against, and it is the only
39//! one guaranteed to be bit-for-bit reproducible run to run. Verification and
40//! validation are judged against it. The other backends are *acceleration*.
41//!
42//! # Cargo features — both OFF by default
43//!
44//! | Feature | Adds | Backend enabled |
45//! |---|---|---|
46//! | *(none)* | — | [`ComputeBackend::Serial`] |
47//! | `parallel` | `rayon` | [`ComputeBackend::CpuMulti`] |
48//! | `gpu` | `wgpu` (non-Android only) | [`ComputeBackend::Gpu`] |
49//!
50//! A default build pulls neither dependency, which keeps the crate
51//! dependency-light and Android/Termux clean. Requesting a backend whose
52//! feature is off is **not an error**: it resolves down to the best available
53//! one (see [`ComputeBackend::resolve`]). That way a caller written on a
54//! desktop with `--features gpu` still runs, correctly and more slowly, on a
55//! phone with no features at all.
56//!
57//! # Android / Termux
58//!
59//! `rayon` is pure Rust with no system component, so the `parallel` feature is
60//! **not** target-gated — it works on Android. `wgpu` is target-gated off
61//! Android in `Cargo.toml`, because Android has no system Vulkan/Metal loader
62//! and the workspace Android rule forbids GPU dependencies in a library build.
63//! Enabling `gpu` on an Android target therefore yields a build with no GPU
64//! backend rather than a broken one.
65//!
66//! # Planned: hybrid CPU+GPU co-execution (NOT implemented)
67//!
68//! There is no `Hybrid` variant and no GPU kernel in this crate — see
69//! [`gpu_adapter_present`] for the extent of the `wgpu` use. One policy
70//! decision for it is nevertheless already settled, because it constrains how
71//! the kernels are written.
72//!
73//! For the **iterative** kernels the shape is *coarse-to-fine*, not a split
74//! batch: a GPU `f32` pass produces only the **initial guess**, and the CPU
75//! converges it to the final answer at `f64`. The result is therefore entirely
76//! `f64` and the `f32` accuracy floors govern the guess, not the answer. This
77//! suits batched root finding especially well — [`RootProblem::guess`] already
78//! guarantees that a bad guess is a performance problem and not a correctness
79//! one, and Newton's quadratic convergence turns an `f32`-accurate start into
80//! a `f64` answer in about two iterations.
81//!
82//! [`RootProblem::guess`]: crate::math::parallel::RootProblem::guess
83//!
84//! For the **elementwise** kernels, where a batch genuinely is split across the
85//! two devices, the CPU half runs `f32` to match the GPU, so that precision is
86//! uniform across one output array rather than varying with the split ratio.
87//! Those kernels are an `f32`-class throughput path sitting roughly `1e-7`
88//! relative from the [`ComputeBackend::Serial`] oracle.
89//!
90//! Numerical differentiation is excluded from both shapes: a finite difference
91//! has no fixed point to converge to, so there is no guess to seed and the
92//! `f32` floor would land directly on the answer.
93//!
94//! Full reasoning, the per-kernel floors at `f32`, the measured iteration
95//! savings, and the caveats none of this resolves are in
96//! `docs/hybrid-precision-policy.md`.
97//!
98//! # Units
99//!
100//! Nothing in this module carries a physical dimension. Sizes are counts of
101//! independent work items, which is a pure number. `uom` typing belongs on the
102//! kernels' own signatures and must not be stripped to get data onto a device —
103//! convert at the buffer boundary and convert back.
104//!
105// The link definitions below are fully qualified on purpose. `lib.rs` carries
106// an outer `///` summary on `pub mod compute;` while this file carries inner
107// `//!` docs; rustdoc concatenates the two and resolves the merged block at the
108// *crate root*, where the names below are not in scope. Bare `[`ComputeBackend`]`
109// therefore fails here even though the type is declared in this very file.
110//! [`ComputeBackend`]: crate::compute::ComputeBackend
111//! [`ComputeBackend::Serial`]: crate::compute::ComputeBackend::Serial
112//! [`ComputeBackend::CpuMulti`]: crate::compute::ComputeBackend::CpuMulti
113//! [`ComputeBackend::Gpu`]: crate::compute::ComputeBackend::Gpu
114//! [`ComputeBackend::resolve`]: crate::compute::ComputeBackend::resolve
115//! [`ThreadCount`]: crate::compute::ThreadCount
116//! [`select_backend`]: crate::compute::select_backend
117//! [`gpu_adapter_present`]: crate::compute::gpu_adapter_present
118
119use std::sync::OnceLock;
120
121/// Which execution backend a numerical kernel should use.
122///
123/// # Variants and what they cost
124///
125/// - [`Serial`](Self::Serial) — one thread, scalar. Always available. The
126///   deterministic trusted reference; bit-for-bit reproducible run to run.
127/// - [`CpuMulti`](Self::CpuMulti) — `rayon` across CPU cores. Requires the
128///   `parallel` feature. Pays thread-pool overhead, so it loses on small
129///   problems; see [`CPU_MULTI_MIN_WORK_ITEMS`]. Reduction order is not
130///   generally reproducible unless a kernel says otherwise.
131/// - [`Gpu`](Self::Gpu) — `wgpu` compute. Requires the `gpu` feature, a
132///   non-Android target, and an actual adapter at run time. Pays a host-device
133///   transfer per dispatch, so it needs a much larger problem to win; see
134///   [`GPU_MIN_WORK_ITEMS`]. WGSL has no `f64`, so a GPU kernel that computes
135///   in `f32` will NOT match the serial `f64` path to full precision — each
136///   kernel must document its own measured deviation.
137///
138/// # Choosing one
139///
140/// Prefer [`select_backend`] over hand-picking. If you do name a backend,
141/// route it through [`resolve`](Self::resolve) so an unavailable choice
142/// degrades instead of failing.
143///
144/// # Units
145///
146/// Dimensionless — this is a mode selector, not a quantity.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
148pub enum ComputeBackend {
149    /// Single-threaded scalar execution — always present, always the oracle.
150    #[default]
151    Serial,
152    /// Multi-threaded CPU execution via `rayon` (`parallel` feature).
153    CpuMulti,
154    /// GPU compute via `wgpu` (`gpu` feature, non-Android, adapter present).
155    Gpu,
156}
157
158/// The number of work items below which [`ComputeBackend::CpuMulti`] is not
159/// worth its thread-pool overhead, and [`select_backend`] returns
160/// [`ComputeBackend::Serial`] instead.
161///
162/// # How this number was chosen
163///
164/// **It is a placeholder, it has NOT been justified by measurement, and the
165/// one kernel family that has since been measured found it far too low.**
166/// Bead `op-yvj.4.7` covers the crossover benchmarks that will replace it.
167///
168/// The failure mode this guards is real and easy to reproduce: spawning
169/// threads to add two 50-element vectors is slower than doing it serially.
170///
171/// # Measured counter-evidence — read before relying on this
172///
173/// [`crate::fields::parallel`] measured its own crossover on 2026-08-12 (4
174/// logical cores, release, `--features parallel`) and found that **at exactly
175/// this threshold the parallel path was about 6x SLOWER** — `0.17x` speedup at
176/// `n = 4096`, and it lost 10 sweeps out of 10 at every size below 65 536.
177/// Those kernels are memory-bandwidth bound at 1-2 flops per element, so
178/// thread overhead dominates much further out than it would for compute-dense
179/// work. That module therefore overrides this constant at 131 072 via
180/// [`crate::fields::parallel::field_parallel_crossover`].
181///
182/// [`crate::ldu_matrix::parallel`] measured the same day and found the
183/// opposite for *its* sparse matrix-vector product: 4096 cells is precisely
184/// where that kernel breaks even, so `SPMV_MIN_CELLS` keeps this value. But
185/// the vector operations in that same module want `VECOP_MIN_ELEMENTS =
186/// 262 144`, because `axpy` at 4096 elements runs at `0.05x` — twenty times
187/// slower.
188///
189/// [`crate::math::parallel`] then measured the opposite direction:
190/// batched root finding crosses over at **256** problems, 16x *below* this
191/// constant, because it is compute-dense per lane where the field kernels are
192/// memory-bound.
193///
194/// Four kernel families have now been measured and they want **256, 4 096,
195/// 131 072 and 262 144** — a 1024x spread, with this constant sitting in the
196/// middle and wrong at both ends. That is the real finding: no single
197/// crate-wide threshold can be right for all of them.
198///
199/// Worse for any hope of a universal number: the root-finding crossover
200/// depends on the *caller's* residual cost, which this crate cannot know. A
201/// cheap residual and an expensive one cross over at different sizes with
202/// identical kernel code.
203///
204/// The honest reading: this constant is a *floor against absurdity*, not a
205/// tuned value, and a kernel that has not measured its own crossover should
206/// not assume this one is safe for it. Per-kernel overrides are expected, not
207/// exceptional.
208///
209/// # Units
210///
211/// A count of independent work items (cells, faces, roots, quadrature points),
212/// dimensionless.
213pub const CPU_MULTI_MIN_WORK_ITEMS: usize = 4_096;
214
215/// The number of work items below which [`ComputeBackend::Gpu`] is not worth
216/// the host-device transfer, and [`select_backend`] falls back to the best
217/// available CPU backend.
218///
219/// # How this number was chosen
220///
221/// **Placeholder awaiting measurement — see [`CPU_MULTI_MIN_WORK_ITEMS`].**
222/// It is set an order of magnitude above the CPU-multi threshold because a GPU
223/// dispatch pays for a round trip across the PCIe bus in addition to kernel
224/// launch, so it needs correspondingly more work to amortise.
225///
226/// Note that this workspace's own experience argues for caution rather than
227/// optimism about GPU wins: beads `op-u6s.8` and `op-u6s.9` record GPU
228/// transport work struggling to beat a good multi-threaded CPU path, and
229/// `op-u6s.9` is still open.
230///
231/// # Units
232///
233/// A count of independent work items, dimensionless.
234pub const GPU_MIN_WORK_ITEMS: usize = 65_536;
235
236impl ComputeBackend {
237    /// Whether this backend can actually run in this build, on this target,
238    /// right now.
239    ///
240    /// - [`Serial`](Self::Serial) — always `true`.
241    /// - [`CpuMulti`](Self::CpuMulti) — `true` when the `parallel` feature is
242    ///   on. Compile-time only; rayon needs no run-time probe.
243    /// - [`Gpu`](Self::Gpu) — `true` only when the `gpu` feature is on, the
244    ///   target is not Android, **and** a GPU adapter was found by
245    ///   [`gpu_adapter_present`]. This one genuinely queries the machine.
246    #[must_use]
247    pub fn is_available(self) -> bool {
248        match self {
249            Self::Serial => true,
250            Self::CpuMulti => cfg!(feature = "parallel"),
251            Self::Gpu => gpu_adapter_present(),
252        }
253    }
254
255    /// Degrade this backend to the best one that is actually available,
256    /// without erroring.
257    ///
258    /// The order is `Gpu -> CpuMulti -> Serial`: a requested GPU with no
259    /// adapter (or no `gpu` feature) tries multi-CPU, and failing that runs
260    /// serially. [`Serial`](Self::Serial) is always reachable, so this never
261    /// fails and never panics.
262    ///
263    /// This is the "graceful absence of a GPU" contract: a missing adapter is
264    /// a normal outcome on a headless server, in CI, on Android, or on a
265    /// laptop with no discrete GPU — not an error condition.
266    #[must_use]
267    pub fn resolve(self) -> Self {
268        match self {
269            Self::Gpu if Self::Gpu.is_available() => Self::Gpu,
270            Self::Gpu | Self::CpuMulti if Self::CpuMulti.is_available() => Self::CpuMulti,
271            _ => Self::Serial,
272        }
273    }
274
275    /// A short human-readable label, for benchmark tables and log lines.
276    #[must_use]
277    pub fn label(self) -> &'static str {
278        match self {
279            Self::Serial => "serial",
280            Self::CpuMulti => "cpu-multi",
281            Self::Gpu => "gpu",
282        }
283    }
284}
285
286/// **The** backend-selection policy — one named function, so a reader can find
287/// the rule instead of hunting for scattered `if n > 1024` tests.
288///
289/// # The rule
290///
291/// Given `work_items`, the number of *independent* items a kernel will process:
292///
293/// 1. If `work_items >= ` [`GPU_MIN_WORK_ITEMS`] and the GPU is available, use
294///    [`ComputeBackend::Gpu`].
295/// 2. Otherwise if `work_items >= ` [`CPU_MULTI_MIN_WORK_ITEMS`] and the
296///    `parallel` feature is on, use [`ComputeBackend::CpuMulti`].
297/// 3. Otherwise use [`ComputeBackend::Serial`].
298///
299/// # Arguments
300///
301/// - `work_items` — count of independent work items (cells, faces, roots,
302///   quadrature points). Dimensionless. Pass the honest count: passing a
303///   matrix's row count when the kernel actually loops over its faces will
304///   pick the wrong backend.
305///
306/// # Returns
307///
308/// A backend that is guaranteed available, so the caller may use it directly
309/// without a further [`ComputeBackend::resolve`].
310///
311/// # Why a caller might not use this
312///
313/// Benchmarks and parity gates need to run a *named* backend regardless of
314/// size — that is what [`ComputeBackend::resolve`] is for. This function is
315/// for production callers that just want the fastest correct option.
316///
317/// # Example
318///
319/// ```
320/// use outram_foam_basic_lib::compute::{select_backend, ComputeBackend};
321///
322/// // A tiny problem always runs serially, whatever features are enabled.
323/// assert_eq!(select_backend(16), ComputeBackend::Serial);
324///
325/// // Whatever it picks for a large problem is guaranteed to be runnable.
326/// assert!(select_backend(1_000_000).is_available());
327/// ```
328#[must_use]
329pub fn select_backend(work_items: usize) -> ComputeBackend {
330    if work_items >= GPU_MIN_WORK_ITEMS && ComputeBackend::Gpu.is_available() {
331        ComputeBackend::Gpu
332    } else if work_items >= CPU_MULTI_MIN_WORK_ITEMS && ComputeBackend::CpuMulti.is_available() {
333        ComputeBackend::CpuMulti
334    } else {
335        ComputeBackend::Serial
336    }
337}
338
339/// How many worker threads [`ComputeBackend::CpuMulti`] should use.
340///
341/// Mirrors the established shape in `boon-lay`'s and `outram-mc-libs`'s own
342/// compute modules so the workspace has one vocabulary for this rather than
343/// three.
344///
345/// # Units
346///
347/// Dimensionless counts. [`Fraction`](Self::Fraction) is a pure ratio.
348#[derive(Debug, Clone, Copy, PartialEq, Default)]
349pub enum ThreadCount {
350    /// Every logical core, via [`std::thread::available_parallelism`], falling
351    /// back to 1 if the query fails. The default.
352    #[default]
353    Auto,
354    /// An explicit worker count, clamped up to a minimum of 1.
355    Fixed(usize),
356    /// A fraction of the available logical cores, e.g. `0.5` for half. The
357    /// product is rounded to nearest and clamped to at least 1. A non-finite
358    /// or non-positive fraction clamps to 1 rather than panicking.
359    Fraction(f64),
360}
361
362impl ThreadCount {
363    /// Resolve to a concrete worker-thread count, always `>= 1`.
364    ///
365    /// # Returns
366    ///
367    /// A thread count in `[1, usize::MAX]`. Never zero, so it is always safe
368    /// to hand to a thread-pool builder.
369    ///
370    /// # Example
371    ///
372    /// ```
373    /// use outram_foam_basic_lib::compute::ThreadCount;
374    ///
375    /// assert_eq!(ThreadCount::Fixed(0).resolve(), 1); // clamped, not a panic
376    /// assert_eq!(ThreadCount::Fixed(3).resolve(), 3);
377    /// assert!(ThreadCount::Auto.resolve() >= 1);
378    /// ```
379    #[must_use]
380    pub fn resolve(self) -> usize {
381        let cores = || {
382            std::thread::available_parallelism()
383                .map(std::num::NonZeroUsize::get)
384                .unwrap_or(1)
385        };
386        match self {
387            Self::Auto => cores(),
388            Self::Fixed(n) => n.max(1),
389            Self::Fraction(f) => {
390                if !f.is_finite() || f <= 0.0 {
391                    1
392                } else {
393                    ((cores() as f64) * f).round().max(1.0) as usize
394                }
395            }
396        }
397    }
398}
399
400/// Cached answer to "is there a usable GPU adapter on this machine?", so the
401/// probe runs at most once per process.
402static GPU_PRESENT: OnceLock<bool> = OnceLock::new();
403
404/// Whether a usable GPU compute adapter exists, probed once and cached.
405///
406/// # Behaviour
407///
408/// - Without the `gpu` feature, or on Android, this is `false` with no probe
409///   attempted.
410/// - Otherwise it asks `wgpu` for an adapter, once. `false` is a normal
411///   outcome — headless CI, a container, a machine with no discrete GPU — and
412///   is **not** an error. Nothing here panics or returns `Err`.
413///
414/// # Returns
415///
416/// `true` only if a GPU kernel could actually be dispatched.
417#[must_use]
418pub fn gpu_adapter_present() -> bool {
419    *GPU_PRESENT.get_or_init(probe_gpu_adapter)
420}
421
422/// The one-shot probe behind [`gpu_adapter_present`] — Android and
423/// feature-disabled builds.
424#[cfg(any(not(feature = "gpu"), target_os = "android"))]
425fn probe_gpu_adapter() -> bool {
426    false
427}
428
429/// The one-shot probe behind [`gpu_adapter_present`] — desktop builds with the
430/// `gpu` feature.
431///
432/// Requests a headless adapter (no surface, so no display handle is needed).
433/// A failure is reported as `false`, never as a panic.
434#[cfg(all(feature = "gpu", not(target_os = "android")))]
435fn probe_gpu_adapter() -> bool {
436    let instance = wgpu::Instance::default();
437    pollster_lite::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
438        .is_ok()
439}
440
441/// A minimal thread-parking executor, so probing for an adapter does not drag
442/// an async runtime into this crate.
443///
444/// Mirrors the same device in `njoy-outram-park-fork`'s `gpu.rs`; kept private
445/// because it exists only to make one `wgpu` call blocking.
446#[cfg(all(feature = "gpu", not(target_os = "android")))]
447mod pollster_lite {
448    use std::future::Future;
449    use std::pin::Pin;
450    use std::sync::Arc;
451    use std::task::{Context, Poll, Wake, Waker};
452
453    struct ThreadWaker(std::thread::Thread);
454
455    impl Wake for ThreadWaker {
456        fn wake(self: Arc<Self>) {
457            self.0.unpark();
458        }
459    }
460
461    /// Drive `future` to completion on the calling thread, parking between
462    /// polls.
463    pub fn block_on<F: Future>(future: F) -> F::Output {
464        let mut future = std::pin::pin!(future);
465        let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
466        let mut context = Context::from_waker(&waker);
467        loop {
468            match Pin::new(&mut future).poll(&mut context) {
469                Poll::Ready(value) => return value,
470                Poll::Pending => std::thread::park(),
471            }
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests;