Skip to content

Fix Qwen3.6 converted RMSNorm double shift - #1623

Merged
zcbenz merged 1 commit into
ml-explore:mainfrom
PhilipJohnBasile:agent/qwen36-norm-sanitize
Aug 18, 2026
Merged

zcbenz merged 1 commit into
ml-explore:mainfrom
PhilipJohnBasile:agent/qwen36-norm-sanitize

Conversation

@PhilipJohnBasile

Copy link
Copy Markdown
Contributor

Summary

  • use the raw Qwen Conv1D tensor layout as the structural signal that RMSNorm weights still need the MLX +1 conversion
  • stop treating the presence of MTP tensors as evidence that norm weights are zero-centered
  • cover dense and MoE Qwen3.5/3.6 conversion, including already-converted checkpoints that still contain MTP tensors

Root cause

MTP keys are not a reliable checkpoint-format signal. An already-converted VLM/MTP checkpoint can contain MTP tensors while its Conv1D and RMSNorm weights are already in MLX layout. The old predicate shifted every norm by +1 again, producing incoherent generation.

This implements the maintainer feedback on #1198 without adding a value-distribution heuristic. It also addresses the fresh tensor-level reproduction in #1197.

Validation

  • focused dense and MoE regression: passed
  • black --check mlx_lm/models/qwen3_5.py tests/test_models.py: passed
  • git diff --check: passed
  • full tests.test_models: 75 passed, 1 skipped; the sole failure is the existing, reproducible test_ssm numerical comparison in untouched code

Local checkpoint inspection also confirmed the intended raw-conversion case: Qwen3.6 base RMSNorm mean -0.0238, with Conv1D shape (10240, 1, 4), so the raw checkpoint still receives the required +1 conversion.

@PhilipJohnBasile
PhilipJohnBasile force-pushed the agent/qwen36-norm-sanitize branch from 32b3447 to df65dcc Compare August 12, 2026 02:56
@PhilipJohnBasile

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (254d153). Verified the bug is still live on main and the fix resolves it:

  • New regression test test_qwen3_5_family_convert_then_load_norm_not_shift_twice fails on main (already-converted checkpoint that merely contains mtp.* tensors gets its RMSNorm weights +1-shifted a second time — old should_shift_norm_weights = has_mtp_weights or has_unsanitized_conv1d gate) and passes with this patch (gate on the raw conv1d layout alone, which is the reliable converted-GGUF signal).
  • mlx_lm/models/qwen3_5.py on main still carries the buggy gate; no upstream change has addressed it.

Ready for CI + review.

@avlp12

avlp12 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

I verified this patch independently on a different member of the family and it fixes that case too.

My model is Qwen/Qwen3.8-27B — dense qwen3_5, 64 layers, hybrid GatedDeltaNet/full-attention — converted to 4-bit with the MTP head preserved. I applied only the two predicate lines from this PR to an otherwise stock install and re-ran teacher-forced NLL on a fixed Korean passage with identical weights and prompt:

mlx-lm NLL
stock 17.460
with this patch 1.679

17.46 is worse than uniform over a 248k vocab, so the logits are actively wrong rather than degraded. The value reproduces to three decimals across runs, and mlx_lm.generate is fluent once patched. The patched number matches what I get from my own fork, which had arrived at the same predicate change.

Two things worth adding from my side:

The reports so far are on 35B MoE checkpoints; this one is dense, so the fix is confirmed on both branches.

The bug is invisible unless a checkpoint carries an MTP head — mine without one measured correctly throughout. That made it read as "the MTP-preserving build is broken" rather than "the loader is", and I spent a while suspecting my own quantization before finding it here. Worth keeping in mind when triaging similar reports.

One gap I would suggest closing: the existing regression re-sanitizes weights whose MTP keys were already stripped, so it cannot exercise the case users actually hit — a converted checkpoint that still contains MTP tensors at load time. I added that assertion in #1735, which is otherwise the same fix as this PR. Reverting the predicate makes it fail. Feel free to lift it into this PR; I would rather see this one merged than mine.

