Coverage for transformer_lens/model_bridge/generalized_components/normalization.py: 95%

178 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +0000

1"""Normalization bridge component implementation.""" 

2import contextlib 

3import warnings 

4from typing import Any, ContextManager, Dict, Optional, Tuple, cast 

5 

6import torch 

7 

8from transformer_lens.hook_points import HookPoint 

9from transformer_lens.model_bridge._relevance_rules import ( 

10 RelevanceRuleConflictError, 

11 ln_rule_grad, 

12) 

13from transformer_lens.model_bridge.generalized_components.base import ( 

14 GeneralizedComponent, 

15) 

16 

17# The native-autograd path returns HF's own output, so hook edits and backward hooks 

18# can only be honored by switching to the python-norm computation, whose numerics 

19# differ from HF's at float-rounding scale. 

20NATIVE_PATH_BWD_FALLBACK_WARNING = ( 

21 "Backward hooks on hook_scale/hook_normalized require grad-connected hook tensors; " 

22 "falling back from the native-autograd path to the python-norm path. Output numerics " 

23 "may differ from the unhooked forward at float-rounding scale." 

24) 

25NATIVE_PATH_EDIT_FALLBACK_WARNING = ( 

26 "A forward hook edited hook_scale/hook_normalized on the native-autograd path; the " 

27 "output is reconstructed from the hooked values instead of HF's native forward. " 

28 "Output numerics may differ from the unhooked forward at float-rounding scale." 

29) 

30# While the LN-rule is active, the fallbacks above would silently compose the rule 

31# with the hook edit and break the bit-identical-forward guarantee, so they raise 

32# instead of warning. 

33RULE_ACTIVE_BWD_HOOK_CONFLICT = ( 

34 "Backward hooks on hook_scale/hook_normalized are incompatible with an active " 

35 "LN-rule on '{name}': the rule-wrapped native forward keeps these hook points " 

36 "out of its backward graph, so a backward hook here would silently never fire." 

37) 

38RULE_ACTIVE_EDIT_HOOK_CONFLICT = ( 

39 "A forward hook edited hook_scale/hook_normalized while the LN-rule is active on " 

40 "'{name}': honoring the edit would require the python-norm fallback, which would " 

41 "compose the rule with the edit and break the bit-identical-forward guarantee." 

42) 

43 

44 

45class _NativeLNRuleForward(torch.autograd.Function): 

46 """Wrap a normalization module's own forward call in the LN-rule's VJP. 

47 

48 Forward returns ``component(x)`` unchanged, so the result is bit-identical to 

49 the native forward by construction. Backward routes the x-path gradient 

50 through the centering op ordinarily but treats ``denom`` as a constant (the 

51 LN-rule), while ``weight`` and ``bias`` receive their ordinary gradient since 

52 the rule only redefines how relevance reaches the input, not parameter 

53 training gradients. A parameter-free norm (``weight`` is ``None``, e.g. 

54 OLMo's ``OlmoLayerNorm``) is treated as a unit scale in both directions. 

55 """ 

56 

57 @staticmethod 

58 def forward( 

59 ctx: Any, 

60 x_centered: torch.Tensor, 

61 denom: torch.Tensor, 

62 weight: Optional[torch.Tensor], 

63 bias: Optional[torch.Tensor], 

64 x: torch.Tensor, 

65 component: torch.nn.Module, 

66 offset: bool, 

67 input_dtype: torch.dtype, 

68 ) -> torch.Tensor: 

69 ctx.save_for_backward(x_centered, denom, weight) 

70 ctx.has_bias = bias is not None 

71 ctx.bias_requires_grad = bool(bias is not None and bias.requires_grad) 

72 ctx.offset = offset 

73 result = component(x) 

74 if result.dtype != input_dtype: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 result = result.to(input_dtype) 

76 return result 

77 

78 @staticmethod 

79 def backward( 

80 ctx: Any, grad_output: torch.Tensor 

81 ) -> Tuple[ 

82 torch.Tensor, 

83 None, 

84 Optional[torch.Tensor], 

85 Optional[torch.Tensor], 

86 None, 

87 None, 

88 None, 

89 None, 

90 ]: 

