Coverage for transformer_lens/model_bridge/generalized_components/base.py: 84%

220 statements  

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

1"""Base class for generalized transformer components.""" 

2from __future__ import annotations 

3 

4import inspect 

5import warnings 

6from collections.abc import Callable 

7from typing import Any, Dict, List, Optional, Union 

8 

9import torch 

10import torch.nn as nn 

11 

12from transformer_lens.conversion_utils.conversion_steps.base_tensor_conversion import ( 

13 BaseTensorConversion, 

14) 

15from transformer_lens.hook_points import HookPoint 

16 

17 

18class CloneOutputUnderGradMixin(nn.Module): 

19 """Clone the forward output so HF's in-place mutation cannot corrupt it. 

20 

21 Under grad, autograd forbids in-place writes to backward-hook views; under 

22 no_grad, cached hook_out tensors alias the storage HF then rewrites. 

23 Mix in ahead of a bridge class: ``class X(CloneOutputUnderGradMixin, LinearBridge)``. 

24 """ 

25 

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

27 out = super().forward(*args, **kwargs) 

28 if isinstance(out, torch.Tensor): 28 ↛ 30line 28 didn't jump to line 30 because the condition on line 28 was always true

29 out = out.clone() 

30 return out 

31 

32 

33# Bumped whenever any component rebinds its hook_aliases (MoEBridge's dense/ 

34# sparse dispatch, OlmoHybrid's per-layer selection). Alias caches key on it: 

35# a rebind can leave the hook REGISTRY unchanged while changing what the 

36# aliases point at, so size-based cache keys cannot see it. 

37_ALIAS_GENERATION = 0 

38 

39 

40def alias_generation() -> int: 

41 """Current global alias-rebind generation.""" 

42 return _ALIAS_GENERATION 

43 

44 

45def _bump_alias_generation() -> None: 

46 global _ALIAS_GENERATION 

47 _ALIAS_GENERATION += 1 

48 

49 

50class GeneralizedComponent(nn.Module): 

51 """Base class for generalized transformer components. 

52 

53 This class provides a standardized interface for transformer components 

54 and handles hook registration and execution. 

55 """ 

56 

57 is_list_item: bool = False 

58 hook_out_is_single_residual_stream: bool = False 

59 compatibility_mode: bool = False 

60 disable_warnings: bool = False 

61 hook_aliases: Dict[str, Union[str, List[str]]] = {} 

62 

63 # Projection-hook protocol between container bridges (MLPBridge) and the 

64 # projection bridges they wrap (LinearBridge / Conv1DBridge): the wrapper 

65 # records that its hook_out fired so the container only re-fires it as a 

66 # bypass fallback, and the container suppresses the wrapper's next hook_in 

67 # after pre-firing it itself. See MLPBridge.forward. 

68 _fired_hook_out: bool = False 

69 _suppress_next_hook_in: bool = False 

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

71 

72 def __init__( 

73 self, 

74 name: Optional[str], 

75 config: Optional[Any] = None, 

76 submodules: Optional[Dict[str, "GeneralizedComponent"]] = None, 

77 conversion_rule: Optional[BaseTensorConversion] = None, 

78 hook_alias_overrides: Optional[Dict[str, str]] = None, 

79 optional: bool = False, 

80 ): 

81 """Initialize the generalized component. 

82 

83 Args: 

84 name: The name of this component (None if component has no container in remote model) 

85 config: Optional configuration object for the component 

86 submodules: Dictionary of GeneralizedComponent submodules to register 

87 conversion_rule: Optional conversion rule for this component's hooks 

88 hook_alias_overrides: Optional dictionary to override default hook aliases. 

89 For example, {"hook_attn_out": "ln1_post.hook_out"} will make hook_attn_out 

90 point to ln1_post.hook_out instead of the default value in self.hook_aliases. 

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

92 """ 

93 super().__init__() 

94 self.name = name 

95 self.config = config 

96 self.submodules = submodules or {} 

97 self.conversion_rule = conversion_rule 

98 self.optional = optional 

99 self._hook_registry: Dict[str, HookPoint] = {} 

100 self._hook_alias_registry: Dict[str, Union[str, List[str]]] = {} 

101 self._property_alias_registry: Dict[str, str] = {} 

102 self.hook_in = HookPoint() 

103 self.hook_out = HookPoint() 

104 # real_components maps TL keys to (remote_path, actual_instance) tuples 

