Skip to main content

rlx_ir/ops/
backward.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Backward / training op builders.
6//!
7//! These nodes are emitted by `rlx-opt::autodiff` when it walks a
8//! forward graph in reverse and needs a closed-form gradient kernel
9//! (rather than composing one from primitives). Output shapes follow
10//! directly from the forward shapes: `relu_backward` and
11//! `maxpool2d_backward` match the original input; conv backward shapes
12//! match the original input / weight; cross-entropy returns one loss
13//! per row of logits.
14//!
15//! Shape checks here are debug-only; the verifier in `verify.rs` does
16//! the rigorous version.
17
18use crate::op::{AttentionBwdWrt, MaskKind};
19use crate::{DType, Graph, NodeId, Op, Shape};
20
21impl Graph {
22    /// ReLU backward: `dx = dy where x > 0 else 0`. Output shape matches `x`.
23    pub fn relu_backward(&mut self, x: NodeId, dy: NodeId) -> NodeId {
24        let x_shape = self.shape(x).clone();
25        debug_assert_eq!(
26            self.shape(x),
27            self.shape(dy),
28            "relu_backward: x and dy must have identical shapes"
29        );
30        self.push(Op::ReluBackward, vec![x, dy], x_shape, None)
31    }
32
33    /// Element-wise activation backward — closed-form derivative of
34    /// any single-input activation other than ReLU. See
35    /// `Op::ActivationBackward` for the per-kind formulae.
36    pub fn activation_backward(
37        &mut self,
38        kind: crate::op::Activation,
39        x: NodeId,
40        dy: NodeId,
41    ) -> NodeId {
42        let x_shape = self.shape(x).clone();
43        debug_assert_eq!(
44            self.shape(x),
45            self.shape(dy),
46            "activation_backward: x and dy must have identical shapes"
47        );
48        self.push(Op::ActivationBackward { kind }, vec![x, dy], x_shape, None)
49    }
50
51    /// LayerNorm backward w.r.t. the input. Inputs `[x, gamma, dy]`.
52    /// Output shape matches `x`. Currently axis = -1 only.
53    pub fn layer_norm_backward_input(
54        &mut self,
55        x: NodeId,
56        gamma: NodeId,
57        dy: NodeId,
58        axis: i32,
59        eps: f32,
60    ) -> NodeId {
61        let x_shape = self.shape(x).clone();
62        debug_assert_eq!(
63            self.shape(x),
64            self.shape(dy),
65            "layer_norm_backward_input: x and dy must match"
66        );
67        self.push(
68            Op::LayerNormBackwardInput { axis, eps },
69            vec![x, gamma, dy],
70            x_shape,
71            None,
72        )
73    }
74
75    /// RMSNorm backward w.r.t. input. Inputs `[x, gamma, beta, dy]`.
76    pub fn rms_norm_backward_input(
77        &mut self,
78        x: NodeId,
79        gamma: NodeId,
80        beta: NodeId,
81        dy: NodeId,
82        axis: i32,
83        eps: f32,
84    ) -> NodeId {
85        let x_shape = self.shape(x).clone();
86        self.push(
87            Op::RmsNormBackwardInput { axis, eps },
88            vec![x, gamma, beta, dy],
89            x_shape,
90            None,
91        )
92    }
93
94    pub fn rms_norm_backward_gamma(
95        &mut self,
96        x: NodeId,
97        gamma: NodeId,
98        beta: NodeId,
99        dy: NodeId,
100        axis: i32,
101        eps: f32,
102    ) -> NodeId {
103        self.push(
104            Op::RmsNormBackwardGamma { axis, eps },
105            vec![x, gamma, beta, dy],
106            self.shape(gamma).clone(),
107            None,
108        )
109    }
110
111    pub fn rms_norm_backward_beta(
112        &mut self,
113        x: NodeId,
114        gamma: NodeId,
115        beta: NodeId,
116        dy: NodeId,
117        axis: i32,
118        eps: f32,
119    ) -> NodeId {
120        self.push(
121            Op::RmsNormBackwardBeta { axis, eps },
122            vec![x, gamma, beta, dy],
123            self.shape(beta).clone(),
124            None,
125        )
126    }
127
128    /// Adjoint of [`Op::Rope`]. `style` must match the forward's — see
129    /// [`Op::RopeBackward::style`].
130    pub fn rope_backward(
131        &mut self,
132        dy: NodeId,
133        cos: NodeId,
134        sin: NodeId,
135        head_dim: usize,
136        n_rot: usize,
137        style: crate::op::RopeStyle,
138    ) -> NodeId {
139        let out_shape = self.shape(dy).clone();
140        self.push(
141            Op::RopeBackward {
142                head_dim,
143                n_rot,
144                style,
145            },
146            vec![dy, cos, sin],
147            out_shape,
148            None,
149        )
150    }
151
152    pub fn cumsum_backward(
153        &mut self,
154        dy: NodeId,
155        out_shape: Shape,
156        axis: i32,
157        exclusive: bool,
158    ) -> NodeId {
159        self.push(
160            Op::CumsumBackward { axis, exclusive },
161            vec![dy],
162            out_shape,
163            None,
164        )
165    }
166
167    pub fn gather_backward(
168        &mut self,
169        dy: NodeId,
170        indices: NodeId,
171        table_shape: Shape,
172        axis: i32,
173    ) -> NodeId {
174        self.push(
175            Op::GatherBackward { axis },
176            vec![dy, indices],
177            table_shape,
178            None,
179        )
180    }
181
182    /// GroupNorm (NCHW) backward w.r.t. input. Inputs `[x, gamma, beta, dy]`.
183    pub fn group_norm_backward_input(
184        &mut self,
185        x: NodeId,
186        gamma: NodeId,
187        beta: NodeId,
188        dy: NodeId,
189        num_groups: usize,
190        eps: f32,
191    ) -> NodeId {
192        let x_shape = self.shape(x).clone();
193        self.push(
194            Op::GroupNormBackwardInput { num_groups, eps },
195            vec![x, gamma, beta, dy],
196            x_shape,
197            None,
198        )
199    }
200
201    /// GroupNorm backward w.r.t. gamma. Inputs `[x, dy]`.
202    pub fn group_norm_backward_gamma(
203        &mut self,
204        x: NodeId,
205        dy: NodeId,
206        gamma_shape: Shape,
207        num_groups: usize,
208        eps: f32,
209    ) -> NodeId {
210        self.push(
211            Op::GroupNormBackwardGamma { num_groups, eps },
212            vec![x, dy],
213            gamma_shape,
214            None,
215        )
216    }
217
218    /// GroupNorm backward w.r.t. beta. Inputs `[x, dy]`.
219    pub fn group_norm_backward_beta(
220        &mut self,
221        x: NodeId,
222        dy: NodeId,
223        beta_shape: Shape,
224        num_groups: usize,
225        eps: f32,
226    ) -> NodeId {
227        self.push(
228            Op::GroupNormBackwardBeta { num_groups, eps },
229            vec![x, dy],
230            beta_shape,
231            None,
232        )
233    }
234
235    /// BatchNorm inference backward w.r.t. input.
236    pub fn batch_norm_inference_backward_input(
237        &mut self,
238        x: NodeId,
239        gamma: NodeId,
240        mean: NodeId,
241        var: NodeId,
242        dy: NodeId,
243        eps: f32,
244    ) -> NodeId {
245        let x_shape = self.shape(x).clone();
246        debug_assert_eq!(self.shape(x), self.shape(dy));
247        self.push(
248            Op::BatchNormInferenceBackwardInput { eps },
249            vec![x, gamma, mean, var, dy],
250            x_shape,
251            None,
252        )
253    }
254
255    /// BatchNorm inference backward w.r.t. gamma.
256    pub fn batch_norm_inference_backward_gamma(
257        &mut self,
258        x: NodeId,
259        mean: NodeId,
260        var: NodeId,
261        dy: NodeId,
262        gamma_shape: Shape,
263        eps: f32,
264    ) -> NodeId {
265        self.push(
266            Op::BatchNormInferenceBackwardGamma { eps },
267            vec![x, mean, var, dy],
268            gamma_shape,
269            None,
270        )
271    }
272
273    /// BatchNorm inference backward w.r.t. beta.
274    pub fn batch_norm_inference_backward_beta(&mut self, dy: NodeId, beta_shape: Shape) -> NodeId {
275        self.push(
276            Op::BatchNormInferenceBackwardBeta,
277            vec![dy],
278            beta_shape,
279            None,
280        )
281    }
282
283    /// LayerNorm backward w.r.t. gamma. Inputs `[x, dy]`. Output shape
284    /// is provided by the caller — typically the gamma's shape, e.g.
285    /// `[D]` for a per-feature 1-D gamma.
286    pub fn layer_norm_backward_gamma(
287        &mut self,
288        x: NodeId,
289        dy: NodeId,
290        gamma_shape: Shape,
291        axis: i32,
292        eps: f32,
293    ) -> NodeId {
294        debug_assert_eq!(
295            self.shape(x),
296            self.shape(dy),
297            "layer_norm_backward_gamma: x and dy must match"
298        );
299        self.push(
300            Op::LayerNormBackwardGamma { axis, eps },
301            vec![x, dy],
302            gamma_shape,
303            None,
304        )
305    }
306
307    /// 2D max-pool backward. `x` is the original NCHW input; `dy` is
308    /// the upstream gradient with shape matching the pool's output.
309    /// Output shape matches `x`.
310    pub fn maxpool2d_backward(
311        &mut self,
312        x: NodeId,
313        dy: NodeId,
314        kernel_size: Vec<usize>,
315        stride: Vec<usize>,
316        padding: Vec<usize>,
317    ) -> NodeId {
318        let x_shape = self.shape(x).clone();
319        debug_assert_eq!(kernel_size.len(), 2, "maxpool2d_backward: 2-D only");
320        debug_assert_eq!(stride.len(), 2);
321        debug_assert_eq!(padding.len(), 2);
322        self.push(
323            Op::MaxPool2dBackward {
324                kernel_size,
325                stride,
326                padding,
327            },
328            vec![x, dy],
329            x_shape,
330            None,
331        )
332    }
333
334    /// Conv2D backward w.r.t. input. `dy` has the conv output shape;
335    /// `w` is the forward weight `[C_out, C_in/groups, kH, kW]`. The
336    /// output shape (the original input shape) is supplied by the
337    /// caller because it can't be unambiguously derived from `dy.shape`
338    /// alone in the presence of strides + padding.
339    pub fn conv2d_backward_input(
340        &mut self,
341        dy: NodeId,
342        w: NodeId,
343        x_shape: Shape,
344        kernel_size: Vec<usize>,
345        stride: Vec<usize>,
346        padding: Vec<usize>,
347        dilation: Vec<usize>,
348        groups: usize,
349    ) -> NodeId {
350        debug_assert_eq!(kernel_size.len(), 2);
351        debug_assert_eq!(stride.len(), 2);
352        debug_assert_eq!(padding.len(), 2);
353        debug_assert_eq!(dilation.len(), 2);
354        self.push(
355            Op::Conv2dBackwardInput {
356                kernel_size,
357                stride,
358                padding,
359                dilation,
360                groups,
361            },
362            vec![dy, w],
363            x_shape,
364            None,
365        )
366    }
367
368    /// Conv2D backward w.r.t. weight. Output shape matches the forward
369    /// weight `[C_out, C_in/groups, kH, kW]`.
370    pub fn conv2d_backward_weight(
371        &mut self,
372        x: NodeId,
373        dy: NodeId,
374        w_shape: Shape,
375        kernel_size: Vec<usize>,
376        stride: Vec<usize>,
377        padding: Vec<usize>,
378        dilation: Vec<usize>,
379        groups: usize,
380    ) -> NodeId {
381        debug_assert_eq!(kernel_size.len(), 2);
382        debug_assert_eq!(stride.len(), 2);
383        debug_assert_eq!(padding.len(), 2);
384        debug_assert_eq!(dilation.len(), 2);
385        self.push(
386            Op::Conv2dBackwardWeight {
387                kernel_size,
388                stride,
389                padding,
390                dilation,
391                groups,
392            },
393            vec![x, dy],
394            w_shape,
395            None,
396        )
397    }
398
399    /// 3D max-pool backward on NCDHW. Output shape matches `x`.
400    pub fn maxpool3d_backward(
401        &mut self,
402        x: NodeId,
403        dy: NodeId,
404        kernel_size: Vec<usize>,
405        stride: Vec<usize>,
406        padding: Vec<usize>,
407    ) -> NodeId {
408        let x_shape = self.shape(x).clone();
409        debug_assert_eq!(x_shape.rank(), 5, "maxpool3d_backward: NCDHW only");
410        debug_assert_eq!(kernel_size.len(), 3, "maxpool3d_backward: 3-D only");
411        debug_assert_eq!(stride.len(), 3);
412        debug_assert_eq!(padding.len(), 3);
413        self.push(
414            Op::MaxPool3dBackward {
415                kernel_size,
416                stride,
417                padding,
418            },
419            vec![x, dy],
420            x_shape,
421            None,
422        )
423    }
424
425    /// Conv3D backward w.r.t. input. Caller supplies original `x_shape`.
426    pub fn conv3d_backward_input(
427        &mut self,
428        dy: NodeId,
429        w: NodeId,
430        x_shape: Shape,
431        kernel_size: Vec<usize>,
432        stride: Vec<usize>,
433        padding: Vec<usize>,
434        dilation: Vec<usize>,
435        groups: usize,
436    ) -> NodeId {
437        debug_assert_eq!(x_shape.rank(), 5, "conv3d_backward_input: NCDHW only");
438        debug_assert_eq!(kernel_size.len(), 3);
439        debug_assert_eq!(stride.len(), 3);
440        debug_assert_eq!(padding.len(), 3);
441        debug_assert_eq!(dilation.len(), 3);
442        self.push(
443            Op::Conv3dBackwardInput {
444                kernel_size,
445                stride,
446                padding,
447                dilation,
448                groups,
449            },
450            vec![dy, w],
451            x_shape,
452            None,
453        )
454    }
455
456    /// Conv3D backward w.r.t. weight. Output shape matches forward weight.
457    pub fn conv3d_backward_weight(
458        &mut self,
459        x: NodeId,
460        dy: NodeId,
461        w_shape: Shape,
462        kernel_size: Vec<usize>,
463        stride: Vec<usize>,
464        padding: Vec<usize>,
465        dilation: Vec<usize>,
466        groups: usize,
467    ) -> NodeId {
468        debug_assert_eq!(w_shape.rank(), 5, "conv3d_backward_weight: 5-D weight");
469        debug_assert_eq!(kernel_size.len(), 3);
470        debug_assert_eq!(stride.len(), 3);
471        debug_assert_eq!(padding.len(), 3);
472        debug_assert_eq!(dilation.len(), 3);
473        self.push(
474            Op::Conv3dBackwardWeight {
475                kernel_size,
476                stride,
477                padding,
478                dilation,
479                groups,
480            },
481            vec![x, dy],
482            w_shape,
483            None,
484        )
485    }
486
487    /// Fused softmax + cross-entropy against a dense target distribution
488    /// (soft labels / one-hot probabilities). `logits [N, C]`,
489    /// `targets [N, C]` → `[N]` per-row loss
490    /// `loss[n] = logsumexp(logits[n]) - Σ_c targets[n,c]·logits[n,c]`.
491    pub fn softmax_cross_entropy(&mut self, logits: NodeId, targets: NodeId) -> NodeId {
492        let logits_shape = self.shape(logits);
493        debug_assert_eq!(logits_shape.rank(), 2, "sce: logits must be 2-D [N, C]");
494        debug_assert_eq!(
495            self.shape(targets).dims(),
496            logits_shape.dims(),
497            "sce: targets must match logits shape [N, C]"
498        );
499        let n = logits_shape.dim(0);
500        let dtype = logits_shape.dtype();
501        let out_shape = Shape::from_dims(&[n], dtype);
502        self.push(
503            Op::SoftmaxCrossEntropy,
504            vec![logits, targets],
505            out_shape,
506            None,
507        )
508    }
509
510    /// Fused softmax + cross-entropy with f32-encoded integer labels.
511    /// `logits [N, C]`, `labels [N]` → `[N]` per-row loss.
512    pub fn softmax_cross_entropy_with_logits(&mut self, logits: NodeId, labels: NodeId) -> NodeId {
513        let logits_shape = self.shape(logits);
514        debug_assert_eq!(
515            logits_shape.rank(),
516            2,
517            "sce_with_logits: logits must be 2-D [N, C]"
518        );
519        let n = logits_shape.dim(0);
520        let dtype = logits_shape.dtype();
521        let out_shape = Shape::from_dims(&[n], dtype);
522        self.push(
523            Op::SoftmaxCrossEntropyWithLogits,
524            vec![logits, labels],
525            out_shape,
526            None,
527        )
528    }
529
530    /// Backward of `softmax_cross_entropy_with_logits`.
531    /// `[logits, labels, d_loss]` → `dlogits` shaped like `logits`.
532    pub fn softmax_cross_entropy_backward(
533        &mut self,
534        logits: NodeId,
535        labels: NodeId,
536        d_loss: NodeId,
537    ) -> NodeId {
538        let logits_shape = self.shape(logits).clone();
539        debug_assert_eq!(
540            logits_shape.rank(),
541            2,
542            "sce_backward: logits must be 2-D [N, C]"
543        );
544        self.push(
545            Op::SoftmaxCrossEntropyBackward,
546            vec![logits, labels, d_loss],
547            logits_shape,
548            None,
549        )
550    }
551
552    /// Element-wise complex squared-magnitude: `|z|² = re² + im²`.
553    /// Input must be `DType::C64`; output is same logical shape but
554    /// `DType::F32`. The canonical real-valued loss surface for
555    /// Wirtinger reverse-mode AD on complex graphs.
556    pub fn complex_norm_sq(&mut self, z: NodeId) -> NodeId {
557        let z_shape = self.shape(z).clone();
558        debug_assert_eq!(
559            z_shape.dtype(),
560            DType::C64,
561            "complex_norm_sq: input must be C64, got {:?}",
562            z_shape.dtype()
563        );
564        let out_shape = Shape::from_dims(z_shape.dims(), DType::F32);
565        self.push(Op::ComplexNormSq, vec![z], out_shape, None)
566    }
567
568    /// Scaled dot-product attention backward w.r.t. `q`, `k`, or `v`.
569    /// See [`Op::AttentionBackward`]. When `mask_kind` is [`MaskKind::Custom`]
570    /// or [`MaskKind::Bias`], pass the same mask tensor used in forward.
571    pub fn attention_backward(
572        &mut self,
573        wrt: AttentionBwdWrt,
574        q: NodeId,
575        k: NodeId,
576        v: NodeId,
577        dy: NodeId,
578        num_heads: usize,
579        head_dim: usize,
580        mask_kind: MaskKind,
581        mask: Option<NodeId>,
582    ) -> NodeId {
583        let out_shape = match wrt {
584            AttentionBwdWrt::Query => self.shape(q).clone(),
585            AttentionBwdWrt::Key => self.shape(k).clone(),
586            AttentionBwdWrt::Value => self.shape(v).clone(),
587        };
588        let mut inputs = vec![q, k, v, dy];
589        if matches!(mask_kind, MaskKind::Custom | MaskKind::Bias) {
590            inputs.push(mask.expect("attention_backward: mask required for Custom/Bias"));
591        }
592        self.push(
593            Op::AttentionBackward {
594                num_heads,
595                head_dim,
596                mask_kind,
597                wrt,
598            },
599            inputs,
600            out_shape,
601            None,
602        )
603    }
604
605    /// Emit `dQ`, `dK`, and `dV` for one [`Op::Attention`] forward node.
606    pub fn attention_backward_all(
607        &mut self,
608        q: NodeId,
609        k: NodeId,
610        v: NodeId,
611        dy: NodeId,
612        num_heads: usize,
613        head_dim: usize,
614        mask_kind: MaskKind,
615        mask: Option<NodeId>,
616    ) -> (NodeId, NodeId, NodeId) {
617        let dq = self.attention_backward(
618            AttentionBwdWrt::Query,
619            q,
620            k,
621            v,
622            dy,
623            num_heads,
624            head_dim,
625            mask_kind,
626            mask,
627        );
628        let dk = self.attention_backward(
629            AttentionBwdWrt::Key,
630            q,
631            k,
632            v,
633            dy,
634            num_heads,
635            head_dim,
636            mask_kind,
637            mask,
638        );
639        let dv = self.attention_backward(
640            AttentionBwdWrt::Value,
641            q,
642            k,
643            v,
644            dy,
645            num_heads,
646            head_dim,
647            mask_kind,
648            mask,
649        );
650        (dq, dk, dv)
651    }
652
653    /// Wirtinger backward for `complex_norm_sq`: given upstream `g`
654    /// (real, same shape as the forward output) and the original
655    /// complex input `z`, returns `dz = g · z` as C64.
656    pub fn complex_norm_sq_backward(&mut self, z: NodeId, g: NodeId) -> NodeId {
657        let z_shape = self.shape(z).clone();
658        debug_assert_eq!(z_shape.dtype(), DType::C64);
659        debug_assert_eq!(self.shape(g).dtype(), DType::F32);
660        debug_assert_eq!(
661            z_shape.dims(),
662            self.shape(g).dims(),
663            "complex_norm_sq_backward: z and g must share logical shape"
664        );
665        self.push(Op::ComplexNormSqBackward, vec![z, g], z_shape, None)
666    }
667
668    /// Element-wise complex conjugate: `z̄ = re - i·im`. Input must be
669    /// `DType::C64`; output is the same shape and dtype. Used by
670    /// Wirtinger VJP rules on C64 binary ops.
671    pub fn conjugate(&mut self, z: NodeId) -> NodeId {
672        let z_shape = self.shape(z).clone();
673        debug_assert_eq!(
674            z_shape.dtype(),
675            DType::C64,
676            "conjugate: input must be C64, got {:?}",
677            z_shape.dtype()
678        );
679        self.push(Op::Conjugate, vec![z], z_shape, None)
680    }
681
682    /// Packed reverse of [`Op::AdaLayerNorm`]: output is 1-D
683    /// `[dx ∥ dscale ∥ dshift]`. See [`unpack_ada_layer_norm_backward`].
684    pub fn ada_layer_norm_backward(
685        &mut self,
686        x: NodeId,
687        scale: NodeId,
688        shift: NodeId,
689        dy: NodeId,
690        norm: crate::op::AdaNormKind,
691        eps: f32,
692    ) -> NodeId {
693        let x_shape = self.shape(x).clone();
694        let scale_shape = self.shape(scale).clone();
695        let nx = x_shape
696            .num_elements()
697            .expect("ada_layer_norm_backward: static x");
698        let ns = scale_shape
699            .num_elements()
700            .expect("ada_layer_norm_backward: static scale");
701        let out = Shape::new(&[nx + 2 * ns], x_shape.dtype());
702        self.push(
703            Op::AdaLayerNormBackward { norm, eps },
704            vec![x, scale, shift, dy],
705            out,
706            None,
707        )
708    }
709
710    /// Packed reverse of [`Op::GatedResidual`]: output is 1-D
711    /// `[dx ∥ dy ∥ dgate]`. See [`unpack_gated_residual_backward`].
712    pub fn gated_residual_backward(
713        &mut self,
714        x: NodeId,
715        y: NodeId,
716        gate: NodeId,
717        dy: NodeId,
718    ) -> NodeId {
719        let x_shape = self.shape(x).clone();
720        let gate_shape = self.shape(gate).clone();
721        let nx = x_shape
722            .num_elements()
723            .expect("gated_residual_backward: static x");
724        let ng = gate_shape
725            .num_elements()
726            .expect("gated_residual_backward: static gate");
727        let out = Shape::new(&[nx + nx + ng], x_shape.dtype());
728        self.push(Op::GatedResidualBackward, vec![x, y, gate, dy], out, None)
729    }
730}
731
732/// Unpack [`Op::AdaLayerNormBackward`] packed output into `(dx, dscale, dshift)`.
733pub fn unpack_ada_layer_norm_backward(
734    g: &mut Graph,
735    packed: NodeId,
736    x_shape: &Shape,
737    scale_shape: &Shape,
738) -> (NodeId, NodeId, NodeId) {
739    let nx = x_shape.num_elements().expect("unpack ada: static x");
740    let ns = scale_shape
741        .num_elements()
742        .expect("unpack ada: static scale");
743    let dtype = x_shape.dtype();
744    let dx_flat = g.add_node(
745        Op::Narrow {
746            axis: 0,
747            start: 0,
748            len: nx,
749        },
750        vec![packed],
751        Shape::new(&[nx], dtype),
752    );
753    let dscale_flat = g.add_node(
754        Op::Narrow {
755            axis: 0,
756            start: nx,
757            len: ns,
758        },
759        vec![packed],
760        Shape::new(&[ns], dtype),
761    );
762    let dshift_flat = g.add_node(
763        Op::Narrow {
764            axis: 0,
765            start: nx + ns,
766            len: ns,
767        },
768        vec![packed],
769        Shape::new(&[ns], dtype),
770    );
771    let dx = reshape_like(g, dx_flat, x_shape);
772    let dscale = reshape_like(g, dscale_flat, scale_shape);
773    let dshift = reshape_like(g, dshift_flat, scale_shape);
774    (dx, dscale, dshift)
775}
776
777/// Unpack [`Op::GatedResidualBackward`] packed output into `(dx, dy, dgate)`.
778pub fn unpack_gated_residual_backward(
779    g: &mut Graph,
780    packed: NodeId,
781    x_shape: &Shape,
782    gate_shape: &Shape,
783) -> (NodeId, NodeId, NodeId) {
784    let nx = x_shape.num_elements().expect("unpack gate: static x");
785    let ng = gate_shape.num_elements().expect("unpack gate: static gate");
786    let dtype = x_shape.dtype();
787    let dx_flat = g.add_node(
788        Op::Narrow {
789            axis: 0,
790            start: 0,
791            len: nx,
792        },
793        vec![packed],
794        Shape::new(&[nx], dtype),
795    );
796    let dy_flat = g.add_node(
797        Op::Narrow {
798            axis: 0,
799            start: nx,
800            len: nx,
801        },
802        vec![packed],
803        Shape::new(&[nx], dtype),
804    );
805    let dgate_flat = g.add_node(
806        Op::Narrow {
807            axis: 0,
808            start: 2 * nx,
809            len: ng,
810        },
811        vec![packed],
812        Shape::new(&[ng], dtype),
813    );
814    let dx = reshape_like(g, dx_flat, x_shape);
815    let dy = reshape_like(g, dy_flat, x_shape);
816    let dgate = reshape_like(g, dgate_flat, gate_shape);
817    (dx, dy, dgate)
818}
819
820fn reshape_like(g: &mut Graph, flat: NodeId, shape: &Shape) -> NodeId {
821    let dims: Vec<i64> = shape
822        .dims()
823        .iter()
824        .map(|d| match d {
825            crate::shape::Dim::Static(n) => *n as i64,
826            _ => panic!("reshape_like: dynamic dim"),
827        })
828        .collect();
829    g.add_node(
830        Op::Reshape {
831            new_shape: dims.clone(),
832        },
833        vec![flat],
834        shape.clone(),
835    )
836}