Skip to content

fix(qwen3_5): load quantized lm_head, not just BF16 (#164) - #169

Merged
localai-bot merged 1 commit into
mainfrom
row/MODEL-QWEN35-LMHEAD-DTYPES
Aug 8, 2026
Merged

fix(qwen3_5): load quantized lm_head, not just BF16 (#164)#169
localai-bot merged 1 commit into
mainfrom
row/MODEL-QWEN35-LMHEAD-DTYPES

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Fixes #164, reported by @rohitpaul. Their diagnosis was right; this adds the part they could not see from outside.

Our benchmarks were not measured on a broken checkpoint. The repo was re-quantized under us.

Reading the safetensors headers of both snapshots we hold on the lab box:

revision lm_head.weight scale
unsloth/Qwen3.6-27B-NVFP4 @890bdef7 BF16 [248320, 5120] none
unsloth/Qwen3.6-27B-NVFP4 @ccdaab7e F8_E4M3 [248320, 5120] weight_scale BF16 [248320, 1]

@890bdef7 is the snapshot every recorded 27B-NVFP4 number ran on, which is why an unconditional BF16 head survived this long: the transformer body has had real compressed-tensors NVFP4 support all along, the head simply was never quantized under us. The reporter used @ccdaab7e. nvidia/Qwen3.6-27B-NVFP4 ships a third form (ModelOpt NVFP4). So the support claim was silently revision-locked — that is the actual defect.

The fix

LoadLmHeadAnyDtype dispatches on the stored dtype:

stored companions handling
BF16 none transpose (unchanged call, byte-exact)
F8_E4M3 weight_scale per-output-channel [V,1] or per-tensor dequant + transpose
U8 NVFP4 weight_scale F8 [V,H/16] + weight_scale_2 (ModelOpt) or weight_global_scale (CT) dequant + transpose

Two details worth flagging:

  • The scale in @ccdaab7e is per-output-channel, not per-tensor as the issue guessed. Reusing the existing scalar-scale FP8 loader would have been quietly wrong rather than loud, which is why this gets its own path and its own test.
  • ModelOpt stores the global scale directly; compressed-tensors stores the reciprocal and DequantCtNvfp4WeightToF32 reciprocates internally. Both spellings are accepted and normalized to the CT divisor convention so the one shared dequant computes each exactly.

All three land on the same bf16 [in, out] Matmul-B operand the logits GEMM already consumes, so the forward is untouched. An unsupported dtype now names what it saw instead of claiming BF16 was expected.

Scope note

Keeping the head quantized end-to-end would save ~2.3 GiB over dequantizing to bf16, but needs an lm_head_fp4-style field plus a forward branch. That is a follow-up. This restores loadability at the memory profile we already benchmark.

Verification

  • New tests/vllm/models/test_qwen3_5_lm_head_dtypes.cpp: 5 cases / 32 assertions, synthetic tensors only (no checkpoint, no GPU, runs in CI) — BF16 unchanged, FP8 per-output-channel, FP8 per-tensor, unsupported dtype names itself, FP8 missing its scale fails loudly.
  • Clean CPU build; full scripts/check-*.py battery green.
  • Real-checkpoint load verification against all three heads (both unsloth revisions + the NVIDIA one, currently downloading) is running on the GB10 box and will be posted here before merge.

Docs: BENCHMARKS.md now pins the revision the 27B NVFP4 grid actually ran on, USAGE.md gains a table of accepted head forms, FEATURES.md/STATUS.md updated.

Reported by rohitpaul: both public Qwen3.6-27B NVFP4 checkpoints die at load
with "dense loader: expected BF16 for lm_head.weight". Confirmed in-tree and,
more usefully, confirmed WHY.

Our benchmarks are not wrong, and the checkpoint is not wrong: the repo was
re-quantized under us. Reading the safetensors headers of both snapshots we
hold on the lab box:

  unsloth/Qwen3.6-27B-NVFP4 @890bdef7  lm_head.weight BF16    [248320, 5120]
  unsloth/Qwen3.6-27B-NVFP4 @ccdaab7e  lm_head.weight F8_E4M3 [248320, 5120]
                                       lm_head.weight_scale BF16 [248320, 1]

@890bdef7 is the snapshot every recorded 27B-NVFP4 number ran on, which is why
an unconditional BF16 head survived this long: the body has had real NVFP4
support all along, the head was simply never quantized under us. The reporter
used @ccdaab7e. nvidia/Qwen3.6-27B-NVFP4 ships a third form, a ModelOpt NVFP4
head. So the support claim was silently revision-locked.

LoadLmHeadAnyDtype now dispatches on the stored dtype:

  BF16     weight [V,H]                                   -> transpose (unchanged)
  F8_E4M3  weight [V,H] + weight_scale [V,1] or scalar    -> per-row/per-tensor
  U8       weight [V,H/2] + weight_scale F8 [V,H/16]
                          + weight_scale_2 | weight_global_scale -> nvfp4

Note the scale is PER-OUTPUT-CHANNEL in @ccdaab7e, not per-tensor as the issue
guessed, so the existing scalar-scale FP8 loader would have been quietly wrong
rather than loud. All three forms land on the same bf16 [in, out] Matmul-B
operand the logits GEMM already consumes, so the forward is untouched and a
BF16 head takes the identical call as before -- byte-exact, no dequant.

ModelOpt spells the global scale weight_scale_2 and stores the scale itself;
compressed-tensors spells it weight_global_scale and stores the reciprocal.
Both are accepted, converted to the CT divisor convention so the shared
DequantCtNvfp4WeightToF32 computes each exactly.

An unsupported dtype now names what it saw and what is accepted, instead of
claiming BF16 was expected.

Keeping the head QUANTIZED end to end would save ~2.3 GiB over dequantizing to
bf16; that needs an lm_head_fp4-style field plus a forward branch and is a
follow-up, not this fix. This restores loadability at the memory profile we
already benchmark.

Test: tests/vllm/models/test_qwen3_5_lm_head_dtypes.cpp, 5 cases / 32
assertions, synthetic tensors only (no checkpoint, no GPU): BF16 unchanged,
FP8 per-output-channel, FP8 per-tensor, unsupported dtype names itself, and
FP8 missing its scale fails loudly.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Opus 5 (1M context)
@mudler
mudler force-pushed the row/MODEL-QWEN35-LMHEAD-DTYPES branch from 619aba4 to 6f3a3d0 Compare August 8, 2026 22:24
@localai-bot
localai-bot merged commit 994cd8d into main Aug 8, 2026
10 of 11 checks passed
mudler added a commit that referenced this pull request Aug 8, 2026
…llow-up)

`nvidia/Qwen3.6-27B-NVFP4` and `unsloth/Qwen3.6-27B-NVFP4` @ccdaab7e both failed
to load. #169 fixed the reported symptom, the BF16-only `lm_head`; running it
against the real checkpoints then exposed two more layers behind it. All three
are the same root cause: the dense loader assumed BF16 wherever the
compressed-tensors NVFP4 probe did not match.

Layer 2, FP8 tower tensors. Both publishers quantize parts of the tower to FP8
while leaving the rest BF16, and they disagree on which parts and on the scale
layout: nvidia ships FP8 `linear_attn` in_proj_qkv/in_proj_z/out_proj with a
per-tensor F32 scale, unsloth @ccdaab7e went FP8 across the whole tower with a
per-output-channel BF16 scale. Rather than teach each loader its own rules, one
`MaterializeBf16Source` now sits under LoadBf16Direct, LoadBf16Transposed and
LoadMergedBf16RawNK: BF16 stays zero-copy on the mmap (unchanged), FP8 is
dequantized to BF16. Reading a per-channel scale as per-tensor would be silently
WRONG rather than loud, so the element count decides and anything else is
rejected.

Layer 3, ModelOpt NVFP4 naming. Our probe was `has(<proj>.weight_packed)`, which
is compressed-tensors only. nvidia's checkpoint is ModelOpt: `<proj>.weight` U8
+ `.weight_scale` F8 + `.weight_scale_2` F32. Every probe missed, so a genuinely
NVFP4 tower fell through to the BF16 path. `IsNvfp4Projection` now accepts both
spellings and `LoadNvfp4AnyNaming` reads either, converting ModelOpt's direct
scale to the CT divisor convention the shared dequant expects (the same
conversion #169 made for lm_head).

W4A16 is the default for ModelOpt. These checkpoints carry a per-tensor
`input_scale`; consuming it sets `alpha`, flips `IsTrueW4A4()` and routes to the
fp4-activation GEMM, which produced incoherent output on nvidia's checkpoint.
Leaving `alpha` at 0 takes the weight-only dispatcher and generates correctly.
`VT_MODELOPT_W4A4=1` restores the other arm for A/B.

Verified on dgx GB10 (sm_121) against the real 21 GB `nvidia/Qwen3.6-27B-NVFP4`:
before, "expected BF16 for ...in_proj_qkv.weight"; after, greedy "The capital of
France is" -> " Paris." Clean CUDA build; clean CPU build.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [ClaudeCode]
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.

Qwen3.6-27B NVFP4 checkpoints fail because dense loader requires BF16 lm_head

2 participants