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

233 statements  

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

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

2from __future__ import annotations 

3 

4import contextlib 

5import inspect 

6import warnings 

7from collections.abc import Callable 

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

9 

10import torch 

11import torch.nn as nn 

12from accelerate.utils import align_module_device 

13 

14from transformer_lens.conversion_utils.conversion_steps.base_tensor_conversion import ( 

15 BaseTensorConversion, 

16) 

17from transformer_lens.hook_points import HookPoint 

18 

19 

20def align_offloaded_subtree(module: nn.Module) -> contextlib.ExitStack: 

21 """Materialize every Accelerate-offloaded descendant of ``module`` for the 

22 caller's duration (an ``ExitStack`` of ``align_module_device`` contexts). 

23 

24 Accelerate attaches offload hooks at leaf level - whichever submodule 

25 directly owns the Parameter (e.g. ``c_attn``, ``c_proj``) - not on 

26 container modules like an attention block as a whole. ``align_module_device`` 

27 on a container alone is therefore a no-op even though its descendants are 

28 offloaded. Walking every descendant and entering each one's 

29 ``align_module_device`` (a cheap no-op for any module that has no hook of 

30 its own) covers both a leaf ``original_component`` and a multi-level 

31 container uniformly, without needing to know in advance which specific 

32 descendant a given architecture adapter's code actually reads from. 

33 """ 

34 stack = contextlib.ExitStack() 

35 for submodule in module.modules(): 

36 stack.enter_context(align_module_device(submodule)) 

37 return stack 

38 

39 

40class CloneOutputUnderGradMixin(nn.Module): 

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

42 

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

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

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

46 """ 

47 

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

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

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

51 out = out.clone() 

52 return out 

53 

54 

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

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

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

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

59_ALIAS_GENERATION = 0 

60 

61 

62def alias_generation() -> int: 

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

64 return _ALIAS_GENERATION 

65 

66 

67def _bump_alias_generation() -> None: 

68 global _ALIAS_GENERATION 

69 _ALIAS_GENERATION += 1 

70 

71 

72class GeneralizedComponent(nn.Module): 

73 """Base class for generalized transformer components. 

74 

75 This class provides a standardized interface for transformer components 

76 and handles hook registration and execution. 

77 """ 

78 

79 is_list_item: bool = False 

80 hook_out_is_single_residual_stream: bool = False 

81 compatibility_mode: bool = False 

82 disable_warnings: bool = False 

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

84 

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

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

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

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

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

90 _fired_hook_out: bool = False 

91 _suppress_next_hook_in: bool = False 

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

93 

94 def __init__( 

95 self, 

96 name: Optional[str], 

97 config: Optional[Any] = None, 

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

99 conversion_rule: Optional[BaseTensorConversion] = None, 

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

101 optional: bool = False, 

102 ): 

103 """Initialize the generalized component. 

104 

105 Args: 

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

107 config: Optional configuration object for the component 

108 submodules: Dictionary of GeneralizedComponent submodules to register 

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

110 hook_alias_overrides: Optional dictionary to override default hook aliases. 

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

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

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

114 """ 

115 super().__init__() 

116 self.name = name 

117 self.config = config 

118 self.submodules = submodules or {} 

119 self.conversion_rule = conversion_rule 

120 self.optional = optional 

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

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

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

124 self.hook_in = HookPoint() 

125 self.hook_out = HookPoint() 

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

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

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

129 if self.conversion_rule is not None: 

130 self.hook_in.hook_conversion = self.conversion_rule 

131 self.hook_out.hook_conversion = self.conversion_rule 

132 

133 # Copy class-level hook_aliases and apply any overrides 

134 if hook_alias_overrides is not None: 

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

136 self.hook_aliases.update(hook_alias_overrides) 

137 

138 def __call__(self, *args: Any, **kwargs: Any) -> Any: 

139 """Run forward(), materializing the wrapped component's params first if offloaded. 

140 

141 Bridge components read the wrapped module's raw parameters directly 

142 (self.weight / self.original_component.bias / etc. via __getattr__ or 

143 direct attribute access) rather than exclusively calling the wrapped 

