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}