91 x_centered, denom, weight = ctx.saved_tensors 

92 if weight is None: 

93 w_eff: torch.Tensor | float = 1.0 

94 else: 

95 w_eff = (1.0 + weight) if ctx.offset else weight 

96 reduce_dims = tuple(range(grad_output.dim() - 1)) 

97 grad_x_centered = ln_rule_grad(grad_output * w_eff, denom) 

98 grad_weight = ( 

99 (grad_output * (x_centered / denom)).sum(dim=reduce_dims) 

100 if weight is not None and weight.requires_grad 

101 else None 

102 ) 

103 grad_bias = ( 

104 grad_output.sum(dim=reduce_dims) if ctx.has_bias and ctx.bias_requires_grad else None 

105 ) 

106 return grad_x_centered, None, grad_weight, grad_bias, None, None, None, None 

107 

108 

109class NormalizationBridge(GeneralizedComponent): 

110 """Normalization bridge that wraps transformer normalization layers but implements the calculation from scratch. 

111 

112 This component provides standardized input/output hooks. 

113 """ 

114 

115 property_aliases = {"w": "weight", "b": "bias"} 

116 

117 def __init__( 

118 self, 

119 name: str, 

120 config: Any, 

121 submodules: Optional[Dict[str, GeneralizedComponent]] = {}, 

122 use_native_layernorm_autograd: bool = False, 

123 uses_rms_norm: Optional[bool] = None, 

124 optional: bool = False, 

125 ): 

126 """Initialize the normalization bridge. 

127 

128 Args: 

129 name: The name of this component 

130 config: Optional configuration 

131 submodules: Dictionary of GeneralizedComponent submodules to register 

132 use_native_layernorm_autograd: If True, use HuggingFace's native LayerNorm 

133 autograd for exact gradient matching. If False, 

134 use custom implementation. Defaults to False. 

135 uses_rms_norm: Force RMSNorm vs LayerNorm; None defers to introspection 

136 then ``config.uses_rms_norm``. 

137 optional: If True, setup skips this subtree when absent (hybrid architectures). 

138 """ 

139 super().__init__(name, config, submodules=submodules, optional=optional) 

140 self.hook_normalized = HookPoint() 

141 self.hook_scale = HookPoint() 

142 self.use_native_layernorm_autograd = use_native_layernorm_autograd 

143 self._uses_rms_norm_override = uses_rms_norm 

144 self._relevance_rule_active = False 

145 

146 @property 

147 def _relevance_rule_kinds(self) -> Tuple[str, ...]: 

148 """``("normalization",)`` only when this instance actually dispatches through 

149 the native-autograd branch the LN-rule wraps (``_hf_autograd_forward_with_hooks``); 

150 empty otherwise, so ``use_relevance_rules`` reports an ln1/ln2 mount that 

151 uses the plain python-norm path (for example ``LayerNormPreBridge`` / 

152 ``RMSNormPreBridge``, or a config without ``layer_norm_folding``) as 

153 skipped rather than silently leaving ordinary gradients in place under a 

154 claimed "installed" rule. 

155 """ 

156 if self.use_native_layernorm_autograd: 

157 return ("normalization",) 

158 if bool(getattr(self.config, "layer_norm_folding", False)): 

159 return ("normalization",) 

160 return () 

161 

162 def _enable_relevance_rule(self, kind: str) -> None: 

163 """Activate the LN-rule for this instance's native-forward branch only.""" 

164 self._relevance_rule_active = True 

165 

166 def _disable_relevance_rule(self, kind: str) -> None: 

167 """Deactivate the LN-rule, restoring today's native-forward behavior.""" 

168 self._relevance_rule_active = False 

169 

170 @property 

171 def uses_rms_norm(self) -> bool: 

172 """Whether this bridge treats the wrapped module as RMSNorm. 

173 

174 Override > module introspection > config. Introspection guards against 

175 a shared config (RMSNorm LM + LayerNorm vision tower) misclassifying 

176 a real ``nn.LayerNorm``. 

177 """ 

178 if self._uses_rms_norm_override is not None: 

179 return self._uses_rms_norm_override 

180 component = self.original_component 

