From 6211da7759fba6ec2d78b88d972c2a072d05f17f Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:24:44 +0000 Subject: [PATCH 01/12] update Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 35 +++++++++++++++++++ modelopt/torch/export/quant_utils.py | 6 ++++ modelopt/torch/export/unified_export_hf.py | 15 ++++++-- .../torch/quantization/utils/core_utils.py | 2 ++ tests/gpu/torch/export/test_export.py | 15 ++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index 06e5923a30f..9a2530bcdd0 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -104,6 +104,21 @@ 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": + # 128x128 block-wise weight-only FP8 (DeepSeek/Qwen-style block FP8). + # Weight-only: no input_activations entry; activations stay dynamic at + # serve time. ``block_structure`` carries the 2D block shape (e.g. + # [128, 128]). + gs = group_size or 128 + return { + "weights": { + "dynamic": False, + "num_bits": 8, + "type": "float", + "strategy": "block", + "block_structure": [gs, gs], + }, + } else: warnings.warn( f"Unsupported quantization algorithm '{quant_algo}' in " @@ -166,6 +181,26 @@ 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 (128x128 block-wise weight-only FP8) is consumed as a native + # DeepSeek/Qwen-style block-FP8 checkpoint rather than via the + # compressed-tensors ``config_groups`` schema. Emit the flat ``quant_method: + # "fp8"`` config that vLLM/SGLang expect (weights stored as fp8_e4m3 with a + # per-block ``weight_scale_inv``; activations quantized dynamically at runtime). + if quant_algo_value == "FP8_PB": + 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 + # This structure is derived based on the example for "FP8" and "NVFP4" # TODO: Handle other quantization algorithms if quant_algo_value == "FP8": diff --git a/modelopt/torch/export/quant_utils.py b/modelopt/torch/export/quant_utils.py index 2af5f6eab0b..d9c7eb06bfc 100755 --- a/modelopt/torch/export/quant_utils.py +++ b/modelopt/torch/export/quant_utils.py @@ -758,6 +758,12 @@ def process_layer_quant_config(layer_config_dict): "quant_algo": "MXFP8", "group_size": block_size_value, } + elif v == "fp8_pb_wo": + # 128x128 block-wise weight-only FP8 (DeepSeek/Qwen-style block FP8). + layer_config = { + "quant_algo": "FP8_PB", + "group_size": block_size_value, + } else: layer_config = {"quant_algo": v} diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index 8bc92ed5eb9..144a03ce8ce 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -78,6 +78,7 @@ from .model_config import ( QUANTIZATION_FP8, QUANTIZATION_FP8_PB_REAL, + QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PC_PT, QUANTIZATION_MXFP8, QUANTIZATION_NONE, @@ -741,9 +742,19 @@ 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_WO: + # DeepSeek/Qwen block-FP8 convention: same value, renamed key. + # weight_scale (= amax/448) is already the per-block dequant multiplier + # that SGLang/vLLM apply as weight_fp8 * weight_scale_inv, matching + # ModelOpt's TE/mcore path. Do NOT invert. Drop the plain weight_scale + # buffer so no stale key survives into the exported state dict. + 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) # Tied-weight dedup: if a previously-processed module shared the same # source weight memory, alias the packed weight + scale buffers so the diff --git a/modelopt/torch/quantization/utils/core_utils.py b/modelopt/torch/quantization/utils/core_utils.py index b0049b5a08d..34d0bc82703 100644 --- a/modelopt/torch/quantization/utils/core_utils.py +++ b/modelopt/torch/quantization/utils/core_utils.py @@ -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", @@ -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", diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index cac0a9a9aef..48cb2ff64d0 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -141,6 +141,21 @@ def test_get_quantization_format(config, expected): "exclude_modules": ["layer8"], }, ), + ( + { + "layer1.quantization": "fp8_pb_wo", # 128x128 block-wise weight-only FP8 + "layer1.awq_block_size": 128, + "layer2.quantization": "fp8_pb_wo", + "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): From dc5d3ded097eab6c2453b6894a478a2a62de798f Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:00:52 +0000 Subject: [PATCH 02/12] refactor to support both weight-only and weight + sctivation Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 46 +++++++++++++++---- modelopt/torch/export/model_config.py | 3 ++ modelopt/torch/export/quant_utils.py | 32 +++++++++---- modelopt/torch/export/unified_export_hf.py | 17 ++++--- .../torch/export/unified_export_megatron.py | 2 + modelopt/torch/quantization/config.py | 4 ++ 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index 9a2530bcdd0..f155870fe8f 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -105,10 +105,30 @@ 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": - # 128x128 block-wise weight-only FP8 (DeepSeek/Qwen-style block FP8). - # Weight-only: no input_activations entry; activations stay dynamic at - # serve time. ``block_structure`` carries the 2D block shape (e.g. - # [128, 128]). + # Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8, + # DeepSeek/Qwen-style block FP8). ``block_structure`` carries the 2D + # weight block shape (e.g. [128, 128]); activations are quantized + # dynamically in 1xgs groups at runtime. + gs = group_size or 128 + return { + "input_activations": { + "dynamic": True, + "num_bits": 8, + "type": "float", + "strategy": "block", + "block_structure": [1, gs], + }, + "weights": { + "dynamic": False, + "num_bits": 8, + "type": "float", + "strategy": "block", + "block_structure": [gs, gs], + }, + } + elif quant_algo == "FP8_PB_WO": + # Block-wise weight-only FP8 (W8A16): weights quantized in gsxgs blocks, + # activations kept in high precision (no input_activations entry). gs = group_size or 128 return { "weights": { @@ -181,11 +201,13 @@ 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 (128x128 block-wise weight-only FP8) is consumed as a native - # DeepSeek/Qwen-style block-FP8 checkpoint rather than via the - # compressed-tensors ``config_groups`` schema. Emit the flat ``quant_method: - # "fp8"`` config that vLLM/SGLang expect (weights stored as fp8_e4m3 with a - # per-block ``weight_scale_inv``; activations quantized dynamically at runtime). + # FP8_PB (128x128 block-wise FP8 weights + dynamic per-token FP8 activations, + # i.e. W8A8) is consumed as a native DeepSeek/Qwen-style block-FP8 checkpoint + # rather than via the compressed-tensors ``config_groups`` schema. Emit the + # flat ``quant_method: "fp8"`` config that vLLM/SGLang expect (weights stored + # as fp8_e4m3 with a per-block ``weight_scale_inv``; activations quantized + # dynamically at runtime). Weight-only block FP8 (FP8_PB_WO) instead flows + # through the compressed-tensors ``config_groups`` path below. if quant_algo_value == "FP8_PB": group_size = original_quantization_details.get("group_size") or 128 exclude_modules = original_quantization_details.get("exclude_modules") or [] @@ -231,6 +253,12 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An "targets": ["Linear"], } new_config["config_groups"] = {"group_0": config_group_details} + elif quant_algo_value == "FP8_PB_WO": + # Weight-only block FP8 (W8A16): block weights, high-precision activations. + group_size = original_quantization_details.get("group_size") or 128 + config_group_details = _quant_algo_to_group_config("FP8_PB_WO", group_size) + config_group_details["targets"] = ["Linear"] + new_config["config_groups"] = {"group_0": config_group_details} elif quant_algo_value == "MIXED_PRECISION": quantized_layers = original_quantization_details.get("quantized_layers", {}) diff --git a/modelopt/torch/export/model_config.py b/modelopt/torch/export/model_config.py index 5f92cc2e5dc..730cbd6f11c 100755 --- a/modelopt/torch/export/model_config.py +++ b/modelopt/torch/export/model_config.py @@ -41,7 +41,10 @@ QUANTIZATION_W4A16_NVFP4 = "w4a16_nvfp4" QUANTIZATION_NVFP4_AWQ = "nvfp4_awq" QUANTIZATION_FP8_PB_REAL = "fp8_pb_real" +# Block-wise FP8, weight-only (BF16 activations at serve time) -> compressed-tensors W8A16. QUANTIZATION_FP8_PB_WO = "fp8_pb_wo" +# Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8) -> flat quant_method: fp8. +QUANTIZATION_FP8_PB_W8A8 = "fp8_pb_w8a8" QUANTIZATION_FP8_PC_PT = "fp8_pc_pt" KV_CACHE_FP8 = "FP8" diff --git a/modelopt/torch/export/quant_utils.py b/modelopt/torch/export/quant_utils.py index d9c7eb06bfc..7afc8aacf3c 100755 --- a/modelopt/torch/export/quant_utils.py +++ b/modelopt/torch/export/quant_utils.py @@ -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, @@ -529,18 +530,25 @@ def _get_quantization_from_layer(layer, quantizer_attr_names: QuantizerAttrNames if weight_quantizer.num_bits == (4, 3): if weight_quantizer.block_sizes: assert weight_quantizer.block_sizes[-1] > 0, "Invalid block_sizes for FP8 quantizer" - # Check if this is MXFP8 (dynamic block quantization with scale_bits (8, 0)) - block_sizes = getattr(weight_quantizer, "block_sizes") + # MXFP8: dynamic block quant with E8M0 (scale_bits (8, 0)) scales. + block_sizes = weight_quantizer.block_sizes if ( isinstance(block_sizes, dict) and block_sizes.get("type", "static") == "dynamic" and block_sizes.get("scale_bits") == (8, 0) ): return QUANTIZATION_MXFP8 - if weight_quantizer.fake_quant: - return QUANTIZATION_FP8_PB_WO - else: + # Block FP8 (DeepSeek/Qwen style). _REAL = pre-packed weights + # carrying quantizer._scale; _WO/_W8A8 = fake-quant simulated PTQ. + # All three export real packed FP8 weights. The input_quantizer + # selects the activation scheme for the fake-quant path: + # enabled -> W8A8 with dynamic per-token activation (flat fp8), + # disabled -> weight-only W8A16 (compressed-tensors). + 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 if weight_quantizer.axis == 0: return QUANTIZATION_FP8_PC_PT return QUANTIZATION_FP8 @@ -758,12 +766,20 @@ def process_layer_quant_config(layer_config_dict): "quant_algo": "MXFP8", "group_size": block_size_value, } - elif v == "fp8_pb_wo": - # 128x128 block-wise weight-only FP8 (DeepSeek/Qwen-style block FP8). + elif v == "fp8_pb_w8a8": + # Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8, + # DeepSeek/Qwen-style block FP8). Consumed via flat quant_method: fp8. layer_config = { "quant_algo": "FP8_PB", "group_size": block_size_value, } + elif v == "fp8_pb_wo": + # Block-wise weight-only FP8 (BF16 activations at serve time). + # Consumed via compressed-tensors as W8A16. + layer_config = { + "quant_algo": "FP8_PB_WO", + "group_size": block_size_value, + } else: layer_config = {"quant_algo": v} @@ -871,7 +887,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 diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index 144a03ce8ce..d7b276e07e8 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -78,7 +78,7 @@ from .model_config import ( QUANTIZATION_FP8, QUANTIZATION_FP8_PB_REAL, - QUANTIZATION_FP8_PB_WO, + QUANTIZATION_FP8_PB_W8A8, QUANTIZATION_FP8_PC_PT, QUANTIZATION_MXFP8, QUANTIZATION_NONE, @@ -744,12 +744,15 @@ def _export_quantized_weight( # Register the corrected weight scale as a buffer. if weight_scale is not None: - if quantization_format == QUANTIZATION_FP8_PB_WO: - # DeepSeek/Qwen block-FP8 convention: same value, renamed key. - # weight_scale (= amax/448) is already the per-block dequant multiplier - # that SGLang/vLLM apply as weight_fp8 * weight_scale_inv, matching - # ModelOpt's TE/mcore path. Do NOT invert. Drop the plain weight_scale - # buffer so no stale key survives into the exported state dict. + if quantization_format == QUANTIZATION_FP8_PB_W8A8: + # W8A8 block FP8 is consumed via the flat ``quant_method: fp8`` path, + # which expects the per-block scale under ``weight_scale_inv``. + # The value (= amax/448) is already the per-block dequant multiplier + # applied as weight_fp8 * weight_scale_inv, matching ModelOpt's + # TE/mcore path. Do NOT invert. Drop the plain weight_scale buffer so + # no stale key survives into the exported state dict. + # NOTE: weight-only block FP8 (FP8_PB_WO) keeps the ``weight_scale`` + # key below, since it is consumed via compressed-tensors. 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] diff --git a/modelopt/torch/export/unified_export_megatron.py b/modelopt/torch/export/unified_export_megatron.py index 070a4478838..a316b4472f5 100644 --- a/modelopt/torch/export/unified_export_megatron.py +++ b/modelopt/torch/export/unified_export_megatron.py @@ -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, @@ -288,6 +289,7 @@ def save_pretrained( if quantization_format in ( QUANTIZATION_FP8_PB_REAL, QUANTIZATION_FP8_PB_WO, + QUANTIZATION_FP8_PB_W8A8, ): quantization = quantization_format elif quantization_format == QUANTIZATION_FP8: diff --git a/modelopt/torch/quantization/config.py b/modelopt/torch/quantization/config.py index 9d0ee7afaf7..d2d6f96d1d0 100644 --- a/modelopt/torch/quantization/config.py +++ b/modelopt/torch/quantization/config.py @@ -1413,6 +1413,9 @@ def _load_quantizer_cfg_dict_list(config_path: str) -> list[dict[str, Any]]: FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG: dict[str, Any] = _load_quantize_config_dict( "configs/ptq/presets/model/fp8_2d_blockwise_weight_only" ) +FP8_2D_BLOCKWISE_W8A8_DYNAMIC_CFG: dict[str, Any] = _load_quantize_config_dict( + "configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic" +) INT4_BLOCKWISE_WEIGHT_ONLY_CFG: dict[str, Any] = _load_quantize_config_dict( "configs/ptq/presets/model/int4_blockwise_weight_only" ) @@ -1495,6 +1498,7 @@ def _load_quantizer_cfg_dict_list(config_path: str) -> list[dict[str, Any]]: # modelopt_recipes/general/ptq/ as a yaml file choices: set[str] = { "FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG", + "FP8_2D_BLOCKWISE_W8A8_DYNAMIC_CFG", "FP8_AFFINE_KV_CFG", "FP8_DEFAULT_CFG", "FP8_KV_CFG", From bed3730df09f92e9b564561cbe94772e396c377c Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:44:24 +0000 Subject: [PATCH 03/12] clean up Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 28 +++++-------------- modelopt/torch/export/model_config.py | 4 +-- modelopt/torch/export/quant_utils.py | 14 +++------- modelopt/torch/export/unified_export_hf.py | 14 ++++------ .../torch/export/unified_export_megatron.py | 2 -- tests/gpu/torch/export/test_export.py | 15 ++++++++++ 6 files changed, 33 insertions(+), 44 deletions(-) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index f155870fe8f..0a0c79586f3 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -105,19 +105,10 @@ 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": - # Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8, - # DeepSeek/Qwen-style block FP8). ``block_structure`` carries the 2D - # weight block shape (e.g. [128, 128]); activations are quantized - # dynamically in 1xgs groups at runtime. + # 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 { - "input_activations": { - "dynamic": True, - "num_bits": 8, - "type": "float", - "strategy": "block", - "block_structure": [1, gs], - }, "weights": { "dynamic": False, "num_bits": 8, @@ -127,8 +118,7 @@ def _quant_algo_to_group_config(quant_algo: str, group_size: int | None = None) }, } elif quant_algo == "FP8_PB_WO": - # Block-wise weight-only FP8 (W8A16): weights quantized in gsxgs blocks, - # activations kept in high precision (no input_activations entry). + # Block-wise weight-only FP8 (no activation quantization). gs = group_size or 128 return { "weights": { @@ -201,13 +191,9 @@ 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 (128x128 block-wise FP8 weights + dynamic per-token FP8 activations, - # i.e. W8A8) is consumed as a native DeepSeek/Qwen-style block-FP8 checkpoint - # rather than via the compressed-tensors ``config_groups`` schema. Emit the - # flat ``quant_method: "fp8"`` config that vLLM/SGLang expect (weights stored - # as fp8_e4m3 with a per-block ``weight_scale_inv``; activations quantized - # dynamically at runtime). Weight-only block FP8 (FP8_PB_WO) instead flows - # through the compressed-tensors ``config_groups`` path below. + # FP8_PB (block-wise FP8, W8A8): emit the flat ``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": group_size = original_quantization_details.get("group_size") or 128 exclude_modules = original_quantization_details.get("exclude_modules") or [] @@ -254,7 +240,7 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An } new_config["config_groups"] = {"group_0": config_group_details} elif quant_algo_value == "FP8_PB_WO": - # Weight-only block FP8 (W8A16): block weights, high-precision activations. + # Weight-only block FP8: weights-only group, no activation indicators. group_size = original_quantization_details.get("group_size") or 128 config_group_details = _quant_algo_to_group_config("FP8_PB_WO", group_size) config_group_details["targets"] = ["Linear"] diff --git a/modelopt/torch/export/model_config.py b/modelopt/torch/export/model_config.py index 730cbd6f11c..35e84ffe0ce 100755 --- a/modelopt/torch/export/model_config.py +++ b/modelopt/torch/export/model_config.py @@ -41,9 +41,9 @@ QUANTIZATION_W4A16_NVFP4 = "w4a16_nvfp4" QUANTIZATION_NVFP4_AWQ = "nvfp4_awq" QUANTIZATION_FP8_PB_REAL = "fp8_pb_real" -# Block-wise FP8, weight-only (BF16 activations at serve time) -> compressed-tensors W8A16. +# Block-wise FP8, weight-only calibration; serves as W8A8 (quant_algo FP8_PB). QUANTIZATION_FP8_PB_WO = "fp8_pb_wo" -# Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8) -> flat quant_method: fp8. +# Block-wise FP8 with activations also calibrated (W8A8, quant_algo FP8_PB). QUANTIZATION_FP8_PB_W8A8 = "fp8_pb_w8a8" QUANTIZATION_FP8_PC_PT = "fp8_pc_pt" diff --git a/modelopt/torch/export/quant_utils.py b/modelopt/torch/export/quant_utils.py index 7afc8aacf3c..0a6e383676e 100755 --- a/modelopt/torch/export/quant_utils.py +++ b/modelopt/torch/export/quant_utils.py @@ -538,12 +538,8 @@ def _get_quantization_from_layer(layer, quantizer_attr_names: QuantizerAttrNames and block_sizes.get("scale_bits") == (8, 0) ): return QUANTIZATION_MXFP8 - # Block FP8 (DeepSeek/Qwen style). _REAL = pre-packed weights - # carrying quantizer._scale; _WO/_W8A8 = fake-quant simulated PTQ. - # All three export real packed FP8 weights. The input_quantizer - # selects the activation scheme for the fake-quant path: - # enabled -> W8A8 with dynamic per-token activation (flat fp8), - # disabled -> weight-only W8A16 (compressed-tensors). + # Block FP8 (serves as W8A8). _REAL = pre-packed weights; _WO/_W8A8 + # = fake-quant PTQ (_W8A8 also calibrates activations). All -> FP8_PB. if not weight_quantizer.fake_quant: return QUANTIZATION_FP8_PB_REAL if input_quantizer is not None and input_quantizer.is_enabled: @@ -767,15 +763,13 @@ def process_layer_quant_config(layer_config_dict): "group_size": block_size_value, } elif v == "fp8_pb_w8a8": - # Block-wise FP8 weights + dynamic per-token FP8 activations (W8A8, - # DeepSeek/Qwen-style block FP8). Consumed via flat quant_method: fp8. + # Block-wise FP8 W8A8 -> flat quant_method: fp8 (dynamic activations). layer_config = { "quant_algo": "FP8_PB", "group_size": block_size_value, } elif v == "fp8_pb_wo": - # Block-wise weight-only FP8 (BF16 activations at serve time). - # Consumed via compressed-tensors as W8A16. + # Block-wise weight-only FP8 -> weights-only config (no activation quant). layer_config = { "quant_algo": "FP8_PB_WO", "group_size": block_size_value, diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index d7b276e07e8..f59d5c702ce 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -79,6 +79,7 @@ QUANTIZATION_FP8, QUANTIZATION_FP8_PB_REAL, QUANTIZATION_FP8_PB_W8A8, + QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PC_PT, QUANTIZATION_MXFP8, QUANTIZATION_NONE, @@ -744,15 +745,10 @@ def _export_quantized_weight( # Register the corrected weight scale as a buffer. if weight_scale is not None: - if quantization_format == QUANTIZATION_FP8_PB_W8A8: - # W8A8 block FP8 is consumed via the flat ``quant_method: fp8`` path, - # which expects the per-block scale under ``weight_scale_inv``. - # The value (= amax/448) is already the per-block dequant multiplier - # applied as weight_fp8 * weight_scale_inv, matching ModelOpt's - # TE/mcore path. Do NOT invert. Drop the plain weight_scale buffer so - # no stale key survives into the exported state dict. - # NOTE: weight-only block FP8 (FP8_PB_WO) keeps the ``weight_scale`` - # key below, since it is consumed via compressed-tensors. + if quantization_format in (QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PB_W8A8): + # Flat quant_method: fp8 expects the per-block scale as weight_scale_inv. + # Value (= amax/448) is the dequant multiplier; do NOT invert. Drop the + # plain weight_scale buffer so no stale key remains. 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] diff --git a/modelopt/torch/export/unified_export_megatron.py b/modelopt/torch/export/unified_export_megatron.py index a316b4472f5..070a4478838 100644 --- a/modelopt/torch/export/unified_export_megatron.py +++ b/modelopt/torch/export/unified_export_megatron.py @@ -41,7 +41,6 @@ KV_CACHE_NVFP4, QUANTIZATION_FP8, QUANTIZATION_FP8_PB_REAL, - QUANTIZATION_FP8_PB_W8A8, QUANTIZATION_FP8_PB_WO, QUANTIZATION_NONE, QUANTIZATION_NVFP4, @@ -289,7 +288,6 @@ def save_pretrained( if quantization_format in ( QUANTIZATION_FP8_PB_REAL, QUANTIZATION_FP8_PB_WO, - QUANTIZATION_FP8_PB_W8A8, ): quantization = quantization_format elif quantization_format == QUANTIZATION_FP8: diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index 48cb2ff64d0..e144875a6a1 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -149,6 +149,21 @@ def test_get_quantization_format(config, expected): "layer2.awq_block_size": 128, "layer8.quantization": None, }, + { + "quant_algo": "FP8_PB_WO", + "kv_cache_quant_algo": None, + "group_size": 128, + "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, From aa39f30cad2944a37bee1fa7acd6f6b1ee00dc24 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:53:30 +0000 Subject: [PATCH 04/12] update Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/recipe/presets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modelopt/recipe/presets.py b/modelopt/recipe/presets.py index 46b55287074..137e456eb16 100644 --- a/modelopt/recipe/presets.py +++ b/modelopt/recipe/presets.py @@ -76,6 +76,7 @@ "nvfp4_mse": "nvfp4_w4a4_weight_mse_fp8_sweep", "nvfp4_local_hessian": "nvfp4_w4a4_weight_local_hessian", "fp8_pb_wo": "fp8_2d_blockwise_weight_only", + "fp8_pb_w8a8": "fp8_2d_blockwise_w8a8_dynamic", "fp8_pc_pt": "fp8_per_channel_per_token", } From cda83b6af6e1baa65349b2e061c79512a4a402e4 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:26:35 +0000 Subject: [PATCH 05/12] update Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 18 ------------------ modelopt/torch/export/quant_utils.py | 13 ++++--------- modelopt/torch/export/unified_export_hf.py | 3 +-- tests/gpu/torch/export/test_export.py | 4 ++-- 4 files changed, 7 insertions(+), 31 deletions(-) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index 0a0c79586f3..d6a7f3307d7 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -117,18 +117,6 @@ def _quant_algo_to_group_config(quant_algo: str, group_size: int | None = None) "block_structure": [gs, gs], }, } - elif quant_algo == "FP8_PB_WO": - # Block-wise weight-only FP8 (no activation quantization). - gs = group_size or 128 - return { - "weights": { - "dynamic": False, - "num_bits": 8, - "type": "float", - "strategy": "block", - "block_structure": [gs, gs], - }, - } else: warnings.warn( f"Unsupported quantization algorithm '{quant_algo}' in " @@ -239,12 +227,6 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An "targets": ["Linear"], } new_config["config_groups"] = {"group_0": config_group_details} - elif quant_algo_value == "FP8_PB_WO": - # Weight-only block FP8: weights-only group, no activation indicators. - group_size = original_quantization_details.get("group_size") or 128 - config_group_details = _quant_algo_to_group_config("FP8_PB_WO", group_size) - config_group_details["targets"] = ["Linear"] - new_config["config_groups"] = {"group_0": config_group_details} elif quant_algo_value == "MIXED_PRECISION": quantized_layers = original_quantization_details.get("quantized_layers", {}) diff --git a/modelopt/torch/export/quant_utils.py b/modelopt/torch/export/quant_utils.py index 0a6e383676e..2997d3ab00b 100755 --- a/modelopt/torch/export/quant_utils.py +++ b/modelopt/torch/export/quant_utils.py @@ -538,8 +538,9 @@ def _get_quantization_from_layer(layer, quantizer_attr_names: QuantizerAttrNames and block_sizes.get("scale_bits") == (8, 0) ): return QUANTIZATION_MXFP8 - # Block FP8 (serves as W8A8). _REAL = pre-packed weights; _WO/_W8A8 - # = fake-quant PTQ (_W8A8 also calibrates activations). All -> FP8_PB. + # Block FP8. _REAL = pre-packed weights; else fake-quant PTQ. + # Input quantizer enabled -> W8A8 (dynamic activations at serve); + # disabled -> FP8_PB_WO (unchanged from main). if not weight_quantizer.fake_quant: return QUANTIZATION_FP8_PB_REAL if input_quantizer is not None and input_quantizer.is_enabled: @@ -763,17 +764,11 @@ def process_layer_quant_config(layer_config_dict): "group_size": block_size_value, } elif v == "fp8_pb_w8a8": - # Block-wise FP8 W8A8 -> flat quant_method: fp8 (dynamic activations). + # Block-wise FP8 W8A8 -> flat quant_method: fp8. layer_config = { "quant_algo": "FP8_PB", "group_size": block_size_value, } - elif v == "fp8_pb_wo": - # Block-wise weight-only FP8 -> weights-only config (no activation quant). - layer_config = { - "quant_algo": "FP8_PB_WO", - "group_size": block_size_value, - } else: layer_config = {"quant_algo": v} diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index f59d5c702ce..f17514a408c 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -79,7 +79,6 @@ QUANTIZATION_FP8, QUANTIZATION_FP8_PB_REAL, QUANTIZATION_FP8_PB_W8A8, - QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PC_PT, QUANTIZATION_MXFP8, QUANTIZATION_NONE, @@ -745,7 +744,7 @@ def _export_quantized_weight( # Register the corrected weight scale as a buffer. if weight_scale is not None: - if quantization_format in (QUANTIZATION_FP8_PB_WO, QUANTIZATION_FP8_PB_W8A8): + if quantization_format == QUANTIZATION_FP8_PB_W8A8: # Flat quant_method: fp8 expects the per-block scale as weight_scale_inv. # Value (= amax/448) is the dequant multiplier; do NOT invert. Drop the # plain weight_scale buffer so no stale key remains. diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index e144875a6a1..1447f9ddd9c 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -143,14 +143,14 @@ def test_get_quantization_format(config, expected): ), ( { - "layer1.quantization": "fp8_pb_wo", # 128x128 block-wise weight-only FP8 + "layer1.quantization": "fp8_pb_wo", # 128x128 block-wise FP8 "layer1.awq_block_size": 128, "layer2.quantization": "fp8_pb_wo", "layer2.awq_block_size": 128, "layer8.quantization": None, }, { - "quant_algo": "FP8_PB_WO", + "quant_algo": "FP8_PB", "kv_cache_quant_algo": None, "group_size": 128, "exclude_modules": ["layer8"], From 8addf5a5246953e43dab7b1f52c56a7dc057cd02 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:42:44 +0000 Subject: [PATCH 06/12] clean up Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 2 +- modelopt/torch/export/model_config.py | 2 -- modelopt/torch/export/quant_utils.py | 10 ++++------ modelopt/torch/quantization/config.py | 4 ---- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index d6a7f3307d7..373af56b199 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -179,7 +179,7 @@ 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 flat ``quant_method: fp8`` config + # 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": diff --git a/modelopt/torch/export/model_config.py b/modelopt/torch/export/model_config.py index 35e84ffe0ce..bb6200d9dc1 100755 --- a/modelopt/torch/export/model_config.py +++ b/modelopt/torch/export/model_config.py @@ -41,9 +41,7 @@ QUANTIZATION_W4A16_NVFP4 = "w4a16_nvfp4" QUANTIZATION_NVFP4_AWQ = "nvfp4_awq" QUANTIZATION_FP8_PB_REAL = "fp8_pb_real" -# Block-wise FP8, weight-only calibration; serves as W8A8 (quant_algo FP8_PB). QUANTIZATION_FP8_PB_WO = "fp8_pb_wo" -# Block-wise FP8 with activations also calibrated (W8A8, quant_algo FP8_PB). QUANTIZATION_FP8_PB_W8A8 = "fp8_pb_w8a8" QUANTIZATION_FP8_PC_PT = "fp8_pc_pt" diff --git a/modelopt/torch/export/quant_utils.py b/modelopt/torch/export/quant_utils.py index 2997d3ab00b..3c457e288d6 100755 --- a/modelopt/torch/export/quant_utils.py +++ b/modelopt/torch/export/quant_utils.py @@ -530,17 +530,15 @@ def _get_quantization_from_layer(layer, quantizer_attr_names: QuantizerAttrNames if weight_quantizer.num_bits == (4, 3): if weight_quantizer.block_sizes: assert weight_quantizer.block_sizes[-1] > 0, "Invalid block_sizes for FP8 quantizer" - # MXFP8: dynamic block quant with E8M0 (scale_bits (8, 0)) scales. - block_sizes = weight_quantizer.block_sizes + # Check if this is MXFP8 (dynamic block quantization with scale_bits (8, 0)) + block_sizes = getattr(weight_quantizer, "block_sizes") if ( isinstance(block_sizes, dict) and block_sizes.get("type", "static") == "dynamic" and block_sizes.get("scale_bits") == (8, 0) ): return QUANTIZATION_MXFP8 - # Block FP8. _REAL = pre-packed weights; else fake-quant PTQ. - # Input quantizer enabled -> W8A8 (dynamic activations at serve); - # disabled -> FP8_PB_WO (unchanged from main). + # 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: @@ -764,7 +762,7 @@ def process_layer_quant_config(layer_config_dict): "group_size": block_size_value, } elif v == "fp8_pb_w8a8": - # Block-wise FP8 W8A8 -> flat quant_method: fp8. + # Block-wise FP8, W8A8 at serve time. layer_config = { "quant_algo": "FP8_PB", "group_size": block_size_value, diff --git a/modelopt/torch/quantization/config.py b/modelopt/torch/quantization/config.py index d2d6f96d1d0..9d0ee7afaf7 100644 --- a/modelopt/torch/quantization/config.py +++ b/modelopt/torch/quantization/config.py @@ -1413,9 +1413,6 @@ def _load_quantizer_cfg_dict_list(config_path: str) -> list[dict[str, Any]]: FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG: dict[str, Any] = _load_quantize_config_dict( "configs/ptq/presets/model/fp8_2d_blockwise_weight_only" ) -FP8_2D_BLOCKWISE_W8A8_DYNAMIC_CFG: dict[str, Any] = _load_quantize_config_dict( - "configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic" -) INT4_BLOCKWISE_WEIGHT_ONLY_CFG: dict[str, Any] = _load_quantize_config_dict( "configs/ptq/presets/model/int4_blockwise_weight_only" ) @@ -1498,7 +1495,6 @@ def _load_quantizer_cfg_dict_list(config_path: str) -> list[dict[str, Any]]: # modelopt_recipes/general/ptq/ as a yaml file choices: set[str] = { "FP8_2D_BLOCKWISE_WEIGHT_ONLY_CFG", - "FP8_2D_BLOCKWISE_W8A8_DYNAMIC_CFG", "FP8_AFFINE_KV_CFG", "FP8_DEFAULT_CFG", "FP8_KV_CFG", From c03ba3f68a53120ae7c1f5b90605196bc1e51d74 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:00:51 +0000 Subject: [PATCH 07/12] fixed failed tests Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- .../model/fp8_2d_blockwise_w8a8_dynamic.yaml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 modelopt_recipes/configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic.yaml diff --git a/modelopt_recipes/configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic.yaml b/modelopt_recipes/configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic.yaml new file mode 100644 index 00000000000..7ce0df50d9b --- /dev/null +++ b/modelopt_recipes/configs/ptq/presets/model/fp8_2d_blockwise_w8a8_dynamic.yaml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# 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 From 20a1f5fe4b66b0df078ee333711ce327e46b6cb1 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:03:51 +0000 Subject: [PATCH 08/12] minor Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/recipe/presets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/modelopt/recipe/presets.py b/modelopt/recipe/presets.py index 137e456eb16..46b55287074 100644 --- a/modelopt/recipe/presets.py +++ b/modelopt/recipe/presets.py @@ -76,7 +76,6 @@ "nvfp4_mse": "nvfp4_w4a4_weight_mse_fp8_sweep", "nvfp4_local_hessian": "nvfp4_w4a4_weight_local_hessian", "fp8_pb_wo": "fp8_2d_blockwise_weight_only", - "fp8_pb_w8a8": "fp8_2d_blockwise_w8a8_dynamic", "fp8_pc_pt": "fp8_per_channel_per_token", } From 440f5cd4e2d4bb43455a146a4d7837fce2feb38e Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:44:14 +0000 Subject: [PATCH 09/12] PR reviews, modelopt bot, claude Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/unified_export_hf.py | 1 + tests/gpu/torch/export/test_export.py | 65 ++++++++++++++----- .../torch/export/test_get_quantization.py | 25 ++++++- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index f17514a408c..aeeccd9b8a8 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -767,6 +767,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, ): diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index 1447f9ddd9c..1888f8b395e 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -141,21 +141,6 @@ def test_get_quantization_format(config, expected): "exclude_modules": ["layer8"], }, ), - ( - { - "layer1.quantization": "fp8_pb_wo", # 128x128 block-wise FP8 - "layer1.awq_block_size": 128, - "layer2.quantization": "fp8_pb_wo", - "layer2.awq_block_size": 128, - "layer8.quantization": None, - }, - { - "quant_algo": "FP8_PB", - "kv_cache_quant_algo": None, - "group_size": 128, - "exclude_modules": ["layer8"], - }, - ), ( { "layer1.quantization": "fp8_pb_w8a8", # 128x128 block-wise FP8 (W8A8) @@ -550,3 +535,53 @@ 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 + + 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" + ) diff --git a/tests/unit/torch/export/test_get_quantization.py b/tests/unit/torch/export/test_get_quantization.py index 1199f4c7cf0..448152a973b 100644 --- a/tests/unit/torch/export/test_get_quantization.py +++ b/tests/unit/torch/export/test_get_quantization.py @@ -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() From b026888d52a7f1ff428a38c4dc40e4096f3d0024 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:17:47 +0000 Subject: [PATCH 10/12] fixed 4D scale export bug Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/unified_export_hf.py | 12 ++++++++++++ tests/gpu/torch/export/test_export.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index aeeccd9b8a8..22c0f2ac649 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -748,6 +748,18 @@ def _export_quantized_weight( # Flat quant_method: fp8 expects the per-block scale as weight_scale_inv. # Value (= amax/448) is the dequant multiplier; do NOT invert. Drop the # plain weight_scale buffer so no stale key remains. + # + # The block-amax is computed with keepdim, yielding a 4-D scale + # [out_blocks, 1, in_blocks, 1]. Collapse the singleton block axes to + # the 2-D [out_blocks, in_blocks] DeepSeek/Qwen layout that both + # ModelOpt's own _QuantFP8Linear reload path and vLLM/SGLang's stock + # block-FP8 loader expect. The squeeze is lossless. + 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] diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index 1888f8b395e..38906a05c37 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -585,3 +585,17 @@ def test_fp8_pb_w8a8_export_uses_weight_scale_inv(tmp_path): 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 " + f"{tuple(scale_inv.shape)}" + ) From b4377c45e58ad4be6206f2ee5439957972d072d8 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:23:30 +0000 Subject: [PATCH 11/12] trimmed comments Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/unified_export_hf.py | 11 ++--------- tests/gpu/torch/export/test_export.py | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/modelopt/torch/export/unified_export_hf.py b/modelopt/torch/export/unified_export_hf.py index 22c0f2ac649..813226b1761 100644 --- a/modelopt/torch/export/unified_export_hf.py +++ b/modelopt/torch/export/unified_export_hf.py @@ -745,15 +745,8 @@ def _export_quantized_weight( # Register the corrected weight scale as a buffer. if weight_scale is not None: if quantization_format == QUANTIZATION_FP8_PB_W8A8: - # Flat quant_method: fp8 expects the per-block scale as weight_scale_inv. - # Value (= amax/448) is the dequant multiplier; do NOT invert. Drop the - # plain weight_scale buffer so no stale key remains. - # - # The block-amax is computed with keepdim, yielding a 4-D scale - # [out_blocks, 1, in_blocks, 1]. Collapse the singleton block axes to - # the 2-D [out_blocks, in_blocks] DeepSeek/Qwen layout that both - # ModelOpt's own _QuantFP8Linear reload path and vLLM/SGLang's stock - # block-FP8 loader expect. The squeeze is lossless. + # 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 diff --git a/tests/gpu/torch/export/test_export.py b/tests/gpu/torch/export/test_export.py index 38906a05c37..c48aa0824ac 100644 --- a/tests/gpu/torch/export/test_export.py +++ b/tests/gpu/torch/export/test_export.py @@ -596,6 +596,5 @@ def test_fp8_pb_w8a8_export_uses_weight_scale_inv(tmp_path): 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 " - f"{tuple(scale_inv.shape)}" + f"{k} must be 2-D [out_blocks, in_blocks], got {tuple(scale_inv.shape)}" ) From 8251f485d3b51f7c48a2fdeb2bf62589c6f27d41 Mon Sep 17 00:00:00 2001 From: Suguna Velury <178320438+sugunav14@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:50:02 +0000 Subject: [PATCH 12/12] claude review Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com> --- modelopt/torch/export/convert_hf_config.py | 5 +++++ modelopt/torch/export/unified_export_megatron.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/modelopt/torch/export/convert_hf_config.py b/modelopt/torch/export/convert_hf_config.py index 373af56b199..ff02e24b6ab 100644 --- a/modelopt/torch/export/convert_hf_config.py +++ b/modelopt/torch/export/convert_hf_config.py @@ -183,6 +183,11 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An # vLLM/SGLang expect (weight_scale_inv + dynamic activations), matching the # official Qwen3.5 FP8 checkpoint. if quant_algo_value == "FP8_PB": + 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] = { diff --git a/modelopt/torch/export/unified_export_megatron.py b/modelopt/torch/export/unified_export_megatron.py index 070a4478838..5b353d010e7 100644 --- a/modelopt/torch/export/unified_export_megatron.py +++ b/modelopt/torch/export/unified_export_megatron.py @@ -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, @@ -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. @@ -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)