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