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}