Coverage for transformer_lens/model_bridge/generalized_components/ssm_mixer.py: 93%

154 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-01 16:23 +0000

1"""Wrap-don't-reimplement bridge for HF's MambaMixer (Mamba-1), plus S6 effective attention.""" 

2from typing import Any, NamedTuple, Optional 

3 

4import torch 

5 

6from transformer_lens.ActivationCache import ActivationCache 

7from transformer_lens.hook_points import HookPoint 

8from transformer_lens.model_bridge.generalized_components.base import ( 

9 GeneralizedComponent, 

10) 

11from transformer_lens.model_bridge.generalized_components.ssm_protocol import ( 

12 SSMStateHookMixin, 

13) 

14 

15 

16def _weightless_rms(x: torch.Tensor, eps: float) -> torch.Tensor: 

17 """FalconMamba's parameter-free RMS over the last dim (HF rms_forward).""" 

18 dtype = x.dtype 

19 x = x.float() 

20 return (x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)).to(dtype) 

21 

22 

23class _S6Terms(NamedTuple): 

24 """S6 intermediates reconstructed from cached Mamba-1 hooks (per-channel).""" 

25 

26 dt: torch.Tensor # [batch, channels, seq] 

27 decay: torch.Tensor # [batch, channels, state, i, j] causal (upper triangle zero) 

28 B: torch.Tensor # [batch, seq, state] (shared across channels) 

29 C: torch.Tensor # [batch, seq, state] (shared across channels) 

30 

31 

32class SSMMixerBridge(SSMStateHookMixin, GeneralizedComponent): 

33 """Opaque wrapper around Mamba-1's MambaMixer. 

34 

35 Submodules (in_proj, conv1d, x_proj, dt_proj, out_proj) are swapped into 

36 the HF mixer by ``replace_remote_component``, so their hooks fire when 

37 slow_forward accesses them. ``A_log`` and ``D`` reach the user via 

38 ``GeneralizedComponent.__getattr__`` delegation. 

39 

40 Decode-step caveat: ``conv1d.hook_out`` fires only on prefill during 

41 stateful generation; see ``DepthwiseConv1DBridge`` for the reason. 

42 """ 

43 

44 hook_aliases = { 

45 "hook_in_proj": "in_proj.hook_out", 

46 "hook_conv": "conv1d.hook_out", 

47 "hook_x_proj": "x_proj.hook_out", 

48 "hook_dt_proj": "dt_proj.hook_out", 

49 "hook_ssm_out": "hook_out", 

50 # Canonical SSM vocabulary (additive): Mamba-1 exposes the discrete time 

51 # step via dt_proj. B/C are bundled in x_proj (not separately hooked). 

52 "hook_ssm_dt": "dt_proj.hook_out", 

53 } 

54 

55 def __init__(self, *args: Any, **kwargs: Any) -> None: 

56 super().__init__(*args, **kwargs) # mixin adds hook_ssm_state + eager_scan 

57 # Real per-step write term dt·x·B, per-channel [batch, channels, seq, state]. 

58 self.hook_ssm_write = HookPoint() 

59 

60 def forward(self, *args: Any, **kwargs: Any) -> Any: 

61 """Hook the input, run HF slow_forward (or the eager scan), hook the output.""" 

62 if self.original_component is None: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 raise RuntimeError( 

64 f"Original component not set for {self.name}. " 

65 "Call set_original_component() first." 

66 ) 

67 

68 hidden_states: Optional[torch.Tensor] = None 

69 if len(args) > 0 and isinstance(args[0], torch.Tensor): 

70 hidden_states = self.hook_in(args[0]) 

71 args = (hidden_states,) + args[1:] 

72 elif "hidden_states" in kwargs and isinstance(kwargs["hidden_states"], torch.Tensor): 72 ↛ 78line 72 didn't jump to line 78 because the condition on line 72 was always true

73 hidden_states = self.hook_in(kwargs["hidden_states"]) 

74 kwargs["hidden_states"] = hidden_states 

75 

76 # Eager-scan slow path: opt-in, prefill only (no cache_params). Keyed on 

77 # explicit state, never on hook-registry introspection. 

78 if self.eager_scan and hidden_states is not None and kwargs.get("cache_params") is None: 

79 output: Any = self._eager_scan_forward(hidden_states, kwargs.get("attention_mask")) 

80 else: 

81 output = self.original_component(*args, **kwargs) 

82 

