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;