Skip to main content

oxicuda_backend/
registry.rs

1//! Backend registry, capability-based selection, and fallback chains.
2//!
3//! This is the host-side "control plane" of the abstraction layer. It does
4//! **not** execute any GPU work; it decides *which* backend should run a
5//! given task, given:
6//!
7//! * which backends a build registered ([`BackendRegistry::register`]),
8//! * whether each is actually present at runtime (its `available` flag,
9//!   set from a probe in the concrete backend crate), and
10//! * what each one can do ([`Capabilities`]).
11//!
12//! The selection logic is fully deterministic and testable without any GPU:
13//! it always prefers the highest-priority *available* backend that satisfies
14//! a [`SelectionRequest`], and degrades down a [`fallback_chain`] that ends
15//! at the CPU reference backend whenever one is registered.
16
17use crate::backend_kind::BackendKind;
18use crate::capabilities::Capabilities;
19use crate::error::{BackendError, BackendResult};
20
21/// A backend known to the registry, plus the runtime facts needed to pick it.
22///
23/// `available` is supplied by the caller (typically the result of a cheap
24/// driver probe such as "did `cuInit` succeed?"). The registry never sets it
25/// itself, so this crate stays free of any device dependency.
26#[derive(Debug, Clone)]
27pub struct BackendEntry {
28    /// Which concrete backend this describes.
29    pub kind: BackendKind,
30    /// Whether the backend is usable on this machine right now.
31    pub available: bool,
32    /// Selection priority — higher wins. Defaults to
33    /// [`BackendKind::default_priority`].
34    pub priority: u32,
35    /// What the backend can do (filled from a driver query, or a sensible
36    /// default for the CPU/portable backends).
37    pub capabilities: Capabilities,
38}
39
40impl BackendEntry {
41    /// Create an entry using the kind's default priority and CPU-profile
42    /// capabilities. Callers override `capabilities` after construction for
43    /// real GPU backends.
44    #[must_use]
45    pub fn new(kind: BackendKind, available: bool) -> Self {
46        Self {
47            kind,
48            available,
49            priority: kind.default_priority(),
50            capabilities: if kind == BackendKind::Cpu {
51                Capabilities::cpu()
52            } else {
53                Capabilities::default()
54            },
55        }
56    }
57
58    /// Builder-style override of the priority.
59    #[must_use]
60    pub fn with_priority(mut self, priority: u32) -> Self {
61        self.priority = priority;
62        self
63    }
64
65    /// Builder-style override of the capability report.
66    #[must_use]
67    pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
68        self.capabilities = capabilities;
69        self
70    }
71}
72
73/// Required-feature predicate used to filter backends during selection.
74///
75/// A backend is eligible only if it is available **and** its capabilities
76/// satisfy every requested flag. All fields default to "don't care".
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct SelectionRequest {
79    /// Require a GPU backend (exclude the CPU fallback from the primary pick).
80    pub require_gpu: bool,
81    /// Require native FP16 compute.
82    pub require_fp16: bool,
83    /// Require native BF16 compute.
84    pub require_bf16: bool,
85    /// Require FP8 compute.
86    pub require_fp8: bool,
87    /// Require Tensor-Core / matrix units.
88    pub require_tensor_cores: bool,
89    /// Require unified / managed memory.
90    pub require_unified_memory: bool,
91    /// Require peer-to-peer device access.
92    pub require_peer_access: bool,
93    /// If `Some`, only this exact backend kind is acceptable.
94    pub pin: Option<BackendKind>,
95}
96
97impl SelectionRequest {
98    /// A request with no constraints (any available backend qualifies).
99    #[must_use]
100    pub const fn any() -> Self {
101        Self {
102            require_gpu: false,
103            require_fp16: false,
104            require_bf16: false,
105            require_fp8: false,
106            require_tensor_cores: false,
107            require_unified_memory: false,
108            require_peer_access: false,
109            pin: None,
110        }
111    }
112
113    /// A request that demands a GPU (used by callers that must not silently
114    /// fall back to the CPU reference path).
115    #[must_use]
116    pub const fn require_gpu() -> Self {
117        let mut r = Self::any();
118        r.require_gpu = true;
119        r
120    }
121
122    /// Pin selection to one specific backend kind.
123    #[must_use]
124    pub const fn pinned(kind: BackendKind) -> Self {
125        let mut r = Self::any();
126        r.pin = Some(kind);
127        r
128    }
129
130    /// Returns `true` if `entry` satisfies every constraint in this request.
131    #[must_use]
132    pub fn is_satisfied_by(&self, entry: &BackendEntry) -> bool {
133        if !entry.available {
134            return false;
135        }
136        if let Some(pin) = self.pin {
137            if entry.kind != pin {
138                return false;
139            }
140        }
141        let caps = &entry.capabilities;
142        if self.require_gpu && !entry.kind.is_gpu() {
143            return false;
144        }
145        if self.require_fp16 && !caps.supports_fp16 {
146            return false;
147        }
148        if self.require_bf16 && !caps.supports_bf16 {
149            return false;
150        }
151        if self.require_fp8 && !caps.supports_fp8 {
152            return false;
153        }
154        if self.require_tensor_cores && !caps.tensor_cores {
155            return false;
156        }
157        if self.require_unified_memory && !caps.unified_memory {
158            return false;
159        }
160        if self.require_peer_access && !caps.peer_access {
161            return false;
162        }
163        true
164    }
165}
166
167/// Routable operation classes used by [`BackendRegistry::route`].
168///
169/// This is a coarse routing key, not the full op set: it lets a consumer ask
170/// "which backend should run my GEMMs?" so that, for example, dense linear
171/// algebra can be pinned to a Tensor-Core backend while element-wise work
172/// goes to whatever is cheapest.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub enum OpClass {
175    /// Dense matrix multiply (`gemm` / `batched_gemm`).
176    MatMul,
177    /// Convolution (`conv2d_forward`).
178    Convolution,
179    /// Scaled dot-product attention.
180    Attention,
181    /// Axis reductions.
182    Reduction,
183    /// Element-wise unary / binary / softmax.
184    Elementwise,
185    /// Host/device memory transfers.
186    Memory,
187}
188
189impl OpClass {
190    /// Every routable op class.
191    pub const ALL: [OpClass; 6] = [
192        OpClass::MatMul,
193        OpClass::Convolution,
194        OpClass::Attention,
195        OpClass::Reduction,
196        OpClass::Elementwise,
197        OpClass::Memory,
198    ];
199
200    /// `true` if this op class benefits from Tensor-Core units, so the
201    /// router prefers a matrix-capable backend when one is available.
202    #[must_use]
203    pub const fn prefers_tensor_cores(self) -> bool {
204        matches!(self, Self::MatMul | Self::Convolution | Self::Attention)
205    }
206}
207
208/// A registry of compute backends with capability-aware selection.
209///
210/// Construction registers nothing; call [`register`](Self::register) (or
211/// [`with_defaults`](Self::with_defaults)) to populate it. Selection is pure
212/// and side-effect-free, so it is trivially unit-testable.
213#[derive(Debug, Clone, Default)]
214pub struct BackendRegistry {
215    entries: Vec<BackendEntry>,
216}
217
218impl BackendRegistry {
219    /// An empty registry.
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            entries: Vec::new(),
224        }
225    }
226
227    /// A registry pre-populated with one entry per [`BackendKind`], all
228    /// flagged **unavailable** except the CPU reference backend.
229    ///
230    /// Concrete crates then flip the `available` flag (and refine the
231    /// capabilities) for backends they detect via
232    /// [`set_available`](Self::set_available) /
233    /// [`set_capabilities`](Self::set_capabilities). This guarantees there
234    /// is always at least one usable backend (the CPU fallback).
235    #[must_use]
236    pub fn with_defaults() -> Self {
237        let mut reg = Self::new();
238        for kind in BackendKind::ALL {
239            reg.register(BackendEntry::new(kind, kind == BackendKind::Cpu));
240        }
241        reg
242    }
243
244    /// Register (or replace) an entry. If a backend of the same kind is
245    /// already present, it is overwritten so a later, more-informed probe
246    /// wins.
247    pub fn register(&mut self, entry: BackendEntry) {
248        if let Some(slot) = self.entries.iter_mut().find(|e| e.kind == entry.kind) {
249            *slot = entry;
250        } else {
251            self.entries.push(entry);
252        }
253    }
254
255    /// Number of registered backends.
256    #[must_use]
257    pub fn len(&self) -> usize {
258        self.entries.len()
259    }
260
261    /// `true` if no backend is registered.
262    #[must_use]
263    pub fn is_empty(&self) -> bool {
264        self.entries.is_empty()
265    }
266
267    /// Borrow the entry for `kind`, if registered.
268    #[must_use]
269    pub fn get(&self, kind: BackendKind) -> Option<&BackendEntry> {
270        self.entries.iter().find(|e| e.kind == kind)
271    }
272
273    /// Mark `kind`'s availability, returning `true` if the kind was present.
274    pub fn set_available(&mut self, kind: BackendKind, available: bool) -> bool {
275        if let Some(e) = self.entries.iter_mut().find(|e| e.kind == kind) {
276            e.available = available;
277            true
278        } else {
279            false
280        }
281    }
282
283    /// Update `kind`'s capability report, returning `true` if present.
284    pub fn set_capabilities(&mut self, kind: BackendKind, caps: Capabilities) -> bool {
285        if let Some(e) = self.entries.iter_mut().find(|e| e.kind == kind) {
286            e.capabilities = caps;
287            true
288        } else {
289            false
290        }
291    }
292
293    /// All currently-available backend kinds.
294    #[must_use]
295    pub fn available_kinds(&self) -> Vec<BackendKind> {
296        self.entries
297            .iter()
298            .filter(|e| e.available)
299            .map(|e| e.kind)
300            .collect()
301    }
302
303    /// Select the single best backend satisfying `req`, or
304    /// [`BackendError::Unsupported`] if none qualifies.
305    ///
306    /// "Best" = highest `priority` among satisfying entries; ties break by
307    /// [`BackendKind::default_priority`] and then declaration order, so the
308    /// result is deterministic.
309    pub fn select(&self, req: &SelectionRequest) -> BackendResult<BackendKind> {
310        self.entries
311            .iter()
312            .filter(|e| req.is_satisfied_by(e))
313            .max_by(|a, b| {
314                a.priority
315                    .cmp(&b.priority)
316                    .then(a.kind.default_priority().cmp(&b.kind.default_priority()))
317            })
318            .map(|e| e.kind)
319            .ok_or_else(|| {
320                BackendError::Unsupported(format!(
321                    "no registered backend satisfies the request {req:?}"
322                ))
323            })
324    }
325
326    /// Convenience: select the best available backend with no constraints,
327    /// preferring a GPU but accepting the CPU fallback.
328    pub fn select_best(&self) -> BackendResult<BackendKind> {
329        self.select(&SelectionRequest::any())
330    }
331
332    /// The ordered fallback chain for `req`: every satisfying backend, most-
333    /// to least-preferred, with the CPU reference backend forced to the end
334    /// if it is available (even when it would also satisfy `req` earlier).
335    ///
336    /// Consumers walk this chain, trying each backend until one initializes
337    /// and runs the op, so a transient GPU failure degrades gracefully to
338    /// the host.
339    #[must_use]
340    pub fn fallback_chain(&self, req: &SelectionRequest) -> Vec<BackendKind> {
341        // Collect satisfying entries together with their configured priority,
342        // then sort most-preferred first: by explicit priority desc, then by
343        // the kind's default priority desc as a deterministic tie-break.
344        let mut ranked: Vec<(u32, BackendKind)> = self
345            .entries
346            .iter()
347            .filter(|e| req.is_satisfied_by(e))
348            .map(|e| (e.priority, e.kind))
349            .collect();
350        ranked.sort_by(|(pa, ka), (pb, kb)| {
351            pb.cmp(pa)
352                .then(kb.default_priority().cmp(&ka.default_priority()))
353        });
354        let mut chain: Vec<BackendKind> = ranked.into_iter().map(|(_, k)| k).collect();
355        // Force the CPU reference backend to the very end if it is present,
356        // so the host path is always the last resort.
357        if let Some(pos) = chain.iter().position(|&k| k == BackendKind::Cpu) {
358            let cpu = chain.remove(pos);
359            chain.push(cpu);
360        }
361        chain
362    }
363
364    /// Route an [`OpClass`] to the best backend.
365    ///
366    /// For Tensor-Core-friendly classes the router first tries to find an
367    /// available matrix-capable backend; if none exists it falls back to the
368    /// plain best-available selection (so routing never fails when *any*
369    /// backend is available).
370    pub fn route(&self, op: OpClass) -> BackendResult<BackendKind> {
371        if op.prefers_tensor_cores() {
372            let tc_req = SelectionRequest {
373                require_tensor_cores: true,
374                ..SelectionRequest::any()
375            };
376            if let Ok(kind) = self.select(&tc_req) {
377                return Ok(kind);
378            }
379        }
380        self.select_best()
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    /// Build a registry with CUDA (no tensor cores), ROCm (tensor cores),
389    /// and CPU, all available.
390    fn three_backend_registry() -> BackendRegistry {
391        let mut reg = BackendRegistry::new();
392        reg.register(BackendEntry::new(BackendKind::Cuda, true));
393        let rocm_caps = Capabilities {
394            tensor_cores: true,
395            supports_fp16: true,
396            ..Capabilities::default()
397        };
398        reg.register(BackendEntry::new(BackendKind::Rocm, true).with_capabilities(rocm_caps));
399        reg.register(BackendEntry::new(BackendKind::Cpu, true));
400        reg
401    }
402
403    #[test]
404    fn defaults_have_cpu_available_and_gpus_absent() {
405        let reg = BackendRegistry::with_defaults();
406        assert_eq!(reg.len(), 7);
407        assert!(reg.get(BackendKind::Cpu).unwrap().available);
408        assert!(!reg.get(BackendKind::Cuda).unwrap().available);
409        // With only the CPU available, best selection is CPU.
410        assert_eq!(reg.select_best().unwrap(), BackendKind::Cpu);
411    }
412
413    #[test]
414    fn select_picks_highest_priority_available() {
415        let reg = three_backend_registry();
416        // CUDA has the highest default priority and is available.
417        assert_eq!(reg.select_best().unwrap(), BackendKind::Cuda);
418    }
419
420    #[test]
421    fn select_skips_unavailable_highest_priority() {
422        let mut reg = three_backend_registry();
423        reg.set_available(BackendKind::Cuda, false);
424        // Now ROCm (next highest) wins.
425        assert_eq!(reg.select_best().unwrap(), BackendKind::Rocm);
426        reg.set_available(BackendKind::Rocm, false);
427        // Only CPU left.
428        assert_eq!(reg.select_best().unwrap(), BackendKind::Cpu);
429    }
430
431    #[test]
432    fn select_honors_capability_requirements() {
433        let reg = three_backend_registry();
434        // Require tensor cores → only ROCm qualifies (CUDA entry has none).
435        let req = SelectionRequest {
436            require_tensor_cores: true,
437            ..SelectionRequest::any()
438        };
439        assert_eq!(reg.select(&req).unwrap(), BackendKind::Rocm);
440    }
441
442    #[test]
443    fn select_require_gpu_excludes_cpu() {
444        let mut reg = BackendRegistry::new();
445        reg.register(BackendEntry::new(BackendKind::Cpu, true));
446        // Only CPU available; require_gpu must fail rather than pick CPU.
447        assert!(reg.select(&SelectionRequest::require_gpu()).is_err());
448        // ... but a constraint-free request happily returns CPU.
449        assert_eq!(reg.select_best().unwrap(), BackendKind::Cpu);
450    }
451
452    #[test]
453    fn select_pinned_backend() {
454        let reg = three_backend_registry();
455        let req = SelectionRequest::pinned(BackendKind::Cpu);
456        assert_eq!(reg.select(&req).unwrap(), BackendKind::Cpu);
457        // Pinning an unavailable backend fails.
458        let mut reg2 = reg.clone();
459        reg2.set_available(BackendKind::Rocm, false);
460        assert!(
461            reg2.select(&SelectionRequest::pinned(BackendKind::Rocm))
462                .is_err()
463        );
464    }
465
466    #[test]
467    fn higher_explicit_priority_overrides_default() {
468        let mut reg = BackendRegistry::new();
469        // Give CPU an absurd priority so it beats CUDA's default 100.
470        reg.register(BackendEntry::new(BackendKind::Cuda, true));
471        reg.register(BackendEntry::new(BackendKind::Cpu, true).with_priority(1000));
472        assert_eq!(reg.select_best().unwrap(), BackendKind::Cpu);
473    }
474
475    #[test]
476    fn fallback_chain_orders_by_priority_with_cpu_last() {
477        let reg = three_backend_registry();
478        let chain = reg.fallback_chain(&SelectionRequest::any());
479        assert_eq!(
480            chain,
481            vec![BackendKind::Cuda, BackendKind::Rocm, BackendKind::Cpu]
482        );
483        // CPU is always the final element when present.
484        assert_eq!(*chain.last().unwrap(), BackendKind::Cpu);
485    }
486
487    #[test]
488    fn fallback_chain_respects_constraints() {
489        let reg = three_backend_registry();
490        let req = SelectionRequest {
491            require_tensor_cores: true,
492            ..SelectionRequest::any()
493        };
494        // Only ROCm has tensor cores; CPU/CUDA excluded.
495        assert_eq!(reg.fallback_chain(&req), vec![BackendKind::Rocm]);
496    }
497
498    #[test]
499    fn fallback_chain_empty_when_nothing_qualifies() {
500        let mut reg = three_backend_registry();
501        reg.set_available(BackendKind::Cuda, false);
502        reg.set_available(BackendKind::Rocm, false);
503        reg.set_available(BackendKind::Cpu, false);
504        assert!(reg.fallback_chain(&SelectionRequest::any()).is_empty());
505        assert!(reg.select_best().is_err());
506    }
507
508    #[test]
509    fn route_matmul_prefers_tensor_core_backend() {
510        let reg = three_backend_registry();
511        // CUDA has higher priority but no tensor cores; ROCm has them.
512        // MatMul prefers tensor cores → ROCm.
513        assert_eq!(reg.route(OpClass::MatMul).unwrap(), BackendKind::Rocm);
514        // Elementwise does not prefer tensor cores → highest priority (CUDA).
515        assert_eq!(reg.route(OpClass::Elementwise).unwrap(), BackendKind::Cuda);
516    }
517
518    #[test]
519    fn route_falls_back_when_no_tensor_cores_anywhere() {
520        let mut reg = BackendRegistry::new();
521        reg.register(BackendEntry::new(BackendKind::Cuda, true)); // no TC
522        reg.register(BackendEntry::new(BackendKind::Cpu, true));
523        // No tensor-core backend exists → MatMul routes to best available.
524        assert_eq!(reg.route(OpClass::MatMul).unwrap(), BackendKind::Cuda);
525    }
526
527    #[test]
528    fn register_replaces_same_kind() {
529        let mut reg = BackendRegistry::new();
530        reg.register(BackendEntry::new(BackendKind::Cuda, false));
531        assert!(!reg.get(BackendKind::Cuda).unwrap().available);
532        reg.register(BackendEntry::new(BackendKind::Cuda, true));
533        assert_eq!(reg.len(), 1);
534        assert!(reg.get(BackendKind::Cuda).unwrap().available);
535    }
536
537    #[test]
538    fn available_kinds_lists_only_available() {
539        let mut reg = three_backend_registry();
540        reg.set_available(BackendKind::Rocm, false);
541        let avail = reg.available_kinds();
542        assert!(avail.contains(&BackendKind::Cuda));
543        assert!(avail.contains(&BackendKind::Cpu));
544        assert!(!avail.contains(&BackendKind::Rocm));
545    }
546
547    #[test]
548    fn set_methods_report_presence() {
549        let mut reg = BackendRegistry::new();
550        assert!(!reg.set_available(BackendKind::Cuda, true));
551        reg.register(BackendEntry::new(BackendKind::Cuda, false));
552        assert!(reg.set_available(BackendKind::Cuda, true));
553        assert!(reg.set_capabilities(BackendKind::Cuda, Capabilities::default()));
554        assert!(!reg.set_capabilities(BackendKind::Metal, Capabilities::default()));
555    }
556
557    #[test]
558    fn op_class_tensor_core_preference() {
559        assert!(OpClass::MatMul.prefers_tensor_cores());
560        assert!(OpClass::Attention.prefers_tensor_cores());
561        assert!(!OpClass::Elementwise.prefers_tensor_cores());
562        assert!(!OpClass::Memory.prefers_tensor_cores());
563        assert_eq!(OpClass::ALL.len(), 6);
564    }
565
566    #[test]
567    fn selection_request_constructors() {
568        assert!(SelectionRequest::require_gpu().require_gpu);
569        assert_eq!(
570            SelectionRequest::pinned(BackendKind::Metal).pin,
571            Some(BackendKind::Metal)
572        );
573        assert_eq!(SelectionRequest::any(), SelectionRequest::default());
574    }
575}