Coverage for transformer_lens/benchmarks/component_outputs.py: 66%

467 statements  

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

1"""Comprehensive component benchmarking utility for TransformerBridge. 

2 

3This module provides utilities to benchmark all standard components in a TransformerBridge 

4model against their HuggingFace equivalents, ensuring output parity. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import Any, Callable, Dict, List, Optional, Tuple, cast 

11 

12import torch 

13from torch import nn 

14 

15from transformer_lens.benchmarks.utils import build_modality_input 

16from transformer_lens.config import TransformerBridgeConfig 

17from transformer_lens.model_bridge.architecture_adapter import ArchitectureAdapter 

18from transformer_lens.model_bridge.generalized_components.base import ( 

19 GeneralizedComponent, 

20) 

21 

22 

23@dataclass 

24class ComponentTestResult: 

25 """Result of testing a single component.""" 

26 

27 component_path: str 

28 component_type: str 

29 passed: bool 

30 max_diff: float 

31 mean_diff: float 

32 output_shape: Tuple[int, ...] 

33 error_message: Optional[str] = None 

34 percentile_diffs: Optional[Dict[str, float]] = None # 50th, 90th, 99th percentile diffs 

35 

36 def get_failure_severity(self) -> str: 

37 """Categorize the severity of a failure. 

38 

39 Returns: 

40 Severity level: "critical", "high", "medium", "low", or "pass" 

41 """ 

42 if self.passed: 

43 return "pass" 

44 if self.error_message: 

45 return "critical" 

46 if self.max_diff > 1e-1: 

47 return "critical" 

48 elif self.max_diff > 1e-3: 

49 return "high" 

50 elif self.max_diff > 1e-4: 

51 return "medium" 

52 else: 

53 return "low" 

54 

55 

56@dataclass 

57class BenchmarkReport: 

58 """Complete benchmark report for all components.""" 

59 

60 model_name: str 

61 total_components: int 

62 passed_components: int 

63 failed_components: int 

64 component_results: List[ComponentTestResult] = field(default_factory=list) 

65 

66 @property 

67 def pass_rate(self) -> float: 

68 """Calculate the pass rate as a percentage.""" 

69 if self.total_components == 0: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 return 0.0 

71 return (self.passed_components / self.total_components) * 100 

72 

73 def print_summary(self, verbose: bool = False) -> None: 

74 """Print a summary of the benchmark results. 

75 

76 Args: 

77 verbose: If True, print details for all components. If False, only print failures. 

78 """ 

79 print("\n" + "=" * 80) 

80 print(f"Component Benchmark Report: {self.model_name}") 

81 print("=" * 80) 

82 print(f"Total components tested: {self.total_components}") 

83 print(f"Passed: {self.passed_components} ({self.pass_rate:.1f}%)") 

84 print(f"Failed: {self.failed_components}") 

85 print("=" * 80) 

86 

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

88 print("\nAll Component Results:") 

89 print("-" * 80) 

90 for result in self.component_results: 

91 self._print_component_result(result) 

92 elif self.failed_components > 0: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 print("\nFailed Components:") 

94 print("-" * 80) 

95 for result in self.component_results: 

96 if not result.passed: 

97 self._print_component_result(result) 

98 

99 print("=" * 80 + "\n") 

100 

101 def _print_component_result(self, result: ComponentTestResult) -> None: 

102 """Print details of a single component result.""" 

103 status = "✓ PASS" if result.passed else "✗ FAIL" 

104 severity = result.get_failure_severity() 

105 

106 # Add severity indicator for failures 

107 if not result.passed and severity != "critical": 

108 status = f"{status} [{severity.upper()}]" 

109 

110 print(f"{status} | {result.component_path}") 

111 print(f" Type: {result.component_type}") 

112 print(f" Shape: {result.output_shape}") 

113 print(f" Max diff: {result.max_diff:.6e}") 

114 print(f" Mean diff: {result.mean_diff:.6e}") 

115 

116 if result.percentile_diffs: 

117 print(f" Percentile diffs:") 

118 for percentile, diff in sorted(result.percentile_diffs.items()): 

119 print(f" {percentile}: {diff:.6e}") 

120 

121 if result.error_message: 

122 print(f" Error: {result.error_message}") 

123 print() 

124 

125 def get_component_type_summary(self) -> Dict[str, Dict[str, int]]: 

126 """Get a summary of results grouped by component type. 

127 

128 Returns: 

129 Dictionary mapping component types to their pass/fail counts 

130 """ 

131 summary: Dict[str, Dict[str, int]] = {} 

132 

133 for result in self.component_results: 

134 comp_type = result.component_type 

135 if comp_type not in summary: 

136 summary[comp_type] = {"passed": 0, "failed": 0, "total": 0} 

137 

138 summary[comp_type]["total"] += 1 

139 if result.passed: 139 ↛ 142line 139 didn't jump to line 142 because the condition on line 139 was always true

140 summary[comp_type]["passed"] += 1 

141 else: 

142 summary[comp_type]["failed"] += 1 

143 

144 return summary 

145 

146 def get_failure_by_severity(self) -> Dict[str, List[ComponentTestResult]]: 

147 """Group failures by severity level. 

148 

149 Returns: 

150 Dictionary mapping severity levels to lists of failed components 

151 """ 

152 failures: Dict[str, List[ComponentTestResult]] = { 

153 "critical": [], 

154 "high": [], 

155 "medium": [], 

156 "low": [], 

157 } 

158 

159 for result in self.component_results: 

160 if not result.passed: 

161 severity = result.get_failure_severity() 

162 if severity in failures: 

163 failures[severity].append(result) 

164 

165 return failures 

166 

167 def print_detailed_analysis(self) -> None: 

168 """Print detailed analysis of benchmark results.""" 

169 print("\n" + "=" * 80) 

170 print("Detailed Benchmark Analysis") 

171 print("=" * 80) 

172 

173 # Component type summary 

174 print("\nResults by Component Type:") 

175 print("-" * 80) 

176 type_summary = self.get_component_type_summary() 

177 for comp_type, stats in sorted(type_summary.items()): 

178 pass_rate = (stats["passed"] / stats["total"]) * 100 if stats["total"] > 0 else 0 

179 print( 

180 f"{comp_type:30s}: {stats['passed']:3d}/{stats['total']:3d} passed ({pass_rate:5.1f}%)" 

181 ) 

182 

183 # Failure severity analysis 

184 if self.failed_components > 0: 

185 print("\nFailures by Severity:") 

186 print("-" * 80) 

187 failures_by_severity = self.get_failure_by_severity() 

188 for severity in ["critical", "high", "medium", "low"]: 

189 count = len(failures_by_severity[severity]) 

190 if count > 0: 

191 print(f"{severity.upper():10s}: {count} component(s)") 

192 for result in failures_by_severity[severity][:3]: # Show first 3 

193 print(f" - {result.component_path} (max_diff: {result.max_diff:.2e})") 

194 if count > 3: 

195 print(f" ... and {count - 3} more") 

196 

197 print("=" * 80 + "\n") 

198 

199 

200def _is_ssm_mixer_internal(component_path: str) -> bool: 

201 """True for a submodule *inside* an SSM/recurrent mixer slot (``.mixer``/``.linear_attn``). 

202 

203 Such submodules wrap the identical HF module (parity is covered by forward_pass_logits) 

204 and take SSM-internal shapes, not the ``[b, seq, d_model]`` residual the isolated harness 

205 feeds — so they are skipped. The mixer node itself (path ending in the slot) is not. 

206 """ 

207 return any(slot in component_path.split(".")[:-1] for slot in ("mixer", "linear_attn")) 

208 

209 

210class ComponentBenchmarker: 

211 """Benchmarking utility for testing TransformerBridge components against HuggingFace.""" 

212 

213 def _is_delegated_block(self) -> bool: 

214 """Return True if the blocks component has maintain_native_attention set.""" 

215 blocks = ( 

216 getattr(self.adapter, "component_mapping", {}).get("blocks") 

217 if self.adapter is not None 

218 else None 

219 ) 

220 return getattr(blocks, "maintain_native_attention", False) 

221 

222 def __init__( 

223 self, 

224 bridge_model: nn.Module, 

225 hf_model: nn.Module, 

226 adapter: ArchitectureAdapter, 

227 cfg: TransformerBridgeConfig, 

228 atol: float = 1e-4, 

229 rtol: float = 1e-4, 

230 ): 

231 """Initialize the component benchmarker. 

232 

233 Args: 

234 bridge_model: The TransformerBridge model 

235 hf_model: The HuggingFace model 

236 adapter: The architecture adapter for mapping components 

237 cfg: The model configuration 

238 atol: Absolute tolerance for comparing outputs 

239 rtol: Relative tolerance for comparing outputs 

240 """ 

241 self.bridge_model = bridge_model 

242 self.hf_model = hf_model 

243 self.adapter = adapter 

244 self.cfg = cfg 

245 

246 # Reconcile dtypes: upcast both models to the higher-precision dtype. 

247 self._bridge_was_upcast = False 

248 self._bridge_original_dtype: Optional[torch.dtype] = None 

249 try: 

250 hf_dtype = next(hf_model.parameters()).dtype 

251 except StopIteration: 

252 hf_dtype = torch.float32 

253 try: 

254 bridge_dtype = next(bridge_model.parameters()).dtype 

255 except StopIteration: 

256 bridge_dtype = torch.float32 

257 if hf_dtype != bridge_dtype: 

258 # Upcast to the higher-precision dtype 

259 target = hf_dtype if hf_dtype.itemsize >= bridge_dtype.itemsize else bridge_dtype 

260 if bridge_dtype != target: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 self._bridge_original_dtype = bridge_dtype 

262 bridge_model.to(target) 

263 self._bridge_was_upcast = True 

264 if hf_dtype != target: 264 ↛ 266line 264 didn't jump to line 266 because the condition on line 264 was always true

265 hf_model.to(target) 

266 self.test_dtype = hf_dtype if hf_dtype.itemsize >= bridge_dtype.itemsize else bridge_dtype 

267 

268 # Adjust tolerances based on dtype for reduced precision formats 

269 model_dtype = getattr(cfg, "dtype", torch.float32) 

270 if model_dtype == torch.bfloat16: 270 ↛ 275line 270 didn't jump to line 275 because the condition on line 270 was never true

271 # bfloat16 has ~7 bits of precision (3 decimal digits) 

272 # Use more lenient tolerance 

273 # Normalization layers (RMSNorm/LayerNorm) can have larger errors due to 

274 # square roots and divisions, so use 0.3 tolerance 

275 self.atol = max(atol, 0.3) 

276 self.rtol = max(rtol, 0.3) 

277 elif model_dtype == torch.float16: 277 ↛ 279line 277 didn't jump to line 279 because the condition on line 277 was never true

278 # float16 has ~10 bits of precision (3-4 decimal digits) 

279 self.atol = max(atol, 5e-3) 

280 self.rtol = max(rtol, 5e-3) 

281 else: 

282 # float32 or float64 - use provided tolerances 

283 self.atol = atol 

284 self.rtol = rtol 

285 

286 def benchmark_all_components( 

287 self, 

288 test_inputs: Optional[Dict[str, torch.Tensor]] = None, 

289 skip_components: Optional[List[str]] = None, 

290 ) -> BenchmarkReport: 

291 """Benchmark all components in the model. 

292 

293 Args: 

294 test_inputs: Optional dictionary of pre-generated test inputs. 

295 If None, will generate default inputs. 

296 skip_components: Optional list of component paths to skip 

297 

298 Returns: 

299 BenchmarkReport with results for all tested components 

300 """ 

301 skip_components = skip_components or [] 

302 # Adapters can exclude subcomponents whose isolated forward cannot run 

303 # on synthesized probes (e.g. fused top-k routers) by path suffix. 

304 skip_suffixes = tuple(getattr(self.adapter, "component_test_skip_suffixes", ())) 

305 component_mapping = self.adapter.get_component_mapping() 

306 

307 # Generate test inputs if not provided 

308 if test_inputs is None: 308 ↛ 311line 308 didn't jump to line 311 because the condition on line 308 was always true

309 test_inputs = self._generate_test_inputs() 

310 

311 results: List[ComponentTestResult] = [] 

312 

313 # Block-type components that need to be tested recursively by layer 

314 # (they are ModuleLists that don't have direct forward methods) 

315 block_components = {"blocks", "encoder_blocks", "decoder_blocks", "L_blocks", "H_blocks"} 

316 

317 # Test top-level components (embed, pos_embed, ln_final, unembed) 

318 for comp_name, component in component_mapping.items(): 

319 if comp_name in skip_components: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 continue 

321 

322 if comp_name in block_components: 

323 # Handle blocks separately - test their subcomponents by layer 

324 continue 

325 

326 result = self._test_component(comp_name, component, test_inputs) 

327 if result is not None: 327 ↛ 318line 327 didn't jump to line 318 because the condition on line 327 was always true

328 results.append(result) 

329 

330 # Test block components recursively 

331 for block_type in block_components: 

332 if block_type in component_mapping and block_type not in skip_components: 

333 blocks_component = component_mapping[block_type] 

334 # Derive the length from the actual stack: asymmetric 

335 # encoder-decoder models (e.g. Blenderbot's 2/12) have 

336 # per-stack counts that cfg.n_layers cannot represent. 

337 bound_blocks = getattr(self.bridge_model, block_type) 

338 n_layers = len(bound_blocks) 

339 

340 for layer_idx in range(n_layers): 

341 # Get the actual block to check which submodules were bound 

342 actual_block = getattr(self.bridge_model, block_type)[layer_idx] 

343 for subcomp_name, subcomponent in blocks_component.submodules.items(): 

344 # Skip optional submodules absent on this layer (hybrid architectures) 

345 if subcomp_name not in actual_block._modules: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 continue 

347 comp_path = f"{block_type}.{layer_idx}.{subcomp_name}" 

348 self._test_component_recursive( 

349 comp_path, subcomponent, test_inputs, results, skip_components 

350 ) 

351 

352 # Clean up test inputs to free memory 

353 if test_inputs is not None: 353 ↛ 361line 353 didn't jump to line 361 because the condition on line 353 was always true

354 for key in list(test_inputs.keys()): 

355 tensor = test_inputs[key] 

356 if tensor is not None and isinstance(tensor, torch.Tensor): 356 ↛ 354line 356 didn't jump to line 354 because the condition on line 356 was always true

357 del tensor 

358 test_inputs.clear() 

359 

360 # Create report 

361 passed = sum(1 for r in results if r.passed) 

362 failed = sum(1 for r in results if not r.passed) 

363 

364 report = BenchmarkReport( 

365 model_name=getattr(self.cfg, "model_name", "unknown"), 

366 total_components=len(results), 

367 passed_components=passed, 

368 failed_components=failed, 

369 component_results=results, 

370 ) 

371 

372 # Restore bridge to its original dtype if we upcast it 

373 if self._bridge_was_upcast and self._bridge_original_dtype is not None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 self.bridge_model.to(self._bridge_original_dtype) 

375 

376 return report 

377 

378 def _test_component_recursive( 

379 self, 

380 component_path: str, 

381 component: GeneralizedComponent, 

382 test_inputs: Dict[str, torch.Tensor], 

383 results: List[ComponentTestResult], 

384 skip_components: Optional[List[str]] = None, 

385 ) -> None: 

386 """Recursively test a component and all its subcomponents. 

387 

388 This method tests the given component and then recursively tests all its 

389 nested subcomponents (e.g., attn.q, attn.k, mlp.gate, etc.). 

390 

391 Note: We skip testing q/k/v subcomponents when the parent attention module 

392 uses joint QKV projection (JointQKVAttentionBridge), as these are virtual 

393 components that don't exist as separate modules in HuggingFace. 

394 

395 Args: 

396 component_path: Path to the component (e.g., "blocks.0.attn") 

397 component: The generalized component bridge 

398 test_inputs: Dictionary of test inputs 

399 results: List to append results to 

400 skip_components: Optional list of component paths to skip 

