Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Changelog

**Bug Fixes**

- Reject INT4 and W4A8 AWQ checkpoint export when a weight's input dimension is not divisible by the configured block size. Choose an ``awq_block_size`` that evenly divides every quantized weight's input dimension.
- Fix NemotronH dense MLP quantization with the ``nvfp4_mlp_only`` and ``nvfp4_omlp_only`` recipe families. NemotronH registers these projections as ``mixer.up_proj`` / ``mixer.down_proj``, which the previous ``*mlp*`` selector missed, producing checkpoints with a null ``quant_algo``.
- Fix ``ShapeInferenceError`` during ONNX INT8 + FP16 quantization (``--high_precision_dtype fp16``) of weakly-typed models (e.g. TensorFlow exports) that carry stale rank-0 ``graph.output`` shapes or ops such as ``TopK`` that ONNX's static shape inference cannot resolve. Stale output shapes are now reconciled via symbolic shape inference, and AutoCast falls back to schema-based type inference so unresolved ops no longer leave tensors untyped.
- Fix fused MoE expert auto-detection (``register_fused_experts_on_the_fly``) skipping modules without an ``act_fn`` attribute. Modules applying a custom gated activation between the two ``F.linear`` calls (e.g. ``MiniMaxM3VLExperts``) were silently skipped, leaving routed experts unquantized and failing HF export. Enables NVFP4/FP8 quantization and export for MiniMax-M2 / MiniMax-M3.
Expand Down
18 changes: 15 additions & 3 deletions modelopt/torch/export/quant_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,12 +792,22 @@ def process_layer_quant_config(layer_config_dict):
return per_layer_config


def pack_int4_in_uint8(weight, weights_scaling_factor):
def _validate_int4_block_size(in_dim, block_size):
if not isinstance(block_size, int) or block_size <= 0:
Comment thread
realAsma marked this conversation as resolved.

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.

[P1] Apply this validation to compressed AWQ weights too. to_quantized_weight returns weight.data for every QTensorWrapper before this helper is called. mtq.compress can produce an INT4QTensor wrapper, and INT4QTensor.quantize pads non-divisible inputs, so a compressed INT4/W4A8 AWQ weight with a partial block bypasses this guard and export can continue with an unsupported packed checkpoint. Please validate the logical input dimension from weight.metadata["shape"][-1] before the wrapper fast path (or explicitly reject compressed AWQ export), and cover that route in a test.

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.

BB: Is the fix valid for INT4 AWQ as well? If not can you raise similar error during AWQ export as well?

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.

🤖 Bot comment.

🐝 I’m checking the INT4 AWQ path and will make sure it fails clearly during export if the current guard does not cover it.

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.

🤖 Bot comment.

The current helper covers uncompressed INT4 AWQ and W4A8 AWQ. The remaining issue is the compressed QTensorWrapper fast path, which bypasses that helper for both formats; the pending fix should validate the wrapper’s logical shape before returning its packed data and test both formats.

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.

BB: Is there an easy way to implement this? PTAL and tell me. if this is simple to do, can you do that?

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.

🤖 Bot comment.

🐝

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.

🤖 Bot comment.

Yes. This is a small export-only change: for compressed INT4/W4A8 AWQ weights, validate the logical input dimension from weight.metadata["shape"][-1] before returning weight.data, and add focused coverage for both formats. I’ll include it with removal of the redundant scale-count guard.

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.

🤖 Bot comment.

Fixed in 89bdde5: compressed INT4 and W4A8 AWQ wrappers now validate the logical input dimension before the fast return, with focused regression coverage.

raise ValueError(f"Block size must be a positive integer, got {block_size}.")
if in_dim % block_size != 0:
Comment thread
realAsma marked this conversation as resolved.
raise NotImplementedError(
f"Cannot pack weight with input dimension {in_dim} and block size {block_size}: "
"partial blocks are not supported."
)
Comment thread
realAsma marked this conversation as resolved.
Comment thread
realAsma marked this conversation as resolved.


def pack_int4_in_uint8(weight, weights_scaling_factor, block_size):
Comment thread
realAsma marked this conversation as resolved.
"""Packs the INT4 weights into uint8 tensor."""
out_dim = weight.shape[-2]
assert out_dim % 2 == 0, f"Cannot pack weight. Out dimension {out_dim} is not an even number."
in_dim = weight.shape[-1]
block_size = weight.shape[-1] // weights_scaling_factor.shape[-1]
_validate_int4_block_size(in_dim, block_size)
Comment thread
realAsma marked this conversation as resolved.

