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
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Changelog

*Quantization*

- Add opt-in FP8 Vision Encoder recipes under the ``qwen3_vl`` and ``qwen3_5`` model types. The vision-only recipe keeps the language model and KV cache in high precision; the joint recipe quantizes Vision Encoder and language-model Linears and uses FP8 KV-cache cast. Both quantize primary and deepstack merger Linears where present, while leaving patch embedding and vision-attention BMMs in high precision. Exported checkpoints require an inference runtime that supports quantized Vision Encoder Linears.
- Add the ``nvfp4_act_headroom`` calibration algorithm for NVFP4 **activation** global scales. Plain ``max`` sets a tensor's global scale from the largest per-block amax seen during calibration, leaving no room above it, so any activation larger than the calibration max saturates. ``nvfp4_act_headroom`` instead anchors the global scale to a low percentile of the per-block amax distribution, ``amax = max(rho * anchor, upper)``, placing the calibrated blocks in the lower part of the FP8 block-scale range and leaving the rest as headroom. ``upper`` is the top of the range the scale commits to representing and defaults to the 99.99th percentile rather than the literal maximum: chasing a lone freak block would drag the global scale up until every other block's FP8 block scale falls below subnormal and flushes to zero, so the rarest blocks are clipped instead. Set ``upper_percentile=100`` to use the literal observed max, which guarantees no calibration data is clipped. The calibrator warns when the per-block range is too wide for ``rho`` to clear any headroom. Tunable via ``anchor_percentile`` (default 1), ``upper_percentile`` (default 99.99) and ``rho`` (default 16384). Applies only to NVFP4 dynamic-block input quantizers. Weight scales are an orthogonal axis, selected by a nested ``weight_scale_algorithm`` (``max`` by default, or ``mse`` / ``local_hessian`` with that algorithm's own options), so one recipe can combine a weight calibration with this activation policy in a single pass. ``SequentialQuantizer`` activation quantizers are not supported and raise. Ships ``modelopt_recipes/general/ptq/nvfp4_act_headroom-kv_fp8_cast.yaml``, which mirrors ``nvfp4_default-kv_fp8_cast`` (dynamic NVFP4 W4A4 plus FP8 KV-cache cast) with only the calibration algorithm swapped, so it exports a standard NVFP4 checkpoint.

*Misc*
Expand All @@ -17,10 +18,14 @@ Changelog

**Backward Breaking Changes**

- Image-text calibration with ``--recipe`` now applies recipe rules to the complete VLM. Custom recipes must explicitly exclude the vision branch when it should remain in high precision. The deprecated ``--auto_quantize_bits`` path also retains the complete VLM, although AutoQuantize continues to reject image-text calibration before quantization.

**Deprecations**

**Bug Fixes**

- Fix image-text calibration for non-Nemotron VLMs by forwarding multimodal batches through the complete VLM while instrumenting only the selected quantization target.

0.46 (2026-08-xx)
^^^^^^^^^^^^^^^^^

Expand Down
57 changes: 52 additions & 5 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Please reference our [framework scripts](#framework-scripts) and our [docs](http
| Llava (VLM)<sup>11</sup> | ✅ | ✅<sup>12</sup> | ✅ | ✅ | - |
| Phi-3-vision, Phi-4-multimodal (VLM)<sup>11</sup> | ✅ | ✅<sup>12</sup> | ✅ | ✅ | ✅ |
| Qwen2, 2.5-VL (VLM)<sup>11</sup> | ✅ | ✅<sup>12</sup> | ✅ | ✅ | ✅ |
| Qwen3-VL, Qwen3.5 (VLM)<sup>11,14</sup> | ✅ | - | - | - | - |
| Gemma 3 (VLM)<sup>11</sup> | ✅ | - | - | - | - |
| Nemotron VL (VLM)<sup>11,13</sup> | ✅ | - | - | - | ✅ |

Expand All @@ -136,18 +137,20 @@ Please reference our [framework scripts](#framework-scripts) and our [docs](http
> *<sup>8.</sup>GLM-4.7 has MTP (Multi-Token Prediction) layers that are automatically loaded and excluded from quantization.* \
> *<sup>9.</sup>Running Whisper model with transformers>=5.0 requires [torchcodec](https://github.com/meta-pytorch/torchcodec?tab=readme-ov-file#installing-cuda-enabled-torchcodec) and other system packages (e.g. ffmpeg).* \
> *<sup>10.</sup>GPT-OSS ships with native MXFP4 weights; NVFP4 export is produced via the closed-form `--cast_mxfp4_to_nvfp4` cast (see [MXFP4 → NVFP4 cast](#mxfp4--nvfp4-cast-for-gpt-oss)).* \
> *<sup>11.</sup>Vision-language model (VLM): only the language model is quantized while the vision encoder is kept in high precision. Pass `--vlm` to the shell script (see [VLM quantization](#vlm-quantization)).* \
> *<sup>11.</sup>Vision-language model (VLM): by default, only the language model is quantized while the vision encoder is kept in high precision. Pass `--vlm` to the shell script (see [VLM quantization](#vlm-quantization)).* \
> *<sup>12.</sup>For VLMs, `int8_sq` only supports TensorRT-LLM checkpoint export and is not compatible with the TensorRT-LLM torch backend.* \
> *<sup>13.</sup>Nemotron VL automatically calibrates with image-text pairs; see [VLM calibration with image-text pairs](#vlm-calibration-with-image-text-pairs-eg-nemotron-vl).*
> *<sup>13.</sup>Nemotron VL automatically calibrates with image-text pairs; see [VLM calibration with image-text pairs](#vlm-calibration-with-image-text-pairs-eg-nemotron-vl).* \
> *<sup>14.</sup>Qwen3-VL and dense Qwen3.5 VLM checkpoints support opt-in FP8 vision encoder quantization through model-specific recipes. Vision Linear layers, including primary and deepstack merger Linears where present, are quantized; patch embedding and vision-attention BMMs remain in high precision. MoE variants are not validated by these recipes.*

> *The accuracy loss after PTQ may vary depending on the actual model and the quantization method. Different models may have different accuracy loss and usually the accuracy loss is more significant when the base model is small. If the accuracy after PTQ is not meeting the requirement, please try either modifying [hf_ptq.py](./hf_ptq.py) and disabling the KV cache quantization or using the [QAT](./../llm_qat/README.md) instead. For NVFP4 quantization specifically, we recommend `nvfp4_mlp_only`, `nvfp4_experts_only`, or `nvfp4_omlp_only` to achieve higher accuracy by restricting quantization to the MLP/expert layers (and optionally the `o_proj` layer) while keeping the attention QKV projections unquantized.*

> You can also create your own custom config using [this](https://nvidia.github.io/Model-Optimizer/guides/_pytorch_quantization.html#custom-calibration-algorithm) guide.

> *Vision-language models (VLMs) are listed in the support matrix above (rows marked `(VLM)`). PTQ for
> VLMs is handled by the same `hf_ptq.py` entry point and shell script as LLMs — the language model is
> quantized while the vision encoder is kept in high precision. Pass `--vlm` to the shell script (see
> [VLM quantization](#vlm-quantization)). For detailed TensorRT-LLM torch backend multimodal support,
> VLMs is handled by the same `hf_ptq.py` entry point and shell script as LLMs. By default, the
> language model is quantized while the vision branch remains in high precision. Dense Qwen3-VL
> and dense Qwen3.5 VLM checkpoints additionally support the opt-in FP8 recipes documented under
> [VLM quantization](#vlm-quantization). For detailed TensorRT-LLM torch backend multimodal support,
> please refer to [this doc](https://github.com/NVIDIA/TensorRT-LLM/blob/main/docs/source/models/supported-models.md#multimodal-feature-support-matrix-pytorch-backend).*

## Framework Scripts
Expand Down Expand Up @@ -269,6 +272,46 @@ scripts/huggingface_example.sh --model <Hugging Face model card or checkpoint> -
Supported `--quant` values for VLMs are `fp8`, `nvfp4`, `int8_sq`, `int4_awq`, and `w4a8_awq` (see
the `(VLM)` rows in the [Support Matrix](#hugging-face-supported-models)).

By default, `hf_ptq.py` applies `--qformat` only to the language model. Model-specific recipes
add FP8 quantization of the vision branch for validated Qwen3-VL and dense Qwen3.5 checkpoints.
Use the recipe directory matching the checkpoint's `model_type`: `qwen3_vl` or `qwen3_5`.

```bash
# Vision encoder only: FP8 vision Linears and merger, BF16 LLM and KV cache.
python hf_ptq.py \
--pyt_ckpt_path <Qwen3-VL-or-Qwen3.5-checkpoint> \
--recipe huggingface/qwen3_vl/ptq/fp8_vision-kv_none \
--calib_with_images \
--calib_size 512 \
--skip_generate \
--export_path <quantized-checkpoint>

# Joint vision encoder + language model FP8 with FP8 KV-cache cast.
python hf_ptq.py \
--pyt_ckpt_path <Qwen3-VL-or-Qwen3.5-checkpoint> \
--recipe huggingface/qwen3_vl/ptq/fp8_vision_lm-kv_fp8_cast \
--calib_with_images \
--calib_size 512 \
--skip_generate \
--export_path <quantized-checkpoint>
```

`fp8_vision-kv_none` starts from all quantizers disabled and enables FP8 only for `nn.Linear`
weights and inputs under the `visual` branch, including the primary merger and any deepstack
mergers. The language model, KV cache, patch embedding, and vision-attention QK/softmax/AV
operations stay in high precision.
`fp8_vision_lm-kv_fp8_cast` applies the standard FP8 model recipe to both model branches and enables FP8
KV-cache cast, while keeping patch embedding and vision-attention operands in high precision.
Other precision combinations can be expressed by composing the same recipe units in a custom
recipe; no model-specific Python path is required.

The exported checkpoint requires an inference runtime that supports quantized vision encoder
Linears. Runtime-specific vision-attention quantization is separate from this ModelOpt checkpoint.
Use the direct `hf_ptq.py` commands above for these recipes; the generic multimodal quickstart is
not a serving validation for a vision-quantized checkpoint. Both examples pass `--skip_generate`
because the script's text-only preview does not forward the image tensors used during calibration.
For a Qwen3.5 checkpoint, replace `qwen3_vl` with `qwen3_5` in the recipe path.

> *This consolidates the former `examples/vlm_ptq` example, which now forwards here.*

#### VLM calibration with image-text pairs (e.g., Nemotron VL)
Expand All @@ -291,6 +334,10 @@ The same flag is exposed by the shell script:
scripts/huggingface_example.sh --model <model> --quant nvfp4 --vlm --calib_with_images --trust_remote_code
```

With `--calib_with_images`, calibration batches always pass through the complete VLM so image
features reach the component selected by the preset or recipe. This also applies to the default
language-model-only path: its quantizers are exercised by the complete multimodal forward.

> Note: when `--calib_with_images` is set, `--calib_size` must be a single value, and the calibration dataset is nvidia/nemotron_vlm_dataset_v2.
This functionality is currently in beta and has been tested on `nvidia/NVIDIA-Nemotron-Nano-12B-v2-VL-BF16`.

Expand Down
25 changes: 15 additions & 10 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,11 +663,15 @@ def load_model(args: argparse.Namespace):
default_padding_side = tokenizer.padding_side
tokenizer.padding_side = "left"

# Quantize only the language model, but keep the full_model for calibration forward.
extracted_lm, extracted_model_type = extract_and_prepare_language_model_from_vl(full_model)
if extracted_lm is not None:
language_model = extracted_lm
model_type = extracted_model_type
# Plain PTQ quantizes only the language model. Recipes and AutoQuantize keep the complete
# VLM so their quantizer rules can target vision and language components in one state.
if args.recipe is None and args.auto_quantize_bits is None:
extracted_lm, extracted_model_type = extract_and_prepare_language_model_from_vl(
full_model
)
if extracted_lm is not None:
language_model = extracted_lm
model_type = extracted_model_type
Comment on lines +666 to +674

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require image calibration for the vision FP8 recipes.

Without --calib_with_images, this flow retains the complete VLM but creates a text-only calibration loop. That loop does not execute visual, so the enabled *visual.*input_quantizer entries receive no activation calibration data. Reject vision FP8 recipes without --calib_with_images, and add a regression test for that error.

Also applies to: 788-792

🤖 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 `@examples/hf_ptq/hf_ptq.py` around lines 666 - 674, Require
--calib_with_images when running vision FP8 recipes, including the corresponding
validation path near the recipe/auto-quantization handling and the related logic
around this block. Reject the configuration with a clear error before
calibration starts, while preserving existing behavior for non-vision recipes
and image-enabled runs, and add a regression test covering the rejection.

else:
if args.specdec_offline_dataset is not None:
language_model = full_model
Expand Down Expand Up @@ -762,7 +766,7 @@ def mono_quantize(
calib_dataloader: DataLoader,
is_nemotron_vl_model: bool,
):
"""Plain quantization of the given language model to a single quantization configuration."""
"""Plain quantization of the selected model target to one quantization configuration."""

model_is_already_quantized = is_quantized(language_model)

Expand All @@ -781,9 +785,10 @@ def mono_quantize(
warnings.warn("Dynamic quantization. Calibration skipped.")
calibrate_loop = None
if use_calibration:
# For Nemotron VL image calibration, the dataloader yields multimodal kwargs (e.g., pixel_values).
# Those kwargs must be consumed by the *full* VLM model, not the extracted language_model.
if args.calib_with_images and is_nemotron_vl_model:
# Image calibration batches contain multimodal kwargs (for example pixel_values).
# They must be consumed by the complete VLM even when only a nested component is the
# quantization target; the full forward still exercises that component's quantizers.
if args.calib_with_images:

@juhi10071998 juhi10071998 Aug 5, 2026

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.

Thanks @Edwardf0t1 , overall looks good to me. I have a comment though about the behavior change for non-Nemotron VLM models that were previously running with -calib-with-image flag

Before this PR, users were (unknowingly) getting a text-only calibration loop even with the flag set. After this PR, the full VLM forward runs — pixel values go through the vision encoder, get projected into the LM's embedding space, and the LM quantizers see those activations. That changes the amax distributions and therefore the output scales, even when the vision encoder itself has no quantizers enabled.

So a Qwen2.5-VL or Phi-4-multimodal user running the same --calib_with_images PTQ command before and after this PR will get a different quantized checkpoint.

If my understanding is correct, should we mention this somewhere in the CHANGELOG so the users are aware of this behavior change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, the full VLM forward now makes the LM quantizers observe vision conditioned activations, so existing --calib_with_images commands may produce different activation ranges and scales. The vision branch still remains unquantized unless explicitly enabled by a recipe. The calibration fix is briefly mentioned in the CHANGELOG, but I can expand the entry to make the checkpoint impact explicit.

calibrate_loop = create_vlm_calibration_loop(full_model, calib_dataloader)
else:
calibrate_loop = create_forward_loop(
Expand All @@ -801,7 +806,7 @@ def mono_quantize(
language_model = mtq.quantize(language_model, quant_cfg, forward_loop=calibrate_loop)

# For VL models, update full_model to use the quantized language model
if is_nemotron_vl_model:
if is_nemotron_vl_model and language_model is not full_model:
language_model_lineage = get_language_model_from_vl(full_model)
if language_model_lineage is not None:
print("Updating full_model with quantized language_model...")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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.

# Dense Qwen3.5 Vision Encoder FP8 with a high-precision language model and KV cache.

imports:
base_disable_all: configs/ptq/units/base_disable_all
vision_fp8: huggingface/qwen3_vl/ptq/vision_fp8.quant_cfg

metadata:
recipe_type: ptq
description: >-
FP8 quantization of dense Qwen3.5 Vision Encoder Linear layers, including any merger Linears;
the language model, KV cache, patch embedding, and vision-attention operands remain in high
precision.

quantize:
algorithm: max
quant_cfg:
- $import: base_disable_all
- $import: vision_fp8
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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.

# Dense Qwen3.5 Vision Encoder and language model FP8 with FP8 KV-cache cast.

imports:
base_disable_all: configs/ptq/units/base_disable_all
default_disabled_quantizers: configs/ptq/units/default_disabled_quantizers
kv_fp8_cast: configs/ptq/units/kv_fp8_cast
vision_fp8: huggingface/qwen3_vl/ptq/vision_fp8.quant_cfg
w8a8_fp8_fp8: configs/ptq/units/w8a8_fp8_fp8

metadata:
recipe_type: ptq
description: >-
W8A8 FP8 quantization of dense Qwen3.5 Vision Encoder and language-model Linear layers,
including any merger Linears, with FP8 KV-cache cast; patch embedding and vision-attention
operands remain in high precision.

quantize:
algorithm: max
quant_cfg:
- $import: base_disable_all
- $import: w8a8_fp8_fp8
- $import: kv_fp8_cast
- $import: default_disabled_quantizers
# Re-enable visual Linears after the standard vision exclusion. The imported snippet also
# keeps vision-attention BMM quantizers disabled as a final defense-in-depth rule.
- $import: vision_fp8
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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.

# Qwen3-VL Vision Encoder FP8 with a high-precision language model and KV cache.

imports:
base_disable_all: configs/ptq/units/base_disable_all
vision_fp8: huggingface/qwen3_vl/ptq/vision_fp8.quant_cfg

metadata:
recipe_type: ptq
description: >-
FP8 quantization of Qwen3-VL Vision Encoder Linear layers, including primary and deepstack
merger Linears; the language model, KV cache, patch embedding, and vision-attention operands
remain in high precision.

quantize:
algorithm: max
quant_cfg:
- $import: base_disable_all
- $import: vision_fp8
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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.

# Qwen3-VL Vision Encoder and language model FP8 with FP8 KV-cache cast.

imports:
base_disable_all: configs/ptq/units/base_disable_all
default_disabled_quantizers: configs/ptq/units/default_disabled_quantizers
kv_fp8_cast: configs/ptq/units/kv_fp8_cast
vision_fp8: huggingface/qwen3_vl/ptq/vision_fp8.quant_cfg
w8a8_fp8_fp8: configs/ptq/units/w8a8_fp8_fp8

metadata:
recipe_type: ptq
description: >-
W8A8 FP8 quantization of Qwen3-VL Vision Encoder and language-model Linear layers, including
primary and deepstack merger Linears, with FP8 KV-cache cast; patch embedding and
vision-attention operands remain in high precision.

quantize:
algorithm: max
quant_cfg:
- $import: base_disable_all
- $import: w8a8_fp8_fp8
- $import: kv_fp8_cast
- $import: default_disabled_quantizers
# Re-enable visual Linears after the standard vision exclusion. The imported snippet also
# keeps vision-attention BMM quantizers disabled as a final defense-in-depth rule.
- $import: vision_fp8
Loading
Loading