Skip to content

Commit 80d9f36

Browse files
authored
Fix recursive TransformerBridge state dict composition (#1661)
* Fix recursive TransformerBridge state dict composition * Clarify filtered checkpoint loading
1 parent 3d59a51 commit 80d9f36

6 files changed

Lines changed: 261 additions & 10 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Regression tests for recursive TransformerBridge checkpoint composition (#1655)."""
2+
3+
from __future__ import annotations
4+
5+
from collections import OrderedDict
6+
from types import SimpleNamespace
7+
8+
import pytest
9+
import torch
10+
from transformers import GPT2Config, GPT2LMHeadModel
11+
12+
from transformer_lens.config import TransformerBridgeConfig
13+
from transformer_lens.model_bridge import TransformerBridge
14+
from transformer_lens.model_bridge.generalized_components import (
15+
JointGateUpMLPBridge,
16+
JointQKVAttentionBridge,
17+
LinearBridge,
18+
)
19+
from transformer_lens.model_bridge.sources import build_bridge_from_module
20+
21+
22+
def _native_bridge() -> TransformerBridge:
23+
cfg = TransformerBridgeConfig(
24+
d_model=32,
25+
d_head=16,
26+
n_heads=2,
27+
n_layers=2,
28+
n_ctx=8,
29+
d_vocab=16,
30+
d_mlp=64,
31+
act_fn="gelu",
32+
normalization_type="LN",
33+
seed=0,
34+
)
35+
return TransformerBridge.boot_native(cfg)
36+
37+
38+
def _parent_with_bridge(bridge: TransformerBridge) -> torch.nn.Module:
39+
parent = torch.nn.Module()
40+
parent.add_module("bridge", bridge)
41+
return parent
42+
43+
44+
def test_state_dict_with_destination_and_prefix_uses_recursive_semantics() -> None:
45+
bridge = _native_bridge()
46+
sentinel = torch.tensor(1)
47+
destination: OrderedDict[str, torch.Tensor] = OrderedDict({"sentinel": sentinel})
48+
49+
returned = bridge.state_dict(destination=destination, prefix="nested.bridge.")
50+
51+
assert returned is destination
52+
assert destination["sentinel"] is sentinel
53+
recursive_keys = set(destination) - {"sentinel"}
54+
assert recursive_keys
55+
assert all(key.startswith("nested.bridge.") for key in recursive_keys)
56+
57+
58+
def test_parent_state_dict_strict_round_trip() -> None:
59+
parent = _parent_with_bridge(_native_bridge())
60+
checkpoint = {key: value.clone() for key, value in parent.state_dict().items()}
61+
62+
with torch.no_grad():
63+
for parameter in parent.parameters():
64+
parameter.zero_()
65+
66+
result = parent.load_state_dict(checkpoint, strict=True)
67+
68+
assert result.missing_keys == []
69+
assert result.unexpected_keys == []
70+
reloaded = parent.state_dict()
71+
for key, value in checkpoint.items():
72+
assert torch.equal(reloaded[key], value), f"{key} did not round-trip"
73+
74+
75+
def test_parent_registration_is_stable_across_first_forward() -> None:
76+
bridge = _native_bridge()
77+
parent = _parent_with_bridge(bridge)
78+
79+
for block in bridge.blocks:
80+
assert block.attn._ln1_module is block.ln1.original_component
81+
82+
keys_before = tuple(parent.state_dict())
83+
assert not any("._ln1_module." in key for key in keys_before)
84+
with torch.no_grad():
85+
bridge(torch.randint(0, bridge.cfg.d_vocab, (1, 4)))
86+
keys_after = tuple(parent.state_dict())
87+
88+
assert keys_after == keys_before
89+
assert not any("._ln1_module." in key for key in keys_after)
90+
91+
92+
def test_nested_joint_qkv_bridge_strict_round_trip() -> None:
93+
cfg = GPT2Config(
94+
vocab_size=32,
95+
n_positions=16,
96+
n_embd=16,
97+
n_layer=1,
98+
n_head=2,
99+
n_inner=32,
100+
pad_token_id=0,
101+
bos_token_id=1,
102+
eos_token_id=2,
103+
)
104+
bridge = build_bridge_from_module(
105+
GPT2LMHeadModel(cfg),
106+
architecture="GPT2LMHeadModel",
107+
hf_config=cfg,
108+
)
109+
parent = _parent_with_bridge(bridge)
110+
111+
checkpoint = {key: value.clone() for key, value in parent.state_dict().items()}
112+
assert not any(".qkv." in key for key in checkpoint)
113+
with torch.no_grad():
114+
for parameter in parent.parameters():
115+
parameter.zero_()
116+
117+
result = parent.load_state_dict(checkpoint, strict=True)
118+
119+
assert result.missing_keys == []
120+
assert result.unexpected_keys == []
121+
reloaded = parent.state_dict()
122+
for key, value in checkpoint.items():
123+
assert torch.equal(reloaded[key], value), f"{key} did not round-trip"
124+
125+
126+
def _filtered_joint_component(kind: str) -> torch.nn.Module:
127+
filtered_child = LinearBridge(name=kind)
128+
filtered_child.set_original_component(torch.nn.Linear(4, 8))
129+
cfg = SimpleNamespace(n_heads=2, d_head=4)
130+
131+
if kind == "qkv":
132+
qkv_component = JointQKVAttentionBridge(
133+
name="attn",
134+
config=cfg,
135+
submodules={"qkv": filtered_child},
136+
)
137+
for child_name in ("q", "k", "v"):
138+
getattr(qkv_component, child_name).set_original_component(torch.nn.Linear(4, 4))
139+
return qkv_component
140+
gate_up_component = JointGateUpMLPBridge(
141+
name="mlp",
142+
config=cfg,
143+
submodules={"gate_up": filtered_child},
144+
)
145+
gate_up_component.add_module("gate_up", filtered_child)
146+
gate_up_component.gate.set_original_component(torch.nn.Linear(4, 4))
147+
getattr(gate_up_component, "in").set_original_component(torch.nn.Linear(4, 4))
148+
return gate_up_component
149+
150+
151+
@pytest.mark.parametrize("filtered_child_name", ["qkv", "gate_up"])
152+
def test_filtered_joint_component_strict_round_trip(filtered_child_name: str) -> None:
153+
component = _filtered_joint_component(filtered_child_name)
154+
filtered_child = component.get_submodule(filtered_child_name)
155+
checkpoint = {key: value.clone() for key, value in component.state_dict().items()}
156+
157+
assert checkpoint
158+
assert not any(key.startswith(f"{filtered_child_name}.") for key in checkpoint)
159+
with torch.no_grad():
160+
for parameter in component.parameters():
161+
parameter.zero_()
162+
163+
result = component.load_state_dict(checkpoint, strict=True)
164+
165+
assert result.missing_keys == []
166+
assert result.unexpected_keys == []
167+
reloaded = component.state_dict()
168+
for key, value in checkpoint.items():
169+
assert torch.equal(reloaded[key], value), f"{key} did not round-trip"
170+
for parameter in filtered_child.parameters():
171+
assert torch.count_nonzero(parameter) == 0

transformer_lens/model_bridge/component_setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ def setup_blocks_bridge(
303303
block_bridge.name = f"{blocks_template.name}.{i}"
304304
block_bridge.set_original_component(original_block)
305305
setup_submodules(block_bridge, architecture_adapter, original_block)
306+
if hasattr(block_bridge, "_wire_ln1_module"):
307+
block_bridge._wire_ln1_module()
306308
bridged_blocks.append(block_bridge)
307309
replace_remote_component(bridged_blocks, blocks_template.name, original_model)
308310
return bridged_blocks

transformer_lens/model_bridge/generalized_components/block.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,28 @@ def __init__(
110110
# Fires pre-ln2 when use_hook_mlp_in is set. See #1317.
111111
self.hook_mlp_in = HookPoint()
112112

113+
def _wire_ln1_module(self) -> None:
114+
"""Keep the raw ln1 execution reference outside the ownership tree."""
115+
from transformer_lens.model_bridge.generalized_components.attention import (
116+
AttentionBridge,
117+
)
118+
119+
ln1 = self.submodules.get("ln1") if self.submodules else None
120+
attn = self.submodules.get("attn") if self.submodules else None
121+
if not isinstance(attn, AttentionBridge):
122+
return
123+
124+
ln1_module = None
125+
if (
126+
ln1 is not None
127+
and getattr(attn, "supports_split_qkv_fork", False)
128+
and getattr(ln1, "original_component", None) is not None
129+
):
130+
ln1_module = ln1.original_component
131+
132+
attn._modules.pop("_ln1_module", None)
133+
object.__setattr__(attn, "_ln1_module", ln1_module)
134+
113135
def _maybe_wire_pre_ln_capture(self) -> None:
114136
"""Install ln1/ln2 forward_pre_hooks that feed the bridge's pre-LN hooks (#1317).
115137
@@ -118,6 +140,7 @@ def _maybe_wire_pre_ln_capture(self) -> None:
118140
forward never calls the raw module, so a hook there would silently miss
119141
on most adapters. Idempotent.
120142
"""
143+
self._wire_ln1_module()
121144
if self._pre_ln_capture_wired:
122145
return
123146
from transformer_lens.model_bridge.generalized_components.attention import (
@@ -140,7 +163,6 @@ def _capture_pre_ln1(_module: torch.nn.Module, args: tuple) -> None:
140163

141164
handle = ln1.register_forward_pre_hook(_capture_pre_ln1)
142165
self._pre_ln_capture_handles.append(handle)
143-
attn._ln1_module = ln1.original_component
144166

145167
ln2 = self.submodules.get("ln2") if self.submodules else None
146168
if ln2 is not None and getattr(ln2, "original_component", None) is not None:

transformer_lens/model_bridge/generalized_components/joint_gate_up_mlp.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ def __init__(
6060
self._activation_fn: Any = None
6161

6262
self._register_state_dict_hook(JointGateUpMLPBridge._filter_gate_up_state_dict)
63+
self.register_load_state_dict_pre_hook(
64+
JointGateUpMLPBridge._restore_filtered_gate_up_state_dict
65+
)
6366

6467
@staticmethod
6568
def _filter_gate_up_state_dict(
@@ -74,6 +77,29 @@ def _filter_gate_up_state_dict(
7477
for k in keys_to_remove:
7578
del state_dict[k]
7679

80+
@staticmethod
81+
def _restore_filtered_gate_up_state_dict(
82+
module: torch.nn.Module,
83+
state_dict: Dict[str, Any],
84+
prefix: str,
85+
local_metadata: Dict[str, Any],
86+
strict: bool,
87+
missing_keys: list[str],
88+
unexpected_keys: list[str],
89+
error_msgs: list[str],
90+
) -> None:
91+
"""Insert current combined weights only to satisfy strict key matching.
92+
93+
Production checkpoints restore authoritative values through the unfiltered
94+
Hugging Face ``_original_component`` path.
95+
"""
96+
del local_metadata, strict, missing_keys, unexpected_keys, error_msgs
97+
gate_up = module._modules.get("gate_up")
98+
if gate_up is None:
99+
return
100+
for key, value in gate_up.state_dict(prefix=f"{prefix}gate_up.").items():
101+
state_dict.setdefault(key, value)
102+
77103
@staticmethod
78104
def _default_split_gate_up(
79105
original_mlp_component: Any,

transformer_lens/model_bridge/generalized_components/joint_qkv_attention.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ def __init__(
100100

101101
# Exclude stale qkv combined weights from state_dict after splitting.
102102
self._register_state_dict_hook(JointQKVAttentionBridge._filter_qkv_state_dict)
103+
self.register_load_state_dict_pre_hook(
104+
JointQKVAttentionBridge._restore_filtered_qkv_state_dict
105+
)
103106

104107
def __deepcopy__(self, memo):
105108
"""Share split_qkv_matrix and config across clones instead of copying.
@@ -143,6 +146,29 @@ def _filter_qkv_state_dict(
143146
for k in keys_to_remove:
144147
del state_dict[k]
145148

149+
@staticmethod
150+
def _restore_filtered_qkv_state_dict(
151+
module: torch.nn.Module,
152+
state_dict: Dict[str, Any],
153+
prefix: str,
154+
local_metadata: Dict[str, Any],
155+
strict: bool,
156+
missing_keys: list[str],
157+
unexpected_keys: list[str],
158+
error_msgs: list[str],
159+
) -> None:
160+
"""Insert current combined weights only to satisfy strict key matching.
161+
162+
Production checkpoints restore authoritative values through the unfiltered
163+
Hugging Face ``_original_component`` path.
164+
"""
165+
del local_metadata, strict, missing_keys, unexpected_keys, error_msgs
166+
qkv = module._modules.get("qkv")
167+
if qkv is None:
168+
return
169+
for key, value in qkv.state_dict(prefix=f"{prefix}qkv.").items():
170+
state_dict.setdefault(key, value)
171+
146172
def _create_qkv_conversion_rule(self) -> BaseTensorConversion:
147173
"""Create the appropriate conversion rule for the individual q, k, and v matrices.
148174

transformer_lens/model_bridge/transformer_bridge.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3824,24 +3824,28 @@ def state_dict(self, destination=None, prefix="", keep_vars=False):
38243824
Converts HuggingFace format keys to TransformerLens format and filters out
38253825
_original_component references and nested HuggingFace components.
38263826
3827-
This returns a clean state dict with only bridge component paths converted to TL format,
3828-
excluding nested HF components (like c_fc, c_proj, c_attn) that exist inside
3829-
original_component modules.
3827+
A direct no-argument call returns a clean state dict with bridge component
3828+
paths converted to TL format. Calls that supply ``destination`` or
3829+
``prefix`` use standard ``nn.Module`` recursive semantics so a Bridge can
3830+
compose inside a parent module.
38303831
38313832
Args:
38323833
destination: Optional dict to store state dict in
38333834
prefix: Optional prefix to add to all keys
38343835
keep_vars: Whether to keep variables as Variables instead of tensors
38353836
38363837
Returns:
3837-
Dict containing the state dict with TransformerLens format keys
3838+
Direct calls return TransformerLens-format keys; recursive calls
3839+
return the supplied destination with standard module-tree keys.
38383840
"""
3839-
if destination is not None:
3840-
raw_state_dict = self.original_model.state_dict(
3841-
destination=destination, prefix=prefix, keep_vars=keep_vars
3841+
if destination is not None or prefix:
3842+
return super().state_dict(
3843+
destination=destination,
3844+
prefix=prefix,
3845+
keep_vars=keep_vars,
38423846
)
3843-
else:
3844-
raw_state_dict = self.original_model.state_dict(prefix=prefix, keep_vars=keep_vars)
3847+
3848+
raw_state_dict = self.original_model.state_dict(keep_vars=keep_vars)
38453849

38463850
# Clean _original_component references and convert to TL format
38473851
# Also filter out nested HuggingFace components that are wrapped by bridge components

0 commit comments

Comments
 (0)