Skip to main content

outram_foam_basic_lib/compute/
hybrid.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//! **Data-level co-execution** — split one batch between the CPU and the GPU
8//! so both run at once. Bead `op-yvj.4.8`, GitHub #18.
9//!
10//! # What this is, and what it is not
11//!
12//! This is a **selection policy over the existing backends**, exactly as #18
13//! asked: it decides how many lanes go to `CpuMulti` and how many to `Gpu`,
14//! then calls the kernels that already exist. It is **not** a fourth
15//! implementation of every kernel, and nothing here contains numerical code.
16//!
17//! Of the three forms #18 identified, this module implements **form 2,
18//! data-level co-execution**, because that is the one the issue called "the
19//! direct answer to the question and the easiest real speedup". Form 1
20//! (task-level overlap of a sequential preconditioner with GPU SpMV) needs the
21//! wgpu dispatch to be genuinely async — this crate's
22//! [`crate::compute::gpu::GpuContext::dispatch`] blocks on readback — so it is
23//! out of scope here and noted in "Not implemented" below. Form 3
24//! (transfer/compute overlap) is a scheduling detail of form 2 and is likewise
25//! not attempted while dispatch is synchronous.
26//!
27//! # The two caveats #18 required be settled *before* implementation
28//!
29//! Both are settled here, in the API rather than in prose.
30//!
31//! **Mixed precision within one result.** A split batch computes some lanes in
32//! `f64` on the CPU and others in `f32` on the GPU, so two adjacent cells can
33//! differ in accuracy purely by which side of the split they landed on. That
34//! is real and it is not hidden: [`SplitPlan::precision_note`] states it per
35//! call, and [`CoExecution::MatchGpuPrecision`] exists for callers who would
36//! rather have *uniform* `f32`-grade accuracy across the whole array than a
37//! seam in the middle of it. Choosing is the caller's; the default
38//! ([`CoExecution::NativePrecision`]) keeps the CPU half at full `f64`,
39//! because silently degrading correct arithmetic to match a less accurate
40//! path is the worse default.
41//!
42//! **Reproducibility.** The split is **static and deterministic** by default:
43//! [`SplitPlan::for_lanes`] is a pure function of the lane count and the
44//! ratio, so the same input gives the same split, and therefore the same
45//! answer, on every run. #18 recommended precisely this, and warned that a
46//! dynamic work-stealing split reorders reductions and changes the last bits
47//! run to run. Work-stealing is therefore **not** offered here; if it is
48//! wanted later it belongs behind an explicit opt-in whose docs say bitwise
49//! reproducibility is surrendered.
50//!
51//! # Where the default ratio comes from
52//!
53//! [`SplitRatio::Measured`] is not a guess. `examples/hybrid_gpu_report.rs`
54//! swept 4 096 to 4 194 304 lanes on the reference machine and the GPU lost to
55//! `CpuMulti` at **every** size, by 5x to 100x. The honest ratio on that
56//! hardware is therefore **zero GPU lanes**, and that is what
57//! [`SplitRatio::Measured`] resolves to — a co-execution planner that
58//! truthfully reports "do not use the GPU here" is doing its job. A caller on
59//! better hardware supplies [`SplitRatio::Fixed`] from their own run of the
60//! report.
61//!
62//! # Not implemented, deliberately
63//!
64//! - **Task-level concurrency** (#18 form 1) — needs async dispatch.
65//! - **Dynamic work stealing** — surrenders reproducibility; see above.
66//! - **Transfer/compute overlap** (#18 form 3) — subsumed by the above.
67
68use crate::compute::ComputeBackend;
69
70/// How to choose the CPU/GPU lane split.
71#[derive(Debug, Clone, Copy, PartialEq, Default)]
72pub enum SplitRatio {
73    /// Use the ratio measured by `examples/hybrid_gpu_report.rs` on the
74    /// reference hardware.
75    ///
76    /// On that machine the measured answer is **0.0** — the GPU never beat
77    /// `CpuMulti` — so this resolves to a CPU-only plan. That is a real
78    /// measurement, not a placeholder: see the module docs.
79    #[default]
80    Measured,
81    /// A caller-supplied fraction of lanes to send to the GPU, clamped to
82    /// `[0.0, 1.0]`. Take it from your own run of the crossover report rather
83    /// than guessing.
84    Fixed(f64),
85}
86
87/// What precision the CPU half of a split batch should run at.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum CoExecution {
90    /// CPU lanes stay `f64`, GPU lanes are `f32`. Fastest and most accurate
91    /// overall, but the output array is **not uniform in accuracy** — see the
92    /// module docs.
93    #[default]
94    NativePrecision,
95    /// Round the CPU half's results to `f32` as well, so every lane in the
96    /// output has the same accuracy grade.
97    ///
98    /// This makes the array *uniform*, not *better*: it throws away good bits
99    /// on the CPU lanes to remove the seam. Choose it when downstream code
100    /// compares cells against each other and a discontinuity at the split
101    /// point would be mistaken for physics.
102    MatchGpuPrecision,
103}
104
105/// A resolved, deterministic plan for one batch.
106///
107/// Produced by [`SplitPlan::for_lanes`]. Holds no borrows and no lifetimes,
108/// per the workspace rules.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct SplitPlan {
111    /// Total lanes in the batch.
112    pub lanes: usize,
113    /// Lanes `0 .. cpu_lanes` run on the CPU backend.
114    pub cpu_lanes: usize,
115    /// Lanes `cpu_lanes .. lanes` run on the GPU.
116    pub gpu_lanes: usize,
117    /// The CPU backend the plan will actually use, already resolved.
118    pub cpu_backend: ComputeBackend,
119    /// Precision policy for the CPU half.
120    pub co_execution: CoExecution,
121}
122
123impl SplitPlan {
124    /// Build the deterministic plan for `lanes` work items.
125    ///
126    /// # Arguments
127    ///
128    /// - `lanes` — total independent work items, dimensionless.
129    /// - `ratio` — how much to give the GPU; see [`SplitRatio`].
130    /// - `co_execution` — precision policy for the CPU half.
131    ///
132    /// # Returns
133    ///
134    /// A plan whose `cpu_lanes + gpu_lanes == lanes` exactly. When the GPU is
135    /// unavailable — no `gpu` feature, Android, or no adapter — `gpu_lanes` is
136    /// `0` and the whole batch is planned for the CPU; that is the graceful
137    /// degradation contract, not an error.
138    ///
139    /// # Determinism
140    ///
141    /// A pure function of its arguments and the machine's backend
142    /// availability. It performs no timing, samples no load, and consults no
143    /// random source, so two runs on one machine always plan identically.
144    #[must_use]
145    pub fn for_lanes(lanes: usize, ratio: SplitRatio, co_execution: CoExecution) -> Self {
146        let cpu_backend = ComputeBackend::CpuMulti.resolve();
147        let gpu_fraction = if ComputeBackend::Gpu.is_available() {
148            match ratio {
149                // Measured on the reference machine: the GPU never won.
150                SplitRatio::Measured => 0.0,
151                SplitRatio::Fixed(f) => f.clamp(0.0, 1.0),
152            }
153        } else {
154            0.0
155        };
156
157        // Round the GPU share down, so any remainder lane goes to the CPU —
158        // the more accurate side. Deterministic for a given (lanes, fraction).
159        let gpu_lanes = ((lanes as f64) * gpu_fraction).floor() as usize;
160        let gpu_lanes = gpu_lanes.min(lanes);
161        SplitPlan {
162            lanes,
163            cpu_lanes: lanes - gpu_lanes,
164            gpu_lanes,
165            cpu_backend,
166            co_execution,
167        }
168    }
169
170    /// Whether this plan actually uses both sides.
171    ///
172    /// `false` for a CPU-only or GPU-only plan, which is the common case on
173    /// hardware where one side dominates.
174    #[must_use]
175    pub fn is_co_executing(&self) -> bool {
176        self.cpu_lanes > 0 && self.gpu_lanes > 0
177    }
178
179    /// A one-line, caller-facing statement of the precision this plan will
180    /// produce — the per-call form of the mixed-precision caveat #18 required
181    /// be documented rather than discovered.
182    ///
183    /// Intended to be logged or surfaced by anything recording a V&V run, so a
184    /// result set carries the precision story with it.
185    #[must_use]
186    pub fn precision_note(&self) -> &'static str {
187        if !self.is_co_executing() {
188            if self.gpu_lanes > 0 {
189                "all lanes computed on the GPU in f32"
190            } else {
191                "all lanes computed on the CPU in f64"
192            }
193        } else {
194            match self.co_execution {
195                CoExecution::NativePrecision => {
196                    "MIXED PRECISION: leading lanes are f64 (CPU), trailing lanes are f32 (GPU); \
197                     accuracy is not uniform across the output array"
198                }
199                CoExecution::MatchGpuPrecision => {
200                    "uniform f32-grade accuracy: CPU lanes deliberately rounded to f32 to match \
201                     the GPU half"
202                }
203            }
204        }
205    }
206
207    /// Apply the [`CoExecution`] policy to the CPU half of a completed result.
208    ///
209    /// Call this on the CPU-computed lanes before splicing the two halves
210    /// together. Under [`CoExecution::NativePrecision`] it is a no-op; under
211    /// [`CoExecution::MatchGpuPrecision`] it rounds each value through `f32`.
212    pub fn harmonise_cpu_half(&self, cpu_values: &mut [f64]) {
213        if self.co_execution == CoExecution::MatchGpuPrecision {
214            for v in cpu_values.iter_mut() {
215                *v = (*v as f32) as f64;
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn split_is_exhaustive_and_deterministic() {
227        for lanes in [0, 1, 63, 1024, 1_000_003] {
228            let a = SplitPlan::for_lanes(lanes, SplitRatio::Fixed(0.3), CoExecution::default());
229            let b = SplitPlan::for_lanes(lanes, SplitRatio::Fixed(0.3), CoExecution::default());
230            assert_eq!(a, b, "the same inputs must plan identically every time");
231            assert_eq!(
232                a.cpu_lanes + a.gpu_lanes,
233                lanes,
234                "every lane must be assigned exactly once"
235            );
236        }
237    }
238
239    #[test]
240    fn measured_ratio_is_cpu_only_on_this_hardware() {
241        // The reference measurement found the GPU never beat CpuMulti, so the
242        // honest planned share is zero. If a future run of the crossover
243        // report changes that, this test is the thing that must be revisited
244        // deliberately rather than silently.
245        let p = SplitPlan::for_lanes(1 << 20, SplitRatio::Measured, CoExecution::default());
246        assert_eq!(p.gpu_lanes, 0);
247        assert_eq!(p.cpu_lanes, 1 << 20);
248        assert!(!p.is_co_executing());
249    }
250
251    #[test]
252    fn no_gpu_means_every_lane_goes_to_the_cpu() {
253        let p = SplitPlan::for_lanes(1000, SplitRatio::Fixed(0.9), CoExecution::default());
254        if !ComputeBackend::Gpu.is_available() {
255            assert_eq!(p.gpu_lanes, 0, "no adapter must degrade to a CPU-only plan");
256            assert_eq!(p.cpu_lanes, 1000);
257        } else {
258            assert_eq!(p.gpu_lanes, 900);
259            assert_eq!(p.cpu_lanes, 100);
260        }
261    }
262
263    #[test]
264    fn ratio_is_clamped_not_wrapped() {
265        let hi = SplitPlan::for_lanes(100, SplitRatio::Fixed(5.0), CoExecution::default());
266        let lo = SplitPlan::for_lanes(100, SplitRatio::Fixed(-2.0), CoExecution::default());
267        assert!(hi.gpu_lanes <= 100);
268        assert_eq!(lo.gpu_lanes, 0);
269        assert_eq!(lo.cpu_lanes, 100);
270    }
271
272    #[test]
273    fn remainder_lane_goes_to_the_more_accurate_side() {
274        // 10 lanes at 0.35 -> floor(3.5) = 3 GPU, 7 CPU. The half-lane is
275        // resolved toward f64, never away from it.
276        let p = SplitPlan::for_lanes(10, SplitRatio::Fixed(0.35), CoExecution::default());
277        if ComputeBackend::Gpu.is_available() {
278            assert_eq!(p.gpu_lanes, 3);
279            assert_eq!(p.cpu_lanes, 7);
280        }
281    }
282
283    #[test]
284    fn precision_note_names_the_mixed_case() {
285        let cpu_only = SplitPlan::for_lanes(100, SplitRatio::Fixed(0.0), CoExecution::default());
286        assert!(cpu_only.precision_note().contains("f64"));
287
288        // Construct the co-executing case directly so the assertion holds on a
289        // machine with no adapter too.
290        let mixed = SplitPlan {
291            lanes: 100,
292            cpu_lanes: 50,
293            gpu_lanes: 50,
294            cpu_backend: ComputeBackend::Serial,
295            co_execution: CoExecution::NativePrecision,
296        };
297        assert!(
298            mixed.precision_note().contains("MIXED PRECISION"),
299            "the mixed-precision caveat must be stated, not implied"
300        );
301
302        let matched = SplitPlan {
303            co_execution: CoExecution::MatchGpuPrecision,
304            ..mixed
305        };
306        assert!(matched.precision_note().contains("uniform f32"));
307    }
308
309    #[test]
310    fn harmonise_rounds_only_when_asked() {
311        let original = std::f64::consts::PI;
312        let mixed = SplitPlan {
313            lanes: 2,
314            cpu_lanes: 1,
315            gpu_lanes: 1,
316            cpu_backend: ComputeBackend::Serial,
317            co_execution: CoExecution::NativePrecision,
318        };
319        let mut v = [original];
320        mixed.harmonise_cpu_half(&mut v);
321        assert_eq!(v[0], original, "NativePrecision must not touch the values");
322
323        let matched = SplitPlan {
324            co_execution: CoExecution::MatchGpuPrecision,
325            ..mixed
326        };
327        let mut v = [original];
328        matched.harmonise_cpu_half(&mut v);
329        assert_eq!(v[0], (original as f32) as f64);
330        assert_ne!(v[0], original, "MatchGpuPrecision must actually round");
331    }
332}