83 # Hook the primary output tensor, preserving tuple structure 

84 if isinstance(output, tuple) and len(output) > 0: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 first = output[0] 

86 if isinstance(first, torch.Tensor): 

87 return (self.hook_out(first),) + output[1:] 

88 return output 

89 if isinstance(output, torch.Tensor): 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true

90 return self.hook_out(output) 

91 return output 

92 

93 def _eager_scan_forward( 

94 self, hidden_states: torch.Tensor, attention_mask: Optional[torch.Tensor] 

95 ) -> torch.Tensor: 

96 """Reimplement the Mamba-1 mixer prefill forward with an eager Python S6 scan. 

97 

98 Reuses HF's in_proj / conv1d / x_proj / dt_proj / out_proj submodules (so 

99 their hooks still fire) and reimplements ONLY the S6 recurrence:: 

100 

101 write_t[c, s] = dt_t[c] · x_t[c] · B_t[s] -> hook_ssm_write [b, c, seq, s] 

102 S_t[c, s] = exp(A[c,s]·dt_t[c]) · S_{t-1} + write_t 

103 S = stack_t S_t -> hook_ssm_state [b, c, seq, s] 

104 y_t[c] = sum_s C_t[s] · S_t[c, s] + D[c] · x_t[c] 

105 

106 Intervening on hook_ssm_write re-runs the recurrence (propagates to later 

107 states); hook_ssm_state is the post-scan trajectory, so a patch there 

108 changes only the same-position output y_t = C_t·S_t — patch hook_ssm_write 

109 for a propagating state edit. 

110 

111 When the wrapped mixer exposes ``dt_layernorm`` / ``b_layernorm`` / 

112 ``c_layernorm`` (Jamba), those norms run after ``x_proj`` so the scan 

113 matches HF's selective-param path. 

114 

115 Kernel-divergence caveat: reproduces HF's scan only to fp tolerance 

116 (≈1e-6 fp32), never bit-for-bit. O(seq) Python; materializes an 

117 O(b·channels·seq·state) write/state tensor. Prefill only. 

118 """ 

119 oc: Any = self.original_component 

120 batch, seq_len, _ = hidden_states.shape 

121 d_inner = oc.intermediate_size 

122 state = oc.ssm_state_size 

123 dt_rank = oc.time_step_rank 

124 

125 def _mask_cf(states: torch.Tensor) -> torch.Tensor: 

126 # HF MambaMixer masks whenever attention_mask is present (no batch guard, 

127 # unlike Mamba-2), so match that — batch-1 padded inputs included. 

128 if attention_mask is not None: 

129 return states * attention_mask.unsqueeze(1).to(states.dtype) 

130 return states 

131 

132 # 1-2. in_proj + gate split, conv — reuse the HF submodule bridges (hooks fire). 

133 # HF masks padding on the channel-first x both before and after the conv. 

134 projected = oc.in_proj(hidden_states).transpose(1, 2) # [b, 2*d_inner, seq] 

135 x_raw, gate = projected.chunk(2, dim=1) # [b, d_inner, seq] 

136 x = oc.act(oc.conv1d(_mask_cf(x_raw))[..., :seq_len]) # [b, d_inner, seq] 

137 x = _mask_cf(x) 

138 

139 # 3. x_proj -> dt low-rank / B / C ; dt_proj -> dt (softplus, channel-first). 

140 # Jamba (and forks) apply RMSNorm to dt/B/C after x_proj; stock Mamba-1 does not. 

141 ssm_params = oc.x_proj(x.transpose(1, 2)) # [b, seq, dt_rank + 2*state] 

142 time_step, B, C = ssm_params.split([dt_rank, state, state], dim=-1) # B, C: [b, seq, state] 

143 # FalconMamba applies a parameter-free RMS to B/C/dt before dt_proj. 

144 rms_eps = getattr(oc, "rms_eps", None) 

145 if rms_eps is not None: 

146 B = _weightless_rms(B, rms_eps) 

147 C = _weightless_rms(C, rms_eps) 

148 time_step = _weightless_rms(time_step, rms_eps) 

149 

150 # Jamba adds module-based dt/B/C layernorms (no-op when absent). 

151 time_step, B, C = self._apply_selective_param_norms(oc, time_step, B, C) 

152 dt = torch.nn.functional.softplus(oc.dt_proj(time_step)).transpose(1, 2).float() 

153 A = -torch.exp(self.A_log.float()) # [d_inner, state] 