105 # For list components, actual_instance will be a list of component instances 

106 self.real_components: Dict[str, tuple] = {} 

107 if self.conversion_rule is not None: 

108 self.hook_in.hook_conversion = self.conversion_rule 

109 self.hook_out.hook_conversion = self.conversion_rule 

110 

111 # Copy class-level hook_aliases and apply any overrides 

112 if hook_alias_overrides is not None: 

113 self.hook_aliases = self.__class__.hook_aliases.copy() 

114 self.hook_aliases.update(hook_alias_overrides) 

115 

116 def _register_hook(self, name: str, hook: HookPoint) -> None: 

117 """Register a hook in the component's hook registry.""" 

118 hook.name = name 

119 self._hook_registry[name] = hook 

120 

121 def _register_aliases(self) -> None: 

122 """Register aliases from class-level dictionaries. 

123 

124 This is called ONLY in enable_compatibility_mode() after weight processing. 

125 It creates actual Python attributes/properties that directly reference the target objects. 

126 

127 Note: This should only be called when compatibility mode is enabled and after 

128 weight processing is complete to ensure property aliases point to processed weights. 

129 """ 

130 if self.hook_aliases: 

131 self._hook_alias_registry.update(self.hook_aliases) 

132 if self.property_aliases: 

133 self._property_alias_registry.update(self.property_aliases) 

134 for alias_name, target_path in self._hook_alias_registry.items(): 

135 resolved = False 

136 if isinstance(target_path, list): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 for single_target in target_path: 

138 try: 

139 target_obj = self 

140 for part in single_target.split("."): 

141 target_obj = getattr(target_obj, part) 

142 object.__setattr__(self, alias_name, target_obj) 

143 resolved = True 

144 break 

145 except AttributeError: 

146 continue 

147 else: 

148 try: 

149 target_obj = self 

150 for part in target_path.split("."): 

151 target_obj = getattr(target_obj, part) 

152 object.__setattr__(self, alias_name, target_obj) 

153 resolved = True 

154 except AttributeError: 

155 pass 

156 if not resolved: 

157 # Surface drops instead of silently swallowing — some aliases are 

158 # legitimately conditional on optional submodules, but an author 

159 # needs to see which ones dropped at bridge-init. 

160 warnings.warn( 

161 f"Hook alias '{alias_name}' -> '{target_path}' on " 

162 f"{type(self).__name__}(name={getattr(self, 'name', None)!r}) " 

163 f"did not resolve; this hook will not be accessible.", 

164 stacklevel=2, 

165 ) 

166 for alias_name, target_path in self._property_alias_registry.items(): 

167 try: 

168 target_obj = self 

169 for part in target_path.split("."): 

170 target_obj = getattr(target_obj, part) 

171 object.__setattr__(self, alias_name, target_obj) 

172 except AttributeError: 

173 pass 

174 

175 def get_hooks(self) -> Dict[str, HookPoint]: 

176 """Get all hooks registered in this component.""" 

177 hooks = self._hook_registry.copy() 

178 if self.compatibility_mode and self._hook_alias_registry: 

179 for alias_name in self._hook_alias_registry.keys(): 

180 if hasattr(self, alias_name): 180 ↛ 179line 180 didn't jump to line 179 because the condition on line 180 was always true

181 target_hook = getattr(self, alias_name) 

182 if isinstance(target_hook, HookPoint): 182 ↛ 179line 182 didn't jump to line 179 because the condition on line 182 was always true

183 hooks[alias_name] = target_hook 

184 return hooks 

185 

186 def _is_getattr_called_internally(self) -> bool: 

187 """This function checks if the __getattr__ method was being called internally 

188 (e.g by the setup process or run_with_cache). 

189 """ 

190 for frame_info in inspect.stack(): 

191 if "setup_components" in frame_info.function or "run_with_cache" in frame_info.function: 

192 return True 

193 return False 

194 

195 def set_original_component(self, original_component: nn.Module) -> None: 

196 """Set the original component that this bridge wraps. 

197 

198 Args: 

199 original_component: The original transformer component to wrap 

200 """ 

201 self.add_module("_original_component", original_component) 

202 # An opaque wrapper (created with config=None) shadows the wrapped 

203 # module's own config. HF forwards sometimes read a submodule's config 

204 # directly (e.g. Qwen2Audio's forward does create_bidirectional_mask( 

205 # config=self.audio_tower.config)), so inherit the real config to avoid 

