GitLab Repo

amachine.am_transformers.am_model_introspection

Architecture-agnostic module resolution for HuggingFace transformer models.

This module is purely about finding things on a model — decoder layers, the final pre-lm_head norm, and whether input/output embeddings are tied. It does not mutate the model in any way (no requires_grad changes here).

Designed to work across common architecture families (Llama, GPT-2, GPT-NeoX, T5-style, custom MoE/shared-MLP variants, etc.) as well as common wrappers (DDP, torch.compile, FSDP) by walking through wrapper attributes.

  1"""
  2Architecture-agnostic module resolution for HuggingFace transformer models.
  3
  4This module is purely about *finding* things on a model — decoder layers,
  5the final pre-lm_head norm, and whether input/output embeddings are tied.
  6It does not mutate the model in any way (no requires_grad changes here).
  7
  8Designed to work across common architecture families (Llama, GPT-2, GPT-NeoX,
  9T5-style, custom MoE/shared-MLP variants, etc.) as well as common wrappers
 10(DDP, torch.compile, FSDP) by walking through wrapper attributes.
 11"""
 12
 13from __future__ import annotations
 14
 15import warnings
 16from dataclasses import dataclass
 17
 18import torch
 19
 20
 21# ---------------------------------------------------------------------------
 22# Known attribute paths for common architecture families.
 23# These are checked in order; the first match wins. Add new architectures
 24# here as needed rather than special-casing them elsewhere.
 25# ---------------------------------------------------------------------------
 26
 27DECODER_LAYER_PATHS: list[tuple[str, ...]] = [
 28    ("model", "layers"),              # Llama, Mistral, Gemma, Qwen, etc.
 29    ("transformer", "h"),              # GPT-2 family
 30    ("gpt_neox", "layers"),            # GPT-NeoX / Pythia
 31    ("model", "decoder", "layers"),    # OPT, some encoder-decoder decoders
 32    ("layers",),                       # bare / already-unwrapped models
 33]
 34
 35FINAL_NORM_PATHS: list[tuple[str, ...]] = [
 36    ("model", "norm"),                       # Llama, Mistral, Gemma, Qwen, etc.
 37    ("transformer", "ln_f"),                  # GPT-2 family
 38    ("gpt_neox", "final_layer_norm"),         # GPT-NeoX / Pythia
 39    ("model", "decoder", "final_layer_norm"), # OPT
 40]
 41
 42# Attributes that indicate "the real model is nested inside this wrapper".
 43WRAPPER_ATTRS: tuple[str, ...] = (
 44    "module",                 # DDP / DataParallel
 45    "_orig_mod",               # torch.compile
 46    "_fsdp_wrapped_module",    # FSDP fallback (best-effort; see caveats below)
 47)
 48
 49
 50class ModuleResolutionError(RuntimeError):
 51    """Raised when a required module (e.g. decoder layers) cannot be located."""
 52
 53
 54class TiedEmbeddingConflictError(RuntimeError):
 55    """Raised when tied input/output embeddings conflict with the caller's
 56    stated intent to train them independently."""
 57
 58
 59@dataclass(frozen=True)
 60class ResolvedModel:
 61    """Bundle of everything resolved from a model, so callers only pay the
 62    wrapper-walking / path-guessing cost once."""
 63
 64    root: torch.nn.Module                    # the model, after unwrapping to the level where layers were found
 65    decoder_layers: torch.nn.ModuleList
 66    final_norm: torch.nn.Module | None
 67    input_embeddings: torch.nn.Module | None
 68    output_embeddings: torch.nn.Module | None
 69    embeddings_tied: bool
 70    tie_word_embeddings_config: bool | None   # raw config value, for reference/debugging
 71
 72
 73# ---------------------------------------------------------------------------
 74# Internals
 75# ---------------------------------------------------------------------------
 76
 77def _resolve_path(module: torch.nn.Module, path: tuple[str, ...]) -> object | None:
 78    obj: object = module
 79    for name in path:
 80        obj = getattr(obj, name, None)
 81        if obj is None:
 82            return None
 83    return obj
 84
 85
 86def _walk_wrappers(model: torch.nn.Module):
 87    """Yield model, then anything reachable through known wrapper attrs,
 88    breadth-first-ish, without revisiting the same object twice."""
 89    pending, visited = [model], set()
 90    while pending:
 91        current = pending.pop()
 92        if id(current) in visited:
 93            continue
 94        visited.add(id(current))
 95        yield current
 96        for attr in WRAPPER_ATTRS:
 97            wrapped = getattr(current, attr, None)
 98            if isinstance(wrapped, torch.nn.Module) and wrapped is not current:
 99                pending.append(wrapped)
