Repository navigation
Description
What happens
Loading a compressed-tensors MoE checkpoint whose routed experts are stored per expert dequantizes them without their per-tensor weight_global_scale when the scheme is NVFP4 (tensor_group), so the experts come back scaled by a wrong constant — no exception, and the fused parameter is filled so missing_keys stays empty.
huggingface/transformers@323f24345ed92f88e68a14aeef7db78ee2b52475 (5.16.0.dev0)
compressed-tensors==0.18.0, torch==2.13.0+cpu, python 3.11.15, Linux x86_64
Reproduction
Standalone — compressed_tensors + transformers only, no checkpoint download. It drives DecompressExperts.convert with exactly the tensors update_weight_conversions collects today, and with the ones the checkpoint actually stores.
"""Standalone repro: NVFP4 MoE experts are dequantized without weight_global_scale."""
import types
import torch
from compressed_tensors.compressors import BaseCompressor
from compressed_tensors.compressors.format import infer_module_format
from compressed_tensors.quantization import (
QuantizationArgs, QuantizationScheme, QuantizationStrategy, QuantizationType)
from compressed_tensors.quantization.utils.helpers import calculate_qparams, generate_gparam
from transformers.integrations.compressed_tensors import DecompressExperts
GROUP = 16
args = QuantizationArgs(num_bits=4, type=QuantizationType.FLOAT, group_size=GROUP,
strategy=QuantizationStrategy.TENSOR_GROUP, symmetric=True)
scheme = QuantizationScheme(targets=["Linear"], weights=args)
compressor = BaseCompressor.get_value_from_registry(infer_module_format(torch.nn.Linear, scheme))
torch.manual_seed(0)
packed, scales, shapes, global_scales, reference = [], [], [], [], []
for expert in range(3): # differing magnitudes -> differing global scales
w = (torch.randn(32, 64) * (expert + 1) * 3.0).to(torch.bfloat16)
gs = generate_gparam(w.min(), w.max())
grouped = w.reshape(w.shape[0], -1, GROUP)
sc, _ = calculate_qparams(grouped.min(dim=-1).values, grouped.max(dim=-1).values,
args, global_scale=gs)
c = compressor.compress({"weight": w, "weight_scale": sc, "weight_global_scale": gs}, scheme)
packed.append(c["weight_packed"]); scales.append(c["weight_scale"])
global_scales.append(c["weight_global_scale"])
shapes.append(torch.tensor(list(w.shape))); reference.append(w.float())
quantizer = types.SimpleNamespace(compressor=types.SimpleNamespace(
quantization_config=types.SimpleNamespace(config_groups={"group_0": scheme})))
op = DecompressExperts(quantizer, scheme=scheme)
# What `update_weight_conversions` collects today: packed / scale / shape. No global scale.
today = op.convert({"experts.gate_proj.weight_packed": packed,
"experts.gate_proj.weight_scale": scales,
"experts.gate_proj.weight_shape": shapes}, [], [])
# What the checkpoint actually stores.
faithful = op.convert({"experts.gate_proj.weight_packed": packed,
"experts.gate_proj.weight_scale": scales,
"experts.gate_proj.weight_shape": shapes,
"experts.gate_proj.weight_global_scale": global_scales}, [], [])
ref = torch.stack(reference)
t = today["experts.gate_proj.weight_packed"].float()
f = faithful["experts.gate_proj.weight_packed"].float()
rel = lambda a, b: (a - b).norm().item() / b.norm().item()
print("per-expert global scales :", [round(g.item(), 1) for g in global_scales])
print(f"relative L2 vs reference : today={rel(t, ref):9.3f} with the global scale={rel(f, ref):.3f}")
print(f"max |weight| : today={t.abs().max():9.2f} with the global scale={f.abs().max():.2f}")
print("no exception was raised, and the fused parameter is filled (missing_keys stays empty)")Actual output:
per-expert global scales : [218.0, 109.5, 79.5]
relative L2 vs reference : today= 103.484 with the global scale=0.099
max |weight| : today= 2688.00 with the global scale=33.75
no exception was raised, and the fused parameter is filled (missing_keys stays empty)
Expected: torch.testing.assert_close(decompressed, faithful_per_expert_decompression) — i.e. relative L2 against the reference weights should be the NVFP4 quantization error (~0.1), not ~103.
Root cause
quantizer_compressed_tensors.py#L210-L212 — update_weight_conversions derives the expert conversion sources from the base .weight patterns as _packed$ / _scale$ / _shape$. NVFP4 stores a fourth per-module tensor, weight_global_scale, which is therefore never collected; and compressed_tensors.py#L108-L113 gives DummyModule no slot for one even if it had been.
Why it is silent rather than a crash. NVFP4Compressor.decompress reads global_scale = state_dict.get("weight_global_scale", None) and passes it to dequantize, which treats None as "no global scaling". This is the difference from #47430, whose missing zero_point raises Asymmetric quant requires zero-point values. The global scale is per tensor (scale_data.max * quant_data.max / max_abs), so each expert is off by a different factor and the relative magnitudes between experts are destroyed too.
Not a regression — it never worked. DummyModule has had the same three-tensor signature since DecompressExperts was introduced in #45630 (09835700, 2026-07-03); weight_global_scale was never carried.
Blast radius
Every scheme that reaches the changed line, executed against this checkout:
| Scheme | Sources derived today | With the fix | Verdict | Verified |
|---|---|---|---|---|
NVFP4 float4 tensor_group g16 |
packed, scale, shape | + weight_global_scale |
bug-fixed | executed |
INT4 int group g128 |
packed, scale, shape | unchanged | no-op | executed |
INT4 int channel |
packed, scale, shape | unchanged | no-op | executed |
INT8 int tensor |
packed, scale, shape | unchanged | no-op | executed |
FP8 float channel |
weight, scale | unchanged (separate branch) | no-op | executed |
tensor_group checkpoint shipping no global scale |
— | .get returns None, same as today |
no-op, no raise | executed |
The 24 MoE families aliased to qwen2_moe/mixtral in conversion_mapping.py |
— | affected iff quantized NVFP4 | bug-fixed | source-read only |
Candidate fixes
- Gate on the strategy (narrow). Collect
weight_global_scale$only whenweights.strategy == "tensor_group"— exactly wheninitialize_module_for_quantizationregisters the parameter — and thread an optionalglobal_scalethroughDummyModule. Mirrors fix(compressed_tensors): collect zero_point for asymmetric MoE expert decompression #47430's shape. Leaves the general problem: the next companion tensor a scheme adds fails the same way. - Derive the whole set (generic). Enumerate the per-module tensors the scheme actually registers instead of hardcoding suffixes, which would subsume fix(compressed_tensors): collect zero_point for asymmetric MoE expert decompression #47430's
zero_pointfix in one place. Larger change, and it touches the FP8 branch's semantics. - Make it loud (different repo). Have
NVFP4Compressor.decompressraise when atensor_groupscheme has no global scale, so the omission can never be silent again. Belongs incompressed-tensors, and is complementary rather than an alternative.
Recommendation: 1, because the blast-radius table shows every non-tensor_group row is byte-identical under it, so it carries no regression risk for existing checkpoints — with 3 as a follow-up upstream in compressed-tensors.
Question
Would you prefer the narrow strategy-gated fix (option 1, mirroring #47430), or a single generic derivation (option 2) that enumerates the scheme's registered per-module tensors and would subsume #47430's
zero_pointfix as well?
Prior work checked
gh pr list --repo huggingface/transformers --state open --search "compressed_tensors MoE experts"
gh pr list --repo huggingface/transformers --state open --search "global_scale"
gh pr list --repo huggingface/transformers --state open --search "nvfp4"
gh api "search/issues?q=repo:huggingface/transformers+weight_global_scale+in:title,body"
gh api "search/issues?q=repo:huggingface/transformers+DecompressExperts"
gh api "search/issues?q=repo:huggingface/transformers+update_weight_conversions"→ No open issue or PR covers weight_global_scale (total_count: 0 for the weight_global_scale search). The closest is #47430 (open), which fixes the same two functions for the asymmetric zero_point — a different tensor, a different scheme, and it crashes rather than corrupting silently. Adjacent but distinct: #47956 (weight_shape sharded away under TP/EP), #47315 (non-32-divisible bit widths), #47407 (run_compressed=False random init, different code path).
Activity
vasqu commentedon Sep 7, 2026
cc @VI-Arthur
I think the compressed tensors code comes from kimi (semi related so cc @zucchini-nlp as well). I don't think we can get around hardcoding for now until maybe compressed tensors gives specialized ops we could reuse from their library 🤔
zucchini-nlp commentedon Sep 8, 2026
Didn't read much into the above huge text but I believe duplicate of #47956, known issue of low prio
Bluear7878 commentedon Sep 28, 2026
The two reports affect the same decompression path, but require different inputs to be preserved:
weight_shapeis lost under TP/EP; reconstructing the shape is ambiguous for 3/5/6/7-bit packing.weight_global_scaleis omitted from the conversion sources andDummyModule, including in a single process with everyweight_shapepresent.A CPU recheck on current
main(07338b6c, 2026-09-28), using three 32×64 experts with stored shapes and no distributed initialization, produced:main@07338b6c7ed92c1Environment: Python 3.11.12, PyTorch 2.13.0+cpu, compressed-tensors 0.18.0. The PR's three focused regression tests also pass. This checks source collection and
DecompressExperts.convert; a full model reload and TP/EP were not rerun.#48372 keeps the narrow, strategy-gated addition discussed above. Could it be reviewed as the NVFP4 scale fix, with #47956 continuing to track distributed shape metadata?
zucchini-nlp commentedon Sep 28, 2026
looks very much agent-coded 🙃
cc @SunMarc for quants, if you want to review/merge support for it
Bluear7878 commentedon Sep 28, 2026
I used an AI assistant for the implementation and tests, as disclosed in the description. Does this raise any concerns for this PR, or is there anything you'd like me to clarify?
SunMarc commentedon Sep 29, 2026
This PR should probably the issue here: #48146