206 # exposing None. Components given an explicit config keep it. 

207 if self.config is None: 

208 self.config = getattr(original_component, "config", None) 

209 

210 @property 

211 def original_component(self) -> Optional[nn.Module]: 

212 """Get the original component.""" 

213 return self._modules.get("_original_component", None) 

214 

215 def add_hook(self, hook_fn: Callable[..., torch.Tensor], hook_name: str = "output") -> None: 

216 """Add a hook function (HookedTransformer-compatible interface). 

217 

218 Args: 

219 hook_fn: Function to call at this hook point 

220 hook_name: Name of the hook point (defaults to "output") 

221 """ 

222 if hook_name == "output": 

223 self.hook_out.add_hook(hook_fn) 

224 elif hook_name == "input": 

225 self.hook_in.add_hook(hook_fn) 

226 else: 

227 raise ValueError( 

228 f"Hook name '{hook_name}' not supported. Supported names are 'output' and 'input'." 

229 ) 

230 

231 def remove_hooks(self, hook_name: str | None = None) -> None: 

232 """Remove hooks (HookedTransformer-compatible interface). 

233 

234 Args: 

235 hook_name: Name of the hook point to remove. If None, removes all hooks. 

236 """ 

237 if hook_name is None: 

238 self.hook_in.remove_hooks(dir="both") 

239 self.hook_out.remove_hooks(dir="both") 

240 elif hook_name == "output": 

241 self.hook_out.remove_hooks(dir="both") 

242 elif hook_name == "input": 

243 self.hook_in.remove_hooks(dir="both") 

244 else: 

245 raise ValueError( 

246 f"Hook name '{hook_name}' not supported. Supported names are 'output' and 'input'." 

247 ) 

248 

249 def set_processed_weights( 

250 self, weights: Dict[str, torch.Tensor], verbose: bool = False 

251 ) -> None: 

252 """Set the processed weights for use in compatibility mode. 

253 

254 This method stores processed weights as attributes on the component so they can be 

255 used directly in the forward pass without modifying the original component. 

256 

257 Components should override this method to handle their specific weight structure. 

258 The weights dict contains keys like "weight", "bias", "W_in", "W_out", etc. 

259 

260 If this component has submodules, this method will automatically distribute the 

261 weights to those subcomponents using ProcessWeights.distribute_weights_to_components. 

262 

263 Args: 

264 weights: Dictionary of processed weight tensors 

265 verbose: If True, print detailed information about weight setting 

266 """ 

267 if verbose: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 print( 

269 f"\n set_processed_weights: {self.__class__.__name__} (name={getattr(self, 'name', 'unknown')})" 

270 ) 

271 print(f" Received {len(weights)} weight keys") 

272 

273 # First, handle single-part keys (keys without ".") by setting them as parameters 

274 # on the original component 

275 if self.original_component is not None: 275 ↛ 304line 275 didn't jump to line 304 because the condition on line 275 was always true

276 for key, weight_tensor in weights.items(): 

277 # Only process keys without "." (single-part keys) 

278 if "." not in key: 

279 # Try to set the parameter on the original component 

280 if hasattr(self.original_component, key): 

281 param = getattr(self.original_component, key) 

282 if param is not None and isinstance(param, torch.nn.Parameter): 

283 if param.shape != weight_tensor.shape: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 raise ValueError( 

285 f"Shape mismatch when setting weight '{key}' in {type(self.original_component).__name__}: " 

286 f"existing param shape {param.shape} != new tensor shape {weight_tensor.shape}" 

287 ) 

288 if verbose: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 print(f" Setting weight: {key} (shape: {weight_tensor.shape})") 

290 # break tying by creating a new param 

291 new_param = nn.Parameter(weight_tensor) 

292 setattr(self.original_component, key, new_param) 

293 elif param is None: 293 ↛ 276line 293 didn't jump to line 276 because the condition on line 293 was always true

294 # Parameter exists but is None (e.g., bias=False in nn.Linear) 

295 # Create a new parameter from the weight tensor 

296 if verbose: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 print( 

298 f" Creating weight: {key} (shape: {weight_tensor.shape}) - was None" 

299 ) 

300 new_param = nn.Parameter(weight_tensor) 

301 setattr(self.original_component, key, new_param) 

302 

303 # If this component has submodules, distribute weights to them 

304 if self.real_components: 

305 from transformer_lens.weight_processing import ProcessWeights 

