Skip to main content

ferrotorch_core/
dispatch.rs

1//! Multi-dispatch key system for composable tensor backends. CL-397.
2//!
3//! Mirrors PyTorch's `DispatchKey` / `DispatchKeySet` / `Dispatcher`
4//! architecture: every tensor carries a set of active dispatch keys
5//! (e.g. `Autograd`, `Quantized`, `Sparse`, `CPU`, `CUDA`), and when
6//! an op is invoked the dispatcher picks the kernel registered for
7//! the **highest-priority** active key.
8//!
9//! This enables layered semantics without hard-coding each
10//! combination in every op:
11//!
12//! - `Autograd` kernels record a backward node and forward to the
13//!   next layer.
14//! - `Quantized` kernels dequantize, forward, and re-quantize.
15//! - `Sparse` kernels call the sparse backend when the tensor is a
16//!   sparse view.
17//! - `CPU` / `CUDA` are the terminal "backend" keys that actually
18//!   run the math.
19//!
20//! The dispatcher walks the set from highest to lowest priority,
21//! picks the first registered kernel, and runs it. The kernel can
22//! mask its own key off and call the dispatcher again to delegate
23//! to the next layer ("redispatch" in PyTorch terminology).
24//!
25//! # Example
26//!
27//! ```ignore
28//! use ferrotorch_core::dispatch::{DispatchKey, DispatchKeySet, Dispatcher};
29//!
30//! let mut dispatcher = Dispatcher::<f32>::new();
31//!
32//! // Register a CPU kernel for the "add" op.
33//! dispatcher.register("add", DispatchKey::Cpu, |inputs, _keyset, _disp| {
34//!     // Actually do the addition...
35//!     Ok(inputs[0].clone())
36//! });
37//!
38//! // Layer an autograd kernel on top that records a backward node
39//! // and redispatches with Autograd masked off.
40//! dispatcher.register("add", DispatchKey::Autograd, |inputs, keyset, disp| {
41//!     // ... record backward ...
42//!     let remaining = keyset.remove(DispatchKey::Autograd);
43//!     disp.call("add", inputs, remaining)
44//! });
45//!
46//! // Call the op with a keyset that has both Autograd and CPU set.
47//! // The dispatcher picks Autograd first (higher priority), which
48//! // then redispatches to Cpu.
49//! let keyset = DispatchKeySet::from([DispatchKey::Autograd, DispatchKey::Cpu]);
50//! let result = dispatcher.call("add", &[tensor], keyset).unwrap();
51//! ```
52
53//!
54//! ## REQ status (per `.design/ferrotorch-core/dispatch.md`)
55//!
56//! | REQ | Status | Evidence |
57//! |---|---|---|
58//! | REQ-1 (DispatchKey enum) | SHIPPED | `pub enum DispatchKey` at `dispatch.rs:70` with 11 variants `Cpu=0..Tracer=10` mirroring (reduced) `c10::DispatchKey` (`c10/core/DispatchKey.h:136`); consumer `lib.rs:152` re-export — R-DEFER-1 S5 grandfathering for the existing dispatch boundary; registering-crate follow-up at #1530 |
59//! | REQ-2 (DispatchKeySet bitmask) | SHIPPED | `pub struct DispatchKeySet { bits: u16 }` at `dispatch.rs:137-251`; consumer `Dispatcher::call` at `:344` walks `keyset.iter_desc()` |
60//! | REQ-3 (priority via discriminant) | SHIPPED | `DispatchKey::priority` at `dispatch.rs:113`; consumer `DispatchKeySet::insert` at `:172` shifts by `priority()`, `iter_desc` at `:235` |
61//! | REQ-4 (highest/iter_desc) | SHIPPED | `highest` at `dispatch.rs:220`, `iter_desc` at `:235`; consumer `Dispatcher::call` at `:357` iterates `keyset.iter_desc()` |
62//! | REQ-5 (Dispatcher<T>) | SHIPPED | `pub struct Dispatcher<T: Float>` at `dispatch.rs:298`, `register` at `:312`, `call` at `:344`; consumer `lib.rs:152` re-exports `Dispatcher` / `Kernel` for downstream registering crates — R-DEFER-1 S5 grandfathering; #1530 |
63//! | REQ-6 (call_direct) | SHIPPED | `Dispatcher::call_direct` at `dispatch.rs:374-390`; consumer `lib.rs:152` re-export |
64//! | REQ-7 (structured errors) | SHIPPED | `Err(FerrotorchError::InvalidArgument)` at `dispatch.rs:351` (empty keyset) and `:362` (no kernel); no `panic!` |
65//! | REQ-8 (Kernel<T> type alias) | SHIPPED | `pub type Kernel<T>` at `dispatch.rs:287-291` with `Send + Sync + 'static`; consumer every `register(...)` callsite + `lib.rs:152` re-export |
66//! | REQ-9 (per-dtype generic) | SHIPPED | `Dispatcher<T: Float>` is generic; consumer `lib.rs:152` re-exports both `Dispatcher` and `Kernel` parameterized on `T` |
67
68use crate::dtype::Float;
69use crate::error::{FerrotorchError, FerrotorchResult};
70use crate::tensor::Tensor;
71
72use std::collections::HashMap;
73
74/// One of the 16 possible dispatch keys, ordered from lowest to
75/// highest priority. The `u8` repr matches the bit position in
76/// [`DispatchKeySet`]'s internal `u16` bitmask, so the priority
77/// ordering is both the enum declaration order and the numeric
78/// order of the discriminants.
79///
80/// Keys are resolved highest-priority-first: the dispatcher walks
81/// from the largest discriminant down and picks the first key that
82/// has a registered kernel for the op.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
84#[repr(u8)]
85pub enum DispatchKey {
86    /// Backend: CPU — terminal key for CPU kernels.
87    Cpu = 0,
88    /// Backend: CUDA — terminal key for CUDA kernels.
89    Cuda = 1,
90    /// Backend: Meta device — shape-only dry runs, no data.
91    Meta = 2,
92    /// Tensor contains sparse data. Sparse kernels intercept ops
93    /// and either call a sparse-specific backend or densify and
94    /// redispatch.
95    Sparse = 3,
96    /// Tensor contains quantized values. Quantized kernels
97    /// dequantize, redispatch, and requantize (for ops without
98    /// native quantized kernels).
99    Quantized = 4,
100    /// Tensor is a nested/jagged tensor. Nested kernels iterate
101    /// per-component and redispatch to the backend.
102    Nested = 5,
103    /// Auto-mixed-precision: cast inputs to the autocast dtype
104    /// before redispatching. Higher priority than Quantized so
105    /// AMP happens before quantization layering.
106    Autocast = 6,
107    /// Autograd: record a backward node and redispatch with
108    /// Autograd masked off. Highest-priority non-profiling key so
109    /// the backward graph sees the post-dispatch view of each op.
110    Autograd = 7,
111    /// Vmap (batched tensor): intercept ops and apply them over
112    /// the batch dimension. Stacks above Autograd so batched
113    /// forwards still see autograd semantics.
114    Vmap = 8,
115    /// Profiler: record an entry in the active profiler before
116    /// redispatching. Sits above Vmap so the profiler sees the
117    /// outer call exactly once regardless of batching.
118    Profiler = 9,
119    /// Tracer: emit an IR node into the active JIT trace.
120    /// Highest priority so tracing happens before any other
121    /// layering transforms the op.
122    Tracer = 10,
123}
124
125impl DispatchKey {
126    /// The numeric priority of this key. Larger = higher priority.
127    #[inline]
128    pub fn priority(self) -> u8 {
129        self as u8
130    }
131
132    /// All 11 defined keys, in priority order (lowest to highest).
133    /// Useful for iterating the full set.
134    pub const ALL: [DispatchKey; 11] = [
135        DispatchKey::Cpu,
136        DispatchKey::Cuda,
137        DispatchKey::Meta,
138        DispatchKey::Sparse,
139        DispatchKey::Quantized,
140        DispatchKey::Nested,
141        DispatchKey::Autocast,
142        DispatchKey::Autograd,
143        DispatchKey::Vmap,
144        DispatchKey::Profiler,
145        DispatchKey::Tracer,
146    ];
147}
148
149/// A set of active [`DispatchKey`]s, stored as a `u16` bitmask for
150/// constant-time membership testing and iteration.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub struct DispatchKeySet {
153    bits: u16,
154}
155
156impl DispatchKeySet {
157    /// An empty set.
158    #[inline]
159    pub const fn empty() -> Self {
160        Self { bits: 0 }
161    }
162
163    /// A set containing every defined key.
164    pub fn all() -> Self {
165        let mut set = Self::empty();
166        for &k in &DispatchKey::ALL {
167            set = set.insert(k);
168        }
169        set
170    }
171
172    /// Construct a set from an iterable of keys. Convenience wrapper over
173    /// the [`FromIterator`] impl below for callers that don't want to chain
174    /// `.into_iter().collect()`.
175    pub fn from_keys<I: IntoIterator<Item = DispatchKey>>(keys: I) -> Self {
176        keys.into_iter().collect()
177    }
178
179    /// Returns true if `key` is in this set.
180    #[inline]
181    pub fn contains(self, key: DispatchKey) -> bool {
182        (self.bits >> key.priority()) & 1 != 0
183    }
184
185    /// Returns a new set with `key` added.
186    #[inline]
187    #[must_use]
188    pub fn insert(self, key: DispatchKey) -> Self {
189        Self {
190            bits: self.bits | (1 << key.priority()),
191        }
192    }
193
194    /// Returns a new set with `key` removed.
195    #[inline]
196    #[must_use]
197    pub fn remove(self, key: DispatchKey) -> Self {
198        Self {
199            bits: self.bits & !(1 << key.priority()),
200        }
201    }
202
203    /// Union of two sets.
204    #[inline]
205    #[must_use]
206    pub fn union(self, other: Self) -> Self {
207        Self {
208            bits: self.bits | other.bits,
209        }
210    }
211
212    /// Intersection of two sets.
213    #[inline]
214    #[must_use]
215    pub fn intersection(self, other: Self) -> Self {
216        Self {
217            bits: self.bits & other.bits,
218        }
219    }
220
221    /// Returns true if this set has no keys.
222    #[inline]
223    pub fn is_empty(self) -> bool {
224        self.bits == 0
225    }
226
227    /// Number of keys in this set.
228    #[inline]
229    pub fn len(self) -> usize {
230        self.bits.count_ones() as usize
231    }
232
233    /// Highest-priority key in this set, or `None` if empty. This
234    /// is the "next" key the dispatcher will resolve.
235    pub fn highest(self) -> Option<DispatchKey> {
236        if self.bits == 0 {
237            return None;
238        }
239        // Walk keys from highest to lowest discriminant and return
240        // the first one present.
241        DispatchKey::ALL
242            .iter()
243            .rev()
244            .find(|&&k| self.contains(k))
245            .copied()
246    }
247
248    /// Returns an iterator over all keys in the set, in
249    /// **descending** priority order (highest first).
250    pub fn iter_desc(self) -> impl Iterator<Item = DispatchKey> {
251        let mut bits = self.bits;
252        std::iter::from_fn(move || {
253            if bits == 0 {
254                return None;
255            }
256            // Find the highest set bit.
257            let top = 15 - bits.leading_zeros() as u8;
258            bits &= !(1 << top);
259            // Map bit position back to a DispatchKey if valid.
260            DispatchKey::ALL
261                .iter()
262                .find(|k| k.priority() == top)
263                .copied()
264        })
265    }
266}
267
268impl Default for DispatchKeySet {
269    fn default() -> Self {
270        Self::empty()
271    }
272}
273
274impl FromIterator<DispatchKey> for DispatchKeySet {
275    fn from_iter<I: IntoIterator<Item = DispatchKey>>(keys: I) -> Self {
276        let mut set = Self::empty();
277        for k in keys {
278            set = set.insert(k);
279        }
280        set
281    }
282}
283
284impl<const N: usize> From<[DispatchKey; N]> for DispatchKeySet {
285    fn from(arr: [DispatchKey; N]) -> Self {
286        Self::from_keys(arr)
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Kernel type and Dispatcher
292// ---------------------------------------------------------------------------
293
294/// A dispatched kernel: takes the op's input tensors, the
295/// currently-active keyset (after all higher-priority keys have
296/// been resolved), and a reference to the dispatcher so the kernel
297/// can redispatch to a lower-priority key.
298///
299/// Kernels return a single output tensor. Ops with multiple
300/// outputs are not yet supported by this dispatcher — they'd need
301/// a separate `KernelMulti` variant.
302pub type Kernel<T> = Box<
303    dyn Fn(&[Tensor<T>], DispatchKeySet, &Dispatcher<T>) -> FerrotorchResult<Tensor<T>>
304        + Send
305        + Sync,
306>;
307
308/// A kernel registration table keyed by `(op_name, dispatch_key)`.
309/// Looking up a kernel is a single HashMap probe.
310///
311/// `T` is the scalar dtype the dispatcher operates on (f32 / f64).
312/// Different dispatchers are typically held per-dtype.
313pub struct Dispatcher<T: Float> {
314    kernels: HashMap<(String, DispatchKey), Kernel<T>>,
315}
316
317impl<T: Float> Dispatcher<T> {
318    /// Create an empty dispatcher with no registered kernels.
319    pub fn new() -> Self {
320        Self {
321            kernels: HashMap::new(),
322        }
323    }
324
325    /// Register a kernel for `(op_name, key)`. Overwrites any
326    /// existing registration for the same pair.
327    pub fn register<F>(&mut self, op_name: impl Into<String>, key: DispatchKey, kernel: F)
328    where
329        F: Fn(&[Tensor<T>], DispatchKeySet, &Dispatcher<T>) -> FerrotorchResult<Tensor<T>>
330            + Send
331            + Sync
332            + 'static,
333    {
334        self.kernels.insert((op_name.into(), key), Box::new(kernel));
335    }
336
337    /// Returns true if a kernel is registered for `(op_name, key)`.
338    pub fn has_kernel(&self, op_name: &str, key: DispatchKey) -> bool {
339        self.kernels.contains_key(&(op_name.to_string(), key))
340    }
341
342    /// Number of registered kernels.
343    pub fn kernel_count(&self) -> usize {
344        self.kernels.len()
345    }
346
347    /// Call `op_name` with `inputs` and the given active keyset.
348    /// Walks the keyset in descending priority order, picks the
349    /// first key that has a kernel registered for the op, and runs
350    /// it. The kernel receives the full `keyset` (not just its
351    /// own key) so it can decide which keys to mask off before
352    /// redispatching.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`FerrotorchError::InvalidArgument`] if no kernel
357    /// is registered for any active key in the set, or if the set
358    /// is empty.
359    pub fn call(
360        &self,
361        op_name: &str,
362        inputs: &[Tensor<T>],
363        keyset: DispatchKeySet,
364    ) -> FerrotorchResult<Tensor<T>> {
365        if keyset.is_empty() {
366            return Err(FerrotorchError::InvalidArgument {
367                message: format!(
368                    "Dispatcher::call({op_name}): empty keyset — no backend to run on"
369                ),
370            });
371        }
372        for key in keyset.iter_desc() {
373            if let Some(kernel) = self.kernels.get(&(op_name.to_string(), key)) {
374                return kernel(inputs, keyset, self);
375            }
376        }
377        Err(FerrotorchError::InvalidArgument {
378            message: format!(
379                "Dispatcher::call({op_name}): no kernel registered for any key in {keyset:?}"
380            ),
381        })
382    }
383
384    /// Call `op_name` with the kernel for a specific `key`,
385    /// bypassing priority resolution. Returns an error if no
386    /// kernel is registered for that key.
387    ///
388    /// Primarily useful for testing and for kernels that want to
389    /// forward directly to a specific lower-priority layer.
390    pub fn call_direct(
391        &self,
392        op_name: &str,
393        inputs: &[Tensor<T>],
394        keyset: DispatchKeySet,
395        key: DispatchKey,
396    ) -> FerrotorchResult<Tensor<T>> {
397        match self.kernels.get(&(op_name.to_string(), key)) {
398            Some(kernel) => kernel(inputs, keyset, self),
399            None => Err(FerrotorchError::InvalidArgument {
400                message: format!(
401                    "Dispatcher::call_direct({op_name}, {key:?}): no kernel registered"
402                ),
403            }),
404        }
405    }
406}
407
408impl<T: Float> Default for Dispatcher<T> {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414impl<T: Float> std::fmt::Debug for Dispatcher<T> {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        f.debug_struct("Dispatcher")
417            .field("kernel_count", &self.kernels.len())
418            .finish()
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::storage::TensorStorage;
430
431    fn make_tensor(data: Vec<f32>, shape: Vec<usize>) -> Tensor<f32> {
432        Tensor::from_storage(TensorStorage::cpu(data), shape, false).unwrap()
433    }
434
435    // ── DispatchKey priority ────────────────────────────────────────
436
437    #[test]
438    fn dispatch_key_priority_ordering() {
439        assert!(DispatchKey::Tracer.priority() > DispatchKey::Autograd.priority());
440        assert!(DispatchKey::Autograd.priority() > DispatchKey::Autocast.priority());
441        assert!(DispatchKey::Autocast.priority() > DispatchKey::Cpu.priority());
442        assert!(DispatchKey::Cuda.priority() > DispatchKey::Cpu.priority());
443    }
444
445    #[test]
446    fn dispatch_key_all_contains_every_key() {
447        assert_eq!(DispatchKey::ALL.len(), 11);
448        // Each key appears exactly once.
449        for k in &DispatchKey::ALL {
450            let count = DispatchKey::ALL.iter().filter(|&other| other == k).count();
451            assert_eq!(count, 1, "duplicate key {k:?}");
452        }
453    }
454
455    // ── DispatchKeySet membership ───────────────────────────────────
456
457    #[test]
458    fn dispatch_key_set_empty() {
459        let set = DispatchKeySet::empty();
460        assert!(set.is_empty());
461        assert_eq!(set.len(), 0);
462        assert_eq!(set.highest(), None);
463        assert!(!set.contains(DispatchKey::Cpu));
464    }
465
466    #[test]
467    fn dispatch_key_set_insert_and_contains() {
468        let set = DispatchKeySet::empty()
469            .insert(DispatchKey::Cpu)
470            .insert(DispatchKey::Autograd);
471        assert_eq!(set.len(), 2);
472        assert!(set.contains(DispatchKey::Cpu));
473        assert!(set.contains(DispatchKey::Autograd));
474        assert!(!set.contains(DispatchKey::Cuda));
475    }
476
477    #[test]
478    fn dispatch_key_set_remove() {
479        let set = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Autograd]);
480        let without_autograd = set.remove(DispatchKey::Autograd);
481        assert_eq!(without_autograd.len(), 1);
482        assert!(without_autograd.contains(DispatchKey::Cpu));
483        assert!(!without_autograd.contains(DispatchKey::Autograd));
484    }
485
486    #[test]
487    fn dispatch_key_set_highest() {
488        let set = DispatchKeySet::from([
489            DispatchKey::Cpu,
490            DispatchKey::Autograd,
491            DispatchKey::Profiler,
492        ]);
493        assert_eq!(set.highest(), Some(DispatchKey::Profiler));
494    }
495
496    #[test]
497    fn dispatch_key_set_iter_desc_gives_priority_order() {
498        let set = DispatchKeySet::from([
499            DispatchKey::Cpu,
500            DispatchKey::Tracer,
501            DispatchKey::Autograd,
502            DispatchKey::Cuda,
503        ]);
504        let order: Vec<_> = set.iter_desc().collect();
505        assert_eq!(
506            order,
507            vec![
508                DispatchKey::Tracer,
509                DispatchKey::Autograd,
510                DispatchKey::Cuda,
511                DispatchKey::Cpu,
512            ]
513        );
514    }
515
516    #[test]
517    fn dispatch_key_set_union_and_intersection() {
518        let a = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Autograd]);
519        let b = DispatchKeySet::from([DispatchKey::Autograd, DispatchKey::Quantized]);
520        let u = a.union(b);
521        assert_eq!(u.len(), 3);
522        assert!(u.contains(DispatchKey::Cpu));
523        assert!(u.contains(DispatchKey::Autograd));
524        assert!(u.contains(DispatchKey::Quantized));
525
526        let i = a.intersection(b);
527        assert_eq!(i.len(), 1);
528        assert!(i.contains(DispatchKey::Autograd));
529    }
530
531    #[test]
532    fn dispatch_key_set_all_contains_every_key() {
533        let set = DispatchKeySet::all();
534        assert_eq!(set.len(), 11);
535        for &k in &DispatchKey::ALL {
536            assert!(set.contains(k));
537        }
538    }
539
540    #[test]
541    fn dispatch_key_set_from_array_literal() {
542        let set = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Cuda]);
543        assert_eq!(set.len(), 2);
544    }
545
546    // ── Dispatcher registration and lookup ──────────────────────────
547
548    #[test]
549    fn dispatcher_register_and_has_kernel() {
550        let mut d = Dispatcher::<f32>::new();
551        assert_eq!(d.kernel_count(), 0);
552        assert!(!d.has_kernel("add", DispatchKey::Cpu));
553
554        d.register(
555            "add",
556            DispatchKey::Cpu,
557            |inputs, _, _| Ok(inputs[0].clone()),
558        );
559        assert_eq!(d.kernel_count(), 1);
560        assert!(d.has_kernel("add", DispatchKey::Cpu));
561        assert!(!d.has_kernel("add", DispatchKey::Cuda));
562        assert!(!d.has_kernel("sub", DispatchKey::Cpu));
563    }
564
565    #[test]
566    fn dispatcher_call_empty_keyset_errors() {
567        let d = Dispatcher::<f32>::new();
568        let t = make_tensor(vec![1.0], vec![1]);
569        let result = d.call("add", &[t], DispatchKeySet::empty());
570        assert!(result.is_err());
571        assert!(format!("{}", result.unwrap_err()).contains("empty keyset"));
572    }
573
574    #[test]
575    fn dispatcher_call_no_kernel_errors() {
576        let d = Dispatcher::<f32>::new();
577        let t = make_tensor(vec![1.0], vec![1]);
578        let keyset = DispatchKeySet::from([DispatchKey::Cpu]);
579        let result = d.call("add", &[t], keyset);
580        assert!(result.is_err());
581        assert!(format!("{}", result.unwrap_err()).contains("no kernel registered"));
582    }
583
584    #[test]
585    fn dispatcher_call_picks_highest_priority_key() {
586        use std::sync::Arc;
587        use std::sync::atomic::{AtomicUsize, Ordering};
588
589        // Track which kernel was called by name.
590        let cpu_count = Arc::new(AtomicUsize::new(0));
591        let autograd_count = Arc::new(AtomicUsize::new(0));
592
593        let mut d = Dispatcher::<f32>::new();
594        let cpu_c = Arc::clone(&cpu_count);
595        d.register("add", DispatchKey::Cpu, move |inputs, _, _| {
596            cpu_c.fetch_add(1, Ordering::Relaxed);
597            Ok(inputs[0].clone())
598        });
599        let ag_c = Arc::clone(&autograd_count);
600        d.register("add", DispatchKey::Autograd, move |inputs, _, _| {
601            ag_c.fetch_add(1, Ordering::Relaxed);
602            Ok(inputs[0].clone())
603        });
604
605        let t = make_tensor(vec![1.0], vec![1]);
606        let keyset = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Autograd]);
607        d.call("add", &[t], keyset).unwrap();
608
609        // Autograd is higher priority, so it should be called.
610        assert_eq!(autograd_count.load(Ordering::Relaxed), 1);
611        assert_eq!(cpu_count.load(Ordering::Relaxed), 0);
612    }
613
614    #[test]
615    fn dispatcher_redispatch_chains_through_keys() {
616        // Autograd kernel masks itself off and calls down to Cpu.
617        use std::sync::Arc;
618        use std::sync::atomic::{AtomicUsize, Ordering};
619
620        let cpu_count = Arc::new(AtomicUsize::new(0));
621        let autograd_count = Arc::new(AtomicUsize::new(0));
622
623        let mut d = Dispatcher::<f32>::new();
624        let cpu_c = Arc::clone(&cpu_count);
625        d.register("add", DispatchKey::Cpu, move |inputs, _, _| {
626            cpu_c.fetch_add(1, Ordering::Relaxed);
627            Ok(inputs[0].clone())
628        });
629        let ag_c = Arc::clone(&autograd_count);
630        d.register("add", DispatchKey::Autograd, move |inputs, keyset, disp| {
631            ag_c.fetch_add(1, Ordering::Relaxed);
632            // Mask off autograd and redispatch.
633            let rest = keyset.remove(DispatchKey::Autograd);
634            disp.call("add", inputs, rest)
635        });
636
637        let t = make_tensor(vec![1.0], vec![1]);
638        let keyset = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Autograd]);
639        d.call("add", &[t], keyset).unwrap();
640
641        assert_eq!(autograd_count.load(Ordering::Relaxed), 1);
642        assert_eq!(cpu_count.load(Ordering::Relaxed), 1);
643    }
644
645    #[test]
646    fn dispatcher_skips_keys_without_kernel() {
647        // Register Cpu only. A keyset that includes Autograd + Cpu
648        // should still resolve because Autograd has no kernel but
649        // Cpu does.
650        let mut d = Dispatcher::<f32>::new();
651        d.register(
652            "add",
653            DispatchKey::Cpu,
654            |inputs, _, _| Ok(inputs[0].clone()),
655        );
656
657        let t = make_tensor(vec![1.0, 2.0], vec![2]);
658        let keyset = DispatchKeySet::from([DispatchKey::Autograd, DispatchKey::Cpu]);
659        let result = d.call("add", &[t], keyset).unwrap();
660        assert_eq!(result.shape(), &[2]);
661    }
662
663    #[test]
664    fn dispatcher_call_direct_bypasses_priority() {
665        use std::sync::Arc;
666        use std::sync::atomic::{AtomicUsize, Ordering};
667
668        let cpu_count = Arc::new(AtomicUsize::new(0));
669        let cuda_count = Arc::new(AtomicUsize::new(0));
670
671        let mut d = Dispatcher::<f32>::new();
672        let cpu_c = Arc::clone(&cpu_count);
673        d.register("add", DispatchKey::Cpu, move |inputs, _, _| {
674            cpu_c.fetch_add(1, Ordering::Relaxed);
675            Ok(inputs[0].clone())
676        });
677        let cuda_c = Arc::clone(&cuda_count);
678        d.register("add", DispatchKey::Cuda, move |inputs, _, _| {
679            cuda_c.fetch_add(1, Ordering::Relaxed);
680            Ok(inputs[0].clone())
681        });
682
683        // call() with both keys → Cuda (higher priority).
684        let t = make_tensor(vec![1.0], vec![1]);
685        let keyset = DispatchKeySet::from([DispatchKey::Cpu, DispatchKey::Cuda]);
686        d.call("add", std::slice::from_ref(&t), keyset).unwrap();
687        assert_eq!(cuda_count.load(Ordering::Relaxed), 1);
688        assert_eq!(cpu_count.load(Ordering::Relaxed), 0);
689
690        // call_direct(Cpu) → forces Cpu kernel.
691        d.call_direct("add", &[t], keyset, DispatchKey::Cpu)
692            .unwrap();
693        assert_eq!(cpu_count.load(Ordering::Relaxed), 1);
694        assert_eq!(cuda_count.load(Ordering::Relaxed), 1);
695    }
696
697    #[test]
698    fn dispatcher_call_direct_missing_kernel_errors() {
699        let d = Dispatcher::<f32>::new();
700        let t = make_tensor(vec![1.0], vec![1]);
701        let keyset = DispatchKeySet::from([DispatchKey::Cpu]);
702        let result = d.call_direct("add", &[t], keyset, DispatchKey::Cpu);
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn dispatcher_full_three_layer_stack() {
708        // Realistic chain: Tracer → Autograd → Cpu.
709        // Tracer emits an IR node marker and redispatches.
710        // Autograd records a backward marker and redispatches.
711        // Cpu does the actual math.
712        use std::sync::Arc;
713        use std::sync::Mutex;
714
715        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
716
717        let mut d = Dispatcher::<f32>::new();
718
719        let log_c = Arc::clone(&log);
720        d.register("add", DispatchKey::Cpu, move |inputs, _, _| {
721            log_c.lock().unwrap().push("cpu");
722            Ok(inputs[0].clone())
723        });
724
725        let log_a = Arc::clone(&log);
726        d.register("add", DispatchKey::Autograd, move |inputs, keyset, disp| {
727            log_a.lock().unwrap().push("autograd");
728            let rest = keyset.remove(DispatchKey::Autograd);
729            disp.call("add", inputs, rest)
730        });
731
732        let log_t = Arc::clone(&log);
733        d.register("add", DispatchKey::Tracer, move |inputs, keyset, disp| {
734            log_t.lock().unwrap().push("tracer");
735            let rest = keyset.remove(DispatchKey::Tracer);
736            disp.call("add", inputs, rest)
737        });
738
739        let t = make_tensor(vec![1.0, 2.0], vec![2]);
740        let keyset =
741            DispatchKeySet::from([DispatchKey::Tracer, DispatchKey::Autograd, DispatchKey::Cpu]);
742        d.call("add", &[t], keyset).unwrap();
743
744        let final_log = log.lock().unwrap();
745        assert_eq!(*final_log, vec!["tracer", "autograd", "cpu"]);
746    }
747}