181 if component is not None: 

182 if isinstance(component, torch.nn.LayerNorm): 

183 return False 

184 if "RMSNorm" in type(component).__name__: 

185 return True 

186 return bool(getattr(self.config, "uses_rms_norm", False)) 

187 

188 def forward(self, hidden_states: torch.Tensor, **kwargs: Any) -> torch.Tensor: 

189 """Forward pass through the normalization bridge. 

190 

191 Args: 

192 hidden_states: Input hidden states 

193 **kwargs: Additional arguments to pass to the original component 

194 

195 Returns: 

196 Normalized output 

197 """ 

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

199 raise RuntimeError( 

200 f"Original component not set for {self.name}. Call set_original_component() first." 

201 ) 

202 assert self.config is not None 

203 hidden_states = self.hook_in(hidden_states) 

204 if self.use_native_layernorm_autograd: 

205 result = self._hf_autograd_forward_with_hooks(hidden_states) 

206 elif hasattr(self.config, "layer_norm_folding") and self.config.layer_norm_folding: 

207 result = self._hf_autograd_forward_with_hooks(hidden_states) 

208 else: 

209 result = self._python_norm_forward(hidden_states) 

210 output = self.hook_out(result) 

211 return output 

212 

213 def _python_norm_forward(self, hidden_states: torch.Tensor) -> torch.Tensor: 

214 """From-scratch normalization with live hooks: edits propagate, gradients flow.""" 

215 # Upcast to float32 for normalization precision (matches HT's RMSNorm behavior) 

216 input_dtype = hidden_states.dtype 

217 if input_dtype not in (torch.float32, torch.float64): 

218 hidden_states = hidden_states.float() 

219 if not self.uses_rms_norm: 

220 hidden_states = hidden_states - hidden_states.mean(-1, keepdim=True) 

221 scale = self.hook_scale( 

222 ( 

223 hidden_states.pow(2).mean(-1, keepdim=True) + getattr(self.config, "eps", 1e-05) 

224 ).sqrt() 

225 ) 

226 hidden_states = self.hook_normalized(hidden_states / scale) 

227 return self._apply_weight_and_bias(hidden_states, input_dtype) 

228 

229 def _apply_weight_and_bias( 

230 self, hidden_states: torch.Tensor, input_dtype: torch.dtype 

231 ) -> torch.Tensor: 

232 """Apply weight/bias in float32 before casting back (matches HF precision).""" 

233 # Gemma-family RMSNorm stores weight as an offset from 1 (output uses 1 + weight). 

234 weight = ( 

235 (1.0 + self.weight) 

236 if getattr(self.config, "rmsnorm_uses_offset", False) 

237 else self.weight 

238 ) 

239 hidden_states = hidden_states * weight 

240 component = self.original_component 

241 if ( 

242 not self.uses_rms_norm 

243 and component is not None 

244 and hasattr(component, "bias") 

245 and component.bias is not None 

246 ): 

247 hidden_states = hidden_states + cast(torch.Tensor, component.bias) 

248 result = hidden_states.to(input_dtype) 

249 if not self.uses_rms_norm and not result.is_contiguous(): 

250 # F.layer_norm materializes a contiguous output while these 

251 # pointwise ops preserve the input's strides; downstream HF code 

252 # may .view() the result (e.g. Idefics3 pixel_shuffle). 

253 result = result.contiguous() 

254 return result 

255 

256 def _hf_autograd_forward_with_hooks(self, x: torch.Tensor) -> torch.Tensor: 

257 """Forward pass that preserves HF's autograd while firing intermediate hooks. 

258 

259 When hooks only observe (return ``None``, e.g. ``run_with_cache``), the result is 

260 HF's own forward — bit-identical numerics and exact autograd. When a forward hook 

261 edits ``hook_scale`` / ``hook_normalized``, the output is reconstructed from the 

262 hooked values so the edit propagates; when backward hooks are attached, the whole 

263 computation takes the python-norm path so hook tensors stay in the autograd graph. 

264 Both fallbacks warn, since their numerics differ from HF's at rounding scale. 

265 

266 Args: 

267 x: Input tensor 

268 

269 Returns: 

270 Normalized output tensor 

271 """ 

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