100
101
102# ---------------------------------------------------------------------------
103# Public resolution functions
104# ---------------------------------------------------------------------------
105
106def find_decoder_layers(model: torch.nn.Module) -> torch.nn.ModuleList:
107    """Find the decoder layer stack, descending through common wrappers if needed.
108
109    Raises ModuleResolutionError if no known path matches on this model or any
110    of its unwrapped inner modules. If your architecture isn't found, add its
111    attribute path to DECODER_LAYER_PATHS above.
112    """
113    for current in _walk_wrappers(model):
114        for path in DECODER_LAYER_PATHS:
115            obj = _resolve_path(current, path)
116            if obj is not None and hasattr(obj, "__len__"):
117                return obj  # type: ignore[return-value]
118    raise ModuleResolutionError(
119        "Could not locate decoder layers. Checked paths: "
120        f"{DECODER_LAYER_PATHS}. Add this architecture's attribute path to "
121        "DECODER_LAYER_PATHS if it uses a different naming convention."
122    )
123
124
125def find_final_norm(model: torch.nn.Module) -> torch.nn.Module | None:
126    """Best-effort lookup of the final pre-lm_head norm.
127
128    Returns None (rather than raising) if not found — some architectures may
129    genuinely lack one, or use an attribute path not yet in FINAL_NORM_PATHS.
130    Callers should decide whether a missing final norm is fatal for their use case.
131    """
132    for current in _walk_wrappers(model):
133        for path in FINAL_NORM_PATHS:
134            obj = _resolve_path(current, path)
135            if isinstance(obj, torch.nn.Module):
136                return obj
137    return None
138
139
140def detect_tied_embeddings(model: torch.nn.Module, strict: bool = False) -> bool:
141    """Detect whether input embeddings and lm_head share the same weight tensor.
142
143    Uses `is` identity on the .weight attribute as the primary signal, since
144    this is exact (not heuristic) for a plainly-constructed, unwrapped model.
145    Cross-checks against config.tie_word_embeddings when available.
146
147    IMPORTANT: call this as early as possible — right after model construction
148    /from_pretrained(), before FSDP, DeepSpeed ZeRO-3, quantization, or PEFT
149    wrapping. Those can partition, flatten, or replace weight tensors such that
150    identity no longer reflects the true tying relationship.
151
152    Args:
153        strict: if True, raise TiedEmbeddingConflictError on a config/identity
154            disagreement instead of warning. Appropriate when you control model
155            construction directly (e.g. generating configs from scratch) and
156            expect config.tie_word_embeddings to be authoritative.
157    """
158    input_emb = model.get_input_embeddings()
159    output_emb = model.get_output_embeddings()
160    if input_emb is None or output_emb is None:
161        return False
162
163    identity_tied = getattr(input_emb, "weight", None) is getattr(output_emb, "weight", None)
164    config_tied = getattr(getattr(model, "config", None), "tie_word_embeddings", None)
165
166    if config_tied is not None and bool(config_tied) != identity_tied:
167        message = (
168            f"Tied-embedding detection disagreement: config.tie_word_embeddings="
169            f"{config_tied} but weight identity check says {identity_tied}."
170        )
171        if strict:
172            raise TiedEmbeddingConflictError(
173                message + " This model was likely constructed without calling "
174                "tie_weights(), or was wrapped (FSDP/DeepSpeed/quantization/PEFT) "
175                "before this check ran."
176            )
177        warnings.warn(
178            message + " Trusting the identity check. If this model went through "
179            "FSDP/DeepSpeed/quantization/PEFT before this check, re-run detection "
180            "earlier in your pipeline.",
181            stacklevel=2,
182        )
183
184    return identity_tied
185
186
187def resolve_model(model: torch.nn.Module, strict_tie_check: bool = False) -> ResolvedModel:
188    """Run all resolution steps once and bundle the results.
189
190    Call this once at setup time (post model-construction, pre distributed/
191    quantization wrapping) and pass the result to downstream consumers (e.g.
192    trainable_parameters.set_trainable_parameters) instead of re-resolving.
193    """
194    decoder_layers = find_decoder_layers(model)
195    final_norm = find_final_norm(model)
196    input_embeddings = model.get_input_embeddings()
197    output_embeddings = model.get_output_embeddings()
198    embeddings_tied = detect_tied_embeddings(model, strict=strict_tie_check)
199    tie_config = getattr(getattr(model, "config", None), "tie_word_embeddings", None)
200
201    return ResolvedModel(
202        root=model,
203        decoder_layers=decoder_layers,
204        final_norm=final_norm,
205        input_embeddings=input_embeddings,
206        output_embeddings=output_embeddings,
207        embeddings_tied=embeddings_tied,
208        tie_word_embeddings_config=tie_config,
209    )
DECODER_LAYER_PATHS: list[tuple[str, ...]] = [('model', 'layers'), ('transformer', 'h'), ('gpt_neox', 'layers'), ('model', 'decoder', 'layers'), ('layers',)]
FINAL_NORM_PATHS: list[tuple[str, ...]] = [('model', 'norm'), ('transformer', 'ln_f'), ('gpt_neox', 'final_layer_norm'), ('model', 'decoder', 'final_layer_norm')]
WRAPPER_ATTRS: tuple[str, ...] = ('module', '_orig_mod', '_fsdp_wrapped_module')
class ModuleResolutionError(builtins.RuntimeError):
51class ModuleResolutionError(RuntimeError):
52    """Raised when a required module (e.g. decoder layers) cannot be located."""