154 x_f, B_f, C_f = x.float(), B.float(), C.float() 

155 

156 # 4. Eager recurrence with intervention hooks. 

157 writes = (dt * x_f)[:, :, :, None] * B_f[:, None, :, :] # [b, d_inner, seq, state] 

158 writes = self.hook_ssm_write(writes) 

159 

160 ssm_state = torch.zeros(batch, d_inner, state, dtype=writes.dtype, device=writes.device) 

161 states = [] 

162 for t in range(seq_len): 

163 decay = torch.exp(A[None] * dt[:, :, t, None]) # [batch, d_inner, state] 

164 ssm_state = decay * ssm_state + writes[:, :, t] 

165 states.append(ssm_state) 

166 state_traj = torch.stack(states, dim=2) # [batch, d_inner, seq, state] 

167 state_traj = self.hook_ssm_state(state_traj) 

168 

169 y = torch.einsum("bcts,bts->bct", state_traj, C_f) # [batch, d_inner, seq] 

170 y = y + self.D.float()[None, :, None] * x_f 

171 scan_output = y * oc.act(gate.float()) # HF gates with self.act, not hardcoded silu 

172 

173 # 5. Output projection — reuse the HF submodule bridge. 

174 contextualized: torch.Tensor = oc.out_proj( 

175 scan_output.transpose(1, 2).to(hidden_states.dtype) 

176 ) 

177 return contextualized 

178 

179 def compute_effective_attention( 

180 self, 

181 cache: ActivationCache, 

182 layer_idx: int, 

183 include_dt_scaling: bool = False, 

184 per_state_coord: bool = False, 

185 ) -> torch.Tensor: 

186 """Materialize Mamba-1's per-channel effective attention from cached hooks. 

187 

188 Mamba-1's S6 selective scan is equivalent to causal attention with a 

189 per-channel, per-state-coordinate learned decay — see "The Hidden 

190 Attention of Mamba" (Ali et al., ACL 2025). Unlike Mamba-2 there is no 

191 head grouping: each of the ``intermediate_size`` channels has its own 

192 ``A`` row, so the "head" axis here is the channel axis:: 

193 

194 M[c, i, j] = sum_n C[i, n] · prod_{k=j+1..i} exp(A[c,n]·dt[c,k]) · B[j, n] 

195 

196 Reads B/C from ``x_proj.hook_out`` (or post-norm 

197 ``b_layernorm`` / ``c_layernorm`` hooks when present, as on Jamba) and 

198 dt from ``dt_proj.hook_out`` (softplus of that output); A via 

199 ``__getattr__``. Read-only: no ``forward()`` re-run. 

200 

201 Args: 

202 cache: ActivationCache from ``run_with_cache`` with this layer's 

203 x_proj and dt_proj hooks. 

204 layer_idx: Block index for this mixer. 

205 include_dt_scaling: False (default) returns the attention-like form; 

206 True multiplies column j by dt[c, j], giving the reconstruction 

207 form satisfying ``y[c,i] = sum_j M[c,i,j]·x[c,j] + D[c]·x[c,i]`` 

208 (x is the post-conv SiLU input; y the pre-gate scan output). 

209 per_state_coord: False (default) sums over the state coordinate and 

210 returns ``[batch, channels, seq, seq]``. True returns the 

211 unsummed ``[batch, channels, state, seq, seq]`` tensor (the 

212 paper's D·N matrices) — OFF by default. 

213 

214 Returns: 

215 ``[batch, intermediate_size, seq, seq]``, or 

216 ``[batch, intermediate_size, state_size, seq, seq]`` when 

217 ``per_state_coord`` is True. 

218 

219 Peak memory is O(batch · intermediate_size · state_size · seq²) — the 

220 per-(channel, state) decay tensor — even for the summed default; use on 

221 short sequences. 

222 """ 

223 t = self._s6_terms(cache, layer_idx) 

224 

225 # M_coord[c, s, i, j] = C[i, s] · decay[c, s, i, j] · B[j, s] 

226 C_col = t.C.permute(0, 2, 1)[:, None, :, :, None] # [batch, 1, state, i, 1] 

227 B_col = t.B.permute(0, 2, 1)[:, None, :, None, :] # [batch, 1, state, 1, j] 

228 M_coord = C_col * t.decay * B_col # [batch, channels, state, i, j] 

229 

230 if include_dt_scaling: 

231 M_coord = M_coord * t.dt[:, :, None, None, :] # × dt[c, j] 

