Skip to content

fix: provide lm_head.weight for tied VLM decoders in preprocess_weights - #295

Merged
justinchuby merged 7 commits into
mainfrom
fix-vlm-tied-weights
May 9, 2026
Merged

fix: provide lm_head.weight for tied VLM decoders in preprocess_weights#295
justinchuby merged 7 commits into
mainfrom
fix-vlm-tied-weights

Conversation

@justinchuby

@justinchuby justinchuby commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

Fix VLM decoder weight loading for models with tie_word_embeddings=True by using the shared tie_word_embeddings() utility.

Problem

When a VLM model (e.g. Qwen-VL, InternVL) has tie_word_embeddings=True, HuggingFace stores only the embedding weight in the checkpoint — lm_head.weight is absent. The previous VLM decoder preprocess_weights code discarded any weight not under the language_model. prefix, so lm_head.weight was never populated. The ONNX model then had an uninitialized LM head, producing garbage logits.

Fix

Use tie_word_embeddings() from _weight_utils.py after stripping the language_model. prefix. This copies the embedding tensor reference to the lm_head.weight key when it is missing.

How ONNX Initializer Sharing Works End-to-End

The weight-sharing mechanism spans three layers:

  1. tie_word_embeddings(state_dict) — When lm_head.weight is missing, assigns state_dict["lm_head.weight"] = state_dict["model.embed_tokens.weight"]. Both keys now point to the same Python tensor object (same data_ptr()).

  2. apply_weights(model, state_dict) in _weight_loading.py — Iterates over the state dict and assigns tensors to ONNX initializers. It tracks tensor.data_ptr() to detect shared storage. When it encounters lm_head.weight and sees the same data_ptr() as the already-assigned model.embed_tokens.weight, it:

    • Calls initializer.replace_all_uses_with(canonical) to redirect all graph uses
    • Deletes the duplicate initializer from model.graph.initializers
  3. Result — The saved ONNX file contains a single copy of the embedding table, used by both the Gather (embedding lookup) and MatMul (LM head projection) nodes. No duplication.

Changes

  • src/mobius/models/qwen_vl.py: Use tie_word_embeddings() in preprocess_weights
  • src/mobius/_weight_utils.py: Enhanced docstring explaining the full sharing mechanism

When tie_word_embeddings=True, the Qwen VL 3-model split decoder models
have lm_head.weight and embed_tokens.weight aliased to the same
nn.Parameter. The ONNX graph creates initializer nodes for both names,
and the weight loader requires data for all initializers.

Previously, preprocess_weights discarded lm_head.weight with the comment
'tied at graph level; no separate entry needed'. This was incorrect:
while the graph correctly shares the tensor, the weight loading
validation (in _check_weights) requires every initializer to have data.

Fix: copy embed_tokens.weight to lm_head.weight in preprocess_weights
when tie_word_embeddings=True, matching the pattern used by CausalLMModel
and Gemma4's VLM decoder.

Affected classes:
- Qwen25VLCausalLMModel (Qwen2.5-VL 3-model composite)
- Qwen25VLDecoderModel (Qwen2.5-VL standalone decoder)
- Qwen3VL3ModelCausalLMModel (Qwen3-VL 3-model composite)
- Qwen3VLDecoderModel (Qwen3-VL standalone decoder)

Tested:
- L1: all Qwen VL graph build tests pass (91 passed, 0 VL failures)
- L5: Qwen3-VL-4B-Instruct f16 ort-genai export succeeds (7.6G decoder + 742M embedding + 638M vision)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing ba2b83ee98b8f3

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 53 53 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 61 61 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 59 59 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 61 61 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 408 408 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing ba2b83ee98b8f3

Model Sub-model Changes Status
qwen model 0
qwen (static-cache) model 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.64706% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mobius/models/qwen_vl.py 71.42% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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 pull request fixes weight loading for Qwen2.5-VL and Qwen3-VL 3-model (ORT GenAI) builds when tie_word_embeddings=True by ensuring the decoder’s lm_head.weight initializer receives data even when the checkpoint only provides embed_tokens.weight.

Changes:

  • In the 3-model composite preprocessors, copy the token embedding weight into decoder.lm_head.weight when embeddings are tied.
  • In standalone decoder preprocessors, stop discarding lm_head.weight for tied embeddings and instead synthesize it from embed_tokens.weight when missing.

Comment thread src/mobius/models/qwen_vl.py Outdated
@justinchuby

Copy link
Copy Markdown
Member Author

I don't think we should copy them. The weights should be shared

