Coverage for transformer_lens/model_bridge/generalized_components/moe.py: 90%

153 statements  

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

1"""Mixture of Experts bridge component. 

2 

3This module contains the bridge component for Mixture of Experts layers. 

4""" 

5 

6from __future__ import annotations 

7 

8from typing import Any, Dict, Mapping, Optional, Tuple 

9 

10import torch 

11 

12from transformer_lens.hook_points import HookPoint 

13from transformer_lens.model_bridge.generalized_components.base import ( 

14 GeneralizedComponent, 

15) 

16from transformer_lens.model_bridge.generalized_components.linear import LinearBridge 

17from transformer_lens.model_bridge.generalized_components.mlp import ( 

18 normalize_mlp_weight, 

19 weight_layout_in_out, 

20) 

21 

22 

23class MoEBridge(GeneralizedComponent): 

24 """Bridge component for Mixture of Experts layers. 

25 

26 This component wraps a Mixture of Experts layer from a remote model and provides a consistent interface 

27 for accessing its weights and performing MoE operations. 

28 

29 hook_router_scores fires only when the wrapped block returns a tuple 

30 (gpt_oss, LLaDA2 remote); 5.13-native SparseMoeBlocks return a plain 

31 tensor, so router observability comes from the ``gate`` submodule's 

32 hook_out instead. 

33 """ 

34 

35 hook_aliases = {"hook_pre": "hook_in", "hook_post": "hook_out"} 

36 

37 # Deliberately NOT gate/in/out: ``gate`` is the ROUTER on sparse layers of 

38 # the same model, so reusing it would flip the meaning of 

39 # blocks.N.mlp.gate.hook_out per layer (#1645's own confusion). dense_gate 

40 # is absent on ungated feed-forwards (Switch's wi/wo); the alias set 

41 # follows the bound shape. 

42 DENSE_SUBMODULE_KEYS = ("dense_in", "dense_out") 

43 DENSE_GATE_KEY = "dense_gate" 

44 _DENSE_HOOK_ALIASES = { 

45 "hook_pre": "dense_in.hook_out", 

46 "hook_post": "dense_out.hook_in", 

47 } 

48 _DENSE_GATED_HOOK_ALIASES = { 

49 "hook_pre": "dense_gate.hook_out", 

50 "hook_pre_linear": "dense_in.hook_out", 

51 "hook_post": "dense_out.hook_in", 

52 } 

53 _DENSE_PROPERTY_ALIASES = { 

54 "b_gate": "dense_gate.bias", 

55 "b_in": "dense_in.bias", 

56 "b_out": "dense_out.bias", 

57 } 

58 

59 def __init__( 

60 self, 

61 name: str, 

62 config: Optional[Any] = None, 

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

64 optional: bool = False, 

65 sparse_required: Tuple[str, ...] = (), 

66 ): 

67 """Initialize the MoE bridge. 

68 

69 Args: 

70 name: The name of the component in the model 

71 config: Optional configuration (unused for MoEBridge) 

72 submodules: Dictionary of GeneralizedComponent submodules to register 

73 optional: If True, setup skips this subtree when absent (dense layers) 

74 sparse_required: Submodule keys that must be declared ``optional`` 

75 (dense layers of an interleaved stack do not have them) but whose 

76 absence on a SPARSE layer is an error rather than a silent skip. 

77 Routers belong here: HF creates them unconditionally on sparse 

78 blocks, so a skip means the attribute was renamed or moved, and 

79 plain ``optional`` would drop their hooks without a word. 

80 """ 

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

82 self.hook_router_scores = HookPoint() 

83 self._bound_dense = False 

84 self._bound_dense_gate = False 

85 # A misspelled key would silently disable the very guard that exists to 

86 # stop silent degradation, so the opt-in is validated on construction. 

87 unknown = set(sparse_required) - set(submodules or {}) 

88 if unknown: 

89 raise ValueError( 

90 f"{name}: sparse_required {sorted(unknown)} are not declared " 

91 f"submodules (declared: {sorted(submodules or {})})" 

92 ) 

93 self._sparse_required = sparse_required 

94 

95 def _binds_dense_projections(self, component: torch.nn.Module) -> bool: 

