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

156 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-10 05:10 +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 self.hook_ssm_write.pos_dim = 2 

60 self.hook_ssm_state.pos_dim = 2 

61 

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

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

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

65 raise RuntimeError( 

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

67 "Call set_original_component() first." 

68 ) 

69 

70 hidden_states: Optional[torch.Tensor] = None 

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

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

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

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

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

76 kwargs["hidden_states"] = hidden_states 

77 

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

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

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

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

82 else: 

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

84 

85 # Hook the primary output tensor, preserving tuple structure 

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

87 first = output[0] 

88 if isinstance(first, torch.Tensor): 

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

90 return output 

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

92 return self.hook_out(output) 

93 return output 

94 

95 def _eager_scan_forward( 

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

97 ) -> torch.Tensor: 

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

99 

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

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

102 

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

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

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

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

107 

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

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

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

111 for a propagating state edit. 

112 

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

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

115 matches HF's selective-param path. 

116 

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

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

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

120 """ 

121 oc: Any = self.original_component 

122 batch, seq_len, _ = hidden_states.shape 

123 d_inner = oc.intermediate_size 

124 state = oc.ssm_state_size 

125 dt_rank = oc.time_step_rank 

126 

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

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

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

130 if attention_mask is not None: 

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

132 return states 

133 

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

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

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

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

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

139 x = _mask_cf(x) 

140 

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

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

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

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

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

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

147 if rms_eps is not None: 

148 B = _weightless_rms(B, rms_eps) 

149 C = _weightless_rms(C, rms_eps) 

150 time_step = _weightless_rms(time_step, rms_eps) 

151 

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

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

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

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

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

157 

158 # 4. Eager recurrence with intervention hooks. 

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

160 writes = self.hook_ssm_write(writes) 

161 

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

163 states = [] 

164 for t in range(seq_len): 

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

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

167 states.append(ssm_state) 

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

169 state_traj = self.hook_ssm_state(state_traj) 

170 

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

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

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

174 

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

176 contextualized: torch.Tensor = oc.out_proj( 

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

178 ) 

179 return contextualized 

180 

181 def compute_effective_attention( 

182 self, 

183 cache: ActivationCache, 

184 layer_idx: int, 

185 include_dt_scaling: bool = False, 

186 per_state_coord: bool = False, 

187 ) -> torch.Tensor: 

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

189 

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

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

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

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

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

195 

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

197 

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

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

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

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

202 

203 Args: 

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

205 x_proj and dt_proj hooks. 

206 layer_idx: Block index for this mixer. 

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

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

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

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

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

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

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

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

215 

216 Returns: 

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

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

219 ``per_state_coord`` is True. 

220 

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

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

223 short sequences. 

224 """ 

225 t = self._s6_terms(cache, layer_idx) 

226 

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

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

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

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

231 

232 if include_dt_scaling: 

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

234 

235 if per_state_coord: 

236 return M_coord 

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

238 

239 def compute_ssm_state( 

240 self, 

241 cache: ActivationCache, 

242 layer_idx: int, 

243 time_step: Optional[int] = None, 

244 ) -> torch.Tensor: 

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

246 

247 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]`` 

248 unrolls to:: 

249 

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

251 

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

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

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

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

256 

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

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

259 

260 Args: 

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

262 x_proj, dt_proj, and conv1d hooks. 

263 layer_idx: Block index for this mixer. 

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

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

266 

267 Returns: 

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

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

270 

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

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

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

274 on short sequences. 

275 """ 

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

277 if conv_key not in cache: 

278 raise RuntimeError( 

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

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

281 ) 

282 t = self._s6_terms(cache, layer_idx) 

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

284 oc: Any = self.original_component 

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

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

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

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

289 if time_step is not None: 

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

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

292 

293 @staticmethod 

294 def _apply_selective_param_norms( 

295 oc: Any, 

296 time_step: torch.Tensor, 

297 B: torch.Tensor, 

298 C: torch.Tensor, 

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

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

301 

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

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

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

305 """ 

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

307 if dt_ln is not None: 

308 time_step = dt_ln(time_step) 

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

310 if b_ln is not None: 

311 B = b_ln(B) 

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

313 if c_ln is not None: 

314 C = c_ln(C) 

315 return time_step, B, C 

316 

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

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

319 

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

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

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

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

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

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

326 """ 

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

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

329 

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

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

332 for key in (x_proj_key, dt_proj_key): 

333 if key not in cache: 

334 raise RuntimeError( 

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

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

337 ) 

338 

339 cfg = self.config 

340 oc = self.original_component 

341 

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

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

344 val = getattr(oc, module_attr, None) 

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

346 return val 

347 return getattr(cfg, cfg_attr, default) 

348 

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

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

351 

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

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

354 seq_len = dt_proj_out.shape[1] 

355 

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

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

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

359 if b_ln_key in cache and c_ln_key in cache: 

360 B = cache[b_ln_key].float() 

361 C = cache[c_ln_key].float() 

362 else: 

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

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

365 

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

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

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

369 if rms_eps is not None: 

370 B = _weightless_rms(B, rms_eps) 

371 C = _weightless_rms(C, rms_eps) 

372 

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

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

375 

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

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

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

379 decay_exp = ( 

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

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

382 causal_mask = torch.tril( 

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

384 ) 

385 decay = torch.where( 

386 causal_mask[None, None, None, :, :], 

387 torch.exp(decay_exp), 

388 torch.zeros_like(decay_exp), 

389 ) 

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