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}