401 """ 

402 skip_components = skip_components or [] 

403 

404 # Skip if in skip list 

405 if component_path in skip_components: 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true

406 return 

407 

408 # Adapter-declared suffix exclusions (isolated forward can't run on 

409 # synthesized probes, e.g. fused top-k routers). 

410 skip_suffixes = tuple(getattr(self.adapter, "component_test_skip_suffixes", ())) 

411 if any(component_path.endswith(suffix) for suffix in skip_suffixes): 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 return 

413 

414 # SSM/recurrent mixer internal submodules can't be tested in isolation (see 

415 # _is_ssm_mixer_internal); the mixer node itself is still tested against HF. 

416 if _is_ssm_mixer_internal(component_path): 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 return 

418 

419 # Skip MLP components that don't exist as separate modules in HF (name=None) 

420 # These are virtual components where fc1/fc2 are directly on the layer 

421 # Component testing doesn't work for these because get_component returns the parent layer 

422 if "mlp" in component_path and hasattr(component, "name") and component.name is None: 

423 return 

424 

425 # Skip MLPs whose forward needs more than a hidden_states probe (BLOOM 

426 # residual; transformers 5.x fused MoE experts need router indices/weights); 

427 # full-model forward_pass_logits already covers their parity. Fetch the 

428 # layer-bound component since the per-block template's original_component is None. 

429 if "mlp" in component_path: 

430 import inspect 

431 

432 try: 

433 bound = self.adapter.get_component(self.bridge_model, component_path) 

434 inner = getattr(bound, "original_component", None) 

435 if inner is not None: 435 ↛ 453line 435 didn't jump to line 453 because the condition on line 435 was always true

436 required = [ 

437 p 

438 for p in inspect.signature(inner.forward).parameters.values() 

439 if p.default is inspect.Parameter.empty 

440 and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) 

441 and p.name != "self" 

442 ] 

443 # An isolatable MLP needs only hidden_states; more required 

444 # positional args means it can't run standalone. 

445 if len(required) > 1: 

446 return 

447 except (AttributeError, ValueError, TypeError): 

448 # Can't resolve/inspect — fall through and let the test run. 

449 pass 

450 

451 # Skip attention components that require position embeddings in Phase 3 

452 # These can't be tested in isolation without full model context for position embeddings 

453 if ( 

454 "attn" in component_path 

455 and hasattr(component, "requires_position_embeddings") 

456 and component.requires_position_embeddings 

457 ): 

458 return 

459 

460 # Skip attention components that use native HF attention (maintain_native_attention=True) 

461 # These have custom forward signatures (e.g., BLOOM requires residual, alibi, attention_mask) 

462 # and can't be tested in isolation without full model context 

463 if ( 463 ↛ 468line 463 didn't jump to line 468 because the condition on line 463 was never true

464 "attn" in component_path 

465 and hasattr(component, "maintain_native_attention") 

466 and component.maintain_native_attention 

467 ): 

468 return 

469 

470 # Skip attention and PLE submodules when using DelegatedAttentionBlockBridge. 

471 # These architectures delegate all math to HF; the benchmark can't call the HF 

472 # attention in isolation (missing position_embeddings, attention_mask, etc.) and 

473 # PLE submodules receive per-layer inputs at a different dimension than hidden_states. 

474 _is_delegated = self._is_delegated_block() 

475 if _is_delegated and "attn" in component_path: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true

476 return 

477 if _is_delegated and any( 477 ↛ 485line 477 didn't jump to line 485 because the condition on line 477 was never true

478 name in component_path 

479 for name in ( 

480 "per_layer_input_gate", 

481 "per_layer_projection", 

482 "post_per_layer_input_norm", 

483 ) 

484 ): 

485 return 

486 

487 # Skip models whose MLP/attn forward signatures require extra context from the block: 

488 # - BLOOM: MLP requires residual and alibi bias 

489 # - T5: requires cache_position for relative position embeddings 

490 # - MPT: MLP.forward(hidden_states, residual) performs the residual addition internally 

491 if "attn" in component_path or "mlp" in component_path: 

492 hf_model_config = getattr(self.hf_model, "config", None) 

493 if hf_model_config and hasattr(hf_model_config, "model_type"): 493 ↛ 500line 493 didn't jump to line 500 because the condition on line 493 was always true

494 if hf_model_config.model_type in ["bloom", "t5", "mpt"]: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true

495 return 

496 

497 # Skip components that require specific shaped inputs from their parent modules 

498 # These components expect intermediate outputs from their parent attention/MLP 

499 # modules and can't be tested with generic hidden state inputs 

500 path_parts = component_path.split(".") 

501 if len(path_parts) >= 3: # e.g., "blocks.0.attn.o" or "blocks.0.mlp.out" 501 ↛ 544line 501 didn't jump to line 544 because the condition on line 501 was always true

502 last_part = path_parts[-1] 

503 

504 # Skip attention output projection (expects concatenated attn output) 

505 # Skip MLP output projection (expects MLP intermediate activations) 

506 # — including *_out variants (dense_out on MoE shared/dense paths). 

507 # Note: q_norm/k_norm are handled specially in _run_component 

508 if last_part in ["o", "out"] or last_part.endswith("_out"): 

509 return 

510 

511 # Skip MLA intermediates (expect compressed-dim inputs, not hidden_states) 

512 if last_part in [ 512 ↛ 520line 512 didn't jump to line 520 because the condition on line 512 was never true

513 "q_a_proj", 

514 "q_a_layernorm", 

515 "q_b_proj", 

516 "kv_a_proj_with_mqa", 

517 "kv_a_layernorm", 

518 "kv_b_proj", 

519 ]: 

520 return 

521 

522 # Skip virtual splits from fused projections (no standalone HF equivalent) 

523 if last_part in ["q", "k", "v", "gate", "in"]: 

524 parent_path = ".".join(path_parts[:-1]) 

525 try: 

526 parent_component = self.adapter.get_component(self.bridge_model, parent_path) 

527 if hasattr(parent_component, "submodules"): 527 ↛ 544line 527 didn't jump to line 544 because the condition on line 527 was always true

528 parent_bridge = cast(GeneralizedComponent, parent_component) 

529 subs = parent_bridge.submodules 

530 # Joint QKV: q/k/v are splits from fused qkv_proj/c_attn 

531 if last_part in ["q", "k", "v"] and ("qkv" in subs or "c_attn" in subs): 

532 return 

533 # Joint gate+up: gate/in are splits from fused gate_up_proj 

534 if last_part in ["gate", "in"] and ( 534 ↛ 538line 534 didn't jump to line 538 because the condition on line 534 was never true

535 "gate_up" in subs 

536 or type(parent_bridge).__name__ == "JointGateUpMLPBridge" 

537 ): 

538 return 

539 except Exception: 

540 pass 

541 

542 # Skip components not wired on this layer (per-layer or per-config variation). 

543 # Only report as failure if the HF model has it but the bridge doesn't. 

544 try: 

545 self.adapter.get_component(self.bridge_model, component_path) 

546 except (AttributeError, ValueError): 

547 parts = component_path.split(".") 

548 if len(parts) >= 3 and parts[1].isdigit(): 548 ↛ 566line 548 didn't jump to line 566 because the condition on line 548 was always true

549 subpath = ".".join([parts[0]] + ["{layer}"] + parts[2:]) 

550 # Per-layer variation: exists on some other layer (e.g., MoE vs dense) 

551 for probe_layer in range(self.cfg.n_layers): 

552 probe_path = subpath.replace("{layer}", str(probe_layer)) 

553 try: 

554 self.adapter.get_component(self.bridge_model, probe_path) 

555 return # Found on another layer — skip this one 

556 except (AttributeError, ValueError): 

557 continue 

558 # Per-config absence: HF model also lacks it (e.g., q_lora_rank=None) 

559 try: 

560 self.adapter.get_component(self.hf_model, component_path) 

561 except (AttributeError, ValueError): 

562 return 

563 # Bridge is missing a component that HF has — likely misconfiguration 

564 

565 # Test this component 

566 result = self._test_component(component_path, component, test_inputs) 

567 if result is not None: 567 ↛ 571line 567 didn't jump to line 571 because the condition on line 567 was always true

568 results.append(result) 

569 

570 # Recursively test subcomponents 

571 if hasattr(component, "submodules") and component.submodules: 

572 for subcomp_name, subcomponent in component.submodules.items(): 

573 sub_path = f"{component_path}.{subcomp_name}" 

574 self._test_component_recursive( 

575 sub_path, subcomponent, test_inputs, results, skip_components 

576 ) 

577 

578 def _test_component( 

579 self, 

580 component_path: str, 

581 component: GeneralizedComponent, 

582 test_inputs: Dict[str, torch.Tensor], 

583 ) -> Optional[ComponentTestResult]: 

584 """Test a single component. 