96 """Whether this layer is the dense variant of an interleaved MoE stack. 

97 

98 Positive detection only: the adapter must have declared the dense 

99 projections AND the wrapped module must actually expose them. Guessing 

100 from the absence of ``experts`` would risk silently stripping hooks off 

101 a sparse block whose experts are named differently. 

102 """ 

103 declared = [self.submodules.get(key) for key in self.DENSE_SUBMODULE_KEYS] 

104 if not all(declared): 

105 return False 

106 return all( 

107 sub is not None and sub.name is not None and hasattr(component, sub.name) 

108 for sub in declared 

109 ) 

110 

111 def _binds_dense_gate(self, component: torch.nn.Module) -> bool: 

112 """Whether the bound dense MLP is gated (SiLU-gated) rather than plain. 

113 

114 A declared-but-unresolvable gate means the HF attribute was renamed, 

115 not that the MLP is ungated — binding it as ungated would silently 

116 alias hook_pre to the UP projection, so raise instead. 

117 """ 

118 gate = self.submodules.get(self.DENSE_GATE_KEY) 

119 if gate is None or gate.name is None: 

120 return False # never declared: a genuinely ungated dense MLP 

121 if hasattr(component, gate.name): 

122 return True 

123 raise ValueError( 

124 f"{self.name}: dense layer wrapped {type(component).__name__} which has " 

125 f"no {gate.name!r}, but this adapter declares {self.DENSE_GATE_KEY!r}" 

126 "so its dense MLPs are gated and the attribute was renamed or moved. " 

127 "Binding it as ungated would alias hook_pre to the up projection. " 

128 "Update the adapter's submodule name, or drop the declaration if this " 

129 "architecture's dense layers really are ungated." 

130 ) 

131 

132 def set_original_component(self, component: torch.nn.Module) -> None: 

133 """Bind the layer, adopting gated-MLP semantics on dense layers. 

134 

135 Dense layers of interleaved MoE stacks get neuron-basis hooks and 

136 weight accessors instead of MoE boundary tensors under those names. 

137 """ 

138 super().set_original_component(component) 

139 is_dense = self._binds_dense_projections(component) 

140 if is_dense == self._bound_dense: 

141 # First sparse bind, or an idempotent rebind: nothing to morph. 

142 if not is_dense: 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was always true

143 return 

144 self._bound_dense = is_dense 

145 if is_dense: 

146 self._bound_dense_gate = self._binds_dense_gate(component) 

147 self.hook_aliases = dict( 

148 self._DENSE_GATED_HOOK_ALIASES 

149 if self._bound_dense_gate 

150 else self._DENSE_HOOK_ALIASES 

151 ) 

152 dense_props = dict(self._DENSE_PROPERTY_ALIASES) 

153 if not self._bound_dense_gate: 

154 dense_props.pop("b_gate", None) 

155 self.property_aliases = {**self.property_aliases, **dense_props} 

156 # A dense layer has no router; leaving the hook in hook_dict would 

157 # advertise an intervention point that can never fire. 

158 if hasattr(self, "hook_router_scores"): 158 ↛ exitline 158 didn't return from function 'set_original_component' because the condition on line 158 was always true

159 self._hook_registry.pop("hook_router_scores", None) 

160 del self.hook_router_scores 

161 else: 

162 # Symmetric restore so a rebinding harness cannot leave a chimera. 

163 self.hook_aliases = dict(type(self).hook_aliases) 

164 self.property_aliases = { 

165 key: value 

166 for key, value in self.property_aliases.items() 

167 if key not in self._DENSE_PROPERTY_ALIASES 

168 } 

169 self._bound_dense_gate = False 

170 if not hasattr(self, "hook_router_scores"): 170 ↛ exitline 170 didn't return from function 'set_original_component' because the condition on line 170 was always true

171 self.hook_router_scores = HookPoint() 

172 

173 def validate_after_setup(self, skipped_optional: list[str]) -> None: 

174 """Fail loudly when a sparse layer is missing a submodule only dense 

175 layers may lack (see ``sparse_required``). 

176 

177 Called by setup_submodules once every submodule has resolved — the 

178 skipped set is not knowable at bind time. 

179 """ 