@zcbenz zcbenz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks!

@zcbenz
zcbenz merged commit 4eeaf20 into ml-explore:main Aug 18, 2026
2 checks passed
PhilipJohnBasile added a commit to PhilipJohnBasile/mlx-lm that referenced this pull request Aug 18, 2026
Rebase the native Qwen MTP implementation onto upstream main after ml-explore#1623, retaining the merged RMSNorm conversion fix and stronger upstream regression coverage.

Signed-off-by: Philip John Basile <[email protected]>
danielhanchen added a commit to unslothai/unsloth-zoo that referenced this pull request Oct 29, 2026
… mlx-lm 0.31 loads (#2210)

mlx-lm 0.31.3's Qwen3.5 / Qwen3.6 `TextModel.sanitize` decides whether
to add `+1` to the RMSNorm weights with `has_mtp_weights or
has_unsanitized_conv1d`. A checkpoint that was already converted to MLX
layout but still carries its MTP head (`mtp.*` or `language_model.mtp.*`
tensors, as in MLX conversions such as
`Jundot/Qwen3.6-35B-A3B-oQ4e-mtp`) therefore gets the shift a second
time. Every norm loads at about twice its value, and generation is
garbage from the first token.

mlx-lm fixed this in ml-explore/mlx-lm#1623 (released in 0.32.0) by
deciding on the raw Conv1D layout alone; see also
ml-explore/mlx-lm#1197. unsloth-zoo still accepts `mlx-lm>=0.31.3`, so
installs on 0.31.x keep the bug.

## Which Unsloth paths hit it

Measured with real cached checkpoints plus an injected `mtp.*` shard, on
mlx-lm 0.31.3:

| load path | Qwen3.5-0.8B-bf16 | Qwen3.6-35B-A3B-8bit |
|---|---|---|
| `mlx_lm.load` | norms +1.000, garbage | norms +1.000, garbage |
| `FastMLXModel.from_pretrained` (`text_only` True or False) | correct
(routed to mlx-vlm, which strips `mtp.*` first) | correct |
| `unsloth_zoo.mlx.distill.load_teacher` (GKD teacher, plain
`mlx_lm.load`) | **norms +1.000, garbage** | **norms +1.000, garbage** |

So the user-facing exposure is distillation with such a teacher, plus
any `FastMLXModel` load that reaches the mlx-lm text branch.

## Change

`_ensure_qwen3_5_mtp_norm_sanitize()` wraps
`mlx_lm.models.qwen3_5.TextModel.sanitize` so that `mtp.*` keys are
dropped before the original runs. The original drops them anyway, so the
only behavior that changes is the shift decision, which then matches
mlx-lm 0.32.0. `qwen3_5_moe` delegates to the same `TextModel.sanitize`,
so it is covered too.

- The patch is installed from `load_teacher` and from `FastMLXModel`'s
mlx-lm branch, next to `_ensure_safe_text_wrapper_sanitize`.
- It applies only while the installed sanitize still keys on MTP
weights; on mlx-lm 0.32.x it is a no-op. It is also idempotent.

## Validation

```text
pytest tests/test_mlx_distributed_loader.py tests/test_mlx_model_file_consent.py   # 27 passed
```

- Tests cover the behavior through `load_teacher` for both converted and
raw Conv1D layouts (raw checkpoints still get the shift), using
stand-ins carrying the 0.31.3 and 0.32.0 sanitize bodies. The
`FastMLXModel` text-load test asserts the install.
- A mutation check caught 5 of 5 deliberate breakages: filter removed,
source gate removed, idempotency removed, and each call site removed.
- Real runtime on mlx-lm 0.31.3: with the patch, `load_teacher` loads
norms equal to the checkpoint and answers "Paris" on both models above,
while plain `mlx_lm.load` without Unsloth still shows +1.000 and
garbage.
- On mlx-lm 0.32.2 the gate sees the fixed sanitize and leaves it
untouched.

---------

Co-authored-by: Daniel Han <[email protected]>
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