144 module's own forward(). Under an Accelerate CPU/disk device_map, 

145 Accelerate only swaps a meta placeholder for the real, materialized 

146 tensor around the wrapped module's OWN forward() call (its pre/post 

147 forward hooks) - a raw attribute read outside that window silently 

148 sees the meta placeholder instead, with no error. align_module_device 

149 wraps this call in the same pre/post-forward hook Accelerate would 

150 have run, so every read during this call - whichever way the code 

151 reaches it - gets real data. It's a no-op (bare yield) when the 

152 wrapped module has no offload hook, which covers the common case of 

153 no device_map, a single device, or a multi-GPU split with no CPU/disk 

154 offload involved. 

155 

156 Deliberately just original_component, not align_offloaded_subtree's 

157 whole-subtree walk: Accelerate hooks the leaf modules that actually own 

158 Parameters (e.g. NormalizationBridge/LinearBridge wrap one directly), so 

159 materializing exactly that leaf for exactly its own call keeps the same 

160 one-leaf-at-a-time memory footprint Accelerate's native per-module hooks 

161 would give a plain (non-bridge) forward pass. A component whose 

162 original_component is a container of several separately-hooked leaves 

163 (e.g. an attention module wrapping distinct q/k/v/o projections) doesn't 

164 need this to also materialize here - each of ITS own sub-bridges calls 

165 into its own leaf the same way. align_offloaded_subtree is for the 

166 narrower case of code that reads a specific descendant's raw params 

167 directly during setup, without going through that descendant's own 

168 bridge __call__ at all (see JointQKVAttentionBridge.set_original_component). 

169 """ 

170 original_component = self._modules.get("_original_component") 

171 if original_component is None: 

172 return super().__call__(*args, **kwargs) 

173 with align_module_device(original_component): 

174 return super().__call__(*args, **kwargs) 

175 

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

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

178 hook.name = name 

179 self._hook_registry[name] = hook 

180 

181 def _register_aliases(self) -> None: 

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

183 

184 Called unconditionally at bridge init (see bridge.py); compatibility mode 

185 additionally re-registers after weight processing. 

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

187 

188 Note: Re-registration expects to run after 

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

190 """ 

191 if self.hook_aliases: 

192 self._hook_alias_registry.update(self.hook_aliases) 

193 if self.property_aliases: 

194 self._property_alias_registry.update(self.property_aliases) 

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

196 resolved = False 

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

198 for single_target in target_path: 

199 try: 

200 target_obj = self 

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

202 target_obj = getattr(target_obj, part) 

203 object.__setattr__(self, alias_name, target_obj) 

204 resolved = True 

205 break 

206 except AttributeError: 

207 continue 

208 else: 

209 try: 

210 target_obj = self 

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

212 target_obj = getattr(target_obj, part) 

213 object.__setattr__(self, alias_name, target_obj) 

214 resolved = True 

215 except AttributeError: 

216 pass 

217 if not resolved: 

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

219 # legitimately conditional on optional submodules, but an author 

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

221 warnings.warn( 

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

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

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

225 stacklevel=2, 

226 ) 

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

228 try: 

229 target_obj = self 

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

231 target_obj = getattr(target_obj, part) 

232 object.__setattr__(self, alias_name, target_obj) 

233 except AttributeError: 

234 pass 

235 

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

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

238 hooks = self._hook_registry.copy() 

239 if self.compatibility_mode and self._hook_alias_registry: 

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

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

242 target_hook = getattr(self, alias_name) 

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

244 hooks[alias_name] = target_hook 

245 return hooks 

246 

247 def _is_getattr_called_internally(self) -> bool: 

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

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

250 """ 

251 for frame_info in inspect.stack(): 

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

253 return True 

254 return False 

255 

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

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

258 

259 Args: 

260 original_component: The original transformer component to wrap 

261 """ 

262 self.add_module("_original_component", original_component) 

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

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

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

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

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

268 if self.config is None: 

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

270 

271 @property 

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

273 """Get the original component.""" 

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

275 

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

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

278 

279 Args: 

280 hook_fn: Function to call at this hook point 

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

282 """ 

283 if hook_name == "output": 

284 self.hook_out.add_hook(hook_fn) 

285 elif hook_name == "input": 

286 self.hook_in.add_hook(hook_fn) 

287 else: 

288 raise ValueError( 

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

290 ) 

291 

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

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

294 

295 Args: 

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

297 """ 

298 if hook_name is None: 

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

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

301 elif hook_name == "output": 

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

303 elif hook_name == "input": 

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

305 else: 

306 raise ValueError( 

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

308 ) 

309 

310 def set_processed_weights( 

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

312 ) -> None: 

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

314 

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

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

317 

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

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

320 

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

322 weights to those subcomponents using ProcessWeights.distribute_weights_to_components. 

323 

324 Args: 

325 weights: Dictionary of processed weight tensors 

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

327 """ 

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

329 print( 

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

331 ) 

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

333 

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

335 # on the original component 

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

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

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

339 if "." not in key: 

340 # Try to set the parameter on the original component 

341 if hasattr(self.original_component, key): 

342 param = getattr(self.original_component, key) 

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

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

345 raise ValueError( 

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

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

348 ) 

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

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

351 # break tying by creating a new param 

352 new_param = nn.Parameter(weight_tensor) 

353 setattr(self.original_component, key, new_param) 

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

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

356 # Create a new parameter from the weight tensor 

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

358 print( 

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

360 ) 

361 new_param = nn.Parameter(weight_tensor) 

362 setattr(self.original_component, key, new_param) 

363 

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

365 if self.real_components: 

366 from transformer_lens.weight_processing import ProcessWeights 

367 

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

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

370 

371 ProcessWeights.distribute_weights_to_components( 

372 state_dict=weights, 

373 component_mapping=self.real_components, 

374 verbose=verbose, 

375 ) 

376 

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

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

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

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

381 raise RuntimeError( 

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

383 ) 

384 input_arg_names = [ 

385 "input", 

386 "hidden_states", 

387 "input_ids", 

388 "query_input", 

389 "x", 

390 "inputs_embeds", 

391 ] 

392 input_found = False 

393 for name in input_arg_names: 

394 if name in kwargs: 

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

396 kwargs[name] = hooked 

397 input_found = True 

398 break 

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

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

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

402 input_found = True 

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

404 if isinstance(output, tuple): 

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

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

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

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

409 ): 

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

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

412 else: 

413 output = self.hook_out(output) 

414 return output 

415 

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

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

418 if modules is not None and name in modules: 

419 return modules[name] 

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

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

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

423 if submodules is not None and name in submodules: 

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

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

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

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

428 original_component = modules.get("_original_component") 

429 if original_component is not None: 

430 try: 

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

432 name_split = name.split(".") 

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

434 for part in name_split[1:]: 

435 current = getattr(current, part) 

436 return current 

437 else: 

438 return getattr(original_component, name) 

439 except AttributeError: 

440 pass 

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

442 

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

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

445 if isinstance(value, HookPoint): 

446 self._register_hook(name, value) 

447 super().__setattr__(name, value) 

448 return 

449 if name == "hook_aliases": 

450 # Any alias rebind invalidates alias caches downstream. 

451 _bump_alias_generation() 

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

453 "name", 

454 "config", 

455 "submodules", 

456 "conversion_rule", 

457 "compatibility_mode", 

458 "disable_warnings", 

459 "optional", 

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

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

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

463 # the attribute — the rebind then silently vanishes. 

464 "hook_aliases", 

465 "property_aliases", 

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

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

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

469 "training", 

470 ]: 

471 super().__setattr__(name, value) 

472 return 

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

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

475 if class_attr.fset is not None: 

476 super().__setattr__(name, value) 

477 return 

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

479 original_component = self._modules["_original_component"] 

480 if hasattr(original_component, name): 

481 try: 

482 setattr(original_component, name, value) 

483 return 

484 except AttributeError: 

485 pass 

486 super().__setattr__(name, value)