232 

233 if per_state_coord: 

234 return M_coord 

235 return M_coord.sum(dim=2) # [batch, channels, seq, seq] 

236 

237 def compute_ssm_state( 

238 self, 

239 cache: ActivationCache, 

240 layer_idx: int, 

241 time_step: Optional[int] = None, 

242 ) -> torch.Tensor: 

243 """Reconstruct Mamba-1's recurrent state ``S`` from cached hook values. 

244 

245 S6 recurrence ``h_t[c,s] = exp(A[c,s]·dt_t[c])·h_{t-1}[c,s] + dt_t[c]·x_t[c]·B_t[s]`` 

246 unrolls to:: 

247 

248 S_t[c, s] = sum_{j<=t} decay[c, s, t, j] · dt_j[c] · x_j[c] · B_j[s] 

249 

250 with the same per-(channel, state) ``decay`` used by 

251 ``compute_effective_attention``. ``x`` is the post-conv SiLU input 

252 (``SiLU(conv1d.hook_out)``). Read-only: no ``forward()`` re-run. Verify 

253 with ``y_t[c] = sum_s C_t[s]·S_t[c,s] + D[c]·x_t[c]``. 

254 

255 On padded batches the cached hooks are unmasked, so ``S`` is exact only at 

256 non-pad positions; pad-position state is out of contract. 

257 

258 Args: 

259 cache: ActivationCache from ``run_with_cache`` with this layer's 

260 x_proj, dt_proj, and conv1d hooks. 

261 layer_idx: Block index for this mixer. 

262 time_step: If given, return only ``S`` at that position 

263 (``[batch, channels, state]``); None returns every step. 

264 

265 Returns: 

266 ``[batch, channels, seq, state]`` for all steps, or 

267 ``[batch, channels, state]`` for a single ``time_step``. 

268 

269 Peak memory is O(batch · channels · state · seq²) — the per-(channel, 

270 state) decay tensor, built in full by ``_s6_terms`` regardless of 

271 ``time_step`` (which bounds only the returned tensor, not this peak); use 

272 on short sequences. 

273 """ 

274 conv_key = f"blocks.{layer_idx}.mixer.conv1d.hook_out" 

275 if conv_key not in cache: 

276 raise RuntimeError( 

277 f"compute_ssm_state needs {conv_key!r} in cache. Run " 

278 "`run_with_cache()` on the bridge before calling this method." 

279 ) 

280 t = self._s6_terms(cache, layer_idx) 

281 seq_len = t.dt.shape[-1] 

282 oc: Any = self.original_component 

283 # x = act(conv output), the SSM scan input; channel-first [batch, channels, seq]. 

284 x = oc.act(cache[conv_key].float()[..., :seq_len]) 

285 dtx = t.dt * x # dt_j[c]·x_j[c], [batch, channels, seq] 

286 # S_t[c, s] = sum_{j<=t} decay[c, s, t, j] · dtx[c, j] · B_j[s] 

287 if time_step is not None: 

288 return torch.einsum("bcsj,bcj,bjs->bcs", t.decay[:, :, :, time_step, :], dtx, t.B) 

289 return torch.einsum("bcsij,bcj,bjs->bcis", t.decay, dtx, t.B) 

290 

291 @staticmethod 

