qualia_core_db/platform/compute_bridge/backend.rs
1//! The open backend registry (HARDWARE_BACKEND_AUTOSELECT_PLAN.md §2.2, §4).
2//!
3//! The bridge's expansion point: backends are **not** a closed enum. Each
4//! acceleration method (CPU, the wgpu backends, later CUDA / ROCm / oneAPI / NPU
5//! runtimes) is one `impl ProbeableBackend` registered into a [`BackendRegistry`].
6//! The benchmark loop, the ranking, the passport schema and `ComputePolicy::select`
7//! all iterate the registry — so **adding a backend is one `register()` call and
8//! never edits the decision tree** (the load-bearing requirement). A backend that
9//! is not `available()` on this machine simply contributes no rows.
10//!
11//! `BackendId` is a `Copy` `&'static str` so it is zero-heap to pass around and
12//! string-keyed in the passport (forward-compatible: a passport written by a core
13//! build is still readable by an expansion build — the new backend is just absent
14//! and gets probed on next boot).
15
16use super::kernel_class::KernelClass;
17use crate::device_benchmark::CircuitBench;
18
19/// Stable, string-keyed backend identifier. `Copy`, zero-heap. Built-ins:
20/// `"cpu"`, `"wgpu"` (which itself reports per-adapter circuits via wgpu's
21/// `Vulkan`/`Dx12`/`Metal`/`Gl`); expansion ids: `"cuda"`, `"rocm"`, `"oneapi"`,
22/// `"npu-directml"`, …
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct BackendId(pub &'static str);
25
26impl BackendId {
27 pub const CPU: BackendId = BackendId("cpu");
28 pub const WGPU: BackendId = BackendId("wgpu");
29
30 pub fn as_str(self) -> &'static str {
31 self.0
32 }
33}
34
35impl core::fmt::Display for BackendId {
36 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
37 f.write_str(self.0)
38 }
39}
40
41/// Why a dispatch could not run on the requested backend. The dispatcher must
42/// degrade to CPU on any of these, never panic (plan §7: CPU never hard-fails).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DispatchError {
45 /// The backend exists but cannot run this kernel class.
46 UnsupportedClass(KernelClass),
47 /// The backend's runtime/SDK is not present on this host.
48 Unavailable(BackendId),
49 /// A backend-internal failure (driver, allocation, …) — caller falls back to CPU.
50 BackendFailure(String),
51}
52
53impl core::fmt::Display for DispatchError {
54 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55 match self {
56 DispatchError::UnsupportedClass(c) => {
57 write!(f, "backend cannot run kernel class {}", c.label())
58 }
59 DispatchError::Unavailable(b) => write!(f, "backend {b} is not available on this host"),
60 DispatchError::BackendFailure(m) => write!(f, "backend failure: {m}"),
61 }
62 }
63}
64impl std::error::Error for DispatchError {}
65
66/// Per-class problem sizes for the measurement panel. Kept small; cached in the
67/// passport so the heavy pass runs once per machine (plan §3 cost guard). `quick()`
68/// shrinks the sizes for low-tier devices / fast boot.
69#[derive(Debug, Clone, Copy)]
70pub struct KernelPanel {
71 /// GEMV/GEMM side length (DenseLinear).
72 pub dense_n: usize,
73 /// Vector length for ElementwiseMap / Reduction / Scan.
74 pub vector_len: usize,
75 /// Grid length for the 1-D Stencil pass.
76 pub grid_n: usize,
77 /// Particle count for the AllPairs (N-body) pass.
78 pub nbody_n: usize,
79 /// Transform length for the FFT (must be a power of two).
80 pub fft_n: usize,
81 /// Monte-Carlo steps for the Divergent pass.
82 pub mc_steps: usize,
83}
84
85impl Default for KernelPanel {
86 fn default() -> Self {
87 Self {
88 dense_n: 1024,
89 vector_len: 1 << 20,
90 grid_n: 1 << 20,
91 nbody_n: 2048,
92 fft_n: 1 << 16,
93 mc_steps: 1 << 20,
94 }
95 }
96}
97
98impl KernelPanel {
99 /// Smaller panel for fast boot / Tier-0 devices (plan §3 `--quick`).
100 pub fn quick() -> Self {
101 Self {
102 dense_n: 256,
103 vector_len: 1 << 16,
104 grid_n: 1 << 16,
105 nbody_n: 512,
106 fft_n: 1 << 12,
107 mc_steps: 1 << 16,
108 }
109 }
110}
111
112/// One acceleration method. Implementors register into a [`BackendRegistry`]; the
113/// rest of the bridge only ever sees them through this trait, which is why adding a
114/// backend never edits `select()`.
115pub trait ProbeableBackend: Send + Sync {
116 /// Stable identifier (`"cpu"`, `"wgpu"`, `"cuda"`, …).
117 fn id(&self) -> BackendId;
118
119 /// Is this backend's runtime/SDK present and usable on THIS machine? A `false`
120 /// backend contributes no rows and is never selected.
121 fn available(&self) -> bool;
122
123 /// Measure this backend on one kernel class, returning a row per physical
124 /// circuit it can drive (e.g. wgpu returns one row per adapter). Empty when the
125 /// backend cannot run the class or is unavailable — recorded honestly as "no
126 /// rows," never a fabricated number.
127 fn probe_class(&self, class: KernelClass, panel: &KernelPanel) -> Vec<CircuitBench>;
128}
129
130/// The registry of acceleration methods. Heap-using and boot-time only (the heavy
131/// probe runs once and is cached in the passport — never on a hot path).
132#[derive(Default)]
133pub struct BackendRegistry {
134 backends: Vec<Box<dyn ProbeableBackend>>,
135}
136
137impl BackendRegistry {
138 pub fn new() -> Self {
139 Self {
140 backends: Vec::new(),
141 }
142 }
143
144 /// Register a backend. Adding one here is the *only* change needed to bring a
145 /// new acceleration method into the benchmark, ranking and policy.
146 pub fn register(&mut self, backend: Box<dyn ProbeableBackend>) -> &mut Self {
147 self.backends.push(backend);
148 self
149 }
150
151 /// All registered backends (including currently-unavailable ones).
152 pub fn iter(&self) -> impl Iterator<Item = &dyn ProbeableBackend> {
153 self.backends.iter().map(|b| b.as_ref())
154 }
155
156 /// Backends actually usable on this host.
157 pub fn available(&self) -> impl Iterator<Item = &dyn ProbeableBackend> {
158 self.backends
159 .iter()
160 .map(|b| b.as_ref())
161 .filter(|b| b.available())
162 }
163
164 pub fn len(&self) -> usize {
165 self.backends.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
169 self.backends.is_empty()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::device_benchmark::CircuitKind;
177
178 /// A synthetic backend proving the registry is open: it registers and is iterated
179 /// without any change to the registry, panel, matrix, or policy code.
180 struct StubBackend {
181 id: BackendId,
182 up: bool,
183 }
184 impl ProbeableBackend for StubBackend {
185 fn id(&self) -> BackendId {
186 self.id
187 }
188 fn available(&self) -> bool {
189 self.up
190 }
191 fn probe_class(&self, _class: KernelClass, _panel: &KernelPanel) -> Vec<CircuitBench> {
192 if !self.up {
193 return Vec::new();
194 }
195 vec![CircuitBench {
196 label: self.id.as_str().to_string(),
197 kind: CircuitKind::Other,
198 backend: self.id.as_str().to_string(),
199 ms_per_gemv: 1.0,
200 gflops: 1.0,
201 upload_gbps: 1.0,
202 rel_score: 1.0,
203 decode_proxy_tok_s: None,
204 }]
205 }
206 }
207
208 #[test]
209 fn registry_is_open_and_iterates_members() {
210 let mut reg = BackendRegistry::new();
211 reg.register(Box::new(StubBackend {
212 id: BackendId("alpha"),
213 up: true,
214 }))
215 .register(Box::new(StubBackend {
216 id: BackendId("beta"),
217 up: false,
218 }));
219 assert_eq!(reg.len(), 2);
220 // Only the available backend is offered for work.
221 let avail: Vec<_> = reg.available().map(|b| b.id()).collect();
222 assert_eq!(avail, vec![BackendId("alpha")]);
223 // Unavailable backend contributes no rows (honest "not probed").
224 let rows: Vec<_> = reg
225 .iter()
226 .flat_map(|b| b.probe_class(KernelClass::DenseLinear, &KernelPanel::quick()))
227 .collect();
228 assert_eq!(rows.len(), 1, "only the available backend yields a row");
229 }
230
231 #[test]
232 fn backend_id_is_copy_and_string_keyed() {
233 let a = BackendId::CPU;
234 let b = a; // Copy
235 assert_eq!(a, b);
236 assert_eq!(BackendId::WGPU.as_str(), "wgpu");
237 }
238}