Skip to main content

burn_mamba/modules/
state_moments.rs

1//! # Pooled SSM-state moments (for state participation ratios)
2//!
3//! [`StateMoments`] carries the **exact** first and second moments of every
4//! per-token SSM state `hₜ ∈ ℝ^{per_head_dim × state_rank}` of a `forward`
5//! pass, treating each state **row** (one `(token, per_head_dim)` pair) as a
6//! sample in `ℝ^{state_rank}` — the same sample convention a token-by-token
7//! `step` loop reading the cache would produce.
8//!
9//! The moments are all a participation ratio (PR, a differentiable effective
10//! rank) needs: with `Σ` the sample covariance, `PR = (tr Σ)² / tr(Σ²)` and
11//! both traces derive from `Σ hhᵀ`, `Σ h`, and the sample count. Storing raw
12//! **sums** (not averages) makes moments *composable*: [`StateMoments::merge`]
13//! pools across forward calls (streaming chunks, eval batches) and the PR of
14//! the merged moments is the exact pooled PR.
15
16use burn::prelude::*;
17
18/// How the `state_rank` axis of a (realified) SSM state groups into complex /
19/// quaternionic coordinates — the realification layout the Mamba-3 rotation
20/// applies to B/C (and hence to the state). Consumed by
21/// [`StateMoments::pr_complex`].
22///
23/// Construct it with `Mamba3::state_pairing()` (the single source of truth,
24/// mirroring exactly what `rotate_bc_forward` applies) — never re-derive the
25/// layout by hand.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StatePairing {
28    /// No rotation pairing: every coordinate is real (`rope_fraction = 0`);
29    /// [`StateMoments::pr_complex`] then equals [`StateMoments::pr`].
30    Real,
31    /// Complex pairs, interleaved (NeoX-style; Mamba-3 SISO): coordinate `2a`
32    /// is the real and `2a + 1` the imaginary part of complex coordinate `a`,
33    /// for `a < num_pairs`; the tail `[2·num_pairs, state_rank)` stays real.
34    ComplexInterleaved {
35        /// Number of rotated complex pairs (`rope_dim / 2`).
36        num_pairs: usize,
37    },
38    /// Complex pairs, half-and-half (GPT-J-style; Mamba-3 MIMO): coordinate
39    /// `a` is the real and `state_rank/2 + a` the imaginary part, for
40    /// `a < num_pairs`; the remainder of **both** halves stays real.
41    ComplexHalfHalf {
42        /// Number of rotated complex pairs (`rope_dim / 2`).
43        num_pairs: usize,
44    },
45    /// Quaternion blocks (Mamba-3 `Quaternion4D`): coordinates `[4j, 4j + 4)`
46    /// form quaternion coordinate `j` (components ordered `w, x, y, z`), for
47    /// `j < num_blocks`; the tail stays real.
48    QuaternionBlocks {
49        /// Number of rotated quaternion blocks (`rope_width / 4`).
50        num_blocks: usize,
51    },
52}
53
54/// Raw (un-normalised) first/second moments of the per-token SSM states of
55/// one block's `forward` pass, pooled over tokens and `per_head_dim` rows.
56///
57/// Produced by `forward_with_state_moments` — closed-form for Mamba-2
58/// (`Mamba2SsdInput::state_moments`, no state materialisation) and serial
59/// chunkwise for Mamba-3 (`Mamba3MomentsInput::state_moments_phys`, the
60/// per-token **physical-frame** states of the complex SSM).
61#[derive(Debug, Clone)]
62pub struct StateMoments {
63    /// Second-moment (Gram) sum `Σₜ hₜᵀ hₜ` — the `ᵀ` contraction pools the
64    /// `per_head_dim` rows, the `Σₜ` the (unpadded) tokens.
65    ///
66    /// # Shape
67    /// - `[batch, nheads, state_rank, state_rank]`
68    pub m2_bhrr: Tensor<4>,
69    /// First-moment sum `Σₜ Σₚ hₜ[p, :]`.
70    ///
71    /// # Shape
72    /// - `[batch, nheads, state_rank]`
73    pub m1_bhr: Tensor<3>,
74    /// Samples pooled into each `(batch, head)` slice:
75    /// `valid_tokens · per_head_dim` (grows additively under [`Self::merge`]).
76    pub count: usize,
77}
78
79impl StateMoments {
80    /// Pool two moment sets (e.g. consecutive streamed `forward` calls, or
81    /// separate eval batches). PR of the merged moments is the exact PR of
82    /// the union of samples.
83    pub fn merge(self, other: Self) -> Self {
84        assert_eq!(
85            self.m2_bhrr.dims(),
86            other.m2_bhrr.dims(),
87            "merged state moments must share [batch, nheads, state_rank]"
88        );
89        Self {
90            m2_bhrr: self.m2_bhrr + other.m2_bhrr,
91            m1_bhr: self.m1_bhr + other.m1_bhr,
92            count: self.count + other.count,
93        }
94    }
95
96    /// Fold the batch dimension into the samples (batch-pooled moments with
97    /// `batch = 1`), matching diagnostics that treat every
98    /// `(token, batch, per_head_dim)` triple as one sample.
99    pub fn pool_batch(self) -> Self {
100        let [batch, _h, _r, _r2] = self.m2_bhrr.dims();
101        Self {
102            m2_bhrr: self.m2_bhrr.sum_dim(0),
103            m1_bhr: self.m1_bhr.sum_dim(0),
104            count: self.count * batch,
105        }
106    }
107
108    /// Participation ratio `(tr Σ)² / tr(Σ²)` of the sample covariance, per
109    /// `(batch, head)` slice; `center` subtracts the sample mean (`Σ` becomes
110    /// the centered covariance instead of the raw second moment).
111    ///
112    /// Differentiable (two traces, no eigendecomposition).
113    ///
114    /// # Shape
115    /// - output: `[batch, nheads]`
116    pub fn pr(&self, center: bool) -> Tensor<2> {
117        let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
118        assert!(self.count > 0, "state moments hold no samples");
119        let device = self.m2_bhrr.device();
120        let samples = self.count as f32;
121
122        let sigma_bhrr = {
123            let m2_bhrr = self.m2_bhrr.clone() / samples;
124            if center {
125                let mu_bhr = self.m1_bhr.clone() / samples;
126                let outer_bhrr =
127                    mu_bhr.clone().unsqueeze_dim::<4>(3) * mu_bhr.unsqueeze_dim::<4>(2);
128                m2_bhrr - outer_bhrr
129            } else {
130                m2_bhrr
131            }
132        };
133
134        // tr Σ via an identity mask; tr(Σ²) = ‖Σ‖²_F (Σ is symmetric).
135        let eye_11rr = Tensor::<2>::eye(state_rank, &device).unsqueeze::<4>();
136        let tr_bh = (sigma_bhrr.clone() * eye_11rr.clone())
137            .sum_dim(3)
138            .sum_dim(2)
139            .reshape([batch, nheads]);
140        // `PR = (tr Σ)² / tr(Σ²)`. Computed via the trace-normalised
141        // `Σ̂ = Σ / tr(Σ).detach()`, keeping **both** traces of `Σ̂`:
142        //
143        //     PR = tr(Σ̂)² / tr(Σ̂²).
144        //
145        // With `c = tr(Σ).detach()` a frozen scalar, the `c²` cancels between
146        // numerator and denominator, so this equals `tr(Σ)²/tr(Σ²)` **as a
147        // function of Σ** — identical value *and* exact gradient — while every
148        // differentiated intermediate stays O(1). Two subtleties that a naive
149        // rewrite gets wrong:
150        //   - Keep the numerator `tr(Σ̂)²`: it is numerically 1, but with the
151        //     normaliser detached its *gradient* w.r.t. Σ is not zero — it
152        //     carries PR's rank-reducing (trace-tangential) component.
153        //     Collapsing to `1/tr(Σ̂²)` drops it, leaving only the radial
154        //     (magnitude) direction, orthogonal to ∇PR — a penalty that no
155        //     longer reduces rank (see `pr_gradient_matches_direct_formula`).
156        //   - Differentiating *through* `tr(Σ)` instead (no detach) is
157        //     value/grad-correct but puts `tr(Σ²)²` in the backward, which
158        //     underflows fp32 to 0 (→ NaN gradient) once the state magnitude
159        //     `tr(Σ) ≲ 1e-11` — which weight decay drives it toward. The
160        //     detached O(1) form is finite at every representable magnitude
161        //     (see `pr_gradient_finite_as_magnitude_shrinks`).
162        //
163        // Two floors, for two quantities at very different scales. The
164        // normaliser `tr Σ` is a *magnitude* that weight decay drives down to —
165        // and below — `div_eps` (the crate's O(1)-calibrated negligibility
166        // threshold): flooring it there would corrupt PR across the live
167        // operating range, so it is floored only at the dtype's smallest
168        // positive normal (`finfo().min_positive`), firing solely for an
169        // all-zero state (`Σ ≡ 0`, e.g. a dead head — then `Σ̂ = 0/ε = 0`, no
170        // `0/0`). `tr(Σ̂²)` is scale-normalised (`∈ [1/r, 1]` for any nonzero Σ)
171        // and nears zero only for `Σ ≡ 0`, so `div_eps(dtype)` is the correct
172        // dtype-aware guard there (cf. `MseLoss`'s fp16 path).
173        let dtype = self.m2_bhrr.dtype();
174        let min_positive = dtype.finfo().expect("state moments are a float dtype").min_positive;
175        let scale_bh = tr_bh.clamp_min(min_positive).detach();
176        let sigma_hat = sigma_bhrr / scale_bh.reshape([batch, nheads, 1, 1]);
177        let tr1_hat_bh = (sigma_hat.clone() * eye_11rr)
178            .sum_dim(3)
179            .sum_dim(2)
180            .reshape([batch, nheads]);
181        let tr2_hat_bh = sigma_hat
182            .powf_scalar(2.0)
183            .sum_dim(3)
184            .sum_dim(2)
185            .reshape([batch, nheads])
186            .clamp_min(crate::utils::div_eps(dtype));
187        tr1_hat_bh.clone() * tr1_hat_bh / tr2_hat_bh
188    }
189
190    /// Participation ratio of the **Hermitian** sample covariance, treating the
191    /// `state_rank` axis as realified complex (or quaternionic) coordinates per
192    /// `pairing` — the Mamba-3 counterpart of [`Self::pr`].
193    ///
194    /// With the pairing's complex view `c = x + iy`, the Hermitian moment is
195    /// `M = A + iS` with `A = Σ(xxᵀ + yyᵀ)` and `S = Σ(xyᵀ − yxᵀ)` — both linear
196    /// recombinations of `m2_bhrr` sub-blocks, so centering `Σ` centers `M`
197    /// identically. `PR_ℂ = (tr M)² / tr(M²)`; the trace is real and equals the
198    /// full real trace (frame-invariant), while `tr(M²) = Σ|M_ab|²`
199    /// (`= ‖A‖²_F + ‖S‖²_F` for the fully-rotated complex case). One complex
200    /// (or quaternionic) direction counts as **one** — the ×2 (×4) realified
201    /// count is a representation artifact — so a rank-1 rotating conveyor reads
202    /// `PR_ℂ ≡ 1` where [`Self::pr`] reads up to the block size.
203    ///
204    /// Un-rotated coordinates (partial `rope_fraction`) stay a real block `U`
205    /// of the mixed Hermitian `[[M, X], [Xᴴ, U]]`; its trace and `Σ|·|²` join
206    /// the sums (`tr(·²)` gains `2‖X‖²_F + ‖U‖²_F`). Quaternionic pairing uses
207    /// the same formulas with `M_jk = Σ q̄ⱼqₖ` (diagonal real, 4-component
208    /// norms).
209    ///
210    /// Differentiable; numerics mirror [`Self::pr`] (detached trace
211    /// normalisation, `min_positive` / `div_eps` floors — see the comments
212    /// there for why).
213    ///
214    /// # Shape
215    /// - output: `[batch, nheads]`
216    pub fn pr_complex(&self, pairing: &StatePairing, center: bool) -> Tensor<2> {
217        let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
218        assert!(self.count > 0, "state moments hold no samples");
219        let device = self.m2_bhrr.device();
220        let samples = self.count as f32;
221
222        // Reorder the `r` axis into the canonical `[x-pairs | y-pairs | real]`
223        // layout (complex pairings only — quaternion blocks are already
224        // consecutive with a trailing real tail), so every block below is one
225        // contiguous `narrow`. `rotated` is the realified width of the
226        // rotated prefix in that canonical order.
227        enum Blocks {
228            Complex { num_pairs: usize },
229            Quaternion { num_blocks: usize },
230        }
231        let (reorder, blocks, rotated) = match *pairing {
232            StatePairing::Real => return self.pr(center),
233            StatePairing::ComplexInterleaved { num_pairs } => {
234                assert!(
235                    num_pairs > 0 && 2 * num_pairs <= state_rank,
236                    "interleaved pairing exceeds state_rank"
237                );
238                let mut idx: Vec<i64> = (0..num_pairs).map(|a| 2 * a as i64).collect();
239                idx.extend((0..num_pairs).map(|a| 2 * a as i64 + 1));
240                idx.extend((2 * num_pairs..state_rank).map(|k| k as i64));
241                (Some(idx), Blocks::Complex { num_pairs }, 2 * num_pairs)
242            }
243            StatePairing::ComplexHalfHalf { num_pairs } => {
244                let half = state_rank / 2;
245                assert!(
246                    num_pairs > 0 && num_pairs <= half,
247                    "half-and-half pairing exceeds state_rank / 2"
248                );
249                let mut idx: Vec<i64> = (0..num_pairs).map(|a| a as i64).collect();
250                idx.extend((0..num_pairs).map(|a| (half + a) as i64));
251                idx.extend((num_pairs..half).map(|k| k as i64));
252                idx.extend((half + num_pairs..state_rank).map(|k| k as i64));
253                (Some(idx), Blocks::Complex { num_pairs }, 2 * num_pairs)
254            }
255            StatePairing::QuaternionBlocks { num_blocks } => {
256                assert!(
257                    num_blocks > 0 && 4 * num_blocks <= state_rank,
258                    "quaternion pairing exceeds state_rank"
259                );
260                (None, Blocks::Quaternion { num_blocks }, 4 * num_blocks)
261            }
262        };
263
264        let sigma_bhrr = {
265            let m2_bhrr = self.m2_bhrr.clone() / samples;
266            let sigma = if center {
267                let mu_bhr = self.m1_bhr.clone() / samples;
268                let outer_bhrr =
269                    mu_bhr.clone().unsqueeze_dim::<4>(3) * mu_bhr.unsqueeze_dim::<4>(2);
270                m2_bhrr - outer_bhrr
271            } else {
272                m2_bhrr
273            };
274            match reorder {
275                Some(idx) => {
276                    let idx = Tensor::<1, Int>::from_ints(idx.as_slice(), &device);
277                    sigma.select(2, idx.clone()).select(3, idx)
278                }
279                None => sigma,
280            }
281        };
282
283        // Frobenius norm² of a `[batch, nheads, ·, ·]` block, per (batch, head).
284        let fro2_bh = |t: Tensor<4>| -> Tensor<2> {
285            t.powf_scalar(2.0)
286                .sum_dim(3)
287                .sum_dim(2)
288                .reshape([batch, nheads])
289        };
290
291        // The Hermitian trace equals the full real trace (the phases cancel on
292        // the diagonal), so the normaliser is exactly [`Self::pr`]'s.
293        let eye_11rr = Tensor::<2>::eye(state_rank, &device).unsqueeze::<4>();
294        let tr_bh = (sigma_bhrr.clone() * eye_11rr.clone())
295            .sum_dim(3)
296            .sum_dim(2)
297            .reshape([batch, nheads]);
298        let dtype = self.m2_bhrr.dtype();
299        let min_positive = dtype
300            .finfo()
301            .expect("state moments are a float dtype")
302            .min_positive;
303        let scale_bh = tr_bh.clamp_min(min_positive).detach();
304        let sigma_hat = sigma_bhrr / scale_bh.reshape([batch, nheads, 1, 1]);
305        let tr1_hat_bh = (sigma_hat.clone() * eye_11rr)
306            .sum_dim(3)
307            .sum_dim(2)
308            .reshape([batch, nheads]);
309
310        // tr(Σ̂²) of the mixed Hermitian [[M, X], [Xᴴ, U]] = Σ|M_ab|² + 2‖X‖² + ‖U‖².
311        let mut tr2_hat_bh = match blocks {
312            Blocks::Complex { num_pairs } => {
313                let np = num_pairs;
314                let xx = sigma_hat.clone().narrow(2, 0, np).narrow(3, 0, np);
315                let yy = sigma_hat.clone().narrow(2, np, np).narrow(3, np, np);
316                let xy = sigma_hat.clone().narrow(2, 0, np).narrow(3, np, np);
317                // A = Σ(xxᵀ + yyᵀ), S = Σ(xyᵀ − yxᵀ); Σ symmetric ⇒ (yx)_ab = (xy)_ba.
318                let a = xx + yy;
319                let s = xy.clone() - xy.transpose();
320                fro2_bh(a) + fro2_bh(s)
321            }
322            Blocks::Quaternion { num_blocks } => {
323                let j = num_blocks;
324                let q_bhjaja = sigma_hat
325                    .clone()
326                    .narrow(2, 0, 4 * j)
327                    .narrow(3, 0, 4 * j)
328                    .reshape([batch, nheads, j, 4, j, 4]);
329                // Component (α, β) sub-block Σ[(4j+α), (4k+β)] as [b, h, J, J].
330                let c = |alpha: usize, beta: usize| -> Tensor<4> {
331                    q_bhjaja
332                        .clone()
333                        .narrow(3, alpha, 1)
334                        .narrow(5, beta, 1)
335                        .reshape([batch, nheads, j, j])
336                };
337                // Components of M_jk = Σ q̄ⱼqₖ (Hamilton product, conjugate left).
338                let w = c(0, 0) + c(1, 1) + c(2, 2) + c(3, 3);
339                let x = c(0, 1) - c(1, 0) - c(2, 3) + c(3, 2);
340                let y = c(0, 2) + c(1, 3) - c(2, 0) - c(3, 1);
341                let z = c(0, 3) - c(1, 2) + c(2, 1) - c(3, 0);
342                fro2_bh(w) + fro2_bh(x) + fro2_bh(y) + fro2_bh(z)
343            }
344        };
345        if rotated < state_rank {
346            let tail = state_rank - rotated;
347            let cross = sigma_hat.clone().narrow(2, 0, rotated).narrow(3, rotated, tail);
348            let u = sigma_hat.narrow(2, rotated, tail).narrow(3, rotated, tail);
349            tr2_hat_bh = tr2_hat_bh + fro2_bh(cross) * 2.0 + fro2_bh(u);
350        }
351        let tr2_hat_bh = tr2_hat_bh.clamp_min(crate::utils::div_eps(dtype));
352        tr1_hat_bh.clone() * tr1_hat_bh / tr2_hat_bh
353    }
354
355    /// Raw uncentered state magnitude `tr Σ = trace(m2)/count` per
356    /// `(batch, head)` — the mean squared state magnitude `⟨‖h‖²⟩`, which is
357    /// [`Self::pr`]'s numerator scale. Reported alongside PR to tell a genuine
358    /// rank-1 state (`PR → 1`, magnitude healthy) apart from a state
359    /// collapsing toward zero (where `pr`'s `1e-12` denominator clamp drags
360    /// the ratio below its true floor of 1).
361    ///
362    /// # Shape
363    /// - output: `[batch, nheads]`
364    pub fn trace(&self) -> Tensor<2> {
365        let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
366        assert!(self.count > 0, "state moments hold no samples");
367        let eye_11rr = Tensor::<2>::eye(state_rank, &self.m2_bhrr.device()).unsqueeze::<4>();
368        (self.m2_bhrr.clone() * eye_11rr)
369            .sum_dim(3)
370            .sum_dim(2)
371            .reshape([batch, nheads])
372            / self.count as f32
373    }
374}
375
376#[cfg(all(test, feature = "_dev-test"))]
377mod tests;