# Scale, round, and clamp to the signed 4-bit range [-8..7].
int8_tensor = (
Expand Down Expand Up @@ -852,6 +862,8 @@ def to_quantized_weight(

# For compressed weights, we directly return the data from wrapper
if isinstance(weight, QTensorWrapper):
if quantization in [QUANTIZATION_INT4_AWQ, QUANTIZATION_W4A8_AWQ]:
_validate_int4_block_size(weight.metadata["shape"][-1], block_size)
return weight.data

if quantization == QUANTIZATION_FP8:
Expand Down Expand Up @@ -912,7 +924,7 @@ def to_quantized_weight(
return (weight / weights_scaling_factor[:, None]).to(torch.float8_e4m3fn)

if quantization in [QUANTIZATION_INT4_AWQ, QUANTIZATION_W4A8_AWQ]:
return pack_int4_in_uint8(weight, weights_scaling_factor)
return pack_int4_in_uint8(weight, weights_scaling_factor, block_size)

if quantization in [
QUANTIZATION_NVFP4,
Expand Down
37 changes: 37 additions & 0 deletions tests/gpu/torch/export/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
get_weight_block_size,
postprocess_state_dict,
process_layer_quant_config,
to_quantized_weight,
)
from modelopt.torch.export.unified_export_hf import export_hf_checkpoint
from modelopt.torch.quantization.config import (
Expand All @@ -69,6 +70,7 @@
W4A8_AWQ_BETA_CFG,
)
from modelopt.torch.quantization.nn import SequentialQuantizer, TensorQuantizer
from modelopt.torch.quantization.qtensor import INT4QTensor, QTensorWrapper


@pytest.mark.parametrize(
Expand Down Expand Up @@ -393,6 +395,41 @@ def test_get_weight_block_size(config, expected_block_size):
assert block_size == 0


@pytest.mark.parametrize("quantization", [QUANTIZATION_INT4_AWQ, QUANTIZATION_W4A8_AWQ])
def test_to_quantized_weight_int4_block_size(quantization):
block_size = 128
in_dim = 2 * block_size
scales = torch.tensor([[1.0, 2.0]] * 4, device="cuda")
quantized_values = torch.arange(1, 5, device="cuda")[:, None]
weight = scales.repeat_interleave(block_size, dim=-1) * quantized_values

packed = to_quantized_weight(weight, scales, quantization, block_size=block_size)

assert packed.shape == (2, in_dim)
assert torch.equal(packed[0], torch.full((in_dim,), 0x21, dtype=torch.uint8, device="cuda"))
assert torch.equal(packed[1], torch.full((in_dim,), 0x43, dtype=torch.uint8, device="cuda"))

partial_weight = torch.cat((weight, quantized_values.repeat(1, 2)), dim=-1)
with pytest.raises(NotImplementedError, match="partial blocks are not supported"):
to_quantized_weight(partial_weight, scales, quantization, block_size=block_size)

compressed_weight, _ = INT4QTensor.quantize(partial_weight, block_size)
with pytest.raises(NotImplementedError, match="partial blocks are not supported"):
to_quantized_weight(
QTensorWrapper(compressed_weight), scales, quantization, block_size=block_size
)


@pytest.mark.parametrize("quantization", [QUANTIZATION_INT4_AWQ, QUANTIZATION_W4A8_AWQ])
@pytest.mark.parametrize("block_size", [None, 0, -1, 2.0])
def test_to_quantized_weight_invalid_int4_block_size(quantization, block_size):
weight = torch.ones((4, 4), device="cuda")
scales = torch.ones((4, 2), device="cuda")

with pytest.raises(ValueError, match="Block size must be a positive integer"):
to_quantized_weight(weight, scales, quantization, block_size=block_size)


@pytest.mark.parametrize(
("config", "maxbound", "expected_amax"),
[
Expand Down
6 changes: 3 additions & 3 deletions tests/gpu/torch/export/test_fsdp2_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,15 @@ def calib_fn(x):
def _export_quantized_weight_test(rank, size, quant_config, bias):
with patch_fsdp_mp_dtypes():
# Initialize model
model = SmallQKVModel(dim=32, bias=bias).to("cuda")
non_fsdp_model = SmallQKVModel(dim=32, bias=bias).to("cuda")
model = SmallQKVModel(dim=128, bias=bias).to("cuda")
non_fsdp_model = SmallQKVModel(dim=128, bias=bias).to("cuda")
non_fsdp_model.load_state_dict(copy.deepcopy(model.state_dict()))
model.eval()
non_fsdp_model.eval()
_compare_parameters_and_buffers(model, non_fsdp_model)

# Create calibration data ONCE
calib_data = torch.randn(1, 32, device="cuda")
calib_data = torch.randn(1, 128, device="cuda")

def calib_fn(x):
return x(calib_data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,14 @@ def test_unified_hf_export_and_check_safetensors(
elif expected_suffix.startswith("tiny_gpt_oss"):
tiny_model_dir = create_tiny_gpt_oss_dir(tmp_path, with_tokenizer=True, num_hidden_layers=1)
else:
tiny_model_dir = create_tiny_llama_dir(tmp_path, with_tokenizer=True, num_hidden_layers=1)
model_dims = (
{"hidden_size": 128, "intermediate_size": 128}
if qformat in {"int4_awq", "w4a8_awq_beta"}
else {}
)
tiny_model_dir = create_tiny_llama_dir(
tmp_path, with_tokenizer=True, num_hidden_layers=1, **model_dims
)

# Create an output directory in tmp_path
# We'll replicate the naming convention, e.g. "tiny_llama-fp8"
Expand Down
Loading