Coverage for transformer_lens/benchmarks/utils.py: 62%

195 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-23 20:45 +0000

1"""Utility types and functions for benchmarking.""" 

2 

3from contextlib import contextmanager 

4from dataclasses import dataclass 

5from enum import Enum 

6from typing import Any, Collection, Dict, Iterator, List, Optional, Union 

7 

8import torch 

9 

10# Prefixes used by tiny/random test models that produce degenerate weights and 

11# should be skipped for certain benchmarks (centering, generation, etc.). 

12TINY_TEST_MODEL_PATTERNS = ( 

13 "tiny-random", 

14 "trl-internal-testing/tiny", 

15 "peft-internal-testing/tiny", 

16) 

17 

18 

19@contextmanager 

20def deterministic_rng(seed: int = 42) -> Iterator[None]: 

21 """Seed sampling locally, leaving the caller's RNG stream untouched. 

22 

23 Benchmarks that sample need reproducible verdicts, but a bare manual_seed 

24 rewrites the process-global stream for every benchmark that follows. 

25 """ 

26 # Sampling draws from the logits' device, so seed CUDA and MPS too; only the 

27 # generators snapshotted here are seeded, so each one is restored afterwards. 

28 cuda_devices = list(range(torch.cuda.device_count())) if torch.cuda.is_available() else [] 

29 mps_state = torch.mps.get_rng_state() if torch.backends.mps.is_available() else None 

30 with torch.random.fork_rng(devices=cuda_devices): 

31 try: 

32 torch.default_generator.manual_seed(seed) 

33 if cuda_devices: 

34 torch.cuda.manual_seed_all(seed) 

35 if mps_state is not None: 

36 torch.mps.manual_seed(seed) 

37 yield 

38 finally: 

39 if mps_state is not None: 

40 torch.mps.set_rng_state(mps_state) 

41 

42 

43def is_tiny_test_model(model_name: str) -> bool: 

44 """Check if a model name belongs to a tiny/random test model.""" 

45 return any(pattern in model_name for pattern in TINY_TEST_MODEL_PATTERNS) 

46 

47 

48# Hook patterns that bridge models inherently don't have because they use HF's 

49# native implementation rather than reimplementing attention/MLP internals. 

50BRIDGE_EXPECTED_MISSING_PATTERNS = [ 

51 "mlp.hook_pre", 

52 "mlp.hook_post", 

53 "hook_mlp_in", 

54 "hook_mlp_out", 

55 "attn.hook_rot_q", 

56 "attn.hook_rot_k", 

57 "hook_pos_embed", 

58 "embed.ln.hook_scale", 

59 "embed.ln.hook_normalized", 

60 "attn.hook_q", 

61 "attn.hook_k", 

62 "attn.hook_v", 

63 # cfg-gated attention hooks. These exist unconditionally on the attention 

64 # bridge (so `run_with_cache` key lookups never KeyError) but only fire 

65 # when their config flag is on. `benchmark_forward_hooks` runs with 

66 # defaults (flags=False) so these correctly don't fire during that 

67 # benchmark — suppressing them here prevents false "didn't fire" 

68 # failures. The affirmative verification that they DO fire when flags 

69 # are on lives in `benchmark_gated_hooks_fire`, which toggles each flag 

70 # and asserts the relevant hooks capture activations. 

71 "hook_result", 

72 "hook_attn_in", 

73 "hook_q_input", 

74 "hook_k_input", 

75 "hook_v_input", 

76 "attn.hook_attn_scores", 

77 "attn.hook_pattern", 

78 # MoE per-expert hooks: Bridge uses HF's batched MoE forward pass via MoEBridge, 

79 # which wraps the entire MoE module. The legacy reference created individual expert 

80 # modules with per-expert hooks (e.g., blocks.0.mlp.experts.3.hook_pre). 

81 "mlp.experts.", 

82 "mlp.hook_experts", 

83 # Parallel attention+MLP architectures (GPT-J, GPT-NeoX): HF has a single 

84 # shared layer norm (ln_1), while HT creates a virtual ln2 that shares weights 

85 # with ln1. The Bridge only wraps the actual HF ln_1, so ln2 hooks don't exist. 

86 # These patterns only match "missing" hooks when ln2 is absent from the Bridge; 

87 # for non-parallel architectures, the Bridge HAS ln2 and these won't be missing. 

88 "ln2.hook_scale", 

89 "ln2.hook_normalized", 

90] 

91 

92 

93def filter_expected_missing_hooks(hook_names: Collection[str]) -> list[str]: 

94 """Filter out hook names that bridge models are expected to be missing.""" 

95 return [ 

96 h 

97 for h in hook_names 

98 if not any(pattern in h for pattern in BRIDGE_EXPECTED_MISSING_PATTERNS) 

99 ] 

100 

101 

102_DEFAULT_WAVEFORM_SAMPLES = 16000 

103 

104 

105def build_modality_input( 

106 bridge: Any, 

107 batch_size: int = 1, 

108 device: Optional[Union[str, torch.device]] = None, 

109 dtype: Optional[torch.dtype] = None, 

110) -> Optional[torch.Tensor]: 

111 """Build a synthetic model input for a non-text bridge, or None for text models. 

112 

113 Audio and vision models take a raw tensor where text models take token ids, and 

114 the shape is architecture-specific: spectrogram encoders (AST) need 

115 ``[batch, max_length, num_mel_bins]`` where waveform encoders (HuBERT, wav2vec2) 

116 need ``[batch, samples]``, and vision encoders need 

117 ``[batch, num_channels, image_size, image_size]``. Shapes come from the HF config 

118 so a checkpoint with non-default dimensions is still handled correctly. 

119 """ 

120 cfg = getattr(bridge, "cfg", None) 

121 if cfg is None: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 return None 

123 

124 hf_config = getattr(getattr(bridge, "original_model", None), "config", None) 

125 

126 def _from_config(name: str, default: int) -> int: 

127 value = getattr(hf_config, name, None) if hf_config is not None else None 

128 if value is None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 value = getattr(cfg, name, None) 

130 return int(value) if isinstance(value, int) else default 

131 

132 if getattr(cfg, "is_visual_model", False): 

133 image_size = _from_config("image_size", 224) 

134 num_channels = _from_config("num_channels", 3) 

135 return torch.randn( 

136 batch_size, num_channels, image_size, image_size, device=device, dtype=dtype 

137 ) 

138 

139 if getattr(cfg, "is_audio_model", False): 

140 # num_mel_bins is what distinguishes a spectrogram encoder from a waveform one. 

141 num_mel_bins = getattr(hf_config, "num_mel_bins", None) if hf_config is not None else None 

142 if num_mel_bins is None: 

143 return torch.randn(batch_size, _DEFAULT_WAVEFORM_SAMPLES, device=device, dtype=dtype) 

144 max_length = _from_config("max_length", 1024) 

145 return torch.randn(batch_size, max_length, int(num_mel_bins), device=device, dtype=dtype) 

146 

147 return None 

148 

149 

150def safe_allclose( 

151 tensor1: torch.Tensor, 

152 tensor2: torch.Tensor, 

153 atol: float = 1e-5, 

154 rtol: float = 1e-5, 

155) -> bool: 

156 """torch.allclose that handles dtype and device mismatches.""" 

157 if tensor1.device != tensor2.device: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 tensor1 = tensor1.cpu() 

159 tensor2 = tensor2.cpu() 

160 if tensor1.dtype != tensor2.dtype: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 tensor1 = tensor1.to(torch.float32) 

162 tensor2 = tensor2.to(torch.float32) 

163 return torch.allclose(tensor1, tensor2, atol=atol, rtol=rtol) 

164 

165 

166class BenchmarkSeverity(Enum): 

167 """Severity levels for benchmark results.""" 

168 

169 INFO = "info" # ✅ PASS - Model working perfectly, all checks passed 

170 WARNING = "warning" # ⚠️ PASS with notes - Acceptable differences worth noting 

