burn_mamba/mamba3/quat_scan/backward.rs
1//! # Custom autodiff node for the quaternion cumulative-product scan
2//!
3//! Implements [`Mamba3QuatScanBackendExt`](super::quat_scan::Mamba3QuatScanBackendExt)
4//! for `Autodiff<B>` via a single Burn [`Backward`] node. The forward keeps only
5//! its two leaf inputs (the per-step quaternions `q` and the carry `init`);
6//! backprop recomputes the prefix product and evaluates the exact quaternion VJP
7//! of the cumulative product, so the `O(log seq)` scan intermediates are never
8//! retained.
9//!
10//! ## Gradient math
11//!
12//! The scan is `cum[t] = qₜ ⊗ cum[t-1]`, `cum[-1] = init` (so `cum[t] = Pₜ ⊗ init`
13//! with the prefix product `Pₜ = qₜ ⊗ ⋯ ⊗ q₀`). Quaternion multiplication is
14//! bilinear, and for the real inner product the per-factor VJPs are exact for
15//! **all** quaternions:
16//!
17//! ```text
18//! o = a ⊗ b , cotangent ḡ ⟹ grad_a = ḡ ⊗ conj(b) , grad_b = conj(a) ⊗ ḡ .
19//! ```
20//!
21//! Let `G[t]` be the total cotangent reaching `cum[t]` (the upstream `d_cum[t]`
22//! plus what flows back from `cum[t+1]`). The reverse recurrence
23//! `G[t] = d_cum[t] + conj(qₜ₊₁) ⊗ G[t+1]` telescopes, and **for unit
24//! quaternions** (which the rotation always produces) collapses to a
25//! parallel-friendly closed form:
26//!
27//! ```text
28//! S[t] = Σ_{s≥t} conj(Pₛ) ⊗ d_cum[s] (suffix-sum = reverse cumsum)
29//! G[t] = Pₜ ⊗ S[t]
30//! d_q[t] = G[t] ⊗ conj(cum[t-1]) (cum[-1] = init)
31//! d_init = S[0]
32//! ```
33//!
34//! `final_carry = cum[:, −1]` is a high-level autodiff slice (in
35//! [`quat_cumprod_recalculated`](super::quat_scan::quat_cumprod_recalculated)),
36//! so its gradient is already folded into `d_cum` at the last position before
37//! this node runs — the node has a single output.
38
39#![allow(non_snake_case)]
40
41use super::quat_scan::{Mamba3QuatScanBackendExt, Quat, quat_prefix_product_soa};
42use crate::utils::fprim::F;
43use burn::backend::autodiff::{
44 Autodiff,
45 checkpoint::{base::Checkpointer, strategy::CheckpointStrategy},
46 grads::Gradients,
47 ops::{Backward, Ops, OpsKind},
48};
49use burn::backend::tensor::FloatTensor;
50use burn::backend::{Backend, BackendTypes};
51
52/// Recompute-based gradient of the quaternion cumulative product.
53///
54/// Given the saved inputs (`q`, `init`) and the upstream cotangent `d_cum`,
55/// recomputes the prefix product `P` and returns `(d_q, d_init)` via the
56/// closed-form unit-quaternion VJP documented in the module header. All ops are
57/// parallel (a prefix product + a reverse-cumsum); there is no token loop.
58fn quat_scan_backward<B: Backend>(
59 q_bshj4: F<B, 5>,
60 init_bhj4: F<B, 4>,
61 d_cum_bshj4: F<B, 5>,
62) -> (F<B, 5>, F<B, 4>) {
63 // Unpack to struct-of-arrays once; all the heavy math is narrow/cat-free.
64 let q = Quat::from_rank5(q_bshj4);
65 let init = Quat::from_rank5(init_bhj4.unsqueeze_dim::<5>(1)); // [b,1,h,j] components
66 let d_cum = Quat::from_rank5(d_cum_bshj4);
67 let sequence = q.w.dims()[1];
68
69 // Pₜ = qₜ ⊗ … ⊗ q₀ (prefix product, no carry) — recomputed, not stored.
70 let p = quat_prefix_product_soa::<B>(q);
71
72 // cum[t] = Pₜ ⊗ init (broadcast over the sequence axis).
73 let cum = p.clone().mul(init.clone());
74
75 // S[t] = Σ_{s≥t} conj(Pₛ) ⊗ d_cum[s] — suffix-sum (reverse cumsum) per component.
76 let s = p.clone().conj().mul(d_cum).reverse_cumsum_seq();
77
78 // G[t] = Pₜ ⊗ S[t].
79 let g = p.mul(s.clone());
80
81 // cum_prev[t] = cum[t-1]; cum_prev[0] = init (prepend init, drop the last).
82 let cum_prev = cum.shift_prepend(init, sequence);
83
84 // d_q[t] = G[t] ⊗ conj(cum_prev[t]); d_init = S[0].
85 let d_q_bshj4 = g.mul(cum_prev.conj()).pack();
86 let d_init_bhj4 = s.pack().narrow(1, 0, 1).squeeze_dim::<4>(1);
87
88 (d_q_bshj4, d_init_bhj4)
89}
90
91impl<B: Backend + Mamba3QuatScanBackendExt, C: CheckpointStrategy> Mamba3QuatScanBackendExt
92 for Autodiff<B, C>
93{
94 fn quat_cumprod(q_bshj4: FloatTensor<Self>, init_bhj4: FloatTensor<Self>) -> FloatTensor<Self> {
95 // ── Backward node definition ─────────────────────────────────────────
96 #[derive(Debug)]
97 struct QuatScanBackward;
98
99 #[derive(Clone, Debug)]
100 struct State<B: Backend> {
101 // Saved forward inputs (only the leaves — no scan intermediates).
102 q_bshj4: <B as BackendTypes>::FloatTensorPrimitive,
103 init_bhj4: <B as BackendTypes>::FloatTensorPrimitive,
104 shape_q_bshj4: [usize; 5],
105 shape_init_bhj4: [usize; 4],
106 }
107
108 impl<B: Backend + Mamba3QuatScanBackendExt> Backward<B, 2> for QuatScanBackward {
109 type State = State<B>;
110
111 fn backward(
112 self,
113 ops: Ops<Self::State, 2>,
114 grads: &mut Gradients,
115 _checkpointer: &mut Checkpointer,
116 ) {
117 let [node_q, node_init] = ops.parents;
118
119 // Upstream gradient of `cum` (already includes the final_carry
120 // slice's contribution, scattered in at the last position).
121 let d_cum: <B as BackendTypes>::FloatTensorPrimitive =
122 grads.consume::<B>(&ops.node);
123
124 let State {
125 q_bshj4,
126 init_bhj4,
127 shape_q_bshj4,
128 shape_init_bhj4,
129 } = ops.state;
130
131 let q = F::<B, 5>::new(q_bshj4).reshape(shape_q_bshj4);
132 let init = F::<B, 4>::new(init_bhj4).reshape(shape_init_bhj4);
133 // `cum` has the same shape as `q`, hence so does its gradient.
134 let d_cum = F::<B, 5>::new(d_cum).reshape(shape_q_bshj4);
135
136 let (d_q, d_init) = quat_scan_backward::<B>(q, init, d_cum);
137
138 if let Some(n) = node_q {
139 grads.register::<B>(n.id, d_q.inner());
140 }
141 if let Some(n) = node_init {
142 grads.register::<B>(n.id, d_init.inner());
143 }
144 }
145 }
146
147 // ── Shape extraction ───────────────────────────────────────────────
148 use burn::backend::TensorMetadata;
149 let shape_q_bshj4: [usize; 5] = q_bshj4.primitive.shape().dims();
150 let shape_init_bhj4: [usize; 4] = init_bhj4.primitive.shape().dims();
151
152 // ── Register backward / run forward ─────────────────────────────────
153 match QuatScanBackward
154 .prepare::<C>([q_bshj4.node.clone(), init_bhj4.node.clone()])
155 .compute_bound()
156 .stateful()
157 {
158 OpsKind::Tracked(prep) => {
159 let prim_cum =
160 B::quat_cumprod(q_bshj4.primitive.clone(), init_bhj4.primitive.clone());
161 let state = State {
162 q_bshj4: q_bshj4.primitive.clone(),
163 init_bhj4: init_bhj4.primitive.clone(),
164 shape_q_bshj4,
165 shape_init_bhj4,
166 };
167 prep.finish(state, prim_cum)
168 }
169 OpsKind::UnTracked(prep) => {
170 let prim_cum = B::quat_cumprod(q_bshj4.primitive, init_bhj4.primitive);
171 prep.finish(prim_cum)
172 }
173 }
174 }
175}