180 if self._bound_dense or not self._sparse_required: 

181 return 

182 missing = [key for key in self._sparse_required if key in skipped_optional] 

183 if missing: 

184 component = type(self.original_component).__name__ 

185 raise ValueError( 

186 f"{self.name}: sparse MoE layer wrapped {component} but required " 

187 f"submodule(s) {missing} were absent. These are optional only so " 

188 "dense layers of an interleaved stack can skip them; on a sparse " 

189 "layer their absence means the HF attribute was renamed or moved. " 

190 "Update the adapter's submodule name(s) rather than losing the hooks." 

191 ) 

192 

193 @property 

194 def bound_dense(self) -> bool: 

195 """Whether this layer bound the dense variant of an interleaved MoE stack. 

196 

197 Public so weight-collection helpers can find the projections under 

198 ``DENSE_SUBMODULE_KEYS`` instead of the sparse ``gate`` (the router). 

199 """ 

200 return self._bound_dense 

201 

202 def _dense_projection(self, key: str) -> Any: 

203 """Return a bound dense projection. 

204 

205 Raises AttributeError on sparse layers (per-expert weights, no single 

206 W_*) so ``hasattr`` stays False and weight collection skips them. 

207 """ 

208 if not self._bound_dense: 

209 raise AttributeError(f"{self.name}: {key} exists only on dense layers") 

210 module = getattr(self, key, None) 

211 if module is None: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 raise AttributeError(f"{self.name}: dense projection {key!r} is not bound") 

213 return module 

214 

215 @property 

216 def W_gate(self) -> torch.Tensor: 

217 """Gated dense layer's gate weight in TL orientation [d_model, d_mlp].""" 

218 if not self._bound_dense_gate: 

219 raise AttributeError(f"{self.name}: this dense layer is ungated (no W_gate)") 

220 module = self._dense_projection("dense_gate") 

221 return normalize_mlp_weight( 

222 module.weight, weight_layout_in_out(module), module, pattern="in" 

223 ) 

224 

225 @property 

226 def W_in(self) -> torch.Tensor: 

227 """Dense-layer input weight in TL orientation [d_model, d_mlp].""" 

228 module = self._dense_projection("dense_in") 

229 return normalize_mlp_weight( 

230 module.weight, weight_layout_in_out(module), module, pattern="in" 

231 ) 

232 

233 @property 

234 def W_out(self) -> torch.Tensor: 

235 """Dense-layer output weight in TL orientation [d_mlp, d_model].""" 

236 module = self._dense_projection("dense_out") 

237 return normalize_mlp_weight( 

238 module.weight, weight_layout_in_out(module), module, pattern="out" 

239 ) 

240 

241 def get_random_inputs( 

242 self, 

243 batch_size: int = 2, 

244 seq_len: int = 8, 

245 device: Optional[torch.device] = None, 

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

247 ) -> Dict[str, Any]: 

248 """Generate random inputs for component testing. 

249 

250 Args: 

251 batch_size: Batch size for generated inputs 

252 seq_len: Sequence length for generated inputs 

253 device: Device to place tensors on 

254 dtype: Dtype for generated tensors (defaults to float32) 

255 

256 Returns: 

257 Dictionary of input tensors matching the component's expected input signature 

258 """ 

259 if device is None: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 device = torch.device("cpu") 

261 if dtype is None: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 dtype = torch.float32 

263 d_model = self.config.d_model if self.config and hasattr(self.config, "d_model") else 768 

264 # Use positional args to avoid parameter name mismatches across MoE implementations 

265 # (e.g., Mixtral uses "hidden_states", GraniteMoe uses "layer_input") 

266 return {"args": (torch.randn(batch_size, seq_len, d_model, device=device, dtype=dtype),)} 

267 

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

269 """Forward pass through the MoE bridge. 

270 

271 Args: 

272 *args: Input arguments 

273 **kwargs: Input keyword arguments 

274 

275 Returns: 

276 Same return type as original component (tuple or tensor). 

277 For MoE models that return (hidden_states, router_scores), preserves the tuple. 

278 Router scores are also captured via hook for inspection. 

279 """ 

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

281 raise RuntimeError( 

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

283 ) 

