Skip to main content

fuel_graph/
grad.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Symbolic autograd (Phase 6d Track 2).
3//!
4//! The forward backward pass in `NodeHandle::backward` walks the topo
5//! order in reverse and emits gradient nodes inline via a single
6//! 600-line `match` over `Op`. This module factors that out into a
7//! per-op trait so:
8//!
9//! 1. The backward graph is constructed via the same lazy IR machinery
10//!    as the forward — the planner sees both, fuses across them, and
11//!    schedules them together.
12//! 2. Higher-order gradients fall out for free: just call `backward`
13//!    on the resulting backward graph.
14//! 3. Third-party ops can register their own gradient rules without
15//!    forking `fuel-graph`.
16//!
17//! This is the *scaffolding*. The existing `match` in `NodeHandle::backward`
18//! still does most of the work today; ops migrate one at a time as
19//! their rule is implemented here. The dispatcher below is consulted
20//! first; if no rule is registered for an op, the existing match
21//! handles it (forward-compatible split).
22
23use crate::{NodeId, Op, SharedGraph, node_dtype, node_shape, push_node};
24
25/// Result of a backward rule: one gradient `NodeId` per forward input
26/// slot, with `None` for inputs that aren't differentiable (e.g. the
27/// index tensor of `IndexSelect`, or the block table of `PagedAttn`).
28///
29/// Length must match the forward op's `inputs` slice.
30pub type GradientList = Vec<Option<NodeId>>;
31
32/// Per-op backward rule. Implementors emit gradient nodes into the
33/// shared graph and return one gradient per forward input.
34///
35/// Convention: a rule is responsible for *all* input slots of its
36/// op. If an input is non-differentiable, return `None` for that slot.
37/// Don't add the upstream into the per-input gradient sum — the
38/// caller's `accumulate_grad` handles cross-edge accumulation.
39pub trait GradientRule {
40    /// Build gradient nodes for this op.
41    ///
42    /// - `graph`:    the shared graph; rule pushes new nodes into it
43    /// - `op`:       a reference to the forward op (for parameter access)
44    /// - `inputs`:   forward-input NodeIds (one per slot)
45    /// - `output`:   the forward output's NodeId
46    /// - `upstream`: the upstream gradient flowing into this output
47    fn backward(
48        &self,
49        graph: &SharedGraph,
50        op: &Op,
51        inputs: &[NodeId],
52        output: NodeId,
53        upstream: NodeId,
54    ) -> GradientList;
55}
56
57/// Dispatch entry consulted by `NodeHandle::backward` before falling
58/// through to the inline `match`. Returns `None` if no rule is
59/// registered for `op` — the caller then handles it inline.
60///
61/// As ops migrate to `GradientRule` impls, their inline `match` arms
62/// are deleted and the rule is added below.
63pub fn dispatch_gradient(
64    graph: &SharedGraph,
65    op: &Op,
66    inputs: &[NodeId],
67    output: NodeId,
68    upstream: NodeId,
69) -> Option<GradientList> {
70    match op {
71        Op::Add => Some(AddRule.backward(graph, op, inputs, output, upstream)),
72        Op::Mul => Some(MulRule.backward(graph, op, inputs, output, upstream)),
73        Op::Relu => Some(ReluRule.backward(graph, op, inputs, output, upstream)),
74        // --- comparison family: non-differentiable, terminate cleanly ---
75        Op::Equal | Op::Ne | Op::Lt | Op::Le | Op::Gt | Op::Ge => {
76            Some(NoGradientBinaryRule.backward(graph, op, inputs, output, upstream))
77        }
78        // --- ternary select: differentiable through `a` and `b` ---
79        Op::Where => Some(WhereRule.backward(graph, op, inputs, output, upstream)),
80        _ => None,
81    }
82}
83
84// ---- Concrete rules: the migration recipe -----------------------------------
85
86/// `d(a + b)/da = 1`, `d(a + b)/db = 1`. Upstream flows unchanged into both.
87pub struct AddRule;
88impl GradientRule for AddRule {
89    fn backward(
90        &self,
91        _graph: &SharedGraph,
92        _op: &Op,
93        _inputs: &[NodeId],
94        _output: NodeId,
95        upstream: NodeId,
96    ) -> GradientList {
97        vec![Some(upstream), Some(upstream)]
98    }
99}
100
101/// `d(a * b)/da = b`, `d(a * b)/db = a`. Upstream * b → a, upstream * a → b.
102pub struct MulRule;
103impl GradientRule for MulRule {
104    fn backward(
105        &self,
106        graph: &SharedGraph,
107        _op: &Op,
108        inputs: &[NodeId],
109        _output: NodeId,
110        upstream: NodeId,
111    ) -> GradientList {
112        let a = inputs[0];
113        let b = inputs[1];
114        let a_shape = node_shape(graph, a);
115        let dtype = node_dtype(graph, a);
116        let grad_a = push_node(graph, Op::Mul, vec![upstream, b], a_shape.clone(), dtype);
117        let grad_b = push_node(graph, Op::Mul, vec![upstream, a], a_shape, dtype);
118        vec![Some(grad_a), Some(grad_b)]
119    }
120}
121
122/// Backward rule for non-differentiable two-input ops (the comparison
123/// family: `Equal`, `Ne`, `Lt`, `Le`, `Gt`, `Ge`). Returns `None` for
124/// both input slots so the autograd traversal terminates without
125/// extending the gradient graph through the op. Comparison-on-loss-
126/// path is a user error; the missing gradient downstream surfaces it
127/// rather than panicking.
128pub struct NoGradientBinaryRule;
129impl GradientRule for NoGradientBinaryRule {
130    fn backward(
131        &self,
132        _graph: &SharedGraph,
133        _op: &Op,
134        _inputs: &[NodeId],
135        _output: NodeId,
136        _upstream: NodeId,
137    ) -> GradientList {
138        vec![None, None]
139    }
140}
141
142/// Backward rule for [`Op::Where`] (ternary select).
143///
144/// Forward: `out[i] = if cond[i] != 0 { a[i] } else { b[i] }`.
145/// Inputs: `(cond, a, b)` where `cond` is `U8`, `a` and `b` share
146/// dtype `T`.
147///
148/// Gradients:
149/// - `cond`: `None` (`U8` mask is non-differentiable).
150/// - `a`: `upstream * cast(cond, T)` — gradient flows only at the
151///   slots where `a` was picked.
152/// - `b`: `upstream * cast(1 - cond, T)` — gradient flows only at
153///   the slots where `b` was picked.
154///
155/// The `1 - cond` mask is built via `Op::AddScalar(-1.0)` followed by
156/// `Op::Neg`: `m_b = -(m_a - 1) = 1 - m_a`. This avoids needing a
157/// `Const(1)` factory inside the rule (which would require synthesizing
158/// a slot-populated leaf via the device handle).
159pub struct WhereRule;
160impl GradientRule for WhereRule {
161    fn backward(
162        &self,
163        graph: &SharedGraph,
164        _op: &Op,
165        inputs: &[NodeId],
166        _output: NodeId,
167        upstream: NodeId,
168    ) -> GradientList {
169        let cond = inputs[0];
170        let a = inputs[1];
171        let _b = inputs[2];
172        let shape = node_shape(graph, a);
173        let dtype = node_dtype(graph, a);
174        // m_a = cast(cond, dtype) — the "pick a" mask in float space.
175        // Op::Cast is keyed on the target dtype; the source dtype
176        // (U8 here) flows from the input node's dtype.
177        let m_a = push_node(graph, Op::Cast(dtype), vec![cond], shape.clone(), dtype);
178        // m_b = -(m_a + (-1)) = 1 - m_a — the complementary mask.
179        // AddScalar(-1.0) produces (m_a - 1); Neg flips the sign.
180        let m_a_minus_one = push_node(graph, Op::AddScalar(-1.0), vec![m_a], shape.clone(), dtype);
181        let m_b = push_node(graph, Op::Neg, vec![m_a_minus_one], shape.clone(), dtype);
182        // grad_a = upstream * m_a; grad_b = upstream * m_b.
183        let grad_a = push_node(graph, Op::Mul, vec![upstream, m_a], shape.clone(), dtype);
184        let grad_b = push_node(graph, Op::Mul, vec![upstream, m_b], shape, dtype);
185        vec![None, Some(grad_a), Some(grad_b)]
186    }
187}
188
189/// `d(relu(x))/dx = (x > 0 ? 1 : 0) = step(x)`.
190pub struct ReluRule;
191impl GradientRule for ReluRule {
192    fn backward(
193        &self,
194        graph: &SharedGraph,
195        _op: &Op,
196        inputs: &[NodeId],
197        _output: NodeId,
198        upstream: NodeId,
199    ) -> GradientList {
200        let x = inputs[0];
201        let x_shape = node_shape(graph, x);
202        let dtype = node_dtype(graph, x);
203        let mask = push_node(graph, Op::Step, vec![x], x_shape.clone(), dtype);
204        let grad_x = push_node(graph, Op::Mul, vec![upstream, mask], x_shape, dtype);
205        vec![Some(grad_x)]
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::NodeHandle;
213    use fuel_ir::Shape;
214    use std::sync::Arc;
215
216    /// Phase 7.5 G2: tests need a real device for slot-populating
217    /// constructors. Singleton CpuBackendDevice via OnceLock.
218    fn cpu_dev() -> &'static Arc<dyn fuel_backend_contract::DynBackendDevice> {
219        static D: std::sync::OnceLock<Arc<dyn fuel_backend_contract::DynBackendDevice>> =
220            std::sync::OnceLock::new();
221        D.get_or_init(|| Arc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice))
222    }
223
224    /// Confirm that the dispatch hook actually fires for migrated ops:
225    /// `dispatch_gradient(Op::Add, ...)` returns Some, indicating the
226    /// trait-based path handled the gradient. (The legacy match arm
227    /// for Add still exists as a safety net but should never run.)
228    #[test]
229    fn dispatch_fires_for_migrated_ops() {
230        let a = NodeHandle::from_f32(vec![1.0_f32, 2.0, 3.0], Shape::from_dims(&[3]), cpu_dev())
231            .unwrap();
232        let b = a
233            .const_f32_like(vec![4.0_f32, 5.0, 6.0], Shape::from_dims(&[3]))
234            .unwrap();
235        let c = a.add(&b);
236        // Drive a backward to populate upstream
237        let grads = c.backward();
238        // Both inputs should have gradients; for Add both equal upstream.
239        let ga = grads.get(&a).expect("gradient for a");
240        let gb = grads.get(&b).expect("gradient for b");
241        // dispatch_gradient(Op::Add, ...) returns the upstream node twice
242        // verbatim, so ga and gb should be the same NodeId (the same upstream).
243        assert_eq!(
244            ga.id(),
245            gb.id(),
246            "AddRule should funnel both gradients to the same upstream node"
247        );
248    }
249
250    /// Sanity: dispatch_gradient returns None for ops we haven't
251    /// migrated, so they fall through to the legacy match.
252    #[test]
253    fn dispatch_returns_none_for_unmigrated_ops() {
254        // MatMul hasn't been migrated to a GradientRule yet — the
255        // legacy match still handles it. dispatch_gradient should
256        // return None.
257        use crate::SharedGraph;
258        use std::sync::{Arc, RwLock};
259        let g: SharedGraph = Arc::new(RwLock::new(crate::Graph::new()));
260        let dummy = NodeId(0);
261        let result = dispatch_gradient(&g, &Op::MatMul, &[dummy, dummy], dummy, dummy);
262        assert!(
263            result.is_none(),
264            "MatMul should not have a registered rule yet"
265        );
266    }
267
268    /// `WhereRule` returns 3 entries: `None` for cond (non-differentiable
269    /// U8), `Some(grad_a)` reaching `a` only at picked positions,
270    /// `Some(grad_b)` reaching `b` at non-picked positions. The
271    /// per-input grads are built from `Op::Cast`, `Op::AddScalar`,
272    /// `Op::Neg`, `Op::Mul` — all primitives.
273    #[test]
274    fn dispatch_where_returns_none_for_cond_some_for_a_and_b() {
275        use crate::SharedGraph;
276        use fuel_ir::{DType, Shape};
277        use std::sync::{Arc, RwLock};
278        // Build a real graph so push_node calls inside the rule have
279        // somewhere to land. Three placeholder Const nodes for the
280        // three inputs.
281        let g: SharedGraph = Arc::new(RwLock::new(crate::Graph::new()));
282        let (cond, a, b, output, upstream) = {
283            let mut gw = g.write().unwrap();
284            let cond = gw.push(crate::Node {
285                op: Op::Const,
286                inputs: vec![],
287                shape: Shape::from_dims(&[3]),
288                dtype: DType::U8,
289            });
290            let a = gw.push(crate::Node {
291                op: Op::Const,
292                inputs: vec![],
293                shape: Shape::from_dims(&[3]),
294                dtype: DType::F32,
295            });
296            let b = gw.push(crate::Node {
297                op: Op::Const,
298                inputs: vec![],
299                shape: Shape::from_dims(&[3]),
300                dtype: DType::F32,
301            });
302            let output = gw.push(crate::Node {
303                op: Op::Where,
304                inputs: vec![cond, a, b],
305                shape: Shape::from_dims(&[3]),
306                dtype: DType::F32,
307            });
308            let upstream = gw.push(crate::Node {
309                op: Op::Const,
310                inputs: vec![],
311                shape: Shape::from_dims(&[3]),
312                dtype: DType::F32,
313            });
314            (cond, a, b, output, upstream)
315        };
316        let result = dispatch_gradient(&g, &Op::Where, &[cond, a, b], output, upstream)
317            .expect("Op::Where must have a registered GradientRule");
318        assert_eq!(result.len(), 3, "Where has 3 inputs");
319        assert!(
320            result[0].is_none(),
321            "cond gradient must be None (U8 non-differentiable)"
322        );
323        assert!(
324            result[1].is_some(),
325            "a gradient must be Some (differentiable through pick)"
326        );
327        assert!(
328            result[2].is_some(),
329            "b gradient must be Some (differentiable through fallback)"
330        );
331        // Sanity: the two grads should reference different nodes.
332        assert_ne!(
333            result[1].unwrap(),
334            result[2].unwrap(),
335            "grad_a and grad_b should be distinct backward nodes"
336        );
337    }
338
339    /// Comparison family terminates the autograd traversal cleanly:
340    /// the dispatcher returns Some(vec![None, None]) so the caller
341    /// extends nothing into the gradient graph for either input.
342    /// Without this rule, the legacy fallthrough would emit a U8 gradient
343    /// (impossible) or panic.
344    #[test]
345    fn dispatch_returns_none_gradients_for_eq() {
346        use crate::SharedGraph;
347        use std::sync::{Arc, RwLock};
348        let g: SharedGraph = Arc::new(RwLock::new(crate::Graph::new()));
349        let dummy = NodeId(0);
350        let result = dispatch_gradient(&g, &Op::Equal, &[dummy, dummy], dummy, dummy)
351            .expect("Op::Equal must have a registered GradientRule");
352        assert_eq!(result.len(), 2, "Op::Equal has 2 inputs");
353        assert!(
354            result[0].is_none(),
355            "lhs gradient must be None (non-differentiable)"
356        );
357        assert!(
358            result[1].is_none(),
359            "rhs gradient must be None (non-differentiable)"
360        );
361    }
362}