Raised when a required module (e.g. decoder layers) cannot be located.

class TiedEmbeddingConflictError(builtins.RuntimeError):
55class TiedEmbeddingConflictError(RuntimeError):
56    """Raised when tied input/output embeddings conflict with the caller's
57    stated intent to train them independently."""

Raised when tied input/output embeddings conflict with the caller's stated intent to train them independently.

@dataclass(frozen=True)
class ResolvedModel:
60@dataclass(frozen=True)
61class ResolvedModel:
62    """Bundle of everything resolved from a model, so callers only pay the
63    wrapper-walking / path-guessing cost once."""
64
65    root: torch.nn.Module                    # the model, after unwrapping to the level where layers were found
66    decoder_layers: torch.nn.ModuleList
67    final_norm: torch.nn.Module | None
68    input_embeddings: torch.nn.Module | None
69    output_embeddings: torch.nn.Module | None
70    embeddings_tied: bool
71    tie_word_embeddings_config: bool | None   # raw config value, for reference/debugging

Bundle of everything resolved from a model, so callers only pay the wrapper-walking / path-guessing cost once.

ResolvedModel( root: torch.nn.modules.module.Module, decoder_layers: torch.nn.modules.container.ModuleList, final_norm: torch.nn.modules.module.Module | None, input_embeddings: torch.nn.modules.module.Module | None, output_embeddings: torch.nn.modules.module.Module | None, embeddings_tied: bool, tie_word_embeddings_config: bool | None)
root: torch.nn.modules.module.Module
decoder_layers: torch.nn.modules.container.ModuleList
final_norm: torch.nn.modules.module.Module | None
input_embeddings: torch.nn.modules.module.Module | None
output_embeddings: torch.nn.modules.module.Module | None
embeddings_tied: bool
tie_word_embeddings_config: bool | None
def find_decoder_layers( model: torch.nn.modules.module.Module) -> torch.nn.modules.container.ModuleList:
107def find_decoder_layers(model: torch.nn.Module) -> torch.nn.ModuleList:
108    """Find the decoder layer stack, descending through common wrappers if needed.
109
110    Raises ModuleResolutionError if no known path matches on this model or any
111    of its unwrapped inner modules. If your architecture isn't found, add its
112    attribute path to DECODER_LAYER_PATHS above.
113    """
114    for current in _walk_wrappers(model):
115        for path in DECODER_LAYER_PATHS:
116            obj = _resolve_path(current, path)
117            if obj is not None and hasattr(obj, "__len__"):
118                return obj  # type: ignore[return-value]
119    raise ModuleResolutionError(
120        "Could not locate decoder layers. Checked paths: "
121        f"{DECODER_LAYER_PATHS}. Add this architecture's attribute path to "
122        "DECODER_LAYER_PATHS if it uses a different naming convention."
123    )

Find the decoder layer stack, descending through common wrappers if needed.

Raises ModuleResolutionError if no known path matches on this model or any of its unwrapped inner modules. If your architecture isn't found, add its attribute path to DECODER_LAYER_PATHS above.

def find_final_norm( model: torch.nn.modules.module.Module) -> torch.nn.modules.module.Module | None:
126def find_final_norm(model: torch.nn.Module) -> torch.nn.Module | None:
127    """Best-effort lookup of the final pre-lm_head norm.
128
129    Returns None (rather than raising) if not found — some architectures may
130    genuinely lack one, or use an attribute path not yet in FINAL_NORM_PATHS.
131    Callers should decide whether a missing final norm is fatal for their use case.
132    """
133    for current in _walk_wrappers(model):
134        for path in FINAL_NORM_PATHS:
135            obj = _resolve_path(current, path)
136            if isinstance(obj, torch.nn.Module):
137                return obj
138    return None

