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}