171 DANGER = "danger" # ❌ FAIL - Significant mismatches or failures 

172 ERROR = "error" # ❌ ERROR - Test crashed or couldn't run 

173 SKIPPED = "skipped" # ⏭️ SKIPPED - Test skipped (e.g., no reference model available) 

174 

175 

176@dataclass 

177class BenchmarkResult: 

178 """Result of a benchmark test.""" 

179 

180 name: str 

181 severity: BenchmarkSeverity 

182 message: str 

183 details: Optional[Dict[str, Any]] = None 

184 passed: bool = True 

185 phase: Optional[int] = None # Phase number (1, 2, 3, etc.) 

186 

187 def __str__(self) -> str: 

188 """Format result for console output.""" 

189 severity_icons = { 

190 BenchmarkSeverity.INFO: "🟢", 

191 BenchmarkSeverity.WARNING: "🟡", 

192 BenchmarkSeverity.DANGER: "🔴", 

193 BenchmarkSeverity.ERROR: "❌", 

194 BenchmarkSeverity.SKIPPED: "⏭️", 

195 } 

196 icon = severity_icons[self.severity] 

197 

198 if self.severity == BenchmarkSeverity.SKIPPED: 

199 status = "SKIPPED" 

200 else: 

201 status = "PASS" if self.passed else "FAIL" 

202 

203 result = f"{icon} [{status}] {self.name}: {self.message}" 

204 

205 if self.details: 

206 detail_lines = [] 

207 for key, value in self.details.items(): 

208 detail_lines.append(f" {key}: {value}") 

209 result += "\n" + "\n".join(detail_lines) 

210 

211 return result 

212 

213 def print_immediate(self) -> None: 

214 """Print this result immediately to console.""" 

215 print(str(self)) 

216 

217 

218@dataclass 

219class PhaseReferenceData: 

220 """Float32 reference data from Phase 1 for Phase 3 equivalence comparison.""" 

221 

222 hf_logits: Optional[torch.Tensor] = None 

223 hf_loss: Optional[float] = None 

224 test_text: Optional[str] = None 

225 

226 

227def make_capture_hook(storage: dict, name: str): 

228 """Create a forward hook that captures activations into a dict. 

229 

230 Handles both raw tensors and tuples (extracts first element). 

231 """ 

232 

233 def hook_fn(tensor, hook): 

234 if isinstance(tensor, torch.Tensor): 

235 storage[name] = tensor.detach().clone() 

236 elif isinstance(tensor, tuple) and len(tensor) > 0: 

237 if isinstance(tensor[0], torch.Tensor): 

238 storage[name] = tensor[0].detach().clone() 

239 return tensor 

240 

241 return hook_fn 

242 

243 

244def make_grad_capture_hook(storage: dict, name: str, return_none: bool = False): 

245 """Create a backward hook that captures gradients into a dict. 

246 

247 Args: 

248 storage: Dict to store captured gradients 

249 name: Key name for storage 

250 return_none: If True, return None (for backward hooks that shouldn't modify grads) 

251 """ 

252 

253 def hook_fn(tensor, hook=None): 

254 if isinstance(tensor, torch.Tensor): 

255 storage[name] = tensor.detach().clone() 

256 elif isinstance(tensor, tuple) and len(tensor) > 0: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true

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

258 storage[name] = tensor[0].detach().clone() 

259 return None if return_none else tensor 

260 

261 return hook_fn 

262 

263 

264def _squeeze_batch_dim(t1: torch.Tensor, t2: torch.Tensor): 

265 """Handle batch dimension differences (e.g., [seq, dim] vs [1, seq, dim]). 

266 

267 Returns (t1, t2) with matching shapes, or None if shapes are incompatible. 

268 """ 

269 if t1.shape == t2.shape: 

270 return t1, t2 

271 if t1.ndim == t2.ndim - 1 and t2.shape[0] == 1 and t1.shape == t2.shape[1:]: 

272 return t1.unsqueeze(0), t2 

