Skip to main content

burn_mamba/mamba3/
moments.rs

1//! ## Physical-frame state moments for Mamba-3 (serial chunkwise, exact)
2//!
3//! The Mamba-3 state is **complex** (paper Props. *complex-to-real* /
4//! *rope-trick*): the real cache state `h̃ₜ` (`ssm_bhpr`) carries the rotations
5//! absorbed into B̃/C̃, and the **physical** state — what raw, un-rotated C
6//! reads — is the per-token de-rotation `cₜ = Dₜ†h̃ₜ` (see
7//! [`RotationSeq::derotate_states`]). The shipped observable/penalty is the
8//! Hermitian PR of `M_phys = Σₜ cₜᴴcₜ`
9//! ([`StateMoments::pr_complex`](crate::modules::StateMoments::pr_complex)).
10//!
11//! Unlike the Mamba-2 moments (`mamba2/ssd/moments.rs`), **no closed form
12//! exists**: in `M_phys[a,b]` the token index couples to the matrix entry
13//! through the relative phase `e^{i(φ_{t,b} − φ_{t,a})}`, so the per-token
14//! phases do not factor out of the Gram kernel — every exact factorisation
15//! reproduces the per-token state as an intermediate. Exact `M_phys` therefore
16//! costs materialised states, affordable **chunk-locally** in the
17//! `SerialRecalculated` discipline. Per chunk (serial over `n`, one small
18//! `(m2, m1)` accumulator pair carried):
19//!
20//! 1. Chunk-local decay `dₜ = exp(a_cum[t])` and 1-semiseparable mask
21//!    `L[t,j] = exp(a_cum[t] − a_cum[j])` (as SSD Step 1).
22//! 2. One chunk of cache-frame states, folding the combined-injection channel
23//!    axis `M` into the write index:
24//!    `h̃[t] = dₜ·h₋ + Σ_{j≤t,m} L[t,j] · x̂[j,m] ⊗ b̂[j,m]` — a broadcast
25//!    product plus one batched GEMM; the transients are
26//!    `[batch, nheads, l, l·M, per_head_dim]` and one chunk of states
27//!    `[batch, nheads, l, per_head_dim, state_rank]`.
28//! 3. De-rotate the states' `r` axis to the physical frame
29//!    ([`RotationSeq::derotate_states`] — θ-differentiable, which is what lets
30//!    a PR penalty shape the rotation itself).
31//! 4. Accumulate the plain real `m2`/`m1` sums, masked to `valid_len`.
32//!
33//! The **combined injections** realise the trapezoid as one scalar-decay
34//! system with a `2·mimo_rank` channel axis: `x̂ = concat_m(v_γ, v_β)`,
35//! `b̂ = concat_m(B̃, B̃_prev)` (the β stream shift-before-chunking, its first
36//! element from the cache's `k_state`/`v_state`), shared log-decay `da`. The
37//! initial state is the cache's `ssm_bhpr` (+ optional learnable `h₀`),
38//! counted exactly **once** — never the single-ssd kernel's seed-augmented
39//! initial state, whose boundary-β term the β stream already carries.
40//!
41//! The chunk carry `h₋` is read from the **unmasked** last position (zero pads:
42//! `Δ = 0` ⇒ identity decay, zero write, so the state is carried through
43//! unchanged); the `valid_len` mask alone excludes pad garbage from the sums
44//! (pad rotation values are irrelevant — masked before accumulation).
45//!
46//! Plain autodiff over the serial loop retains every chunk's states (the full
47//! per-token trajectory) — the study-scale mode. The at-scale execution model
48//! is a custom recompute backward in the `SerialRecalculated` pattern (module
49//! `backward`, milestone 3).
50
51#[cfg(feature = "autodiff")]
52mod backward;
53mod recalculated;
54
55pub use recalculated::Mamba3MomentsBackendExt;
56
57use crate::mamba3::helpers;
58use crate::mamba3::prelude::Mamba3;
59use crate::mamba3::rotation::RotationSeq;
60use crate::modules::{StateMoments, sanity as san, segsum};
61use burn::backend::Dispatch;
62use burn::prelude::*;
63
64/// Inputs of the physical-frame state-moments computation — the combined
65/// (γ + β) injections of one chunked Mamba-3 `forward`, plus the per-token
66/// cumulative rotation. Built at either pathway's pre-SSD seam; `C`/`D` are
67/// never read. See the module header for the math.
68#[allow(non_snake_case)]
69pub struct Mamba3MomentsInput {
70    /// Combined value injections, already γ/β-scaled — `M = 2·mimo_rank`
71    /// channels (γ channels first, then the shifted β channels). Zero at pads.
72    ///
73    /// # Shape
74    /// - `[batch, nchunks, chunk_len, M, nheads, per_head_dim]`
75    pub xhat_bnlMhp: Tensor<6>,
76    /// Combined key injections: the **rotated** B̃ per γ channel and the
77    /// shifted B̃ₜ₋₁ per β channel. Zero at pads.
78    ///
79    /// # Shape
80    /// - `[batch, nchunks, chunk_len, M, nheads, state_rank]`
81    pub bhat_bnlMhr: Tensor<6>,
82    /// Log-decay `Δₜ·Aₜ` (negative; zero at pads ⇒ identity decay).
83    ///
84    /// # Shape
85    /// - `[batch, nchunks, chunk_len, nheads]`
86    pub da_bnlh: Tensor<4>,
87    /// Per-token cumulative rotation over the **padded** sequence
88    /// (`sequence = nchunks·chunk_len`; pad values are arbitrary — masked).
89    pub rotation: RotationSeq,
90    /// The SSM state entering this call (the cache's `ssm_bhpr` — **not** the
91    /// single-ssd kernel's seed-augmented initial state; see module header).
92    ///
93    /// # Shape
94    /// - `[batch, nheads, per_head_dim, state_rank]`
95    pub initial_state_bhpr: Tensor<4>,
96    /// Optional learnable initial state `h₀`, added to `initial_state_bhpr`
97    /// exactly once.
98    ///
99    /// # Shape
100    /// - `[nheads, per_head_dim, state_rank]`
101    pub init_state_hpr: Option<Tensor<3>>,
102}
103
104impl Mamba3MomentsInput {
105    /// A value-identical copy detached from any autodiff graph (used by the
106    /// diagnostic wrapper so the moments branch records no backward nodes).
107    pub fn detached(&self) -> Self {
108        Self {
109            xhat_bnlMhp: self.xhat_bnlMhp.clone().detach(),
110            bhat_bnlMhr: self.bhat_bnlMhr.clone().detach(),
111            da_bnlh: self.da_bnlh.clone().detach(),
112            rotation: self.rotation.detached(),
113            initial_state_bhpr: self.initial_state_bhpr.clone().detach(),
114            init_state_hpr: self.init_state_hpr.clone().map(Tensor::detach),
115        }
116    }
117
118    /// Exact pooled moments of every per-token **physical-frame** SSM state
119    /// (see the module header). `valid_len` is the unpadded token count —
120    /// states at zero-pad positions are excluded.
121    ///
122    /// Returns raw sums; `count = valid_len · per_head_dim` samples per
123    /// `(batch, head)` slice (MIMO ranks share the state, so the count is
124    /// unchanged from Mamba-2).
125    #[allow(non_snake_case)]
126    pub fn state_moments_phys(&self, valid_len: usize) -> StateMoments {
127        let [batch, nchunks, chunk_len, chan, nheads, per_head_dim] = self.xhat_bnlMhp.dims();
128        let [b2, n2, l2, c2, h2, state_rank] = self.bhat_bnlMhr.dims();
129        assert_eq!([batch, nchunks, chunk_len, chan, nheads], [b2, n2, l2, c2, h2]);
130        assert_eq!([batch, nchunks, chunk_len, nheads], self.da_bnlh.dims());
131        assert!(
132            (1..=nchunks * chunk_len).contains(&valid_len),
133            "valid_len must be within the (padded) sequence"
134        );
135        let device = self.xhat_bnlMhp.device();
136
137        // Combined initial state (cache + optional learnable h₀, once).
138        let mut h_bhpr = self.initial_state_bhpr.clone();
139        if let Some(init_hpr) = &self.init_state_hpr {
140            h_bhpr = h_bhpr
141                + init_hpr.clone().unsqueeze_dim::<4>(0).expand([
142                    batch,
143                    nheads,
144                    per_head_dim,
145                    state_rank,
146                ]);
147        }
148
149        let mut m2_bhrr = Tensor::zeros([batch, nheads, state_rank, state_rank], &device);
150        let mut m1_bhr = Tensor::zeros([batch, nheads, state_rank], &device);
151
152        for n in 0..nchunks {
153            let start = n * chunk_len;
154            if start >= valid_len {
155                // Every remaining token is a pad; nothing left to accumulate.
156                break;
157            }
158
159            // ── Chunk-local decay dₜ and mask L (SSD Step 1) ──────────────────
160            let da_blh = self.da_bnlh.clone().narrow(1, n, 1).squeeze_dim::<3>(1);
161            let a_bhl = da_blh.permute([0, 2, 1]);
162            let d_bhl = a_bhl.clone().cumsum(2).exp();
163            let l_bhll = segsum::<3, 4>(a_bhl).exp();
164            san(&d_bhl);
165            san(&l_bhll);
166
167            // ── One chunk of cache-frame states ───────────────────────────────
168            // xw[b,h,t,j,m,p] = L[t,j] · x̂[j,m,p] (the chunk transient), then
169            // one GEMM contracts the folded (j, m) write index against b̂.
170            let xhat_blMhp = self
171                .xhat_bnlMhp
172                .clone()
173                .narrow(1, n, 1)
174                .squeeze_dim::<5>(1);
175            let bhat_blMhr = self
176                .bhat_bnlMhr
177                .clone()
178                .narrow(1, n, 1)
179                .squeeze_dim::<5>(1);
180
181            let xhat_bh1jMp = xhat_blMhp
182                .permute([0, 3, 1, 2, 4]) // [b, h, j, M, p]
183                .unsqueeze_dim::<6>(2); // [b, h, 1, j, M, p]
184            let l_bhtj11 = l_bhll.unsqueeze_dims::<6>(&[4, 5]);
185            let xw_bhtJMp =
186                (l_bhtj11 * xhat_bh1jMp).reshape([batch, nheads, chunk_len, chunk_len * chan, per_head_dim]);
187
188            let bhat_bh1JMr = bhat_blMhr
189                .permute([0, 3, 1, 2, 4]) // [b, h, j, M, r]
190                .reshape([batch, nheads, 1, chunk_len * chan, state_rank]);
191
192            // intra[b,h,t,p,r] = Σ_{jm} xw[t, jm, p] · b̂[jm, r]
193            let intra_bhtpr = xw_bhtJMp.permute([0, 1, 2, 4, 3]).matmul(bhat_bh1JMr);
194
195            let states_bhtpr = d_bhl.unsqueeze_dims::<5>(&[3, 4])
196                * h_bhpr.clone().unsqueeze_dim::<5>(2)
197                + intra_bhtpr;
198            san(&states_bhtpr);
199
200            // Carry (unmasked: pads are identity steps, so the last position
201            // already holds the state after the last real token).
202            h_bhpr = states_bhtpr
203                .clone()
204                .narrow(2, chunk_len - 1, 1)
205                .squeeze_dim::<4>(2);
206
207            // ── De-rotate to the physical frame, mask, accumulate ─────────────
208            let phys_blhpr = self
209                .rotation
210                .derotate_states(states_bhtpr.permute([0, 2, 1, 3, 4]), start);
211            let mask_1l111 = Tensor::<1, Int>::arange(0..chunk_len as i64, &device)
212                .lower_elem((valid_len - start) as i64)
213                .float()
214                .reshape([1, chunk_len, 1, 1, 1]);
215            let masked_blhpr = phys_blhpr * mask_1l111;
216
217            let masked_bhLPr = masked_blhpr
218                .permute([0, 2, 1, 3, 4])
219                .reshape([batch, nheads, chunk_len * per_head_dim, state_rank]);
220            m2_bhrr = m2_bhrr
221                + masked_bhLPr
222                    .clone()
223                    .permute([0, 1, 3, 2])
224                    .matmul(masked_bhLPr.clone());
225            m1_bhr = m1_bhr + masked_bhLPr.sum_dim(2).squeeze_dim::<3>(2);
226        }
227        san(&m2_bhrr);
228        san(&m1_bhr);
229
230        StateMoments {
231            m2_bhrr,
232            m1_bhr,
233            count: valid_len * per_head_dim,
234        }
235    }
236
237    /// [`Self::state_moments_phys`] with a custom **recompute backward** — the
238    /// at-scale execution model. Mathematically identical (values and
239    /// gradients, asserted by the tests); only the backward's memory profile
240    /// differs: plain autodiff retains every chunk's states (the full
241    /// per-token trajectory), while this node saves only the leaf inputs and
242    /// re-materialises one chunk at a time during backprop (see
243    /// `moments/backward.rs`).
244    ///
245    /// The optional learnable `init_state_hpr` is folded into the initial
246    /// state *outside* the node (a plain autodiff add), so its gradient flows
247    /// through `d_initial`.
248    pub fn state_moments_phys_recalculated(&self, valid_len: usize) -> StateMoments {
249        let [batch, nchunks, chunk_len, chan, nheads, per_head_dim] = self.xhat_bnlMhp.dims();
250        let [b2, n2, l2, c2, h2, state_rank] = self.bhat_bnlMhr.dims();
251        assert_eq!([batch, nchunks, chunk_len, chan, nheads], [b2, n2, l2, c2, h2]);
252        assert_eq!([batch, nchunks, chunk_len, nheads], self.da_bnlh.dims());
253        assert!(
254            (1..=nchunks * chunk_len).contains(&valid_len),
255            "valid_len must be within the (padded) sequence"
256        );
257
258        let mut initial_bhpr = self.initial_state_bhpr.clone();
259        if let Some(init_hpr) = &self.init_state_hpr {
260            initial_bhpr = initial_bhpr
261                + init_hpr.clone().unsqueeze_dim::<4>(0).expand([
262                    batch,
263                    nheads,
264                    per_head_dim,
265                    state_rank,
266                ]);
267        }
268
269        let (rot, quaternion, rope_dim, rotate_pairwise) = match &self.rotation {
270            RotationSeq::Angle {
271                cum_bsha,
272                rope_dim,
273                rotate_pairwise,
274            } => {
275                assert_eq!(cum_bsha.dims()[1], nchunks * chunk_len);
276                (
277                    cum_bsha.clone().into_dispatch(),
278                    false,
279                    *rope_dim,
280                    *rotate_pairwise,
281                )
282            }
283            RotationSeq::Quaternion { cum_bshj4 } => {
284                assert_eq!(cum_bshj4.dims()[1], nchunks * chunk_len);
285                (cum_bshj4.clone().into_dispatch(), true, 0, false)
286            }
287        };
288
289        let (m2, m1) = <Dispatch as Mamba3MomentsBackendExt>::mamba3_state_moments_phys(
290            self.xhat_bnlMhp.clone().into_dispatch(),
291            self.bhat_bnlMhr.clone().into_dispatch(),
292            self.da_bnlh.clone().into_dispatch(),
293            rot,
294            initial_bhpr.into_dispatch(),
295            valid_len,
296            quaternion,
297            rope_dim,
298            rotate_pairwise,
299        );
300        let m2_bhrr = Tensor::<4>::from_dispatch(m2);
301        let m1_bhr = Tensor::<3>::from_dispatch(m1);
302        san(&m2_bhrr);
303        san(&m1_bhr);
304
305        StateMoments {
306            m2_bhrr,
307            m1_bhr,
308            count: valid_len * per_head_dim,
309        }
310    }
311}
312
313impl Mamba3 {
314    /// Build the [`Mamba3MomentsInput`] from one `forward`'s **pre-SSD,
315    /// sequence-level** tensors — the pathway-agnostic seam (both the
316    /// double-ssd and single-ssd forwards call this with the same pieces, so
317    /// the moments are structurally identical across pathways).
318    ///
319    /// Performs the β stream's shift-before-chunking (`prev_*` = the cache's
320    /// `v_state`/`k_state`, read **before** they are overwritten), the γ/β
321    /// scaling, the MIMO value expansion, and the zero-pad + chunk reshape.
322    ///
323    /// # Shapes
324    /// - `x_bshp`: raw values `[batch, sequence, nheads, per_head_dim]`
325    /// - `b_rot_bsmhr`: **rotated** B̃ `[batch, sequence, mimo, nheads, r]`
326    /// - `gamma_bsh` / `beta_bsh` / `da_bsh`: trapezoid coefficients
327    /// - `prev_x_bhp` / `prev_b_bmhr`: the cache's previous-token entries
328    /// - `ssm_bhpr`: the cache's SSM state entering this call (**not** the
329    ///   single-ssd kernel's seed-augmented initial state)
330    #[allow(clippy::too_many_arguments)]
331    pub(crate) fn build_moments_input(
332        &self,
333        x_bshp: Tensor<4>,
334        b_rot_bsmhr: Tensor<5>,
335        gamma_bsh: Tensor<3>,
336        beta_bsh: Tensor<3>,
337        da_bsh: Tensor<3>,
338        prev_x_bhp: Tensor<3>,
339        prev_b_bmhr: Tensor<4>,
340        ssm_bhpr: Tensor<4>,
341        rotation_seq: RotationSeq,
342        chunk_len: usize,
343    ) -> Mamba3MomentsInput {
344        let [batch, sequence, nheads, per_head_dim] = x_bshp.dims();
345        let [.., mimo_rank, _nheads, state_rank] = b_rot_bsmhr.dims();
346        let device = x_bshp.device();
347
348        // ── β stream: shift-before-chunking ───────────────────────────────────
349        let prev_x_b1hp = prev_x_bhp.unsqueeze_dim::<4>(1);
350        let x_prev_bshp = if sequence == 1 {
351            prev_x_b1hp
352        } else {
353            Tensor::cat(
354                vec![prev_x_b1hp, x_bshp.clone().narrow(1, 0, sequence - 1)],
355                1,
356            )
357        };
358        let prev_b_b1mhr = prev_b_bmhr.unsqueeze_dim::<5>(1);
359        let b_prev_bsmhr = if sequence == 1 {
360            prev_b_b1mhr
361        } else {
362            Tensor::cat(
363                vec![
364                    prev_b_b1mhr,
365                    b_rot_bsmhr.clone().narrow(1, 0, sequence - 1),
366                ],
367                1,
368            )
369        };
370
371        // ── γ/β scaling ───────────────────────────────────────────────────────
372        let x_gamma_bshp = x_bshp * gamma_bsh.unsqueeze_dim::<4>(3);
373        let x_beta_bshp = x_prev_bshp * beta_bsh.unsqueeze_dim::<4>(3);
374
375        // ── Zero-pad to a chunk_len multiple ──────────────────────────────────
376        let sequence_padded = sequence.next_multiple_of(chunk_len);
377        let pad = sequence_padded - sequence;
378        #[rustfmt::skip]
379        let (x_gamma_bShp, x_beta_bShp, da_bSh, b_bSmhr, b_prev_bSmhr) = if pad == 0 {
380            (x_gamma_bshp, x_beta_bshp, da_bsh, b_rot_bsmhr, b_prev_bsmhr)
381        } else {
382            let pad_bShp = Tensor::zeros([batch, pad, nheads, per_head_dim], &device);
383            let pad_bSh = Tensor::zeros([batch, pad, nheads], &device);
384            let pad_bSmhr = Tensor::zeros([batch, pad, mimo_rank, nheads, state_rank], &device);
385            (
386                Tensor::cat(vec![x_gamma_bshp, pad_bShp.clone()], 1),
387                Tensor::cat(vec![x_beta_bshp, pad_bShp], 1),
388                Tensor::cat(vec![da_bsh, pad_bSh], 1),
389                Tensor::cat(vec![b_rot_bsmhr, pad_bSmhr.clone()], 1),
390                Tensor::cat(vec![b_prev_bsmhr, pad_bSmhr], 1),
391            )
392        };
393
394        // ── Chunk + MIMO value expansion + combined channels ──────────────────
395        let nchunks = sequence_padded / chunk_len;
396        let x_gamma_bnlhp =
397            x_gamma_bShp.reshape([batch, nchunks, chunk_len, nheads, per_head_dim]);
398        let x_beta_bnlhp = x_beta_bShp.reshape([batch, nchunks, chunk_len, nheads, per_head_dim]);
399        let da_bnlh = da_bSh.reshape([batch, nchunks, chunk_len, nheads]);
400        let b_bnlmhr =
401            b_bSmhr.reshape([batch, nchunks, chunk_len, mimo_rank, nheads, state_rank]);
402        let b_prev_bnlmhr =
403            b_prev_bSmhr.reshape([batch, nchunks, chunk_len, mimo_rank, nheads, state_rank]);
404
405        let mimo_x_hmp = self.mimo_x_hmp.as_ref().map(|p| p.val());
406        let v_gamma_bnlmhp =
407            helpers::build_v_with_mimo::<5, 6>(x_gamma_bnlhp, mimo_x_hmp.as_ref(), 3);
408        let v_beta_bnlmhp =
409            helpers::build_v_with_mimo::<5, 6>(x_beta_bnlhp, mimo_x_hmp.as_ref(), 3);
410
411        Mamba3MomentsInput {
412            xhat_bnlMhp: Tensor::cat(vec![v_gamma_bnlmhp, v_beta_bnlmhp], 3),
413            bhat_bnlMhr: Tensor::cat(vec![b_bnlmhr, b_prev_bnlmhr], 3),
414            da_bnlh,
415            rotation: rotation_seq.pad_to(sequence_padded),
416            initial_state_bhpr: ssm_bhpr,
417            init_state_hpr: self.init_state_hpr.as_ref().map(|s| s.val()),
418        }
419    }
420}
421
422#[cfg(all(test, feature = "_dev-test"))]
423mod tests;