306 

307 if verbose: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 print(f" Has {len(self.real_components)} subcomponents, distributing weights...") 

309 

310 ProcessWeights.distribute_weights_to_components( 

311 state_dict=weights, 

312 component_mapping=self.real_components, 

313 verbose=verbose, 

314 ) 

315 

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

317 """Generic forward pass for bridge components with input/output hooks.""" 

318 original_component = self._modules.get("_original_component", None) 

319 if original_component is None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 raise RuntimeError( 

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

322 ) 

323 input_arg_names = [ 

324 "input", 

325 "hidden_states", 

326 "input_ids", 

327 "query_input", 

328 "x", 

329 "inputs_embeds", 

330 ] 

331 input_found = False 

332 for name in input_arg_names: 

333 if name in kwargs: 

334 hooked = self.hook_in(kwargs[name]) 

335 kwargs[name] = hooked 

336 input_found = True 

337 break 

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

339 hooked_input = self.hook_in(args[0]) 

340 args = (hooked_input,) + args[1:] 

341 input_found = True 

342 output = original_component(*args, **kwargs) 

343 if isinstance(output, tuple): 

344 hooked_first = self.hook_out(output[0]) 

345 output = (hooked_first,) + output[1:] 

346 elif not isinstance(output, torch.Tensor) and isinstance( 

347 getattr(output, "last_hidden_state", None), torch.Tensor 

348 ): 

349 # ModelOutput-returning components (e.g. vision/audio towers). 

350 output.last_hidden_state = self.hook_out(output.last_hidden_state) 

351 else: 

352 output = self.hook_out(output) 

353 return output 

354 

355 def __getattr__(self, name: str) -> Any: 

356 modules = object.__getattribute__(self, "__dict__").get("_modules") 

357 if modules is not None and name in modules: 

358 return modules[name] 

359 if name == "original_component": 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") 

361 submodules = object.__getattribute__(self, "__dict__").get("submodules") 

362 if submodules is not None and name in submodules: 

363 # Don't return submodule here - it should be accessed via _modules after add_module() 

364 # Raising AttributeError allows PyTorch's add_module() to work correctly 

365 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") 

366 if modules is not None: 366 ↛ 380line 366 didn't jump to line 380 because the condition on line 366 was always true

367 original_component = modules.get("_original_component") 

368 if original_component is not None: 

369 try: 

370 if "." in name: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 name_split = name.split(".") 

372 current = getattr(original_component, name_split[0]) 

373 for part in name_split[1:]: 

374 current = getattr(current, part) 

375 return current 

376 else: 

377 return getattr(original_component, name) 

378 except AttributeError: 

379 pass 

380 raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") 

381 

382 def __setattr__(self, name: str, value: Any) -> None: 

383 """Set attribute, with passthrough to original component for compatibility.""" 

384 if isinstance(value, HookPoint): 

385 self._register_hook(name, value) 

386 super().__setattr__(name, value) 

387 return 

388 if name == "hook_aliases": 

389 # Any alias rebind invalidates alias caches downstream. 

390 _bump_alias_generation() 

391 if name.startswith("_") or name in [ 

392 "name", 

393 "config", 

394 "submodules", 

395 "conversion_rule", 

396 "compatibility_mode", 

397 "disable_warnings", 

398 "optional", 

399 # Components rebind these per layer at bind time (MoEBridge's 

400 # dense/sparse dispatch). Without the carve-out the assignment is 

401 # forwarded to the wrapped HF module whenever it happens to expose 

402 # the attribute — the rebind then silently vanishes. 

403 "hook_aliases", 

404 "property_aliases", 

405 # train()/eval() set self.training; redirecting it to the original 

406 # component leaves the wrapper stuck in training mode (dropout at 

407 # inference). Recursion still reaches the original via _modules. 

408 "training", 

409 ]: 

410 super().__setattr__(name, value) 

411 return 

412 class_attr = getattr(type(self), name, None) 

413 if class_attr is not None and isinstance(class_attr, property): 

414 if class_attr.fset is not None: 

415 super().__setattr__(name, value) 

416 return 

417 if hasattr(self, "_modules") and "_original_component" in self._modules: 

418 original_component = self._modules["_original_component"] 

419 if hasattr(original_component, name): 

420 try: 

421 setattr(original_component, name, value) 

422 return 

423 except AttributeError: 

424 pass 

425 super().__setattr__(name, value)