585 

586 Args: 

587 component_path: Path to the component (e.g., "embed", "blocks.0.attn") 

588 component: The generalized component bridge 

589 test_inputs: Dictionary of test inputs 

590 

591 Returns: 

592 ComponentTestResult or None if the component cannot be tested 

593 """ 

594 try: 

595 # Skip rotary_emb for DelegatedAttentionBlockBridge architectures. 

596 # Gemma4's RotaryEmbeddingBridge wraps a rotary that returns a set-like 

597 # structure which the benchmark comparison can't subscript. 

598 if self._is_delegated_block() and component_path == "rotary_emb": 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true

599 return None 

600 

601 # Get bridge component 

602 # The adapter returns nn.Module, but for bridge models it's actually GeneralizedComponent 

603 bridge_component = cast( 

604 GeneralizedComponent, self.adapter.get_component(self.bridge_model, component_path) 

605 ) 

606 

607 # Get HuggingFace component 

608 hf_component = self.adapter.get_component(self.hf_model, component_path) 

609 

610 # Determine appropriate test input based on component type 

611 test_input = self._get_test_input_for_component(component_path, test_inputs) 

612 if test_input is None: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true

613 return None 

614 

615 # Get input args/kwargs from the Bridge component 

616 # All bridge components inherit from GeneralizedComponent and have get_dummy_inputs() 

617 batch, seq_len, _ = test_input.shape 

618 pos_indices = ( 

619 torch.arange(seq_len, device=test_input.device).unsqueeze(0).expand(batch, -1) 

620 ) 

621 

622 # For embedding components, generate the embedding input once 

623 shared_embed_input = None 

624 if component_path in ("embed", "encoder_embed", "decoder_embed"): 

625 shared_embed_input = test_inputs.get("modality_input") 

626 if shared_embed_input is None: 626 ↛ 634line 626 didn't jump to line 634 because the condition on line 626 was always true

627 batch, seq_len, _ = test_input.shape 

628 shared_embed_input = torch.randint( 

629 0, self.cfg.d_vocab, (batch, seq_len), device=test_input.device 

630 ) 

631 

632 # Generate shared inputs for attention/MLP/rotary components that have get_random_inputs() 

633 # This is needed for model-specific inputs like position_embeddings or attention_mask 

634 shared_inputs = None 

635 if ( 

636 ( 

637 "attn" in component_path 

638 or "mlp" in component_path 

639 or "rotary" in component_path 

640 or "conv" in component_path 

641 ) 

642 and hasattr(bridge_component, "get_random_inputs") 

643 and callable(getattr(bridge_component, "get_random_inputs")) 

644 ): 

645 batch_size, seq_len = test_input.shape[:2] 

646 # Cast to callable to satisfy mypy - we've already verified it exists and is callable 

647 get_random_inputs_fn = cast( 

648 Callable[..., Dict[str, Any]], bridge_component.get_random_inputs 

649 ) 

650 shared_inputs = get_random_inputs_fn( 

651 batch_size=batch_size, 

652 seq_len=seq_len, 

653 device=test_input.device, 

654 dtype=test_input.dtype, 

655 ) 

656 if "attn" in component_path: 

657 self._add_direct_attention_mask_if_needed( 

658 shared_inputs, hf_component, batch_size, seq_len 

659 ) 

660 

661 # Override position_embeddings with correct values from HF model's rotary_emb 

662 # This is needed for models with partial RoPE or non-standard rotary dims 

663 if ( 663 ↛ 668line 663 didn't jump to line 668 because the condition on line 663 was never true

664 "attn" in component_path 

665 and "position_embeddings" in shared_inputs 

666 and hasattr(self.hf_model, "model") 

667 ): 

668 rotary_attr = getattr(self.hf_model.model, "rotary_emb", None) 

669 if callable(rotary_attr): 

670 try: 

671 position_ids = ( 

672 torch.arange(seq_len, device=test_input.device) 

673 .unsqueeze(0) 

674 .expand(batch_size, -1) 

675 ) 

676 position_embeddings = rotary_attr(test_input, position_ids) 

677 shared_inputs["position_embeddings"] = position_embeddings 

678 except Exception: 

679 # If rotary_emb fails, keep the fallback position_embeddings from get_random_inputs() 

680 pass 

681 

682 # Run through both components with shared inputs (for attention) or standard inputs (for others) 

683 bridge_output = self._run_component( 

684 bridge_component, test_input, component_path, shared_embed_input, shared_inputs 

685 ) 

686 hf_output = self._run_component( 

687 hf_component, test_input, component_path, shared_embed_input, shared_inputs 

688 ) 

689 

690 # Extract tensors if outputs are tuples 

691 # Legacy modules (e.g. GPT-1's Attention) return lists, not tuples. 

692 bridge_tensor = ( 

693 bridge_output[0] if isinstance(bridge_output, (tuple, list)) else bridge_output 

694 ) 

695 hf_tensor = hf_output[0] if isinstance(hf_output, (tuple, list)) else hf_output 

696 

697 # Marian/Bart apply a trained final_logits_bias AFTER lm_head inside the 

698 # model forward. The bridge folds it into the unembed bias (b_U), so the 

699 # isolated HF lm_head must add it too for a fair comparison — otherwise 

700 # the two diverge by exactly the bias (a false failure; the assembled 

701 # unembed+bias path is already covered by forward_pass_logits). 

702 if "unembed" in component_path or "lm_head" in component_path: 

703 flb = getattr(self.hf_model, "final_logits_bias", None) 

704 if ( 

705 flb is not None 

706 and isinstance(hf_tensor, torch.Tensor) 

707 and hf_tensor.shape[-1] == flb.shape[-1] 

708 ): 

709 hf_tensor = hf_tensor + flb.to(hf_tensor.dtype) 

710 

711 # Ensure both are tensors 

712 if not isinstance(bridge_tensor, torch.Tensor) or not isinstance( 712 ↛ 715line 712 didn't jump to line 715 because the condition on line 712 was never true

713 hf_tensor, torch.Tensor 

714 ): 

715 return ComponentTestResult( 

716 component_path=component_path, 

717 component_type=type(component).__name__, 

718 passed=False, 

719 max_diff=float("inf"), 

720 mean_diff=float("inf"), 

721 output_shape=(), 

722 error_message=f"Outputs are not tensors: bridge={type(bridge_tensor)}, hf={type(hf_tensor)}", 

723 ) 

724 

725 # Compare outputs 

726 passed, max_diff, mean_diff, percentile_diffs = self._compare_outputs( 

727 bridge_tensor, hf_tensor 

728 ) 

729 

730 # Get output shape before deleting tensors 

731 output_shape = tuple(bridge_tensor.shape) 

732 

733 # Clean up output tensors immediately to free memory 

734 del bridge_output, hf_output, bridge_tensor, hf_tensor 

735 if shared_inputs is not None: 

736 # Clean up shared inputs 

737 for key in list(shared_inputs.keys()): 

738 val = shared_inputs[key] 

739 if val is not None and isinstance(val, torch.Tensor): 

740 del val 

741 shared_inputs[key] = None 

742 if shared_embed_input is not None: 

743 del shared_embed_input 

744 

745 return ComponentTestResult( 

746 component_path=component_path, 

747 component_type=type(component).__name__, 

748 passed=passed, 

749 max_diff=max_diff, 

750 mean_diff=mean_diff, 

751 output_shape=output_shape, 

752 percentile_diffs=percentile_diffs, 

753 ) 

754 

755 except Exception as e: 

756 return ComponentTestResult( 

757 component_path=component_path, 

758 component_type=type(component).__name__, 

759 passed=False, 

760 max_diff=float("inf"), 

761 mean_diff=float("inf"), 

762 output_shape=(), 

763 error_message=str(e), 

764 ) 

765 

766 @staticmethod 

767 def _add_direct_attention_mask_if_needed( 

768 shared_inputs: Dict[str, Any], 

769 hf_component: Any, 

770 batch_size: int, 

771 seq_len: int, 

772 ) -> None: 

773 """Add a causal mask for direct HF attention calls that need parent context.""" 

774 if "attention_mask" in shared_inputs: 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true

775 return 

776 hidden_states = shared_inputs.get("hidden_states") 

777 if not isinstance(hidden_states, torch.Tensor): 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

778 return 

779 if not getattr(hf_component, "is_causal", False): 

780 return 

781 if getattr(hf_component, "is_cross_attention", False): 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 return 

783 

784 min_dtype = torch.finfo(hidden_states.dtype).min 

785 causal_mask = torch.ones(seq_len, seq_len, device=hidden_states.device, dtype=torch.bool) 

786 causal_mask = torch.tril(causal_mask).view(1, 1, seq_len, seq_len) 

787 attention_mask = torch.zeros( 

788 batch_size, 

789 1, 

790 seq_len, 

791 seq_len, 

792 device=hidden_states.device, 

793 dtype=hidden_states.dtype, 

794 ) 

795 shared_inputs["attention_mask"] = attention_mask.masked_fill(~causal_mask, min_dtype) 

796 

797 def _run_component( 

798 self, 

799 component: nn.Module, 

800 test_input: torch.Tensor, 

801 component_path: str, 

802 shared_embed_input: Optional[torch.Tensor] = None, 

803 shared_inputs: Optional[dict] = None, 

804 ) -> Any: 

805 """Run a component with appropriate arguments. 

