Skip to content

fix(checkpoint): resolve tie_word_embeddings top-level-first to match HF tying#2732

Merged
yuhezhang-ai merged 2 commits into
NVIDIA-NeMo:mainfrom
Achyuthan-S:Achyuthan-S/feat/tie-embeddings-reject-guard
Jun 25, 2026
Merged

fix(checkpoint): resolve tie_word_embeddings top-level-first to match HF tying#2732
yuhezhang-ai merged 2 commits into
NVIDIA-NeMo:mainfrom
Achyuthan-S:Achyuthan-S/feat/tie-embeddings-reject-guard

Conversation

@Achyuthan-S

@Achyuthan-S Achyuthan-S commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

First of two PRs for the tied-embedding audit (#2512). This one is the shared resolver plus the is_tied_word_embeddings() swap; the constructor reject-guards across the separate-head model classes will follow in a second PR.

What

Adds get_controlling_tie_word_embeddings(config, model_class_name) and routes is_tied_word_embeddings() through it. The old logic read text_config.tie_word_embeddings first, which disagrees with how HF actually ties lm_head — HF ties on the top-level config flag. Verified by construction under transformers 5.8.1:

  • mistral3_vlm: top-level True / text False → HF builds it tied (old code reported untied)
  • qwen2_5_omni: top-level False / text True → HF builds it untied (old code reported tied)

So the resolver is top-level-first, falling back to text_config only when there's no top-level flag. has_local_tied_lm_head() is unchanged (still the storage-based guard for save filtering).

One thing to confirm

Your sketch returned the top-level flag for all four composite models. I kept Qwen3OmniMoe / Qwen3VLMoe force-untied instead, because following top-level for them flips the existing test_model_state_disables_tied_embeddings_for_non_tied_models guarantee (it keeps lm_head.weight in the saved state dict for those). Their real configs default top-level untied, so it's identical in practice — I just went conservative to preserve that checkpoint safety. Qwen2_5Omni / Mistral3 follow top-level via the general rule, as intended. Happy to switch those two to follow top-level too if you'd prefer — it'd just mean updating that test.

Tests

Updated the resolver / is_tied unit tests to the top-level-first contract and added resolver coverage. Full checkpoint suite stays green.

pytest tests/unit_tests/utils/test_checkpoint_utils.py tests/unit_tests/checkpoint/ -q
# 13 + 208 passed

Resolver semantics (resolved in review)

Per @yuhezhang-ai's review, the resolver returns the actual top-level flag for all composite models, including Qwen3 Omni/VL MoE (no force-untied). Checkpoint save safety stays storage-based via has_local_tied_lm_head() — a config-tied model with a separate lm_head still keeps lm_head.weight on save. Added ModelState tests for both directions (kept when storage is separate; dropped when shared).
(Also bump the test line from # 13 + 208 passed to # 13 + 209 passed.)

Part of #2512 — reject-guard PR to follow.

cc @yuhezhang-ai

… HF tying

Add get_controlling_tie_word_embeddings() and route is_tied_word_embeddings() through it. HF ties lm_head on the top-level config flag, not nested text_config (verified by construction for mistral3_vlm and qwen2_5_omni under transformers 5.8.1). Keep Qwen3OmniMoe/Qwen3VLMoe force-untied to preserve the ModelState save guarantee; has_local_tied_lm_head() is unchanged.

Refs NVIDIA-NeMo#2512

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
@Achyuthan-S Achyuthan-S requested a review from a team as a code owner June 23, 2026 13:09
Copilot AI review requested due to automatic review settings June 23, 2026 13:09
@copy-pr-bot

copy-pr-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates NeMo AutoModel’s checkpoint utilities to resolve tie_word_embeddings using Hugging Face’s effective semantics (top-level config flag controls tying), and adds unit tests to lock in the new contract. This supports the tied-embedding audit (#2512) by making tied/untied detection consistent with HF construction behavior, which affects checkpoint save/load filtering decisions around lm_head.weight.

Changes:

  • Add get_controlling_tie_word_embeddings(config, model_class_name) with top-level-first resolution and a small force-untied exclusion list.
  • Route is_tied_word_embeddings() through the new resolver (replacing the prior text_config-first behavior).
  • Update and extend unit tests to cover the resolver contract and exclusion behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
nemo_automodel/components/checkpoint/utils.py Introduces the shared tie-flag resolver and updates is_tied_word_embeddings() to use top-level-first semantics.
tests/unit_tests/utils/test_checkpoint_utils.py Updates existing tests to the new top-level-first contract and adds resolver-focused coverage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 161 to +165
"""
non_tied_lm_head_models = {
"Qwen3OmniMoeThinkerForConditionalGeneration", # complicated config structure
"Qwen3VLMoeForConditionalGeneration", # top-level lm_head is untied despite nested text config
}
model_class_name = type(model).__name__
for m in non_tied_lm_head_models:
if m in model_class_name:
return False
config = getattr(model, "config", None)
text_config = getattr(config, "get_text_config", lambda: None)()
return bool(getattr(text_config, "tie_word_embeddings", getattr(config, "tie_word_embeddings", False)))
if config is None:
return False
return get_controlling_tie_word_embeddings(config, type(model).__name__)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, though worth noting this is pre-existing (the old code matched on type(model).__name__ too), and the top-level-first change actually improves the wrapped case: old text-first would mis-report these as tied when the exclusion missed, whereas top-level-first returns untied (their real default) regardless of wrapping. The exclusion only adds value for a synthetic top=True. If we keep the force-untied exclusion (the open question above), I'll add wrapper-unwrapping so the class-name match is reliable.

Comment on lines 48 to 52
def test_is_tied_word_embeddings_respects_qwen3_vl_moe_exclusion():
"""Qwen3VLMoe stays force-untied (separate top-level head), ignoring nested text_config=True."""

class DummyTextConfig:
tie_word_embeddings = True

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — if the force-untied exclusion stays (the open question in the description), I'll set the top-level flag to True here so the test actually guards the exclusion rather than passing via the general rule. Holding until we settle whether these two follow top-level or stay force-untied.

@yuhezhang-ai

Copy link
Copy Markdown
Contributor

@Achyuthan-S Thanks for the PR!

I’m okay with the resolver-first approach, but I don’t think we should merge this as-is.

Two points:

  1. I’d like the resolver to represent the actual controlling flag, even for Qwen3 Omni/VL MoE. So for those models it should still return the top-level tie_word_embeddings value, not force-return False. The checkpoint save safety should continue to come from has_local_tied_lm_head() being storage-based. Otherwise the follow-up constructor guards won’t be able to detect and reject the unsupported top_level=True case.

  2. Since this PR is mostly shared plumbing, I’d like either a small amount of standalone coverage showing the checkpoint behavior it fixes now, or we can just fold this into the reject-guard PR. I don’t want to merge a resolver-only change whose main consumer is still future work unless the contract and immediate behavior are clearly pinned down.

So my preference is: update this PR to follow the top-level flag for the Qwen3 cases and add/adjust tests around the actual checkpoint behavior; then we can keep the broader audit/reject guards in a second PR. Otherwise, please combine this with the guard work so we review the end-to-end behavior together.

…eview

Per review: resolver returns the actual top-level flag for Qwen3 Omni/VL MoE instead of forcing untied, so the constructor guard can detect/reject unsupported top-level=True. Save safety stays storage-based via has_local_tied_lm_head(). Adds ModelState coverage for the storage-gated save behavior.

Refs NVIDIA-NeMo#2512

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
@Achyuthan-S

Copy link
Copy Markdown
Contributor Author

Hi @yuhezhang-ai , updated per your review: Qwen3 Omni/VL MoE now return the top-level flag (force-untied removed). Added two ModelState tests — config-tied but separate storage keeps lm_head.weight; shared storage drops it. Guards still planned as PR 2.

@yuhezhang-ai

Copy link
Copy Markdown
Contributor

/ok to test f45cbee

@Achyuthan-S

Copy link
Copy Markdown
Contributor Author

Hey @yuhezhang-ai — thanks for the review. CI is green on f45cbee. Let me know if anything else is needed from my side before merge; constructor guards will follow as PR 2 for #2512.

@yuhezhang-ai yuhezhang-ai merged commit 27f9c34 into NVIDIA-NeMo:main Jun 25, 2026
70 checks passed
yuhezhang-ai pushed a commit that referenced this pull request Jul 1, 2026
…families (#2805)

* feat(models): reject tie_word_embeddings=True on separate-head model families

Add reject_unsupported_tied_word_embeddings() (built on the #2732 resolver) and wire it into the __init__ of the 25 verified untied-default model classes, so setting tie_word_embeddings=True raises a clear error instead of a silently-untied head. Excludes mistral3_vlm (HF default tied) and step3p5/step3p7/nemotron_omni (pending hub verification).

Refs #2512

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>

* fix(models): run tie guard on top-level config before unwrap/super (review)

Per review: in composite models the guard was reading the unwrapped text_config/thinker_config, which could miss a top-level tie_word_embeddings=True. Run it on the original config before any unwrap or super().__init__(), which also fails fast. Refs #2512

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>

* fix(checkpoint): resolve Omni wrapper tie flag from thinker_config (review)

Qwen2_5OmniConfig/Qwen3OmniMoeConfig don't expose tie_word_embeddings at the top level; the controlling flag is on config.thinker_config. Unwrap to it in the resolver so the constructor guard catches a tied request via the full wrapper config. Add wrapper-path unit coverage, clarify the resolver docstring, and fix the copyright year to 2026. Refs #2512

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>

---------

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants