oxigdal_gpu/cpu_fallback.rs
1//! Automatic CPU fallback for GPU operations.
2//!
3//! This module provides a generic fallback system that transparently routes
4//! GPU operations to CPU implementations when GPU errors such as device loss
5//! or backend unavailability are encountered.
6//!
7//! # Design
8//!
9//! The core abstraction is [`execute_with_fallback`], a free function that
10//! accepts a GPU closure and a CPU closure. When the GPU closure returns an
11//! error whose kind matches the active [`FallbackConfig`], the CPU closure is
12//! invoked instead and its result is returned as if it had come from the GPU.
13//!
14//! [`FallbackContext`] wraps a [`GpuContext`] together with a `FallbackConfig`
15//! and additionally checks the device-lost flag *before* even attempting the
16//! GPU closure, avoiding redundant error generation on a known-dead device.
17//!
18//! # CPU primitive operations
19//!
20//! The [`cpu`] sub-module contains pure-Rust implementations of common raster
21//! element-wise operations used as CPU fallback kernels.
22
23use std::sync::Arc;
24
25use crate::{GpuContext, GpuError, GpuResult};
26
27// ---------------------------------------------------------------------------
28// FallbackConfig
29// ---------------------------------------------------------------------------
30
31/// Configuration that controls when and how CPU fallback is activated.
32///
33/// By default fallback is enabled and will trigger on [`GpuError::DeviceLost`]
34/// and on "no backend" adapter-not-found errors.
35#[derive(Debug, Clone)]
36pub struct FallbackConfig {
37 /// Whether automatic CPU fallback is enabled at all.
38 ///
39 /// When `false`, GPU errors are always propagated and `cpu_op` is never
40 /// called.
41 pub enabled: bool,
42
43 /// Trigger fallback when the GPU error is [`GpuError::DeviceLost`].
44 pub trigger_on_device_lost: bool,
45
46 /// Trigger fallback when the GPU error indicates no adapter / no backend
47 /// is available (e.g. [`GpuError::NoAdapter`] or
48 /// [`GpuError::BackendNotAvailable`]).
49 pub trigger_on_no_backend: bool,
50
51 /// If `Some(ms)`, GPU operations that exceed this wall-clock duration will
52 /// fall back to CPU. The timeout is implemented at the *caller* level via
53 /// [`execute_with_fallback_timed`]; the free function
54 /// [`execute_with_fallback`] ignores this field.
55 pub timeout_ms: Option<u64>,
56
57 /// Emit a diagnostic message to `stderr` when falling back to CPU.
58 pub log_on_fallback: bool,
59}
60
61impl Default for FallbackConfig {
62 fn default() -> Self {
63 Self {
64 enabled: true,
65 trigger_on_device_lost: true,
66 trigger_on_no_backend: true,
67 timeout_ms: None,
68 log_on_fallback: false,
69 }
70 }
71}
72
73impl FallbackConfig {
74 /// Create a configuration with fallback disabled entirely.
75 ///
76 /// GPU errors are always propagated; the CPU closure is never called.
77 pub fn disabled() -> Self {
78 Self {
79 enabled: false,
80 ..Default::default()
81 }
82 }
83
84 /// Create a configuration with verbose logging enabled.
85 pub fn with_logging(mut self) -> Self {
86 self.log_on_fallback = true;
87 self
88 }
89
90 /// Set an optional timeout in milliseconds.
91 pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
92 self.timeout_ms = Some(timeout_ms);
93 self
94 }
95
96 /// Decide whether `err` should trigger a fallback to CPU.
97 ///
98 /// Returns `false` whenever `self.enabled == false`, regardless of the
99 /// error kind.
100 pub fn should_fallback(&self, err: &GpuError) -> bool {
101 if !self.enabled {
102 return false;
103 }
104
105 // Device-lost family
106 if self.trigger_on_device_lost && matches!(err, GpuError::DeviceLost { .. }) {
107 return true;
108 }
109
110 // No-adapter / no-backend family — covers both the structured variants
111 // and any message-level descriptions produced by older code paths.
112 if self.trigger_on_no_backend {
113 let is_no_adapter = matches!(err, GpuError::NoAdapter { .. });
114 let is_backend_na = matches!(err, GpuError::BackendNotAvailable { .. });
115 // String-based heuristic for error messages that don't map to a
116 // single variant (e.g. wgpu internal messages surfaced as Internal).
117 let msg = err.to_string();
118 let is_msg_match = msg.contains("No wgpu backend")
119 || msg.contains("no backend")
120 || msg.contains("no adapter");
121 if is_no_adapter || is_backend_na || is_msg_match {
122 return true;
123 }
124 }
125
126 false
127 }
128}
129
130// ---------------------------------------------------------------------------
131// ExecutionPath + FallbackResult
132// ---------------------------------------------------------------------------
133
134/// Which execution path produced the result.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub enum ExecutionPath {
137 /// The GPU operation completed successfully.
138 Gpu,
139 /// The CPU fallback was used (GPU was unavailable or returned a
140 /// fallback-triggering error).
141 Cpu,
142}
143
144/// The output of a fallback-aware operation, bundling the value with the
145/// execution path that produced it.
146#[derive(Debug)]
147pub struct FallbackResult<T> {
148 /// The computed value.
149 pub value: T,
150 /// Which path (GPU or CPU) produced the value.
151 pub path: ExecutionPath,
152}
153
154impl<T: Clone> Clone for FallbackResult<T> {
155 fn clone(&self) -> Self {
156 Self {
157 value: self.value.clone(),
158 path: self.path,
159 }
160 }
161}
162
163// ---------------------------------------------------------------------------
164// Core free functions
165// ---------------------------------------------------------------------------
166
167/// Execute `gpu_op` and, if it returns a fallback-triggering error, run
168/// `cpu_op` instead.
169///
170/// # Behaviour
171///
172/// | GPU result | `config.enabled` | Action |
173/// |---|---|---|
174/// | `Ok(v)` | any | return `FallbackResult { value: v, path: Gpu }` |
175/// | fallback-triggering error | `true` | invoke `cpu_op`, return `Cpu` path |
176/// | fallback-triggering error | `false` | propagate the GPU error |
177/// | non-fallback error | any | propagate the GPU error unchanged |
178///
179/// # Examples
180///
181/// ```rust
182/// use oxigdal_gpu::cpu_fallback::{FallbackConfig, ExecutionPath, execute_with_fallback};
183/// use oxigdal_gpu::GpuError;
184///
185/// let cfg = FallbackConfig::default();
186/// let result = execute_with_fallback(
187/// &cfg,
188/// || Err::<u32, GpuError>(GpuError::device_lost("test")),
189/// || 42_u32,
190/// ).unwrap();
191///
192/// assert_eq!(result.value, 42);
193/// assert_eq!(result.path, ExecutionPath::Cpu);
194/// ```
195pub fn execute_with_fallback<T, GpuFn, CpuFn>(
196 config: &FallbackConfig,
197 gpu_op: GpuFn,
198 cpu_op: CpuFn,
199) -> GpuResult<FallbackResult<T>>
200where
201 GpuFn: FnOnce() -> GpuResult<T>,
202 CpuFn: FnOnce() -> T,
203{
204 match gpu_op() {
205 Ok(value) => Ok(FallbackResult {
206 value,
207 path: ExecutionPath::Gpu,
208 }),
209 Err(err) if config.should_fallback(&err) => {
210 if config.log_on_fallback {
211 eprintln!("[oxigdal-gpu] GPU op failed ({err}), falling back to CPU");
212 }
213 Ok(FallbackResult {
214 value: cpu_op(),
215 path: ExecutionPath::Cpu,
216 })
217 }
218 Err(err) => Err(err),
219 }
220}
221
222/// Like [`execute_with_fallback`], but additionally checks whether the GPU op
223/// takes longer than `config.timeout_ms` milliseconds. If the timeout fires
224/// before the GPU closure returns, `cpu_op` is called and `ExecutionPath::Cpu`
225/// is reported.
226///
227/// When `config.timeout_ms` is `None` this function behaves identically to
228/// [`execute_with_fallback`].
229///
230/// # Note
231///
232/// The GPU closure is executed on a background thread and its result is
233/// forwarded over a channel. A `recv_timeout` call on the receiving end
234/// implements the deadline. If the deadline fires the CPU closure is called
235/// on the calling thread; the background GPU thread continues to completion
236/// and its result is silently discarded.
237///
238/// The GPU closure must be `Send + 'static` so it can be moved into the
239/// background thread.
240pub fn execute_with_fallback_timed<T, GpuFn, CpuFn>(
241 config: &FallbackConfig,
242 gpu_op: GpuFn,
243 cpu_op: CpuFn,
244) -> GpuResult<FallbackResult<T>>
245where
246 T: Send + 'static,
247 GpuFn: FnOnce() -> GpuResult<T> + Send + 'static,
248 CpuFn: FnOnce() -> T,
249{
250 let timeout = match config.timeout_ms {
251 None => return execute_with_fallback(config, gpu_op, cpu_op),
252 Some(ms) => std::time::Duration::from_millis(ms),
253 };
254
255 let (tx, rx) = std::sync::mpsc::channel::<GpuResult<T>>();
256 std::thread::spawn(move || {
257 // Ignore send errors — the receiver may have timed out and dropped.
258 let _ = tx.send(gpu_op());
259 });
260
261 match rx.recv_timeout(timeout) {
262 Ok(Ok(value)) => Ok(FallbackResult {
263 value,
264 path: ExecutionPath::Gpu,
265 }),
266 Ok(Err(err)) if config.should_fallback(&err) => {
267 if config.log_on_fallback {
268 eprintln!("[oxigdal-gpu] GPU op failed ({err}), falling back to CPU");
269 }
270 Ok(FallbackResult {
271 value: cpu_op(),
272 path: ExecutionPath::Cpu,
273 })
274 }
275 Ok(Err(err)) => Err(err),
276 // Timeout or sender dropped — fall back to CPU.
277 Err(_recv_err) => {
278 if config.log_on_fallback {
279 eprintln!(
280 "[oxigdal-gpu] GPU op timed out after {}ms, falling back to CPU",
281 timeout.as_millis()
282 );
283 }
284 Ok(FallbackResult {
285 value: cpu_op(),
286 path: ExecutionPath::Cpu,
287 })
288 }
289 }
290}
291
292// ---------------------------------------------------------------------------
293// FallbackContext
294// ---------------------------------------------------------------------------
295
296/// A [`GpuContext`] wrapper that carries a [`FallbackConfig`] and provides
297/// fallback-aware operation dispatch.
298///
299/// Before invoking the GPU closure, `FallbackContext` checks the device-lost
300/// flag. If the device is already known to be lost, it routes directly to the
301/// CPU closure without attempting any GPU work.
302///
303/// # Example
304///
305/// ```rust,no_run
306/// use oxigdal_gpu::{GpuContext, GpuError};
307/// use oxigdal_gpu::cpu_fallback::{FallbackConfig, FallbackContext, ExecutionPath};
308///
309/// # async fn example() -> Result<(), GpuError> {
310/// let gpu = GpuContext::new().await?;
311/// let fb = FallbackContext::with_default_config(gpu);
312///
313/// let result = fb.execute(
314/// || Ok::<u32, GpuError>(1_u32),
315/// || 2_u32,
316/// )?;
317///
318/// assert_eq!(result.path, ExecutionPath::Gpu);
319/// # Ok(())
320/// # }
321/// ```
322pub struct FallbackContext {
323 inner: Arc<GpuContext>,
324 config: FallbackConfig,
325}
326
327impl FallbackContext {
328 /// Create a new `FallbackContext` with the supplied configuration.
329 pub fn new(ctx: GpuContext, config: FallbackConfig) -> Self {
330 Self {
331 inner: Arc::new(ctx),
332 config,
333 }
334 }
335
336 /// Create a `FallbackContext` with the default [`FallbackConfig`].
337 pub fn with_default_config(ctx: GpuContext) -> Self {
338 Self::new(ctx, FallbackConfig::default())
339 }
340
341 /// Return a reference to the underlying [`GpuContext`].
342 pub fn gpu_context(&self) -> &GpuContext {
343 &self.inner
344 }
345
346 /// Return a reference to the active [`FallbackConfig`].
347 pub fn fallback_config(&self) -> &FallbackConfig {
348 &self.config
349 }
350
351 /// Return `true` if the GPU device has been lost.
352 pub fn is_device_lost(&self) -> bool {
353 self.inner.is_device_lost()
354 }
355
356 /// Execute `gpu_op` with automatic CPU fallback per the configured policy.
357 ///
358 /// If the device-lost flag is already set, the GPU closure is skipped
359 /// entirely and `cpu_op` is called directly (provided fallback is enabled).
360 /// Otherwise the behaviour mirrors [`execute_with_fallback`].
361 pub fn execute<T, GpuFn, CpuFn>(
362 &self,
363 gpu_op: GpuFn,
364 cpu_op: CpuFn,
365 ) -> GpuResult<FallbackResult<T>>
366 where
367 GpuFn: FnOnce() -> GpuResult<T>,
368 CpuFn: FnOnce() -> T,
369 {
370 if self.inner.is_device_lost() {
371 if self.config.enabled {
372 if self.config.log_on_fallback {
373 eprintln!("[oxigdal-gpu] Device already lost, running CPU directly");
374 }
375 return Ok(FallbackResult {
376 value: cpu_op(),
377 path: ExecutionPath::Cpu,
378 });
379 }
380 return Err(GpuError::device_lost("device was lost"));
381 }
382 execute_with_fallback(&self.config, gpu_op, cpu_op)
383 }
384}
385
386impl std::fmt::Debug for FallbackContext {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 f.debug_struct("FallbackContext")
389 .field("config", &self.config)
390 .field("device_lost", &self.inner.is_device_lost())
391 .finish()
392 }
393}
394
395// ---------------------------------------------------------------------------
396// CPU fallback primitive operations
397// ---------------------------------------------------------------------------
398
399/// Pure-Rust CPU implementations of common raster element-wise operations.
400///
401/// These are used as CPU fallback kernels when the GPU is unavailable.
402/// All functions operate on `&[f32]` slices and return owned `Vec<f32>`.
403///
404/// When the input slices have different lengths the shorter slice determines
405/// how many elements are processed (same semantics as `Iterator::zip`).
406pub mod cpu {
407 /// Element-wise addition: `result[i] = a[i] + b[i]`.
408 pub fn add_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
409 a.iter().zip(b.iter()).map(|(x, y)| x + y).collect()
410 }
411
412 /// Element-wise subtraction: `result[i] = a[i] - b[i]`.
413 pub fn sub_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
414 a.iter().zip(b.iter()).map(|(x, y)| x - y).collect()
415 }
416
417 /// Element-wise multiplication: `result[i] = a[i] * b[i]`.
418 pub fn mul_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
419 a.iter().zip(b.iter()).map(|(x, y)| x * y).collect()
420 }
421
422 /// Element-wise division: `result[i] = a[i] / b[i]`.
423 ///
424 /// Division by zero follows IEEE 754 semantics (produces `±inf` or `NaN`).
425 pub fn div_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
426 a.iter().zip(b.iter()).map(|(x, y)| x / y).collect()
427 }
428
429 /// Element-wise maximum: `result[i] = a[i].max(b[i])`.
430 pub fn max_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
431 a.iter().zip(b.iter()).map(|(x, y)| x.max(*y)).collect()
432 }
433
434 /// Element-wise minimum: `result[i] = a[i].min(b[i])`.
435 pub fn min_slices(a: &[f32], b: &[f32]) -> Vec<f32> {
436 a.iter().zip(b.iter()).map(|(x, y)| x.min(*y)).collect()
437 }
438
439 /// Scalar addition: `result[i] = data[i] + scalar`.
440 pub fn add_scalar(data: &[f32], scalar: f32) -> Vec<f32> {
441 data.iter().map(|x| x + scalar).collect()
442 }
443
444 /// Scalar subtraction: `result[i] = data[i] - scalar`.
445 pub fn sub_scalar(data: &[f32], scalar: f32) -> Vec<f32> {
446 data.iter().map(|x| x - scalar).collect()
447 }
448
449 /// Scalar multiplication: `result[i] = data[i] * scalar`.
450 pub fn mul_scalar(data: &[f32], scalar: f32) -> Vec<f32> {
451 data.iter().map(|x| x * scalar).collect()
452 }
453
454 /// Scalar division: `result[i] = data[i] / scalar`.
455 ///
456 /// Division by zero follows IEEE 754 semantics.
457 pub fn div_scalar(data: &[f32], scalar: f32) -> Vec<f32> {
458 data.iter().map(|x| x / scalar).collect()
459 }
460
461 /// Clamp all elements to `[lo, hi]`: `result[i] = data[i].clamp(lo, hi)`.
462 pub fn clamp(data: &[f32], lo: f32, hi: f32) -> Vec<f32> {
463 data.iter().map(|x| x.clamp(lo, hi)).collect()
464 }
465
466 /// Absolute value: `result[i] = data[i].abs()`.
467 pub fn abs(data: &[f32]) -> Vec<f32> {
468 data.iter().map(|x| x.abs()).collect()
469 }
470
471 /// Square root: `result[i] = data[i].sqrt()`.
472 pub fn sqrt(data: &[f32]) -> Vec<f32> {
473 data.iter().map(|x| x.sqrt()).collect()
474 }
475
476 /// Power: `result[i] = data[i].powf(exponent)`.
477 pub fn powf(data: &[f32], exponent: f32) -> Vec<f32> {
478 data.iter().map(|x| x.powf(exponent)).collect()
479 }
480
481 // -----------------------------------------------------------------------
482 // Reductions
483 // -----------------------------------------------------------------------
484
485 /// Arithmetic mean of all elements. Returns `0.0` for empty slices.
486 pub fn mean(data: &[f32]) -> f32 {
487 if data.is_empty() {
488 return 0.0;
489 }
490 data.iter().sum::<f32>() / data.len() as f32
491 }
492
493 /// Sum of all elements. Returns `0.0` for empty slices.
494 pub fn sum(data: &[f32]) -> f32 {
495 data.iter().sum()
496 }
497
498 /// Minimum element value. Returns [`f32::MAX`] for empty slices.
499 pub fn min_value(data: &[f32]) -> f32 {
500 data.iter().cloned().fold(f32::MAX, f32::min)
501 }
502
503 /// Maximum element value. Returns [`f32::MIN`] for empty slices.
504 pub fn max_value(data: &[f32]) -> f32 {
505 data.iter().cloned().fold(f32::MIN, f32::max)
506 }
507
508 /// Population variance of all elements. Returns `0.0` for slices with
509 /// fewer than two elements.
510 pub fn variance(data: &[f32]) -> f32 {
511 if data.len() < 2 {
512 return 0.0;
513 }
514 let m = mean(data);
515 data.iter().map(|x| (x - m) * (x - m)).sum::<f32>() / data.len() as f32
516 }
517
518 /// Population standard deviation. Returns `0.0` for slices with fewer
519 /// than two elements.
520 pub fn std_dev(data: &[f32]) -> f32 {
521 variance(data).sqrt()
522 }
523
524 // -----------------------------------------------------------------------
525 // NDVI helper
526 // -----------------------------------------------------------------------
527
528 /// Normalised Difference Vegetation Index: `(nir - red) / (nir + red)`.
529 ///
530 /// Pixels where `nir + red == 0` produce `0.0` rather than `NaN` /
531 /// infinity, matching the common no-data convention.
532 pub fn ndvi(red: &[f32], nir: &[f32]) -> Vec<f32> {
533 red.iter()
534 .zip(nir.iter())
535 .map(|(r, n)| {
536 let denom = n + r;
537 if denom == 0.0 { 0.0 } else { (n - r) / denom }
538 })
539 .collect()
540 }
541}