806 

807 Args: 

808 component: The component to run 

809 test_input: The test input tensor 

810 component_path: Path to the component for debugging 

811 shared_embed_input: Pre-generated input for embedding components (token ids, 

812 or a spectrogram/pixel tensor for audio and vision models) 

813 shared_inputs: Pre-generated inputs from get_random_inputs() to use for both bridge and HF components 

814 

815 Returns: 

816 The component output 

817 """ 

818 # q_norm/k_norm expect d_head, not d_model 

819 if component_path.endswith(".q_norm") or component_path.endswith(".k_norm"): 819 ↛ 821line 819 didn't jump to line 821 because the condition on line 819 was never true

820 # Reshape test_input from (batch, seq, d_model) to (batch, seq, d_head) 

821 batch, seq, d_model = test_input.shape 

822 d_head = self.cfg.d_head 

823 # Use just d_head dimensions as test input 

824 test_input_reshaped = test_input[..., :d_head] 

825 return component(test_input_reshaped) 

826 

827 # Use shared inputs if provided (generated from bridge component's get_random_inputs()) 

828 if shared_inputs is not None: 

829 # Check if shared_inputs contains positional args 

830 if "args" in shared_inputs: 

831 # Call with positional args (e.g., for rotary embeddings) 

832 return component(*shared_inputs["args"]) 

833 else: 

834 # Call with keyword args (e.g., for attention) 

835 try: 

836 return component(**shared_inputs) 

837 except TypeError: 

838 # Pre-kwarg-era modules (e.g. GPT-1's Attention) take the 

839 # input positionally and reject the hidden_states keyword. 

840 hidden = shared_inputs.get("hidden_states") 

841 if hidden is None: 

842 raise 

843 mask = shared_inputs.get("attention_mask") 

844 if mask is not None: 

845 return component(hidden, attention_mask=mask) 

846 return component(hidden) 

847 

848 # Fallback: Use legacy calling conventions for components without get_random_inputs() 

849 if "attn" in component_path and "attn" == component_path.split(".")[-1]: 849 ↛ 851line 849 didn't jump to line 851 because the condition on line 849 was never true

850 # Attention components (legacy fallback) 

851 try: 

852 # Try TransformerLens-style attention 

853 return component( 

854 query_input=test_input, 

855 key_input=test_input, 

856 value_input=test_input, 

857 past_kv_cache_entry=None, 

858 attention_mask=None, 

859 ) 

860 except TypeError: 

861 try: 

862 # Try HuggingFace-style attention 

863 return component(hidden_states=test_input) 

864 except TypeError: 

865 # Try simple call 

866 return component(test_input) 

867 elif component_path in ("embed", "encoder_embed", "decoder_embed"): 

868 # Token ids for text models; a spectrogram or pixel tensor for audio/vision. 

869 if shared_embed_input is not None: 869 ↛ 872line 869 didn't jump to line 872 because the condition on line 869 was always true

870 embed_input = shared_embed_input 

871 else: 

872 batch, seq_len, _ = test_input.shape 

873 embed_input = torch.randint( 

874 0, self.cfg.d_vocab, (batch, seq_len), device=test_input.device 

875 ) 

876 return component(embed_input) 

877 elif component_path == "pos_embed" or "pos_embed" in component_path: 

878 # Position embedding expects integer position indices 

879 batch, seq_len, _ = test_input.shape 

880 # For positional embeddings, we need position indices 

881 pos_indices = ( 

882 torch.arange(seq_len, device=test_input.device).unsqueeze(0).expand(batch, -1) 

883 ) 

884 try: 

885 return component(pos_indices) 

886 except (TypeError, IndexError): 

887 # Some pos embeds just return their embeddings directly 

888 # or may not take inputs 

889 try: 

890 if hasattr(component, "weight") and isinstance(component.weight, torch.Tensor): 890 ↛ 893line 890 didn't jump to line 893 because the condition on line 890 was always true

891 return component.weight[:seq_len] 

892 else: 

893 raise AttributeError("Component has no weight attribute") 

894 except AttributeError: 

895 # Skip this component 

896 raise ValueError("Cannot test pos_embed - unclear interface") 

897 elif isinstance(getattr(component, "original_component", component), torch.nn.Embedding): 

898 # Any other embedding table (BERT's token_type_embed) rejects the 

899 # float default: embeddings index with integer ids. The HF side is 

900 # the bare nn.Embedding, the bridge side wraps one. Ids are derived 

901 # from test_input so both sides index identically. 

902 embedding_table = getattr(component, "original_component", component) 

903 assert isinstance(embedding_table, torch.nn.Embedding) # narrowed by the elif 

904 num_ids = int(embedding_table.num_embeddings) 

905 id_input = (test_input.abs().sum(dim=-1) * 1e3).long() % num_ids 

906 return component(id_input) 

907 elif component_path == "project_in": 907 ↛ 909line 907 didn't jump to line 909 because the condition on line 907 was never true

908 # project_in expects word_embed_proj_dim, not d_model. 

909 word_embed_proj_dim = getattr(self.cfg, "word_embed_proj_dim", None) 

910 if word_embed_proj_dim is not None and word_embed_proj_dim != self.cfg.d_model: 

911 test_input = test_input[..., :word_embed_proj_dim] 

912 return component(test_input) 

913 elif ( 

914 component_path == "unembed" 

915 or "unembed" in component_path 

916 or "lm_head" in component_path 

917 ): 

918 # Unembed may expect word_embed_proj_dim (e.g., OPT-350m project_out). 

919 word_embed_proj_dim = getattr(self.cfg, "word_embed_proj_dim", None) 

920 if ( 920 ↛ 925line 920 didn't jump to line 925 because the condition on line 920 was never true

921 word_embed_proj_dim is not None 

922 and word_embed_proj_dim != self.cfg.d_model 

923 and test_input.shape[-1] != word_embed_proj_dim 

924 ): 

925 test_input = test_input[..., :word_embed_proj_dim] 

926 return component(test_input) 

927 else: 

928 # Standard components (MLP, LayerNorm, etc.) 

929 try: 

930 return component(test_input) 

931 except TypeError: 

932 # Try with hidden_states kwarg 

933 return component(hidden_states=test_input) 

934 

935 def _get_test_input_for_component( 

936 self, component_path: str, test_inputs: Dict[str, torch.Tensor] 

937 ) -> Optional[torch.Tensor]: 

938 """Get the appropriate test input for a component. 