292 def _apply_selective_param_norms( 

293 oc: Any, 

294 time_step: torch.Tensor, 

295 B: torch.Tensor, 

296 C: torch.Tensor, 

297 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: 

298 """Apply Jamba-style RMSNorms on dt/B/C when the HF mixer exposes them. 

299 

300 Stock Mamba-1 has no ``dt_layernorm`` / ``b_layernorm`` / ``c_layernorm``; 

301 Jamba's ``JambaMambaMixer`` does. Calling through the (possibly bridged) 

302 attributes keeps their hooks firing on the eager-scan path. 

303 """ 

304 dt_ln = getattr(oc, "dt_layernorm", None) 

305 if dt_ln is not None: 

306 time_step = dt_ln(time_step) 

307 b_ln = getattr(oc, "b_layernorm", None) 

308 if b_ln is not None: 

309 B = b_ln(B) 

310 c_ln = getattr(oc, "c_layernorm", None) 

311 if c_ln is not None: 

312 C = c_ln(C) 

313 return time_step, B, C 

314 

315 def _s6_terms(self, cache: ActivationCache, layer_idx: int) -> _S6Terms: 

316 """Reconstruct the shared S6 intermediates (dt, per-(channel,state) decay, B, C). 

317 

318 Reused by ``compute_effective_attention`` and ``compute_ssm_state``. Reads 

319 B/C from ``x_proj.hook_out`` (shared across channels), or — when present — 

320 from Jamba's post-norm ``b_layernorm`` / ``c_layernorm`` hooks. ``dt`` comes 

321 from ``dt_proj.hook_out`` (softplus); that already sits after Jamba's 

322 ``dt_layernorm``. A via ``__getattr__``. Dims come from the wrapped HF 

323 mixer; cfg is the fallback (mirrors the Mamba-2 bridge). 

324 """ 

325 if self.config is None: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 raise RuntimeError("SSMMixerBridge.config must be set") 

327 

328 x_proj_key = f"blocks.{layer_idx}.mixer.x_proj.hook_out" 

329 dt_proj_key = f"blocks.{layer_idx}.mixer.dt_proj.hook_out" 

330 for key in (x_proj_key, dt_proj_key): 

331 if key not in cache: 

332 raise RuntimeError( 

333 f"S6 reconstruction needs {key!r} in cache. Run " 

334 "`run_with_cache()` on the bridge before calling this." 

335 ) 

336 

337 cfg = self.config 

338 oc = self.original_component 

339 

340 def _mamba_dim(module_attr: str, cfg_attr: str, default: Any) -> Any: 

341 if oc is not None: 341 ↛ 345line 341 didn't jump to line 345 because the condition on line 341 was always true

342 val = getattr(oc, module_attr, None) 

343 if val is not None: 343 ↛ 345line 343 didn't jump to line 345 because the condition on line 343 was always true

344 return val 

345 return getattr(cfg, cfg_attr, default) 

346 

347 state_size = int(_mamba_dim("ssm_state_size", "state_size", 16)) 

348 dt_rank = int(_mamba_dim("time_step_rank", "time_step_rank", 0)) 

349 

350 x_proj_out = cache[x_proj_key].float() # [batch, seq, dt_rank + 2*state] 

351 dt_proj_out = cache[dt_proj_key].float() # [batch, seq, channels] 

352 seq_len = dt_proj_out.shape[1] 

353 

354 # Prefer post-norm B/C when the adapter mapped Jamba's selective-param LNs. 

355 b_ln_key = f"blocks.{layer_idx}.mixer.b_layernorm.hook_out" 

356 c_ln_key = f"blocks.{layer_idx}.mixer.c_layernorm.hook_out" 

357 if b_ln_key in cache and c_ln_key in cache: 

358 B = cache[b_ln_key].float() 

359 C = cache[c_ln_key].float() 

360 else: 

361 # Stock Mamba-1: B, C are the raw x_proj tails (shared across channels). 

362 _time_step, B, C = x_proj_out.split([dt_rank, state_size, state_size], dim=-1) 

363 

364 # FalconMamba normalizes B/C after x_proj; dt_proj.hook_out is already 

365 # post-RMS (HF applied it before dt_proj), so dt needs no correction. 

366 rms_eps = getattr(oc, "rms_eps", None) if oc is not None else None 

367 if rms_eps is not None: 

368 B = _weightless_rms(B, rms_eps) 

369 C = _weightless_rms(C, rms_eps) 

370 

371 dt = torch.nn.functional.softplus(dt_proj_out).transpose(1, 2) # [batch, channels, seq] 

372 A = -torch.exp(self.A_log.float()) # [channels, state] 

373 

374 # decay[c, s, i, j] = exp(A[c,s]·(cumdt[c,i]-cumdt[c,j])) for i >= j, else 0. 

375 cumdt = torch.cumsum(dt, dim=-1) # [batch, channels, seq] 

376 dcs = cumdt[:, :, :, None] - cumdt[:, :, None, :] # [batch, channels, i, j] 

377 decay_exp = ( 

378 A[None, :, :, None, None] * dcs[:, :, None, :, :] 

379 ) # [batch, channels, state, i, j] 

380 causal_mask = torch.tril( 

381 torch.ones(seq_len, seq_len, dtype=torch.bool, device=decay_exp.device) 

382 ) 

383 decay = torch.where( 

384 causal_mask[None, None, None, :, :], 

385 torch.exp(decay_exp), 

386 torch.zeros_like(decay_exp), 

387 ) 

388 return _S6Terms(dt=dt, decay=decay, B=B, C=C)