284 if len(args) > 0: 

285 hooked = self.hook_in(args[0]) 

286 args = (hooked,) + args[1:] 

287 elif "hidden_states" in kwargs: 287 ↛ 290line 287 didn't jump to line 290 because the condition on line 287 was always true

288 hooked = self.hook_in(kwargs["hidden_states"]) 

289 kwargs = {**kwargs, "hidden_states": hooked} 

290 output = self.original_component(*args, **kwargs) 

291 if isinstance(output, tuple): 

292 if not output: 

293 raise TypeError( 

294 f"{self.name}: expected a non-empty tuple whose first " 

295 "element is a torch.Tensor from the wrapped MoE component, " 

296 "got an empty tuple." 

297 ) 

298 

299 hidden_states = output[0] 

300 if not isinstance(hidden_states, torch.Tensor): 

301 raise TypeError( 

302 f"{self.name}: expected the first tuple element from the " 

303 f"wrapped MoE component to be a torch.Tensor, got " 

304 f"{type(hidden_states).__name__}." 

305 ) 

306 if len(output) > 1: 306 ↛ 319line 306 didn't jump to line 319 because the condition on line 306 was always true

307 router_scores = output[1] 

308 # Some MoEs pack extras with the logits (LLaDA2 returns 

309 # (router_logits, topk_idx)); hook the first tensor. 

310 if isinstance(router_scores, tuple): 

311 router_scores = next( 

312 (t for t in router_scores if isinstance(t, torch.Tensor)), None 

313 ) 

314 # The hook is removed on dense binds (no router exists there), so 

315 # a dense layer whose wrapped module still returns a tuple must 

316 # pass the extras through untouched rather than raise. 

317 if isinstance(router_scores, torch.Tensor) and hasattr(self, "hook_router_scores"): 

318 self.hook_router_scores(router_scores) 

319 hidden_states = self.hook_out(hidden_states) 

320 return (hidden_states,) + output[1:] 

321 else: 

322 hidden_states = self.hook_out(output) 

323 return hidden_states 

324 

325 

326class MoERouterBridge(LinearBridge): 

327 """Bridge MoE router logits while preserving HF's tuple return. 

328 

329 5.13 TopKRouters return ``(router_logits, topk_weights, topk_indices)``; 

330 hook_out fires on the logits (element ``logits_index`` — JetMoe puts them 

331 last) and the tuple is re-packed so HF's unpacking is undisturbed. 

332 """ 

333 

334 def __init__(self, *args: Any, logits_index: int = 0, **kwargs: Any): 

335 super().__init__(*args, **kwargs) 

336 self.logits_index = logits_index 

337 

338 def forward(self, input: torch.Tensor, *args: Any, **kwargs: Any) -> Any: 

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

340 raise RuntimeError( 

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

342 ) 

343 input = self.hook_in(input) 

344 output = self.original_component(input, *args, **kwargs) 

345 if not isinstance(output, tuple) or len(output) == 0: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 return self.hook_out(output) 

347 idx = self.logits_index % len(output) 

348 router_logits = self.hook_out(output[idx]) 

349 return output[:idx] + (router_logits,) + output[idx + 1 :] 

350 

351 def set_processed_weights( 

352 self, weights: Mapping[str, Optional[torch.Tensor]], verbose: bool = False 

353 ) -> None: 

354 """Copy router weights onto nested params by dotted path (JetMoe nests its 

355 Linear at ``router.layer.weight``); router weights are never processed.""" 

356 if "weight" in weights: 

357 super().set_processed_weights(weights, verbose=verbose) 

358 return 

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

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

361 for key, tensor in weights.items(): 

362 if tensor is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 continue 

364 target: Any = self.original_component 

365 *path, leaf = key.split(".") 

366 for part in path: 

367 target = getattr(target, part) 

368 param = getattr(target, leaf) 

369 if param.shape != tensor.shape: 

370 raise ValueError( 

371 f"Router weight {key} shape {tuple(tensor.shape)} does not match " 

372 f"parameter shape {tuple(param.shape)} on {self.name}" 

373 ) 

374 with torch.no_grad(): 

375 param.copy_(tensor.to(dtype=param.dtype, device=param.device))