justinchuby and others added 2 commits May 8, 2026 20:42
Replace inline lm_head.weight handling with the tie_word_embeddings()
utility from _weight_utils.py. This ensures the same tensor object is
referenced by both embed_tokens.weight and lm_head.weight keys in the
state dict, allowing apply_weights' id()-based deduplication to unify
them into a single ONNX initializer.

For 3-model split composites (Qwen25VLCausalLMModel, Qwen3VL3Model),
the embed/lm_head keys use 'decoder.' prefix so we pass custom key
names to tie_word_embeddings().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
@justinchuby
justinchuby force-pushed the fix-vlm-tied-weights branch from 7eaaa98 to 57447ec Compare May 8, 2026 21:11
@justinchuby
justinchuby requested review from Copilot and titaiwangms May 8, 2026 22:17
@justinchuby
justinchuby enabled auto-merge (squash) May 8, 2026 22:17
@justinchuby
justinchuby disabled auto-merge May 8, 2026 22:18

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

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Document how the Python object identity created by tie_word_embeddings
flows through apply_weights (data_ptr()-based dedup) to produce a
single ONNX initializer shared by both Gather and MatMul nodes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
@justinchuby
justinchuby enabled auto-merge (squash) May 8, 2026 22:27

@titaiwangms titaiwangms 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.

Review summary

Direction is right and existing tests pass cleanly (2736 unit tests in 32s, 115 qwen tests), but adversarial trace turned up one path that's still silently broken, plus a coverage gap that lets it slip through the suite.

Major

  1. Qwen3VLDecoderModel.preprocess_weights() is silently still broken. The rename loop (qwen_vl.py:843-852) strips both model. and language_model. unconditionally, so for an HF key like model.language_model.embed_tokens.weight the dict ends up with embed_tokens.weight (no model. prefix). The new call tie_word_embeddings(renamed) uses defaults embed_key="model.embed_tokens.weight", head_key="lm_head.weight" — neither is present, so the helper silently no-ops and lm_head.weight is never created. The standalone Qwen3-VL decoder build with tied weights will hit the same ValueError: Component 'decoder' has 1 initializer(s) without weights: 'decoder.lm_head.weight' this PR is trying to fix.
    Either:

    tie_word_embeddings(renamed, embed_key="embed_tokens.weight", head_key="lm_head.weight")

    or rework the rename so model.embed_tokens.weight lands in renamed (so it matches the standalone ONNX initializer name and keeps default-args semantics consistent with Qwen25VLDecoderModel).

  2. No regression test for any of the four fixed paths. The full suite passes, but:

    • No test invokes preprocess_weights on Qwen25VLCausalLMModel, Qwen25VLDecoderModel, Qwen3VL3ModelCausalLMModel, or Qwen3VLDecoderModel.
    • All qwen-VL graph-build tests use tie_word_embeddings=False.
    • Graph-build tests don't call preprocess_weights at all — the failure surfaces at apply_weights time.
    • The only qwen-VL preprocess_weights test (test_qwen35_vl_preprocess_weights_model_prefix) is for a different class in qwen35.py and doesn't set tying.
      Add ~4 cheap co-located tests in src/mobius/models/qwen_vl_test.py with a tiny fake state dict and tie_word_embeddings=True, asserting decoder.lm_head.weight in renamed and data_ptr() identity with the embed. These would have caught both the original bug and bug #1 above.
  3. Missing "why" comment at the composite tie sites. The deleted comment was wrong; nothing replaced it. The non-obvious reason — onnxscript qualifies parameter names by module path, so the __init__-time alias decoder.lm_head.weight = decoder.model.embed_tokens.weight doesn't cross composite module boundaries — is the entire reason this PR exists. Without a comment, the next person will re-delete the block. Suggested:

    # onnxscript qualifies params by module path, so the in-tree alias set in
    # Qwen25VLDecoderModel.__init__ does not cross composite boundaries. Establish
    # the identity here so apply_weights sees a single data_ptr() across both
    # initializers.
    if self.config.tie_word_embeddings:
        tie_word_embeddings(renamed, embed_key=..., head_key=...)