Best-effort lookup of the final pre-lm_head norm.

Returns None (rather than raising) if not found — some architectures may genuinely lack one, or use an attribute path not yet in FINAL_NORM_PATHS. Callers should decide whether a missing final norm is fatal for their use case.

def detect_tied_embeddings(model: torch.nn.modules.module.Module, strict: bool = False) -> bool:
141def detect_tied_embeddings(model: torch.nn.Module, strict: bool = False) -> bool:
142    """Detect whether input embeddings and lm_head share the same weight tensor.
143
144    Uses `is` identity on the .weight attribute as the primary signal, since
145    this is exact (not heuristic) for a plainly-constructed, unwrapped model.
146    Cross-checks against config.tie_word_embeddings when available.
147
148    IMPORTANT: call this as early as possible — right after model construction
149    /from_pretrained(), before FSDP, DeepSpeed ZeRO-3, quantization, or PEFT
150    wrapping. Those can partition, flatten, or replace weight tensors such that
151    identity no longer reflects the true tying relationship.
152
153    Args:
154        strict: if True, raise TiedEmbeddingConflictError on a config/identity
155            disagreement instead of warning. Appropriate when you control model
156            construction directly (e.g. generating configs from scratch) and
157            expect config.tie_word_embeddings to be authoritative.
158    """
159    input_emb = model.get_input_embeddings()
160    output_emb = model.get_output_embeddings()
161    if input_emb is None or output_emb is None:
162        return False
163
164    identity_tied = getattr(input_emb, "weight", None) is getattr(output_emb, "weight", None)
165    config_tied = getattr(getattr(model, "config", None), "tie_word_embeddings", None)
166
167    if config_tied is not None and bool(config_tied) != identity_tied:
168        message = (
169            f"Tied-embedding detection disagreement: config.tie_word_embeddings="
170            f"{config_tied} but weight identity check says {identity_tied}."
171        )
172        if strict:
173            raise TiedEmbeddingConflictError(
174                message + " This model was likely constructed without calling "
175                "tie_weights(), or was wrapped (FSDP/DeepSpeed/quantization/PEFT) "
176                "before this check ran."
177            )
178        warnings.warn(
179            message + " Trusting the identity check. If this model went through "
180            "FSDP/DeepSpeed/quantization/PEFT before this check, re-run detection "
181            "earlier in your pipeline.",
182            stacklevel=2,
183        )
184
185    return identity_tied

Detect whether input embeddings and lm_head share the same weight tensor.

Uses is identity on the .weight attribute as the primary signal, since this is exact (not heuristic) for a plainly-constructed, unwrapped model. Cross-checks against config.tie_word_embeddings when available.

IMPORTANT: call this as early as possible — right after model construction /from_pretrained(), before FSDP, DeepSpeed ZeRO-3, quantization, or PEFT wrapping. Those can partition, flatten, or replace weight tensors such that identity no longer reflects the true tying relationship.

Arguments:
  • strict: if True, raise TiedEmbeddingConflictError on a config/identity disagreement instead of warning. Appropriate when you control model construction directly (e.g. generating configs from scratch) and expect config.tie_word_embeddings to be authoritative.
def resolve_model( model: torch.nn.modules.module.Module, strict_tie_check: bool = False) -> ResolvedModel:
188def resolve_model(model: torch.nn.Module, strict_tie_check: bool = False) -> ResolvedModel:
189    """Run all resolution steps once and bundle the results.
190
191    Call this once at setup time (post model-construction, pre distributed/
192    quantization wrapping) and pass the result to downstream consumers (e.g.
193    trainable_parameters.set_trainable_parameters) instead of re-resolving.
194    """
195    decoder_layers = find_decoder_layers(model)
196    final_norm = find_final_norm(model)
197    input_embeddings = model.get_input_embeddings()
198    output_embeddings = model.get_output_embeddings()
199    embeddings_tied = detect_tied_embeddings(model, strict=strict_tie_check)
200    tie_config = getattr(getattr(model, "config", None), "tie_word_embeddings", None)
201
202    return ResolvedModel(
203        root=model,
204        decoder_layers=decoder_layers,
205        final_norm=final_norm,
206        input_embeddings=input_embeddings,
207        output_embeddings=output_embeddings,
208        embeddings_tied=embeddings_tied,
209        tie_word_embeddings_config=tie_config,
210    )

Run all resolution steps once and bundle the results.

Call this once at setup time (post model-construction, pre distributed/ quantization wrapping) and pass the result to downstream consumers (e.g. trainable_parameters.set_trainable_parameters) instead of re-resolving.