939 

940 Args: 

941 component_path: Path to the component 

942 test_inputs: Dictionary of available test inputs 

943 

944 Returns: 

945 The appropriate test input tensor, or None if not applicable 

946 """ 

947 # Use standard hidden state input for most components 

948 return test_inputs.get("hidden_states") 

949 

950 def _generate_test_inputs(self) -> Dict[str, torch.Tensor]: 

951 """Generate default test inputs for benchmarking. 

952 

953 Returns: 

954 Dictionary of test input tensors 

955 """ 

956 batch_size = 2 

957 seq_len = 8 

958 d_model = self.cfg.d_model 

959 

960 # Use the reconciled dtype from __init__. 

961 dtype = self.test_dtype 

962 try: 

963 device = next(self.hf_model.parameters()).device 

964 except StopIteration: 

965 device = torch.device("cpu") 

966 

967 inputs = { 

968 "hidden_states": torch.randn(batch_size, seq_len, d_model, dtype=dtype, device=device), 

969 } 

970 

971 # Vision/audio models have no token vocabulary — d_vocab stays at its -1 sentinel. 

972 if self.cfg.d_vocab > 0: 972 ↛ 978line 972 didn't jump to line 978 because the condition on line 972 was always true

973 inputs["token_ids"] = torch.randint( 

974 0, self.cfg.d_vocab, (batch_size, seq_len), device=device 

975 ) 

976 

977 # Audio/vision embeddings consume a raw spectrogram or pixel tensor, not token ids. 

978 modality_input = build_modality_input( 

979 self.bridge_model, batch_size=batch_size, device=device, dtype=dtype 

980 ) 

981 if modality_input is not None: 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true

982 inputs["modality_input"] = modality_input 

983 

984 return inputs 

985 

986 def _compare_outputs( 

987 self, bridge_output: torch.Tensor, hf_output: torch.Tensor 

988 ) -> Tuple[bool, float, float, Dict[str, float]]: 

989 """Compare two output tensors. 