273 raise RuntimeError(f"Original component not set for {self.name}") 

274 if isinstance(self.original_component, torch.nn.Identity): 

275 # Non-normalizing slot (ModernBertDecoder layer 0): fire hooks with 

276 # pass-through values instead of fabricated LN stats. 

277 _ = self.hook_scale(torch.ones_like(x[..., :1])) 

278 _ = self.hook_normalized(x) 

279 return x 

280 if self.hook_scale.bwd_hooks or self.hook_normalized.bwd_hooks: 

281 if self._relevance_rule_active: 

282 raise RelevanceRuleConflictError( 

283 RULE_ACTIVE_BWD_HOOK_CONFLICT.format(name=self.name) 

284 ) 

285 warnings.warn(NATIVE_PATH_BWD_FALLBACK_WARNING) 

286 return self._python_norm_forward(x) 

287 has_fwd_hooks = bool(self.hook_scale.fwd_hooks or self.hook_normalized.fwd_hooks) 

288 # No hooks: skip building a graph for observation-only intermediates. With hooks, 

289 # keep grad so an edited value stays connected to the input. 

290 grad_ctx: ContextManager[Any] = ( 

291 contextlib.nullcontext() if has_fwd_hooks else torch.no_grad() 

292 ) 

293 with grad_ctx: 

294 # Upcast to float32 for hook precision (matches HT's RMSNorm/LayerNorm behavior) 

295 x_float = x.float() if x.dtype not in (torch.float32, torch.float64) else x 

296 if not self.uses_rms_norm: 

297 x_centered = x_float - x_float.mean(-1, keepdim=True) 

298 else: 

299 x_centered = x_float 

300 eps_tensor = getattr(self.original_component, "eps", None) 

301 if eps_tensor is None: 

302 eps_tensor = getattr(self.original_component, "variance_epsilon", None) 

303 if eps_tensor is None: 

304 eps_value: float | torch.Tensor = getattr(self.config, "eps", 1e-05) 

305 else: 

306 eps_value = eps_tensor 

307 variance = x_centered.pow(2).mean(-1, keepdim=True) 

308 if isinstance(eps_value, torch.Tensor): 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 inv_rms = torch.rsqrt(variance + eps_value) 

310 scale = (variance + eps_value).sqrt() 

311 else: 

312 inv_rms = torch.rsqrt(variance + float(eps_value)) 

313 scale = (variance + float(eps_value)).sqrt() 

314 # Use rsqrt for x_normalized to match HF's actual computation path 

315 # (LlamaRMSNorm uses x * rsqrt(variance + eps)). Keep scale as sqrt 

316 # for hook_scale (denominator convention used by HookedTransformer). 

317 x_normalized = x_centered * inv_rms 

318 hooked_scale = self.hook_scale(scale) 

319 if hooked_scale is not scale: 

320 # Edited scale: recompute with the denominator convention so the edit 

321 # feeds hook_normalized, mirroring the python-norm path's ordering. 

322 x_normalized = x_centered / hooked_scale 

323 hooked_normalized = self.hook_normalized(x_normalized) 

324 input_dtype = x.dtype 

325 # A hook returning None keeps the original tensor object (see HookPoint), so 

326 # identity is the edit signal. Note in-place mutation of the hook value without 

327 # returning it is NOT detected — return the tensor from the hook to edit. 

328 if hooked_scale is scale and hooked_normalized is x_normalized: 

329 if self._relevance_rule_active: 

330 return self._native_forward_with_ln_rule(x, input_dtype) 

331 result = self.original_component(x) 

332 if result.dtype != input_dtype: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 result = result.to(input_dtype) 

334 return result 

335 if self._relevance_rule_active: 

336 raise RelevanceRuleConflictError(RULE_ACTIVE_EDIT_HOOK_CONFLICT.format(name=self.name)) 

337 warnings.warn(NATIVE_PATH_EDIT_FALLBACK_WARNING) 

338 return self._apply_weight_and_bias(hooked_normalized, input_dtype) 

339 

340 def _native_forward_with_ln_rule( 

341 self, x: torch.Tensor, input_dtype: torch.dtype 

342 ) -> torch.Tensor: 

343 """Call the native forward unchanged while routing its backward through the LN-rule. 

344 

345 Recomputes the centered numerator and denominator independently of the 

346 hook-observation pass above (which may run under ``torch.no_grad()``), so 

347 the recompute here stays grad-connected to ``x`` regardless of whether 

348 forward hooks are attached. Autograd then flows from the returned tensor 

349 back through the centering op ordinarily and, at the LN-rule Function 

350 boundary, treats the denominator as a constant -- the same contract as 

351 the shared ``ln_rule`` primitive, without reproducing the native kernel's 

352 own forward numerics. 

353 """ 

354 component = self.original_component 

355 assert component is not None 

356 x_float = x.float() if x.dtype not in (torch.float32, torch.float64) else x 

357 if not self.uses_rms_norm: 

358 x_centered = x_float - x_float.mean(-1, keepdim=True) 

359 else: 

360 x_centered = x_float 

361 eps_tensor = getattr(component, "eps", None) 

362 if eps_tensor is None: 

363 eps_tensor = getattr(component, "variance_epsilon", None) 

364 if eps_tensor is None: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 eps_value: float | torch.Tensor = getattr(self.config, "eps", 1e-05) 

366 else: 

367 eps_value = eps_tensor 

368 variance = x_centered.pow(2).mean(-1, keepdim=True) 

369 denom = ( 

370 (variance + eps_value).sqrt() 

371 if isinstance(eps_value, torch.Tensor) 

372 else (variance + float(eps_value)).sqrt() 

373 ) 

374 # A parameter-free norm (OLMo's OlmoLayerNorm) exposes no usable weight; 

375 # pass None so the rule treats the scale as 1 in both forward and backward. 

376 try: 

377 weight: Optional[torch.Tensor] = cast(torch.Tensor, self.weight) 

378 except AttributeError: 

379 weight = None 

380 bias = getattr(component, "bias", None) if not self.uses_rms_norm else None 

381 offset = bool(getattr(self.config, "rmsnorm_uses_offset", False)) 

382 result: torch.Tensor = _NativeLNRuleForward.apply( 

383 x_centered, denom, weight, bias, x, component, offset, input_dtype 

384 ) 

385 return result 

386 

387 

388class LayerNormPreBridge(NormalizationBridge): 

389 """Param-free LayerNorm (LNPre): hook_scale / hook_normalized, no weight or bias.""" 

390 

391 property_aliases: Dict[str, str] = {} 

392 

393 def __init__( 

394 self, 

395 name: str, 

396 config: Any, 

397 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

398 optional: bool = False, 

399 ): 

400 """Initialize the param-free LayerNorm bridge.""" 

401 super().__init__( 

402 name, 

403 config, 

404 submodules=submodules or {}, 

405 use_native_layernorm_autograd=False, 

406 uses_rms_norm=False, 

407 optional=optional, 

408 ) 

409 

410 def _apply_weight_and_bias( 

411 self, hidden_states: torch.Tensor, input_dtype: torch.dtype 

412 ) -> torch.Tensor: 

413 """No weight or bias to apply — only restore the input dtype.""" 

414 return hidden_states.to(input_dtype) 

415 

416 

417class RMSNormPreBridge(NormalizationBridge): 

418 """Param-free RMSNorm (RMSPre): hook_scale / hook_normalized, no learnable scale.""" 

419 

420 property_aliases: Dict[str, str] = {} 

421 

422 def __init__( 

423 self, 

424 name: str, 

425 config: Any, 

426 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

427 optional: bool = False, 

428 ): 

429 """Initialize the param-free RMSNorm bridge.""" 

430 super().__init__( 

431 name, 

432 config, 

433 submodules=submodules or {}, 

434 use_native_layernorm_autograd=False, 

435 uses_rms_norm=True, 

436 optional=optional, 

437 ) 

438 

439 def _apply_weight_and_bias( 

440 self, hidden_states: torch.Tensor, input_dtype: torch.dtype 

441 ) -> torch.Tensor: 

442 """No learnable scale to apply — only restore the input dtype.""" 

443 return hidden_states.to(input_dtype)