Minor

  1. _weight_utils.tie_word_embeddings silent no-op masks routing bugs. When tie=True and both configured keys are absent, the helper does nothing — which is exactly what surfaces as bug #1. Recommend: raise (or at least log a clear warning naming both keys) when neither key is present and the caller said tie=True. Tightly coupled to this PR; worth rolling in.

  2. _weight_utils.tie_word_embeddings docstring says "copies"; the implementation is identity assignment. state_dict[head_key] = state_dict[embed_key] is a Python reference assignment, same object, same data_ptr(). The PR's correctness depends on identity, not copy. The next maintainer reading "copies" will reasonably "fix" it to clone() and silently break the tying. Update the docstring to say something like "assigning it to the same Python tensor object so downstream consumers see one data_ptr()." (Note: the diff/branch does not currently change _weight_utils.py — please ship the docstring fix with this PR.)

  3. Composite preprocess drops lm_head.* before the helper runs. In Qwen25VLCausalLMModel:126-128 and Qwen3VL3ModelCausalLMModel:788-790, when tie=True the loop skips lm_head.* keys entirely. If a (non-standard but legal) checkpoint stored only lm_head.weight and not the embed, that value is dropped before tie_word_embeddings could backfill the embed. Defense-in-depth: keep lm_head.* in renamed regardless and let the helper normalize.

  4. Qwen2VLCausalLMModel.preprocess_weights (line 414) not migrated. After this PR the file has three different idioms for the same concept:

    • inline assignment (Qwen2VL)
    • post-loop helper with explicit keys (composite Qwen2.5/3-VL)
    • post-loop helper with defaults (standalone Qwen2.5/3-VL decoders)
      Plus the legitimately-different .pop() in single-model Qwen3VLCausalLMModel. Migrate Qwen2VL to the helper now, or add a # TODO: align with Qwen2.5/Qwen3 pattern comment so the asymmetry is flagged rather than invisible.
  5. Stale comment at Qwen3VLCausalLMModel:718. The wording # lm_head.weight is tied at graph level; discard any separate checkpoint entry. is word-for-word the comment this PR deletes as wrong elsewhere. For this single-model class it's accurate (in-tree alias survives onnxscript), but the wording reads ambiguously after this PR. Tighten to make the single-model-vs-composite distinction explicit, e.g. "Single-model build: onnxscript preserves the in-tree alias, so the lm_head checkpoint entry is redundant — pop it."

Nit

  1. embed_key/head_key strings ("decoder.model.embed_tokens.weight" / "decoder.lm_head.weight") duplicated in two composites — module-level constants if a third composite lands.
  2. Qwen3VLDecoderModel.preprocess_weights docstring is one line; sister Qwen25VLDecoderModel has a four-line docstring covering HF key format and standalone-vs-composite distinction. Match the level.
  3. # Handle weight tying at qwen_vl.py:191 replaces a wrong-but-explicit comment with a content-free one. One line: "HF checkpoint omits lm_head.weight when tied; fill it in for the initializer."

Praise

  • Right call using the existing helper instead of growing a new pattern.
  • Both composite paths and both standalone-decoder paths get the same treatment in one PR — good symmetry (modulo bug #1).
  • Commit messages name the wrong assumption, the failure mode, and the L1/L5 verification — exactly the right context for a non-obvious weight-loading fix.

QA / test run

  • python -m pytest tests/build_graph_test.py tests/cli_test.py src/ -q -k "not phi4mm and not apply_weights_unknown" --tb=short -n auto2736 passed, 41 skipped, 0 failed in 32.4s.
  • Qwen-only subset → 115 passed, 24 skipped, 0 failed in 12.6s.
  • Lint cannot run in the test env: ruff 0.12.12 installed, pyproject.toml:133 references RUF067 requiring ruff 0.15.11. Pre-existing environment mismatch, not caused by this PR.

Net: the fix's intent is correct and the existing 3-model composite paths trace cleanly, but bug #1 (Qwen3VLDecoderModel) and the absent regression coverage mean this still slips the same class of failure. Worth one more pass.

justinchuby and others added 3 commits May 8, 2026 23:38
- Fix Qwen3VLDecoderModel tie_word_embeddings key mismatch: after
  stripping language_model., keys are embed_tokens.weight not
  model.embed_tokens.weight. Pass explicit embed_key/head_key.
- Add 8 regression tests (qwen_vl_test.py) verifying lm_head.weight
  presence and data_ptr() identity for all 4 model classes.
- Add warning to tie_word_embeddings when both keys are absent.
- Add 'why' comments explaining onnxscript module boundary limitation
  at all tie_word_embeddings call sites.
- Tighten Qwen3VLCausalLMModel comment to explain single-model-vs-
  composite distinction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Fail explicitly instead of silently doing nothing when both
embed_key and head_key are absent from state_dict. This catches
key name mismatches early (e.g. after prefix stripping).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
@justinchuby
justinchuby requested a review from titaiwangms May 9, 2026 01:42
@justinchuby
justinchuby disabled auto-merge May 9, 2026 02:00
@justinchuby
justinchuby merged commit 03f524f into main May 9, 2026
21 of 23 checks passed
@justinchuby
justinchuby deleted the fix-vlm-tied-weights branch May 9, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants