Skip to content

[Bug Report] TransformerBridge is not composable as an nn.Module: parent traversal misses live parameters #1655

Description

@emerardd

Describe the bug

TransformerBridge behaves like a standard torch.nn.Module when used directly, but it is not composable as a child of another nn.Module.

TransformerBridge.__init__() stores the live source model with:

self.__dict__["original_model"] = model

This intentionally bypasses nn.Module.__setattr__() and keeps original_model outside the registered _modules tree. Bridge then restores several root-level operations through explicit overrides, including parameters(), named_parameters(), to(), train(), state_dict(), and load_state_dict().

Those overrides make direct calls on the Bridge work, but a parent module does not use them for ordinary recursive traversal:

  • parameter discovery walks registered modules and parameters;
  • parent.to(...), .half(), and .bfloat16() recurse through _apply();
  • parent.requires_grad_(...), optimizers, and zero_grad() use the parent's recursively discovered parameters;
  • parent checkpointing passes a shared destination and prefix through recursive state_dict() calls and ignores a child's replacement return object.

The Bridge component tree re-registers only the source modules represented by the adapter mapping. Unmapped parts of the hidden source model are still used by forward, but are invisible to the parent module. As a result, parent optimizers and lifecycle operations can silently omit live parameters, parent dtype conversion can create a mixed-dtype model that crashes, and nested checkpoint round-trips fail.

Code example

This reproduction constructs tiny random models from config only and performs no downloads:

import torch
from transformers import BertConfig, BertForMaskedLM

from transformer_lens.model_bridge.sources import build_bridge_from_module


class Parent(torch.nn.Module):
    def __init__(self, bridge):
        super().__init__()
        self.bridge = bridge

    def forward(self, tokens):
        return self.bridge(tokens)


def make_bridge():
    config = BertConfig(
        vocab_size=32,
        hidden_size=16,
        intermediate_size=32,
        num_hidden_layers=2,
        num_attention_heads=4,
        max_position_embeddings=16,
    )
    return build_bridge_from_module(
        BertForMaskedLM(config).eval(),
        "BertForMaskedLM",
        hf_config=config,
        dtype=torch.float32,
        device="cpu",
        model_name="tiny-parent-module-repro",
    )


bridge = make_bridge()
parent = Parent(bridge)

bridge_ids = {id(parameter) for parameter in bridge.parameters()}
parent_ids = {id(parameter) for parameter in parent.parameters()}
missing_ids = bridge_ids - parent_ids

print(len(bridge_ids), len(parent_ids), len(missing_ids))
print(
    [
        name
        for name, parameter in bridge.original_model.named_parameters()
        if id(parameter) in missing_ids
    ]
)

parent.to(torch.float64)
print({parameter.dtype for parameter in bridge.parameters()})
parent(torch.tensor([[1, 2, 3]]))

On current dev-4.x (ac4f7f134b12), the output is:

42 37 5
[
  'bert.embeddings.token_type_embeddings.weight',
  'bert.embeddings.LayerNorm.weight',
  'bert.embeddings.LayerNorm.bias',
  'cls.predictions.transform.dense.weight',
  'cls.predictions.transform.dense.bias',
]

{torch.float64, torch.float32}
RuntimeError: mixed dtype (CPU): all inputs must share same datatype.

The direct controls work correctly:

bridge.to(torch.float64)
  all 42 parameters are float64
  forward returns float64 logits

bridge.requires_grad_(False)
  no source-model parameter still requires gradients

The corresponding parent operations miss the same five parameters:

parent.to(torch.float64)
  37 parameters are float64
   5 parameters remain float32

parent.requires_grad_(False)
  the same 5 source-model parameters still require gradients

There is also a nested checkpoint symptom. With a tiny GPT-2 Bridge:

destination = {}
returned = bridge.state_dict(destination=destination, prefix="bridge.")

produces:

returned is destination = False
destination first key   = bridge.transformer.wte._original_component.weight
returned first key      = bridge.transformer.wte.weight

When the Bridge is registered under Parent, parent.state_dict() retains the raw recursive entries rather than the returned TL-key dictionary, contains no W_Q key, and parent.load_state_dict(parent.state_dict()) fails under the default strict load.

Expected behavior

A TransformerBridge registered as a child module should participate in standard PyTorch ownership and recursion:

  • the parent should discover every unique live parameter used by Bridge forward;
  • parent-level dtype/device conversion and freezing should reach all live parameters and buffers;
  • optimizers built from parent.parameters() should not silently omit source-model parameters;
  • nested state_dict() should honor the supplied destination and prefix and strictly round-trip through the parent.

Related issues and duplicate boundary

Suggested fix direction

Adding more root-level delegation methods is not a complete fix. An _apply() override could repair direct dtype/device movement but would not make parent.parameters() complete; a state_dict() patch alone would not repair parent optimizers or freezing.

The implementation needs one authoritative registered ownership graph for the parameters and buffers used by forward. One possible direction is to register the source model as the owning graph and make Bridge components non-owning views/proxies over it. Naively registering both the complete source model and all current Bridge component wrappers may create duplicate serialization paths, so parameter identity, state-dict keys, hooks, and existing raw/TL checkpoint behavior need to be considered together.

It may also be cleaner to keep standard recursive PyTorch state_dict() semantics separate from an explicitly named TransformerLens-format export API, rather than making one method serve both parent recursion and key conversion.

Suggested regression coverage:

  • A parent containing a tiny BERT-family Bridge sees the same unique parameter identities as direct bridge.parameters().
  • parent.to(device/dtype), .half(), and .bfloat16() reach every live forward parameter and buffer without mixed-dtype execution.
  • parent.requires_grad_(False) and parent.zero_grad() reach all source-model parameters.
  • An optimizer created from parent.parameters() can update every intended trainable parameter.
  • bridge.state_dict(destination=dest, prefix="bridge.") returns dest and writes the intended prefixed keys into it.
  • A parent containing a Bridge can strictly load its own state_dict().
  • Parent-level save/mutate/load restores the values actually used by forward.
  • Existing direct Bridge checkpoint behavior, TransformerLens-format analysis access, and Tracr raw-key loading remain supported.
  • If DDP/FSDP composition is intended to be supported, add a lightweight wrapper smoke test based on standard parameter enumeration; the current report does not claim a full distributed reproduction.

System Info

  • Installed from source in the repository uv environment
  • Windows 11 / PowerShell
  • Python 3.12.10
  • TransformerLens dev-4.x commit ac4f7f134b12
  • CPU-only reproduction; no model or tokenizer download

Additional context

The visible symptom varies by architecture because adapter mappings re-register different fractions of each source-model tree. BERT is a useful control because it leaves five forward parameters outside the Bridge's registered component graph while direct Bridge delegation still sees all of them.

Checklist

  • I have checked that there is no similar issue in the repo (required)

Metadata

Metadata

Assignees

Labels

TransformerBridgeBug specific to the new TransformerBridge systembugSomething isn't workingcomplexity-highVery complicated changes for people to address who are quite familiar with the codeneeds-investigationIssues that need to be recreated, or investigated before work can be done

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions