Skip to main content

fuel_dispatch/
cast_fusion.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cast-fusion capability predicate — builds the
3//! [`fuel_graph::opt::CapabilityPredicate`] consumed by
4//! `CastFusionRule` from the live `KernelBindingTable`.
5//!
6//! The rule itself lives in `fuel-graph` and is decoupled from the
7//! binding table by design (`fuel-graph` doesn't know what
8//! `BackendId` means). The predicate factory here closes the gap:
9//! it walks the binding table to answer "does any registered
10//! backend have a kernel for `(op_kind, dtypes)`?"
11//!
12//! ## Usage
13//!
14//! ```rust,ignore
15//! use fuel_graph::opt::{CastFusionRule, RuleRegistry};
16//! use fuel_memory::cast_fusion::cast_fusion_predicate;
17//!
18//! let registry = RuleRegistry::default_rules()
19//!     .with_rule(Box::new(CastFusionRule::new(cast_fusion_predicate())));
20//! ```
21//!
22//! The predicate consults [`crate::dispatch::global_bindings`] at
23//! call time, so it picks up registrations done after construction.
24
25use std::sync::Arc;
26
27use fuel_graph::Op;
28use fuel_graph::opt::CapabilityPredicate;
29use fuel_ir::dispatch::OpKind;
30use fuel_ir::{DType, probe::BackendId};
31
32use crate::dispatch::global_bindings;
33
34/// Build a [`CapabilityPredicate`] backed by the process-wide
35/// [`crate::dispatch::global_bindings`].
36///
37/// The predicate answers `true` if at least one registered backend
38/// has a kernel for `(op, dtypes)`. The route picker chooses
39/// among the backends later; the cast-fusion rule only cares that
40/// *some* backend can execute the rewritten consumer.
41///
42/// Op → OpKind mapping is partial: the `op_kind` helper covers
43/// the ops we expect to benefit most from cast-fusion (elementwise
44/// unary/binary, reductions, MatMul, fused linear, attention). Ops
45/// outside this set always return `false` from the predicate; the
46/// cast survives and dispatch falls back to the existing
47/// `Cast → Op` chain. Extending coverage is mechanical — add a
48/// match arm to `op_kind`.
49pub fn cast_fusion_predicate() -> CapabilityPredicate {
50    Arc::new(|op: &Op, dtypes: &[DType]| -> bool {
51        let Some(kind) = op_kind(op) else {
52            return false;
53        };
54        let bindings = global_bindings();
55        // Try every registered backend. The route picker decides
56        // which to use later; the rule only needs to know *some*
57        // backend can execute the rewritten consumer.
58        for backend in ALL_BACKENDS {
59            if bindings.lookup(kind, dtypes, *backend).is_ok() {
60                return true;
61            }
62        }
63        false
64    })
65}
66
67/// Every BackendId variant the cast-fusion predicate consults.
68/// Iterating an enum requires either an explicit list or
69/// `strum::EnumIter` — the explicit list is cheap and predicate
70/// performance isn't sensitive (one cast-fusion check per Cast →
71/// Op pair per optimization pass).
72const ALL_BACKENDS: &[BackendId] = &[
73    BackendId::Cpu,
74    BackendId::Cuda,
75    BackendId::Vulkan,
76    BackendId::Metal,
77];
78
79/// Map a `fuel_graph::Op` variant to its corresponding `OpKind`.
80/// Returns `None` for ops that don't carry an `OpKind` mapping
81/// (the cast-fusion predicate then conservatively declines the
82/// rewrite, leaving the Cast in place).
83///
84/// The mapping is partial by design. Adding a variant to either
85/// `Op` or `OpKind` doesn't break this function — it just means
86/// the new variant's cast-fusion opportunities are missed until
87/// an arm is added.
88fn op_kind(op: &Op) -> Option<OpKind> {
89    match op {
90        // --- elementwise binary ---
91        Op::Add => Some(OpKind::AddElementwise),
92        Op::Sub => Some(OpKind::SubElementwise),
93        Op::Mul => Some(OpKind::MulElementwise),
94        Op::Div => Some(OpKind::DivElementwise),
95        Op::Maximum => Some(OpKind::MaximumElementwise),
96        Op::Minimum => Some(OpKind::MinimumElementwise),
97        Op::Pow => Some(OpKind::PowElementwise),
98        Op::Rem => Some(OpKind::RemElementwise),
99
100        // --- elementwise unary ---
101        Op::Relu => Some(OpKind::ReluElementwise),
102        Op::Neg => Some(OpKind::NegElementwise),
103        Op::Sqr => Some(OpKind::SqrElementwise),
104        Op::Sqrt => Some(OpKind::SqrtElementwise),
105        Op::Recip => Some(OpKind::RecipElementwise),
106        Op::Abs => Some(OpKind::AbsElementwise),
107        Op::Tanh => Some(OpKind::TanhElementwise),
108        Op::Exp => Some(OpKind::ExpElementwise),
109        Op::Log => Some(OpKind::LogElementwise),
110        Op::Sin => Some(OpKind::SinElementwise),
111        Op::Cos => Some(OpKind::CosElementwise),
112        Op::Sigmoid => Some(OpKind::SigmoidElementwise),
113        Op::Silu => Some(OpKind::SiluElementwise),
114        Op::GeluTanh => Some(OpKind::GeluTanhElementwise),
115        Op::Step => Some(OpKind::StepElementwise),
116        Op::Floor => Some(OpKind::FloorElementwise),
117        Op::Ceil => Some(OpKind::CeilElementwise),
118        Op::Round => Some(OpKind::RoundElementwise),
119        Op::Sign => Some(OpKind::SignElementwise),
120        Op::Erf => Some(OpKind::ErfElementwise),
121        Op::Gelu => Some(OpKind::GeluElementwise),
122        Op::Rsqrt => Some(OpKind::RsqrtElementwise),
123
124        // --- compares (T → U8) ---
125        Op::Equal => Some(OpKind::EqualElementwise),
126        Op::Ne => Some(OpKind::NotEqualElementwise),
127        Op::Lt => Some(OpKind::LessElementwise),
128        Op::Le => Some(OpKind::LessEqualElementwise),
129        Op::Gt => Some(OpKind::GreaterElementwise),
130        Op::Ge => Some(OpKind::GreaterEqualElementwise),
131
132        // --- reductions (scalar + along-one-dim) ---
133        // Both Op::*All and Op::*Dim map to the same OpKind; the
134        // binding-table dispatch threads dim info through
135        // OpParams::Reduce, not the OpKind key.
136        Op::SumAll | Op::SumDim(_) => Some(OpKind::SumReduce),
137        Op::MaxAll | Op::MaxDim(_) => Some(OpKind::MaxReduce),
138        Op::MinAll | Op::MinDim(_) => Some(OpKind::MinReduce),
139        Op::MeanAll | Op::MeanDim(_) => Some(OpKind::MeanReduce),
140        Op::ReduceSumTo(_) => Some(OpKind::ReduceSumTo),
141        Op::ReduceMaxTo(_) => Some(OpKind::ReduceMaxTo),
142
143        // --- dense linear algebra ---
144        Op::MatMul => Some(OpKind::MatMul),
145        // Phase 7.6 step 5 (2026-05-11): FusedLinear lives behind
146        // `Op::Fused(FUSED_LINEAR, _)` now. The cast-fusion rule
147        // doesn't need to match it specially — the route through
148        // Op::Fused already carries the correct dtype info on the
149        // operand nodes; if cast-fusion ever needs to fire on a
150        // fused-linear consumer, add an Op::Fused(FUSED_LINEAR, _)
151        // arm.
152
153        // Everything else is a structural or specialized op the
154        // cast-fusion rule doesn't target today. Notably: Cast
155        // itself (the rule doesn't fire on the Cast node), Copy /
156        // Move / Release (residency), view ops, FlashAttn /
157        // PagedAttn (specialized inputs), Affine (carries a
158        // float scalar in the variant — predicate can't easily
159        // synthesize a matching binding-table key), Where /
160        // MaskedFill (multi-dtype inputs without a clean
161        // "single-cast-input" pattern), Triu / Tril / Roll /
162        // Flip / Pad / CumSum / IndexSelect / Gather / etc.
163        _ => None,
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use fuel_graph::NodeHandle;
171    use fuel_graph::opt::{CastFusionRule, RuleRegistry};
172    use fuel_graph::topo_order_multi;
173    use fuel_ir::Shape;
174    use std::sync::Arc as StdArc;
175
176    fn cpu_dev() -> &'static StdArc<dyn fuel_backend_contract::DynBackendDevice> {
177        use std::sync::OnceLock;
178        static D: OnceLock<StdArc<dyn fuel_backend_contract::DynBackendDevice>> = OnceLock::new();
179        D.get_or_init(|| StdArc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice))
180    }
181
182    /// Sanity: predicate returns true for an (op, dtypes) combo
183    /// that is registered in the default CPU bindings. We don't
184    /// rely on a *specific* registration — the binding table is
185    /// shared across tests — but `(NegElementwise, [F32, F32])` is
186    /// part of the standard CPU registration set so it should
187    /// always be there.
188    #[test]
189    fn predicate_accepts_registered_op() {
190        let predicate = cast_fusion_predicate();
191        // Build the dtype vector the predicate expects:
192        // [input dtypes ..., output dtype]. For unary Neg: input
193        // F32, output F32.
194        let dtypes = [DType::F32, DType::F32];
195        assert!(predicate(&Op::Neg, &dtypes));
196    }
197
198    /// Predicate returns false for ops outside its OpKind mapping.
199    /// Op::Const has no kernel-dispatch shape; the predicate
200    /// declines and the cast (if any) is preserved.
201    #[test]
202    fn predicate_rejects_unmapped_op() {
203        let predicate = cast_fusion_predicate();
204        let dtypes = [DType::F32];
205        assert!(!predicate(&Op::Const, &dtypes));
206    }
207
208    /// End-to-end: a graph `x:f32 → Cast(BF16) → Neg(BF16)`
209    /// optimized with the predicate-backed CastFusionRule
210    /// eliminates the Cast (since the CPU backend registers
211    /// `(NegElementwise, [F32, F32])` and `[BF16, BF16]`).
212    #[test]
213    fn end_to_end_cast_neg_collapses_to_neg() {
214        // Force the global binding table to be initialized (this
215        // also wires the standard CPU registrations).
216        let _bindings = global_bindings();
217
218        let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
219        let xc = x.cast(DType::BF16);
220        let y = xc.neg();
221        let graph = y.graph().clone();
222
223        let registry =
224            RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(cast_fusion_predicate())));
225        let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
226        assert_eq!(new_roots.len(), 1);
227        let new_root = new_roots[0];
228
229        let g = graph.read().unwrap();
230        // No Cast remains reachable from the new root.
231        let reachable = topo_order_multi(&g, &[new_root]);
232        let cast_count = reachable
233            .iter()
234            .filter(|&&n| matches!(g.node(n).op, Op::Cast(_)))
235            .count();
236        assert_eq!(
237            cast_count, 0,
238            "Cast should be eliminated when NegElementwise is registered for both [F32,F32] and [BF16,BF16]"
239        );
240        // The rewritten Neg consumes the original F32 Const directly.
241        assert!(matches!(g.node(new_root).op, Op::Neg));
242        assert_eq!(g.node(new_root).inputs, vec![x.id()]);
243    }
244
245    /// The end-to-end test above also verifies the predicate
246    /// answers for the *source* dtype path: the rewritten
247    /// `Neg(F32)` must have a registered kernel. If only the
248    /// target-dtype path were registered (`Neg(BF16)` but not
249    /// `Neg(F32)`) the rule should *not* fire — but we don't have
250    /// a clean way to deregister bindings mid-test, so we trust
251    /// the unit tests at the graph layer to verify the no-fire
252    /// path and check the live binding table here for the
253    /// "yes-fire" side only.
254    #[test]
255    fn live_binding_table_contains_neg_for_both_dtypes() {
256        let _bindings = global_bindings();
257        let predicate = cast_fusion_predicate();
258        assert!(
259            predicate(&Op::Neg, &[DType::F32, DType::F32]),
260            "fuel-cpu-backend's standard registration includes Neg[F32]"
261        );
262        assert!(
263            predicate(&Op::Neg, &[DType::BF16, DType::BF16]),
264            "fuel-cpu-backend's standard registration includes Neg[BF16]"
265        );
266    }
267}