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
36 changes: 36 additions & 0 deletions modelopt/torch/export/convert_hf_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ def _quant_algo_to_group_config(quant_algo: str, group_size: int | None = None)
},
"weights": {"dynamic": False, "num_bits": 8, "type": "float", "group_size": gs},
}
elif quant_algo == "FP8_PB":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it FP8_PB or fp8_pb_w8a8?

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.

"FP8_PB" is correct here. This function only receives the quant_algo value. Do you recommend renaming it to FP8_PB_W8A8 or changing the internal format string to fp8_pb?

# Block-wise FP8 (W8A8). Weights in gsxgs blocks; activations quantized
# dynamically per-token at runtime (no input_activations entry).
gs = group_size or 128
return {
"weights": {
"dynamic": False,
"num_bits": 8,
"type": "float",
"strategy": "block",
"block_structure": [gs, gs],
},
}
Comment on lines +107 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Export] Scale-name mismatch for FP8_PB in the MIXED_PRECISION path.

This _quant_algo_to_group_config("FP8_PB") branch is only reached via the MIXED_PRECISION config_groups path (the pure-FP8_PB case early-returns the native quant_method: fp8 dict above). It emits a compressed-tensors group with strategy: "block", which consumers read with the conventional weight_scale tensor name. But _export_quantized_weight unconditionally registers the per-block scale for QUANTIZATION_FP8_PB_W8A8 as weight_scale_inv and deletes weight_scale. So a mixed-precision checkpoint that contains an FP8_PB layer will have *.weight_scale_inv tensors described by a config that expects *.weight_scale → the block-FP8 layers won't load in a compressed-tensors consumer.

Why it matters: the PR description explicitly advertises a mixed recipe (qwen3p5_mixed_nvfp4_fp8pb_w8a8.yaml) combining NVFP4 + FP8_PB, which is exactly the path that hits this branch.

Suggestion: either (a) document/guard that FP8_PB is unsupported in MIXED_PRECISION export and raise a clear error, or (b) make the group config emit the weight_scale_inv convention consistently with the exporter. At minimum add a test for _quant_algo_to_group_config("FP8_PB") round-tripping with the actual exported tensor names.

else:
warnings.warn(
f"Unsupported quantization algorithm '{quant_algo}' in "
Expand Down Expand Up @@ -166,6 +179,29 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An
original_quantization_details = input_config.get("quantization", {})
quant_algo_value = original_quantization_details.get("quant_algo")

# FP8_PB (block-wise FP8, W8A8): emit the native ``quant_method: fp8`` config
# vLLM/SGLang expect (weight_scale_inv + dynamic activations), matching the
# official Qwen3.5 FP8 checkpoint.
if quant_algo_value == "FP8_PB":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it FP8_PB or fp8_pb_w8a8?

kv_cache_quant_algo = original_quantization_details.get("kv_cache_quant_algo")
assert not kv_cache_quant_algo, (
"FP8_PB export does not support kv_cache quantization yet "
f"(got kv_cache_quant_algo={kv_cache_quant_algo!r})."
)
group_size = original_quantization_details.get("group_size") or 128
exclude_modules = original_quantization_details.get("exclude_modules") or []
fp8_config: dict[str, Any] = {
"quant_method": "fp8",
"fmt": "e4m3",
"activation_scheme": "dynamic",
"weight_block_size": [group_size, group_size],
"modules_to_not_convert": exclude_modules,
}
producer_info = input_config.get("producer")
if producer_info:
fp8_config["producer"] = producer_info
return fp8_config
Comment on lines +185 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] This early-return builds the native quant_method: fp8 dict and returns immediately, which silently drops any kv_cache_quant_algo present in original_quantization_details (the generic path below emits kv_cache_scheme). For the shipped fp8_2d_blockwise_w8a8_dynamic preset the KV cache is disabled, so this is currently fine — but if FP8_PB is ever combined with FP8 KV-cache quantization, the KV scheme would be lost without warning. Consider either carrying kv_cache_scheme into fp8_config when kv_cache_quant_algo is set, or asserting it is unset here so the silent drop can't happen.


# This structure is derived based on the example for "FP8" and "NVFP4"
# TODO: Handle other quantization algorithms
if quant_algo_value == "FP8":
Expand Down
1 change: 1 addition & 0 deletions modelopt/torch/export/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
QUANTIZATION_NVFP4_AWQ = "nvfp4_awq"
QUANTIZATION_FP8_PB_REAL = "fp8_pb_real"
QUANTIZATION_FP8_PB_WO = "fp8_pb_wo"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do we still need QUANTIZATION_FP8_PB_WO? Maybe we can remove it after this PR.

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.

FP8_PB_WO is also used in the Megatron path, that's why I avoided removing it.

QUANTIZATION_FP8_PB_W8A8 = "fp8_pb_w8a8"
QUANTIZATION_FP8_PC_PT = "fp8_pc_pt"

KV_CACHE_FP8 = "FP8"
Expand Down
17 changes: 13 additions & 4 deletions modelopt/torch/export/quant_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
KV_CACHE_NVFP4_AFFINE,
QUANTIZATION_FP8,
QUANTIZATION_FP8_PB_REAL,
QUANTIZATION_FP8_PB_W8A8,
QUANTIZATION_FP8_PB_WO,
QUANTIZATION_FP8_PC_PT,
QUANTIZATION_INT4_AWQ,
Expand Down Expand Up @@ -537,10 +538,12 @@ def _get_quantization_from_layer(layer, quantizer_attr_names: QuantizerAttrNames
and block_sizes.get("scale_bits") == (8, 0)
):
return QUANTIZATION_MXFP8
if weight_quantizer.fake_quant:
return QUANTIZATION_FP8_PB_WO
else:
# Block FP8: input quantizer enabled -> W8A8, else weight-only.
if not weight_quantizer.fake_quant:
return QUANTIZATION_FP8_PB_REAL
if input_quantizer is not None and input_quantizer.is_enabled:
return QUANTIZATION_FP8_PB_W8A8
return QUANTIZATION_FP8_PB_WO
Comment on lines +542 to +546

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] This detection change is shared by the Megatron export path and silently breaks it for one case.

get_quantization_format is also called by unified_export_megatron.py (_get_quantization_format → here). Previously, a block-FP8 module with fake_quant=True always returned QUANTIZATION_FP8_PB_WO, which Megatron handles (unified_export_megatron.py:288-290). After this change, the same module with an enabled input quantizer now returns QUANTIZATION_FP8_PB_W8A8, which is not in the Megatron branch → quantization stays None and the layer is exported as if unquantized.

So a model that was previously exportable as block-FP8 via Megatron (input quantizer enabled but fake_quant) now silently mis-exports. Even if no current Megatron recipe enables the input quantizer, this is a latent foot-gun.

Suggestion: either add QUANTIZATION_FP8_PB_W8A8 to the Megatron handling at unified_export_megatron.py:288, or raise a clear "unsupported on Megatron export" error there so it can't silently fall through to None.

if weight_quantizer.axis == 0:
return QUANTIZATION_FP8_PC_PT
return QUANTIZATION_FP8
Expand Down Expand Up @@ -758,6 +761,12 @@ def process_layer_quant_config(layer_config_dict):
"quant_algo": "MXFP8",
"group_size": block_size_value,
}
elif v == "fp8_pb_w8a8":
# Block-wise FP8, W8A8 at serve time.
layer_config = {
"quant_algo": "FP8_PB",
"group_size": block_size_value,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
sugunav14 marked this conversation as resolved.
else:
layer_config = {"quant_algo": v}

Expand Down Expand Up @@ -865,7 +874,7 @@ def to_quantized_weight(
if quantization == QUANTIZATION_MXFP8:
return MXFP8QTensor.quantize_with_scale(weight, weights_scaling_factor)

if quantization == QUANTIZATION_FP8_PB_WO:
if quantization in (QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PB_W8A8):
return FP8QTensor.quantize(
weight, weights_scaling_factor.squeeze(), block_sizes={-1: block_size, -2: block_size}
)[0]._quantized_data
Expand Down
19 changes: 17 additions & 2 deletions modelopt/torch/export/unified_export_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
from .model_config import (
QUANTIZATION_FP8,
QUANTIZATION_FP8_PB_REAL,
QUANTIZATION_FP8_PB_W8A8,
QUANTIZATION_FP8_PC_PT,
QUANTIZATION_MXFP8,
QUANTIZATION_NONE,
Expand Down Expand Up @@ -741,9 +742,22 @@ def _export_quantized_weight(

setattr(sub_module, weight_name, nn.Parameter(quantized_weight, requires_grad=False))

# Register the corrected weight_scale as a buffer
# Register the corrected weight scale as a buffer.
if weight_scale is not None:
sub_module.register_buffer(quantizer_attrs.weight_scale, weight_scale)
if quantization_format == QUANTIZATION_FP8_PB_W8A8:
# Store per-block scale as 2-D weight_scale_inv (amax/448, not
# inverted); squeeze the keepdim block-amax [out, 1, in, 1].
if (
weight_scale.dim() == 4
and weight_scale.shape[1] == 1
and weight_scale.shape[3] == 1
):
weight_scale = weight_scale.squeeze(3).squeeze(1)
sub_module.register_buffer(quantizer_attrs.weight_scale_inv, weight_scale)
if quantizer_attrs.weight_scale in sub_module._buffers:
del sub_module._buffers[quantizer_attrs.weight_scale]
else:
sub_module.register_buffer(quantizer_attrs.weight_scale, weight_scale)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
sugunav14 marked this conversation as resolved.

# Tied-weight dedup: if a previously-processed module shared the same
# source weight memory, alias the packed weight + scale buffers so the
Expand All @@ -758,6 +772,7 @@ def _export_quantized_weight(
setattr(sub_module, weight_name, getattr(_prior, weight_name))
for _attr in (
quantizer_attrs.weight_scale,
quantizer_attrs.weight_scale_inv,
quantizer_attrs.weight_scale_2,
quantizer_attrs.input_scale,
):
Expand Down
11 changes: 11 additions & 0 deletions modelopt/torch/export/unified_export_megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
KV_CACHE_NVFP4,
QUANTIZATION_FP8,
QUANTIZATION_FP8_PB_REAL,
QUANTIZATION_FP8_PB_W8A8,
QUANTIZATION_FP8_PB_WO,
QUANTIZATION_NONE,
QUANTIZATION_NVFP4,
Expand Down Expand Up @@ -296,6 +297,11 @@ def save_pretrained(
quantization = "NVFP4"
elif quantization_format == QUANTIZATION_W4A16_NVFP4:
quantization = "W4A16_NVFP4"
elif quantization_format == QUANTIZATION_FP8_PB_W8A8:
raise NotImplementedError(
"Block-wise FP8 W8A8 (FP8_PB_W8A8) export is not supported on the "
"Megatron path; export via the HF path (unified_export_hf)."
)

# We use the last PP rank and the 1st EP rank to write the config because
# medusa_heads and eagle_module only exist in the last stage.
Expand Down Expand Up @@ -860,6 +866,11 @@ def _get_quantized_state(
"""
name_to_value = {}
qformat: str = self._get_quantization_format(module)
if qformat == QUANTIZATION_FP8_PB_W8A8:
raise NotImplementedError(
"Block-wise FP8 W8A8 (FP8_PB_W8A8) export is not supported on the "
"Megatron path; export via the HF path (unified_export_hf)."
)
if qformat is None and "norm" not in prefix:
self._record_excluded_module(prefix)
block_size = get_weight_block_size(module)
Expand Down
2 changes: 2 additions & 0 deletions modelopt/torch/quantization/utils/core_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def weight_attr_names(module: nn.Module) -> "Generator[str, None, None]":
"input_quantizer",
"output_quantizer",
"weight_scale",
"weight_scale_inv",
"weight_scale_2",
"input_scale",
"output_scale",
Expand All @@ -287,6 +288,7 @@ def quantizer_attr_names(weight_name: str = "weight") -> QuantizerAttrNames:
input_quantizer=f"{prefix}input_quantizer",
output_quantizer=f"{prefix}output_quantizer",
weight_scale=f"{prefix}weight_scale",
weight_scale_inv=f"{prefix}weight_scale_inv",
weight_scale_2=f"{prefix}weight_scale_2",
input_scale=f"{prefix}input_scale",
output_scale=f"{prefix}output_scale",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: maybe consider remove the "2d" in the file name to make it shorter.

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.

I added the 2d so that the name better reflects the actual format to help users understand without having to go through the yaml and understand details.

# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# QuantizeConfig preset for FP8 E4M3 2D blockwise weights + dynamic per-token FP8
# activations (W8A8). The dynamic input quantizer makes PTQ calibrate as W8A8;
# it stores no input_scale, so exported weights match the weight-only preset.

# modelopt-schema: modelopt.torch.quantization.config.QuantizeConfig
imports:
base_disable_all: configs/ptq/units/base_disable_all
default_disabled_quantizers: configs/ptq/units/default_disabled_quantizers
fp8: configs/numerics/fp8

algorithm: max
quant_cfg:
- $import: base_disable_all
- quantizer_name: '*weight_quantizer'
cfg:
$import: fp8
block_sizes:
-1: 128
-2: 128
- quantizer_name: '*input_quantizer'
cfg:
$import: fp8
block_sizes:
-1: 128
type: dynamic
- $import: default_disabled_quantizers
78 changes: 78 additions & 0 deletions tests/gpu/torch/export/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ def test_get_quantization_format(config, expected):
"exclude_modules": ["layer8"],
},
),
(
{
"layer1.quantization": "fp8_pb_w8a8", # 128x128 block-wise FP8 (W8A8)
"layer1.awq_block_size": 128,
"layer2.quantization": "fp8_pb_w8a8",
"layer2.awq_block_size": 128,
"layer8.quantization": None,
},
{
"quant_algo": "FP8_PB",
"kv_cache_quant_algo": None,
"group_size": 128,
"exclude_modules": ["layer8"],
},
),
],
)
def test_process_layer_quant_config(layer_config_dict, expected_processed_dict):
Expand Down Expand Up @@ -520,3 +535,66 @@ def is_excluded(module_name: str) -> bool:
assert not is_excluded("model.layers.0.mlp.experts.0.down_proj"), (
f"Routed experts should not be excluded, got patterns: {exclude_modules}"
)


def test_fp8_pb_w8a8_export_uses_weight_scale_inv(tmp_path):
"""W8A8 block-FP8 (FP8_PB) export stores per-block scales as weight_scale_inv
(DeepSeek/Qwen convention) and drops the plain weight_scale; activations are
dynamic so no input_scale is stored."""
from safetensors import safe_open

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move safe_open import to module scope.

Line 544 imports inside the test function without justification, which makes import failures surface at runtime instead of collection time.

Suggested fix
+from safetensors import safe_open
...
 def test_fp8_pb_w8a8_export_uses_weight_scale_inv(tmp_path):
@@
-    from safetensors import safe_open

As per path instructions, tests/**/*.py must keep imports at the top of the file unless there is an explicit circular-import/optional-dependency justification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/gpu/torch/export/test_export.py` at line 544, The import statement
`from safetensors import safe_open` is currently located inside a test function
at line 544 instead of at the module scope. Move this import to the top of the
file with the other module-level imports to ensure import failures are caught at
collection time rather than at runtime, and to comply with the project's import
organization standards. Unless there is a circular dependency or optional
dependency justification, all imports must be at module scope in tests/**/*.py
files.

Source: Path instructions


model = get_tiny_qwen3_moe().to("cuda")
model.config.architectures = ["Qwen3MoeForCausalLM"]

cfg = {
"quant_cfg": [
{"quantizer_name": "*", "enable": False},
{
"quantizer_name": "*weight_quantizer",
"cfg": {"num_bits": (4, 3), "block_sizes": {-1: 128, -2: 128}, "axis": None},
"enable": True,
},
{
"quantizer_name": "*input_quantizer",
"cfg": {
"num_bits": (4, 3),
"block_sizes": {-1: 128, "type": "dynamic"},
"axis": None,
},
"enable": True,
},
{"quantizer_name": "*lm_head*", "enable": False},
],
"algorithm": "max",
}
dummy_inputs = {k: v.to("cuda") for k, v in model.dummy_inputs.items()}
mtq.quantize(model, cfg, lambda m: m(**dummy_inputs))

export_dir = tmp_path / "fp8_pb_w8a8"
export_hf_checkpoint(model, export_dir=export_dir)

keys = set()
for st in export_dir.glob("*.safetensors"):
with safe_open(st, framework="pt") as f:
keys.update(f.keys())

assert any(k.endswith(".weight_scale_inv") for k in keys), (
"block FP8 must store weight_scale_inv"
)
assert not any(k.endswith(".weight_scale") for k in keys), "plain weight_scale must be dropped"
assert not any(k.endswith(".input_scale") for k in keys), (
"dynamic activations store no input_scale"
)

# Per-block scales must be 2-D [out_blocks, in_blocks] (DeepSeek/Qwen
# convention) -- not the 4-D [out_blocks, 1, in_blocks, 1] block-amax shape.
# This is what ModelOpt's _QuantFP8Linear reload path and vLLM/SGLang's stock
# block-FP8 loader expect.
for st in export_dir.glob("*.safetensors"):
with safe_open(st, framework="pt") as f:
for k in list(f.keys()):
if k.endswith(".weight_scale_inv"):
scale_inv = f.get_tensor(k)
assert scale_inv.ndim == 2, (
f"{k} must be 2-D [out_blocks, in_blocks], got {tuple(scale_inv.shape)}"
)
25 changes: 24 additions & 1 deletion tests/unit/torch/export/test_get_quantization.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,39 @@
from modelopt.torch.export.layer_utils import get_quantization_format
from modelopt.torch.export.model_config import (
QUANTIZATION_FP8,
QUANTIZATION_FP8_PB_W8A8,
QUANTIZATION_NVFP4,
QUANTIZATION_W4A8_AWQ,
)
from modelopt.torch.export.quant_utils import get_quant_config
from modelopt.torch.quantization.nn import NVFP4StaticQuantizer

# Block-wise FP8 W8A8 on the ".1" linear: weight block + enabled dynamic input quantizer.
_fp8_pb_w8a8_config = {
"quant_cfg": [
{"quantizer_name": "*", "enable": False},
{
"quantizer_name": "*.1.weight_quantizer",
"cfg": {"num_bits": (4, 3), "block_sizes": {-1: 128, -2: 128}, "axis": None},
"enable": True,
},
{
"quantizer_name": "*.1.input_quantizer",
"cfg": {"num_bits": (4, 3), "block_sizes": {-1: 128, "type": "dynamic"}, "axis": None},
"enable": True,
},
],
"algorithm": "max",
}


@pytest.mark.parametrize(
("config", "expected"),
[(partial_fp8_config, QUANTIZATION_FP8), (partial_w4a8_config, QUANTIZATION_W4A8_AWQ)],
[
(partial_fp8_config, QUANTIZATION_FP8),
(partial_w4a8_config, QUANTIZATION_W4A8_AWQ),
(_fp8_pb_w8a8_config, QUANTIZATION_FP8_PB_W8A8),
],
)
def test_get_quantization_format(config, expected):
model = ToyModel()
Expand Down
Loading