Skip to main content

kopitiam_gpu/
executor.rs

1//! The cascade: try the GPU, fall back to the CPU, always return an answer.
2//!
3//! This mirrors KOPITIAM's Offline-First pipeline (existing knowledge -> native
4//! Rust -> local AI -> cloud AI). Here the same shape applies to compute:
5//!
6//! ```text
7//!     GPU (wgpu compute shader)   -- fast path, used when a GPU is present
8//!         |  Err / no adapter
9//!         v
10//!     CPU (pure Rust)             -- correct path, always available
11//! ```
12//!
13//! Two moving parts:
14//!
15//! * [`ComputeOp`] — an operation that knows how to do itself BOTH ways: a GPU
16//!   implementation (fallible — the GPU can be busy, out of memory, or absent)
17//!   and a CPU implementation (pure Rust, infallible — it is the floor the
18//!   cascade lands on).
19//! * [`Executor`] — holds the cached [`GpuContext`] (or `None` if this machine
20//!   has no GPU) and runs the cascade: GPU first, CPU on any failure.
21//!
22//! Because the CPU path is a real, correct implementation and not a stub, the
23//! guarantee is total: `Executor::run` returns the right answer on every
24//! machine, GPU or not. On a no-GPU machine it simply never touches wgpu.
25
26use crate::context::GpuContext;
27
28/// An operation implemented on BOTH the GPU and the CPU.
29///
30/// Implementors provide the two paths; the [`Executor`] chooses between them.
31/// Keep `Input`/`Output` on plain Rust types (`&[f32]`, `Vec<f32>`, ...) so an
32/// op is usable without pulling in wgpu types at the call site.
33///
34/// Contract the two paths MUST honour: for the same input, `compute_gpu` (when
35/// it succeeds) and `compute_cpu` must produce the **same result** — same length
36/// and, for floats, bit-for-bit equal for exactly-representable arithmetic like
37/// elementwise add. The test suite asserts exactly this whenever a GPU is
38/// present. If a kernel is only approximately equal to its CPU twin (e.g. a
39/// fast-math reduction), say so in that op's docs and loosen its test to a
40/// tolerance — do not weaken this trait's default expectation silently.
41pub trait ComputeOp {
42    /// The operation's input, borrowed. A GAT (`Input<'a>`) so the input can
43    /// hold borrows (`&[f32]`) without forcing them to be `'static` — the whole
44    /// reason a plain associated type wouldn't do here. Both paths take it by
45    /// shared reference, so the cascade can hand the SAME input to the GPU path
46    /// and then, on failure, to the CPU path.
47    type Input<'a>;
48    /// The operation's output.
49    type Output;
50
51    /// Run on the GPU. Fallible: returns `Err` if the GPU path could not
52    /// complete for ANY reason (shader/pipeline error, buffer map failure,
53    /// device lost). A returned `Err` is the cascade's signal to fall back to
54    /// CPU — it must never be a panic.
55    fn compute_gpu(
56        &self,
57        ctx: &GpuContext,
58        input: &Self::Input<'_>,
59    ) -> Result<Self::Output, GpuOpError>;
60
61    /// Run on the CPU in pure Rust. Infallible and always correct — this is the
62    /// floor the cascade lands on, so it must be a real implementation.
63    fn compute_cpu(&self, input: &Self::Input<'_>) -> Self::Output;
64}
65
66/// A GPU path failed to complete. The cascade treats ANY of these as "fall back
67/// to CPU", so the variants exist for logging/diagnosis, not for the caller to
68/// recover differently per case.
69#[derive(Debug, thiserror::Error)]
70pub enum GpuOpError {
71    /// A wgpu-level failure (buffer mapping, device poll, validation). String
72    /// because the underlying wgpu error types are not uniform or `'static`.
73    #[error("GPU compute failed: {0}")]
74    Backend(String),
75
76    /// The op's own precondition was violated in a way the GPU path can't run
77    /// (e.g. mismatched input lengths). Kept distinct so a genuine caller bug is
78    /// not silently masked by the CPU fallback in logs.
79    #[error("invalid input for GPU op: {0}")]
80    InvalidInput(String),
81}
82
83/// Runs [`ComputeOp`]s through the GPU->CPU cascade.
84///
85/// Build ONE and reuse it: it probes the GPU exactly once at construction and
86/// caches the handle. A program that makes an `Executor` per call pays the
87/// adapter-enumeration cost every time and defeats the caching.
88#[derive(Debug, Default)]
89pub struct Executor {
90    /// The cached GPU handle, or `None` if this machine has no usable GPU. Once
91    /// `None`, the cascade skips straight to CPU for every op — no repeated
92    /// probing, no repeated failures.
93    ctx: Option<GpuContext>,
94}
95
96impl Executor {
97    /// Build an executor, probing for a GPU once.
98    ///
99    /// Never fails: a missing GPU is not an error here, it just means every op
100    /// will take the CPU path. Use [`Executor::has_gpu`] if you need to know
101    /// which way the cascade will go.
102    pub fn new() -> Self {
103        // `.ok()` collapses "no GPU" into `None`; that is the whole graceful-
104        // degradation move. We deliberately discard the GpuUnavailable reason
105        // here — callers that care can call GpuContext::new() themselves.
106        let ctx = GpuContext::new().ok();
107        if ctx.is_none() {
108            // The GPU-present counterpart is logged inside GpuContext::new. This
109            // is the line the maintainer looks for on the tablet to confirm we
110            // ran on CPU (no reachable Vulkan/GL adapter), not the GPU. stderr,
111            // so it never mixes into a command's stdout.
112            eprintln!("[kopitiam-gpu] CPU fallback (no GPU adapter)");
113        }
114        Self { ctx }
115    }
116
117    /// Build an executor that is FORCED onto the CPU path, ignoring any GPU.
118    ///
119    /// This is what makes the CPU fallback testable on a machine that *does*
120    /// have a GPU: with no adapter to borrow, `run` can only cascade to CPU.
121    pub fn cpu_only() -> Self {
122        Self { ctx: None }
123    }
124
125    /// Does this executor have a GPU to use? `false` means every `run` lands on
126    /// CPU. Handy for tests (skip the GPU==CPU assertion when there is no GPU)
127    /// and for logging which path a workload will take.
128    pub fn has_gpu(&self) -> bool {
129        self.ctx.is_some()
130    }
131
132    /// The cached GPU context, if any. Lets a caller run the GPU path directly
133    /// (e.g. a test asserting GPU==CPU) without going through the cascade.
134    pub fn gpu_context(&self) -> Option<&GpuContext> {
135        self.ctx.as_ref()
136    }
137
138    /// Run an op through the cascade: **GPU first, CPU on any failure.**
139    ///
140    /// This is the one method callers normally use. It always returns a result:
141    ///
142    /// * GPU present and its path succeeds -> the GPU result.
143    /// * GPU present but its path returns `Err` -> the CPU result (logged as a
144    ///   fall-back at `warn`... once tracing is wired; today the `Err` is simply
145    ///   swallowed into the CPU path).
146    /// * no GPU at all -> straight to the CPU result.
147    ///
148    /// The cascade itself is the small `?`/`unwrap_or_else` dance below: build a
149    /// `Result` for the GPU attempt (short-circuiting to `Err` if there is no
150    /// context), then `unwrap_or_else` onto the infallible CPU path.
151    pub fn run<O>(&self, op: &O, input: &O::Input<'_>) -> O::Output
152    where
153        O: ComputeOp,
154    {
155        self.try_gpu(op, input)
156            .unwrap_or_else(|_| op.compute_cpu(input))
157    }
158
159    /// The GPU leg of the cascade as a single `Result`.
160    ///
161    /// `ok_or(...)?` turns "no cached context" into the same `Err` channel as a
162    /// failed kernel, so [`run`](Self::run)'s `unwrap_or_else` handles both the
163    /// no-GPU and the GPU-failed cases with one branch.
164    fn try_gpu<O>(&self, op: &O, input: &O::Input<'_>) -> Result<O::Output, GpuOpError>
165    where
166        O: ComputeOp,
167    {
168        let ctx = self
169            .ctx
170            .as_ref()
171            .ok_or_else(|| GpuOpError::Backend("no GPU context on this machine".into()))?;
172        op.compute_gpu(ctx, input)
173    }
174}