Buckets:
| """Mamba-3 (Lahoti et al., arXiv 2603.15569), implemented to the paper. | |
| THREE CHANGES FROM MAMBA-2, implemented here in the order the paper derives | |
| them. Nothing is improvised: where this file departs from the paper it says | |
| so, and the equivalence tests at the bottom are how that claim is checked. | |
| 1. EXPONENTIAL-TRAPEZOIDAL DISCRETIZATION (Sec 3.1). Mamba-1/2 approximate | |
| integral_{t_{k-1}}^{t_k} e^{A(t_k - tau)} B(tau) x(tau) d tau | |
| by holding the endpoint (Euler). Mamba-3 averages both endpoints | |
| (trapezoidal), which makes the recurrence depend on x_{t-1} as well as | |
| x_t: | |
| h_t = alpha_t h_{t-1} + beta_t B_{t-1} x_{t-1} + gamma_t B_t x_t | |
| with alpha_t = exp(Delta_t A), beta_t = (1 - lambda_t) Delta_t alpha_t, | |
| gamma_t = lambda_t Delta_t and a data-dependent lambda_t = sigmoid(.) | |
| (lambda = 1/2 is the classical trapezoid, initialised here). That two-tap dependence IS the implicit convolution | |
| the paper describes - it is why Mamba-3 can drop the short causal conv | |
| that Mamba-1/2 carry as a separate component (Sec 3.1, and eq 7's | |
| factorisation of L into a 1-semiseparable matrix times a 2-band matrix). | |
| 2. COMPLEX STATE via the RoPE trick (Sec 3.2, Prop 2 and 3). A complex SSM | |
| of state N/2 is equivalent to a REAL SSM of state N whose transition is a | |
| scalar decay times a block-diagonal of 2x2 rotations: | |
| R_t = blockdiag({ R(Delta_t theta_t[i]) }) | |
| h_t = alpha_t R_t h_{t-1} + ... | |
| Real non-negative eigenvalues cannot express rotation, which is why | |
| Mamba-2 provably fails state tracking (parity). This file implements the | |
| real-equivalent form, not complex arithmetic, exactly as Prop 2 licenses. | |
| 3. MIMO (Sec 3.3). x_t: (P,) -> (P,R), B_t: (N,) -> (N,R), C_t likewise; | |
| the state stays (N,P) and the update becomes a matmul B_t x_t^T rather | |
| than an outer product. Memory traffic is dominated by h_t and barely | |
| moves; FLOPs rise by R. Arithmetic intensity goes Theta(1) -> Theta(R), | |
| which is the whole point during memory-bound decode. | |
| WHAT THIS FILE DOES NOT DO. No fused CUDA scan - the paper's speed comes | |
| from hardware-aware kernels and this runs on MPS/CPU with an explicit loop | |
| for the recurrent path and a materialised mask for the parallel path. The | |
| MODELLING claims are reproducible here; the LATENCY claims are not. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| def rotate_pairs(h: torch.Tensor, ang: torch.Tensor) -> torch.Tensor: | |
| """Apply blockdiag 2x2 rotations to the state's (even, odd) channel pairs. | |
| h: (..., N) state, N even | |
| ang: (..., N/2) rotation angle per pair | |
| """ | |
| a, b = h[..., 0::2], h[..., 1::2] | |
| c, s = torch.cos(ang), torch.sin(ang) | |
| out = torch.empty_like(h) | |
| out[..., 0::2] = c * a - s * b | |
| out[..., 1::2] = s * a + c * b | |
| return out | |
| def ssm_scan(alpha, beta, gamma, theta, S, Cm, P, state=None, prev_S=None): | |
| """The Mamba-3 recurrence (paper eq 11), shared by every Mamba-3 path. | |
| This is the SINGLE SOURCE OF TRUTH for the recurrence, so a paper-faithfulness | |
| fix here (e.g. the beta-term rotation) applies everywhere and cannot drift. | |
| h_t = R_t(alpha_t h_{t-1} + beta_t S_{t-1}) + gamma_t S_t | |
| y_t = C_t^T h_t | |
| alpha,beta,gamma: (B,T,H) per-step scalars | |
| theta: (B,T,H,N/2) per-step rotation angles (already * dt) | |
| S: (B,T,H,N,P) per-step write B_t x_t^T (or summed streams) | |
| Cm: (B,T,H,N,R) readout | |
| P: head dim; state/prev_S carry across chunks for streaming. | |
| Returns (y_flat (B,T,H*R*P), final state h, last write S[:,-1]). | |
| """ | |
| B, T, H, N, _ = S.shape | |
| R = Cm.shape[-1] | |
| h = torch.zeros(B, H, N, P, device=S.device, dtype=S.dtype) \ | |
| if state is None else state | |
| ys = [] | |
| for t in range(T): | |
| prev = S[:, t - 1] if t > 0 else prev_S | |
| pre = alpha[:, t, :, None, None] * h | |
| if prev is not None: | |
| pre = pre + beta[:, t, :, None, None] * prev | |
| h = rotate_pairs(pre.transpose(-1, -2), theta[:, t].unsqueeze(-2) | |
| .expand(-1, -1, P, -1)).transpose(-1, -2) | |
| h = h + gamma[:, t, :, None, None] * S[:, t] | |
| ys.append((Cm[:, t].transpose(-1, -2) @ h).reshape(B, H * R * P)) | |
| return torch.stack(ys, 1), h, S[:, -1] | |
| class Mamba3Layer(nn.Module): | |
| """One Mamba-3 layer. Recurrent and parallel paths, provably equal. | |
| d_model input/output width | |
| d_state N, the SSM state size (even; pairs are rotated) | |
| rank R of the MIMO lift. rank=1 is SISO. | |
| head_dim P, channels sharing one (alpha, Delta, B) recurrence. | |
| """ | |
| def __init__(self, d_model: int, d_state: int = 64, rank: int = 1, | |
| head_dim: int = 32, dt_min: float = 1e-3, dt_max: float = 0.5): | |
| super().__init__() | |
| assert d_state % 2 == 0, "d_state must be even: the state rotates in pairs" | |
| assert d_model % head_dim == 0, "d_model must divide into heads of head_dim" | |
| self.d_model, self.N, self.R, self.P = d_model, d_state, rank, head_dim | |
| self.H = d_model // head_dim | |
| # Gate branch (Mamba-2/3 block, Fig 2): the SSM output is multiplied by | |
| # SiLU(in_proj(input)) before out_proj. Dimension d_model*R to match the | |
| # pre-out_proj SSM output (H*R*P). Was defined-but-unused before. | |
| self.in_proj = nn.Linear(d_model, d_model * rank) | |
| self.B_proj = nn.Linear(d_model, self.H * d_state * rank) | |
| self.C_proj = nn.Linear(d_model, self.H * d_state * rank) | |
| self.x_proj = nn.Linear(d_model, d_model * rank) | |
| self.dt_proj = nn.Linear(d_model, self.H) | |
| self.theta_proj = nn.Linear(d_model, self.H * (d_state // 2)) | |
| # lambda_t: the DATA-DEPENDENT trapezoidal weight (paper Prop 1, Remark | |
| # 3). gamma_t = lambda_t * dt, beta_t = (1-lambda_t) * dt * alpha_t. | |
| # lambda=1/2 is the classical trapezoid; the paper found NOT enforcing | |
| # that (letting the model choose per step) is empirically better. Bias 0 | |
| # -> sigmoid 0.5 at init, so training starts from the classical rule. | |
| self.lam_proj = nn.Linear(d_model, self.H) | |
| nn.init.zeros_(self.lam_proj.weight) | |
| nn.init.zeros_(self.lam_proj.bias) | |
| self.out_proj = nn.Linear(d_model * rank, d_model) | |
| self.norm = nn.RMSNorm(d_model) if hasattr(nn, "RMSNorm") else nn.LayerNorm(d_model) | |
| # BCNorm / QKNorm (paper section 3.2). RMS normalisation AFTER the B and C | |
| # projections, mirroring QKNorm in Transformers. The paper reports this | |
| # is what stabilises large-scale runs, to the point that they remove | |
| # Mamba-2's post-gate RMSNorm on the strength of it. It was missing from | |
| # the first version of this file and E17's ssm arm then diverged | |
| # (0.330 -> 19.313) into a constant predictor, which was patched with | |
| # gradient clipping - a generic fix for a problem the paper had already | |
| # solved specifically. Normalising over the N state dimensions, which is | |
| # the axis B and C index into. | |
| rms = nn.RMSNorm if hasattr(nn, "RMSNorm") else nn.LayerNorm | |
| self.B_norm = rms(d_state) | |
| self.C_norm = rms(d_state) | |
| # B, C biases (paper appendix): learnable, head-specific, channel-wise, | |
| # data-INDEPENDENT, added after the norm, initialised to all ones. The | |
| # paper finds models insensitive to the init as long as it is positive, | |
| # and that B and C biases together beat either alone. | |
| self.B_bias = nn.Parameter(torch.ones(self.H, d_state, rank)) | |
| self.C_bias = nn.Parameter(torch.ones(self.H, d_state, rank)) | |
| # A is data-INDEPENDENT here. Remark 1: the paper found data-dependent | |
| # A_t performs similarly and chose it for consistency; the simpler | |
| # form is used here and the difference is recorded rather than hidden. | |
| self.A_log = nn.Parameter(torch.log(torch.rand(self.H) * 0.9 + 0.05)) | |
| # dt bias initialised so Delta spans [dt_min, dt_max] - the standard | |
| # Mamba init. Delta sets the memory horizon, so this is the prior on | |
| # how long the layer remembers before it learns anything. | |
| dt = torch.exp(torch.rand(self.H) * (math.log(dt_max) - math.log(dt_min)) | |
| + math.log(dt_min)) | |
| self.dt_proj.bias.data = dt + torch.log(-torch.expm1(-dt)) | |
| def _coeffs(self, u: torch.Tensor): | |
| """alpha, beta, gamma, B, C, x, theta for every timestep.""" | |
| Bsz, T, _ = u.shape | |
| H, N, R, P = self.H, self.N, self.R, self.P | |
| dt = F.softplus(self.dt_proj(u)) # (B,T,H) > 0 | |
| A = -torch.exp(self.A_log) # (H,) < 0 | |
| alpha = torch.exp(dt * A) # (B,T,H) in (0,1) | |
| # Exponential-trapezoidal with a DATA-DEPENDENT weight lambda_t (paper | |
| # Prop 1 / eq 5-6): gamma_t = lambda_t*dt (current input), beta_t = | |
| # (1-lambda_t)*dt*alpha_t (previous input). lambda=1/2 recovers the | |
| # classical trapezoid; lambda=1 recovers Mamba-2's exponential-Euler. | |
| lam = torch.sigmoid(self.lam_proj(u)) # (B,T,H) in (0,1) | |
| gamma = lam * dt | |
| beta = (1.0 - lam) * dt * alpha | |
| # BCNorm then bias, in that order - the paper adds the bias AFTER the | |
| # norm, so the bias is not rescaled by it. | |
| Bm = self.B_proj(u).view(Bsz, T, H, R, N) | |
| Bm = self.B_norm(Bm).permute(0, 1, 2, 4, 3) + self.B_bias | |
| Cm = self.C_proj(u).view(Bsz, T, H, R, N) | |
| Cm = self.C_norm(Cm).permute(0, 1, 2, 4, 3) + self.C_bias | |
| x = self.x_proj(u).view(Bsz, T, H, P, R) | |
| theta = self.theta_proj(u).view(Bsz, T, H, N // 2) * dt.unsqueeze(-1) | |
| return alpha, beta, gamma, Bm, Cm, x, theta | |
| def gate_and_proj(self, y_flat: torch.Tensor, nu: torch.Tensor): | |
| """Apply the Mamba gate then the output projection. | |
| y_flat: (..., d_model*R) SSM output before out_proj. | |
| nu: (..., d_model) the NORMALISED layer input (in_proj sees the | |
| same normalised input the SSM path does). | |
| out = out_proj( y_flat * SiLU(in_proj(nu)) ). | |
| """ | |
| return self.out_proj(y_flat * F.silu(self.in_proj(nu))) | |
| def forward(self, u: torch.Tensor, state=None, return_state=False): | |
| """Recurrent path. u: (B,T,d_model). This is the streaming form. | |
| The recurrence itself lives in the shared `ssm_scan` (paper eq 11), so | |
| callers cannot drift. Here the per-step write is the | |
| single-stream outer product S_t = B_t x_t^T. | |
| state: None or the (h, S_last) pair returned by a previous call with | |
| return_state=True. Segment-wise streaming must carry S_last as well as | |
| h: the first step of the next segment needs beta_t B_{t-1} x_{t-1}^T, | |
| the trapezoidal term, and carrying h alone silently drops it. | |
| """ | |
| nu = self.norm(u) | |
| alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu) | |
| S = Bm @ x.transpose(-1, -2) # (B,T,H,N,P) | |
| h0, prev_S = (None, None) if state is None else state | |
| y_flat, h, S_last = ssm_scan(alpha, beta, gamma, theta, S, Cm, self.P, | |
| state=h0, prev_S=prev_S) | |
| out = self.gate_and_proj(y_flat, nu) | |
| return (out, (h, S_last)) if return_state else out | |
| def step(self, u_t: torch.Tensor, state, prev_bx=None): | |
| """One timestep, constant memory. The deployment path. | |
| state: (B,H,N,P) or None. prev_bx: B_{t-1}x_{t-1}^T for the beta term, | |
| which the trapezoidal rule requires and Mamba-2 does not have. | |
| """ | |
| Bsz = u_t.shape[0] | |
| nu = self.norm(u_t.unsqueeze(1)) | |
| alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu) | |
| H, N, P = self.H, self.N, self.P | |
| h = torch.zeros(Bsz, H, N, P, device=u_t.device, dtype=u_t.dtype) \ | |
| if state is None else state | |
| # Match forward/eq(11): rotate (alpha*h + beta*prev_bx) by R_t, then add | |
| # the current (unrotated) write. prev_bx = B_{t-1}x_{t-1}^T from the last | |
| # step; bx = B_t x_t^T is returned for the next step's beta term. | |
| pre = alpha[:, 0, :, None, None] * h | |
| if prev_bx is not None: | |
| pre = pre + beta[:, 0, :, None, None] * prev_bx | |
| h = rotate_pairs(pre.transpose(-1, -2), theta[:, 0].unsqueeze(-2) | |
| .expand(-1, -1, P, -1)).transpose(-1, -2) | |
| bx = Bm[:, 0] @ x[:, 0].transpose(-1, -2) | |
| h = h + gamma[:, 0, :, None, None] * bx | |
| y = (Cm[:, 0].transpose(-1, -2) @ h).reshape(Bsz, -1) | |
| return self.gate_and_proj(y, nu[:, 0]), h, bx | |
| # --------------------------------------------------------------------------- | |
| def selftest(verbose: bool = True) -> dict: | |
| """The four invariants, at float64. | |
| These were an ad-hoc scratch script until BCNorm was added and there was | |
| nothing to re-run. A verified file whose verification lives somewhere else | |
| is an unverified file the moment anyone edits it. | |
| python -m steertts.mamba3.layer | |
| """ | |
| torch.manual_seed(0) | |
| torch.set_default_dtype(torch.float64) | |
| net = Mamba3Layer(d_model=32, d_state=16, rank=2, head_dim=8).eval() | |
| B, T = 2, 20 | |
| u = torch.randn(B, T, 32) | |
| out = {} | |
| with torch.no_grad(): | |
| y = net(u) | |
| # 1. CAUSALITY, as a SCALE test rather than an equality one. Appending | |
| # future timesteps moves the past by ~1e-15 here: the permute in | |
| # _coeffs leaves B and C non-contiguous, so matmul takes a different | |
| # kernel path at a different T and the last bits differ. An equality | |
| # assertion would call that a leak, and would also fail spuriously in | |
| # float32. A real leak scales with the size of the future; rounding | |
| # does not. Drive the future through four orders of magnitude and | |
| # require the drift to stay put. | |
| drifts = [] | |
| for mag in (1.0, 1e2, 1e4): | |
| u2 = torch.cat([u, torch.randn(B, 6, 32) * mag], 1) | |
| drifts.append(float((net(u2)[:, :T] - y).abs().max())) | |
| out["causality"] = max(drifts) | |
| out["causality_scaling"] = max(drifts) / max(min(drifts), 1e-300) | |
| # 2. STREAMING == BATCHED. The deployment path must equal training. | |
| st = bx = None | |
| steps = [] | |
| for t in range(T): | |
| o, st, bx = net.step(u[:, t], st, bx) | |
| steps.append(o) | |
| out["streaming"] = float((torch.stack(steps, 1) - y).abs().max()) | |
| # 3. ROTATION IS ORTHOGONAL. State norm preserved, so it cannot blow up. | |
| h = torch.randn(B, 4, 16, 8) | |
| ang = torch.randn(B, 4, 1, 8) | |
| hr = rotate_pairs(h.transpose(-1, -2), ang).transpose(-1, -2) | |
| out["orthogonal"] = float( | |
| (hr.norm(dim=-2) - h.norm(dim=-2)).abs().max()) | |
| # 4. TRAPEZOIDAL TWO-TAP. Perturbing x_{t-1} moves y_t, not y_{t-2}. | |
| up = u.clone(); up[:, 3] += 1.0 | |
| yp = net(up) | |
| out["twotap_y4"] = float((yp[:, 4] - y[:, 4]).abs().max()) | |
| out["twotap_y2"] = float((yp[:, 2] - y[:, 2]).abs().max()) | |
| # 5. BCNORM IS ACTIVE. B's pre-bias rows should have unit RMS. Without | |
| # the norm this is whatever the projection happens to produce, and | |
| # the first version of this file had no norm at all. | |
| a, b, g, Bm, Cm, x, th = net._coeffs(net.norm(u)) | |
| # Bm is (B,T,H,N,R) after the permute, so the normalised axis is -2. | |
| rms = (Bm - net.B_bias).pow(2).mean(-2).sqrt() | |
| out["bcnorm_rms"] = float((rms - 1.0).abs().max()) | |
| out["params"] = sum(p.numel() for p in net.parameters()) | |
| torch.set_default_dtype(torch.float32) | |
| if verbose: | |
| ok = lambda c: "OK" if c else "FAIL" | |
| print("Mamba-3 selftest (float64)") | |
| print(f" 1 causality (future->past) {out['causality']:.3e}" | |
| f" {ok(out['causality'] < 1e-12)}") | |
| print(f" drift x1 vs x10000 ratio {out['causality_scaling']:.3f}" | |
| f" {ok(out['causality_scaling'] < 1.5)} (a leak would scale)") | |
| print(f" 2 step() == forward() {out['streaming']:.3e}" | |
| f" {ok(out['streaming'] < 1e-10)}") | |
| print(f" 3 rotation orthogonal {out['orthogonal']:.3e}" | |
| f" {ok(out['orthogonal'] < 1e-10)}") | |
| print(f" 4 trapezoidal two-tap y4 {out['twotap_y4']:.3e}" | |
| f" y2 {out['twotap_y2']:.3e}" | |
| f" {ok(out['twotap_y4'] > 1e-6 and out['twotap_y2'] == 0.0)}") | |
| print(f" 5 BCNorm rows unit RMS {out['bcnorm_rms']:.3e}" | |
| f" {ok(out['bcnorm_rms'] < 1e-10)}") | |
| print(f" {out['params']:,} params") | |
| return out | |
| if __name__ == "__main__": | |
| selftest() | |
Xet Storage Details
- Size:
- 16.8 kB
- Xet hash:
- 1bbb4a29d3f6e64d00fcd9c967267fae9c91820f88a1a3dad691d6c26d5e5a00
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.