273 if t2.ndim == t1.ndim - 1 and t1.shape[0] == 1 and t2.shape == t1.shape[1:]: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 return t1, t2.unsqueeze(0) 

275 return None 

276 

277 

278def compare_activation_dicts( 

279 dict1: Dict[str, torch.Tensor], 

280 dict2: Dict[str, torch.Tensor], 

281 atol: float = 1e-5, 

282 rtol: float = 0.0, 

283) -> List[str]: 

284 """Compare two activation/gradient dicts, returning mismatch descriptions. 

285 

286 Handles batch-dim squeezing and dtype/device normalization. 

287 """ 

288 mismatches = [] 

289 common_keys = sorted(set(dict1.keys()) & set(dict2.keys())) 

290 for key in common_keys: 

291 t1, t2 = dict1[key], dict2[key] 

292 squeezed = _squeeze_batch_dim(t1, t2) 

293 if squeezed is None: 

294 mismatches.append(f"{key}: Shape mismatch - {t1.shape} vs {t2.shape}") 

295 continue 

296 t1, t2 = squeezed 

297 if not safe_allclose(t1, t2, atol=atol, rtol=rtol): 

298 b, r = t1.float(), t2.float() 

299 max_diff = torch.max(torch.abs(b - r)).item() 

300 mean_diff = torch.mean(torch.abs(b - r)).item() 

301 mismatches.append( 

302 f"{key}: Value mismatch - max_diff={max_diff:.6f}, mean_diff={mean_diff:.6f}" 

303 ) 

304 return mismatches 

305 

306 

307def compare_tensors( 

308 tensor1: torch.Tensor, 

309 tensor2: torch.Tensor, 

310 atol: float = 1e-5, 

311 rtol: float = 1e-5, 

312 name: str = "tensors", 

313) -> BenchmarkResult: 

314 """Compare two tensors and return a benchmark result. 

315 

316 Args: 

317 tensor1: First tensor 

318 tensor2: Second tensor 

319 atol: Absolute tolerance 

320 rtol: Relative tolerance 

321 name: Name of the comparison 

322 

323 Returns: 

324 BenchmarkResult with comparison details 

325 """ 

326 # Check shapes 

327 if tensor1.shape != tensor2.shape: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true

328 return BenchmarkResult( 

329 name=name, 

330 severity=BenchmarkSeverity.DANGER, 

331 message=f"Shape mismatch: {tensor1.shape} vs {tensor2.shape}", 

332 passed=False, 

333 ) 

334 

335 if tensor1.device != tensor2.device: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true

336 tensor1 = tensor1.cpu() 

337 tensor2 = tensor2.cpu() 

338 

339 if tensor1.dtype != tensor2.dtype: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 tensor1 = tensor1.to(torch.float32) 

341 tensor2 = tensor2.to(torch.float32) 

342 

343 if torch.allclose(tensor1, tensor2, atol=atol, rtol=rtol): 343 ↛ 351line 343 didn't jump to line 351 because the condition on line 343 was always true

344 return BenchmarkResult( 

345 name=name, 

346 severity=BenchmarkSeverity.INFO, 

347 message="Tensors match within tolerance", 

348 details={"atol": atol, "rtol": rtol}, 

349 ) 

350 

351 diff = torch.abs(tensor1 - tensor2) 

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

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

354 rel_diff = diff / (torch.abs(tensor1) + 1e-10) 

355 mean_rel = rel_diff.mean().item() 

356 

357 return BenchmarkResult( 

358 name=name, 

359 severity=BenchmarkSeverity.DANGER, 

360 message=f"Tensors differ: max_diff={max_diff:.6f}, mean_rel={mean_rel:.6f}", 

361 details={ 

362 "max_diff": max_diff, 

363 "mean_diff": mean_diff, 

364 "mean_rel": mean_rel, 

365 "atol": atol, 

366 "rtol": rtol, 

367 }, 

368 passed=False, 

369 ) 

370 

371 

372def compare_scalars( 

373 scalar1: Union[float, int], 

374 scalar2: Union[float, int], 

375 atol: float = 1e-5, 

376 name: str = "scalars", 

377) -> BenchmarkResult: 

378 """Compare two scalar values and return a benchmark result. 

379 

380 Args: 

381 scalar1: First scalar 

382 scalar2: Second scalar 

383 atol: Absolute tolerance 

384 name: Name of the comparison 

385 

386 Returns: 

387 BenchmarkResult with comparison details 

388 """ 

389 diff = abs(float(scalar1) - float(scalar2)) 

390 

391 if diff < atol: 391 ↛ 399line 391 didn't jump to line 399 because the condition on line 391 was always true

392 return BenchmarkResult( 

393 name=name, 

394 severity=BenchmarkSeverity.INFO, 

395 message=f"Scalars match: {scalar1:.6f} ≈ {scalar2:.6f}", 

396 details={"diff": diff, "atol": atol}, 

397 ) 

398 else: 

399 return BenchmarkResult( 

400 name=name, 

401 severity=BenchmarkSeverity.DANGER, 

402 message=f"Scalars differ: {scalar1:.6f} vs {scalar2:.6f}", 

403 details={"diff": diff, "atol": atol}, 

404 passed=False, 

405 ) 

406 

407 

408def format_results(results: List[BenchmarkResult]) -> str: 

409 """Format a list of benchmark results for console output. 

410 

411 Args: 

412 results: List of benchmark results 

413 

414 Returns: 

415 Formatted string for console output 

416 """ 

417 output = [] 

418 output.append("=" * 80) 

419 output.append("BENCHMARK RESULTS") 

420 output.append("=" * 80) 

421 

422 # Count by severity 

423 severity_counts = { 

424 BenchmarkSeverity.INFO: 0, 

425 BenchmarkSeverity.WARNING: 0, 

426 BenchmarkSeverity.DANGER: 0, 

427 BenchmarkSeverity.ERROR: 0, 

428 BenchmarkSeverity.SKIPPED: 0, 

429 } 

430 

431 passed = 0 

432 failed = 0 

433 skipped = 0 

434 

435 for result in results: 

436 severity_counts[result.severity] += 1 

437 if result.severity == BenchmarkSeverity.SKIPPED: 

438 skipped += 1 

439 elif result.passed: 

440 passed += 1 

441 else: 

442 failed += 1 

443 

444 # Summary 

445 total = len(results) 

446 run_tests = total - skipped 

447 output.append(f"\nTotal: {total} tests") 

448 if skipped > 0: 

449 output.append(f"Run: {run_tests} tests") 

450 output.append(f"Skipped: {skipped} tests") 

451 if run_tests > 0: 

452 output.append(f"Passed: {passed} ({passed/run_tests*100:.1f}%)") 

453 output.append(f"Failed: {failed} ({failed/run_tests*100:.1f}%)") 

454 output.append("") 

455 output.append(f"🟢 INFO: {severity_counts[BenchmarkSeverity.INFO]}") 

456 output.append(f"🟡 WARNING: {severity_counts[BenchmarkSeverity.WARNING]}") 

457 output.append(f"🔴 DANGER: {severity_counts[BenchmarkSeverity.DANGER]}") 

458 output.append(f"❌ ERROR: {severity_counts[BenchmarkSeverity.ERROR]}") 

459 if skipped > 0: 

460 output.append(f"⏭️ SKIPPED: {severity_counts[BenchmarkSeverity.SKIPPED]}") 

461 output.append("") 

462 output.append("-" * 80) 

463 

464 # Individual results 

465 for result in results: 

466 output.append(str(result)) 

467 output.append("") 

468 

469 output.append("=" * 80) 

470 

471 return "\n".join(output) 

472 

473 

474def bridge_self_target_loss(bridge, test_text: str): 

475 """Loss with the tokenized input as explicit labels. 

476 

477 Seq2seq bridges refuse label-less return_type="loss" (encoder input_ids are 

478 not decoder targets), so every benchmark loss call routes through here. 

479 """ 

480 labels = bridge.to_tokens(test_text) 

481 return bridge(test_text, labels=labels, return_type="loss")