Skip to main content

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}