990 

991 Args: 

992 bridge_output: Output from TransformerBridge component 

993 hf_output: Output from HuggingFace component 

994 

995 Returns: 

996 Tuple of (passed, max_diff, mean_diff, percentile_diffs) 

997 """ 

998 # Check shapes match 

999 if bridge_output.shape != hf_output.shape: 999 ↛ 1000line 999 didn't jump to line 1000 because the condition on line 999 was never true

1000 return False, float("inf"), float("inf"), {} 

1001 

1002 # Compute differences (upcast to float32 for safety) 

1003 bo = bridge_output.float() 

1004 ho = hf_output.float() 

1005 diff = torch.abs(bo - ho) 

1006 max_diff = diff.max().item() 

1007 mean_diff = diff.mean().item() 

1008 

1009 # Compute percentile differences 

1010 flat_diff = diff.flatten() 

1011 percentile_diffs = { 

1012 "50th": torch.quantile(flat_diff, 0.5).item(), 

1013 "90th": torch.quantile(flat_diff, 0.9).item(), 

1014 "99th": torch.quantile(flat_diff, 0.99).item(), 

1015 } 

1016 

1017 # Check if within tolerance 

1018 passed = torch.allclose(bo, ho, atol=self.atol, rtol=self.rtol) 

1019 

1020 return passed, max_diff, mean_diff, percentile_diffs 

1021 

1022 

1023def benchmark_model( 

1024 model_name: str, 

1025 device: str = "cpu", 

1026 atol: float = 1e-4, 

1027 rtol: float = 1e-4, 

1028 skip_components: Optional[List[str]] = None, 

1029 verbose: bool = False, 

1030) -> BenchmarkReport: 

1031 """Benchmark all components in a model. 

1032 

1033 Args: 

1034 model_name: Name of the HuggingFace model to benchmark 

1035 device: Device to run on 

1036 atol: Absolute tolerance for comparisons 

1037 rtol: Relative tolerance for comparisons 

1038 skip_components: Optional list of component paths to skip 

1039 verbose: If True, print detailed results for all components 

1040 

1041 Returns: 

1042 BenchmarkReport with results for all components 

1043 """ 

1044 from transformers import AutoModelForCausalLM 

1045 

1046 from transformer_lens.model_bridge import TransformerBridge 

1047 

1048 # Load models 

1049 print(f"Loading models: {model_name}") 

1050 bridge_model = TransformerBridge.boot_transformers(model_name, device=device) # type: ignore[attr-defined] 

1051 

1052 # Load HF model with same attn_implementation as bridge model (if specified) 

1053 # This ensures numerical consistency between bridge and HF models 

1054 hf_kwargs = {"device_map": device} 

1055 if ( 

1056 hasattr(bridge_model.adapter.cfg, "attn_implementation") 

1057 and bridge_model.adapter.cfg.attn_implementation is not None 

1058 ): 

1059 hf_kwargs["attn_implementation"] = bridge_model.adapter.cfg.attn_implementation 

1060 

1061 hf_model = AutoModelForCausalLM.from_pretrained(model_name, **hf_kwargs) 

1062 

1063 # Set models to eval mode (disable dropout, etc.) 

1064 bridge_model.eval() 

1065 hf_model.eval() 

1066 

1067 # Get adapter 

1068 adapter = bridge_model.adapter 

1069 

1070 # Set up component testing (e.g., sync rotary_emb references for Gemma-3) 

1071 # Pass bridge_model so adapter can set up actual bridge instances, not just templates 

1072 adapter.setup_component_testing(hf_model, bridge_model=bridge_model) 

1073 

1074 # Create benchmarker 

1075 benchmarker = ComponentBenchmarker( 

1076 bridge_model=bridge_model, 

1077 hf_model=hf_model, 

1078 adapter=adapter, 

1079 cfg=bridge_model.cfg, 

1080 atol=atol, 

1081 rtol=rtol, 

1082 ) 

1083 

1084 # Run benchmark 

1085 print("Running component benchmark...") 

1086 report = benchmarker.benchmark_all_components(skip_components=skip_components) 

1087 

1088 # Print report 

1089 report.print_summary(verbose=verbose) 

1090 

1091 return report