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

Filter by extension

Filter by extension

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

- Add the ``nvfp4_act_headroom`` calibration algorithm for NVFP4 **activation** global scales. Instead of setting the global scale from the largest per-block amax seen during calibration (plain ``max``, which leaves no room above it so any larger activation saturates), it anchors the scale to a low percentile of the per-block amax distribution, leaving the rest of the FP8 block-scale range as headroom: ``amax = max(rho * anchor, upper)``, where ``anchor`` and ``upper`` are the per-block amaxes at ``anchor_percentile`` (default 1) and ``upper_percentile`` (default 99.99; set to 100 to never clip calibration data), and ``rho`` (default 16384) is the headroom factor. Applies only to NVFP4 dynamic-block input quantizers; ``SequentialQuantizer`` activation quantizers raise. Weight scales are an orthogonal axis selected by a nested ``weight_scale_algorithm`` (``max`` by default, or ``mse`` / ``local_hessian``), so one recipe can combine a weight calibration with this activation policy in a single pass. Ships ``modelopt_recipes/general/ptq/nvfp4_act_headroom-kv_fp8_cast.yaml``, which mirrors ``nvfp4_default-kv_fp8_cast`` with only the calibration algorithm swapped and exports a standard NVFP4 checkpoint.
- Add layer-wise KV-cache AutoQuantize through ``mtq.auto_quantize_kv_cache`` and ``constraints.kv_effective_bits``. It measures isolated full-vocabulary forward KL for caller-supplied K/V formats, solves a width-weighted additive storage-constrained recipe across eligible layers, preserves search-disabled layers in their existing format, exports the selected per-attention mapping in unified HF checkpoints, and writes a JSON sensitivity report alongside the checkpoint. A cast-mode FP8/NVFP4 recipe at 5.4 bits/scalar is included.

*Megatron Framework (M-LM / M-Bridge)*

Expand Down
35 changes: 33 additions & 2 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,37 @@ leaving the original recipe unchanged.
For models without backprop support (e.g. Llama-4), use the `kl_div` scoring method — see the shipped
`general/auto_quantize/nvfp4_fp8_kl_div_at_5p4bits` recipe.

KV cache is applied as a uniform post-step, not part of the per-layer search. An AutoQuantize recipe
falls back to `--kv_cache_qformat` (default `fp8_cast`) unless it sets an explicit `kv_cache` field.
Weight AutoQuantize recipes still apply KV cache as a uniform post-step and fall back to
`--kv_cache_qformat` (default `fp8_cast`) unless they set an explicit `kv_cache` field.

KV-cache AutoQuantize recipes instead set `constraints.kv_effective_bits`. Their
`candidate_formats` are complete K/V cache configs whose config-level `effective_bits` includes
packed scale overhead. The width-weighted budget covers eligible layers; `disabled_layers` are
preserved and excluded. BF16 is used only as the isolated-KL reference, not as a solver choice.
The shipped canary recipe searches calibrated FP8 K/V (8.0 bits/scalar) and packed NVFP4 K/V
(4.5 bits/scalar) at 5.4 bits/scalar. It intentionally excludes FP8-K/NVFP4-V because the
companion vLLM implementation does not support that asymmetric per-layer format:

```bash
python hf_ptq.py \
--pyt_ckpt_path Qwen/Qwen3-1.7B \
--recipe general/auto_quantize/kv_fp8_nvfp4_cast_kl_div_at_5p4bits \
--auto_quantize_checkpoint /path/to/kv_autoquant.pth \
--export_path /path/to/qwen3-1.7b-mixed-kv
```

Each candidate uses max calibration so its persistent K/V scales are present in the unified HF
checkpoint. Unified export records the selected formats in `kv_cache_quantized_layers` and writes
the JSON-safe sensitivity report to `kv_cache_auto_quantize_report.json`;
`--auto_quantize_checkpoint` stores the resumable raw search state.

> [!NOTE]
> Layer-wise KV checkpoints require the companion
> [vLLM mixed-KV metadata consumer](https://github.com/vllm-project/vllm/pull/52813) or a later
> vLLM release containing it. The repository's currently pinned vLLM 0.26.0 does not consume
> `kv_cache_quantized_layers`, so these checkpoints are export-only in that stock environment.
> Do not deploy them with the pinned runtime. Full FP8 K/V and full NVFP4 K/V use existing vLLM
> kernels once the layer-wise metadata consumer is available.

The one runtime flag is `--auto_quantize_checkpoint` — save/restore the search state to resume an
interrupted search (skips re-scoring):
Expand Down Expand Up @@ -460,6 +489,8 @@ mtq.calibrate(model, algorithm="max", forward_loop=calibrate_loop)

ModelOpt enables quantization of LLMs across multiple GPU nodes using FSDP2 for distributed model sharding and calibration, exposed via the `--use_fsdp2` flag on the standard `hf_ptq.py` entry point.

> *AutoQuantize recipes are not supported with `--use_fsdp2` and are rejected before model loading. Distributed sensitivity scoring, selection, and checkpoint writes must be synchronized before this combination can be enabled safely. Use a PTQ recipe with FSDP2.*

### Usage

#### Slurm (recommended)
Expand Down
77 changes: 70 additions & 7 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@
from modelopt.torch.utils.vlm_dataset_utils import get_vlm_dataset_dataloader

RAND_SEED = 1234
_FSDP2_AUTOQUANT_ERROR = (
"AutoQuantize does not support --use_fsdp2 until distributed sensitivity scoring, "
"selection, and checkpoint writes are synchronized across ranks."
)


def _select_unpadded_logits(logits: torch.Tensor, batch: dict[str, Any]) -> torch.Tensor:
"""Return logits only for token positions selected by ``attention_mask``."""
attention_mask = batch.get("attention_mask")
if attention_mask is None:
return logits
if logits.shape[:-1] != attention_mask.shape:
raise ValueError(
"AutoQuant KL logits and attention_mask must have matching token dimensions; "
f"got {tuple(logits.shape[:-1])} and {tuple(attention_mask.shape)}."
)
return logits[attention_mask.bool()]


def _kv_cfg_uses_constant_amax(kv_quant_cfg: list[dict[str, Any]]) -> bool:
Expand Down Expand Up @@ -338,6 +355,24 @@ def _mtq_candidate_formats(formats) -> list[dict]:
return quantization_formats


def _mtq_kv_candidate_formats(formats) -> list[tuple[dict, str]]:
"""Translate format-agnostic KV candidates while preserving useful preset names."""
candidates = []
for idx, fmt in enumerate(formats):
quant_cfg = fmt.model_dump(exclude_none=True)
candidate_quantizers = quant_cfg.get("quant_cfg", [])
name = None
for preset_name, preset in KV_QUANT_CFG_CHOICES.items():
normalized_preset_quantizers = (
type(fmt)(**preset).model_dump(exclude_none=True).get("quant_cfg", [])
)
if normalized_preset_quantizers == candidate_quantizers:
name = preset_name
break
candidates.append((quant_cfg, name or f"KV_CACHE_FORMAT_{idx}"))
return candidates


def _mtq_inputs_from_auto_quantize_config(
aq_config, args: argparse.Namespace, fixed_quantize_config=None
) -> dict:
Expand All @@ -349,6 +384,16 @@ def _mtq_inputs_from_auto_quantize_config(
to ``--kv_cache_qformat`` when the recipe omits it.
"""
constraints = aq_config.constraints.model_dump(exclude_none=True)
is_kv_search = aq_config.constraints.kv_effective_bits is not None
if is_kv_search:
return {
"search_domain": "kv_cache",
"constraints": {"kv_effective_bits": constraints["kv_effective_bits"]},
"quantization_formats": _mtq_kv_candidate_formats(aq_config.candidate_formats),
"disabled_layers": aq_config.disabled_layers,
"method": aq_config.auto_quantize_method,
"score_size": aq_config.score_size,
}
# cost_excluded_layers (sibling of disabled_layers) maps to the mtq cost key: these layers are
# kept out of the bit-budget denominator (cost_weight 0) — e.g. VL vision towers — distinct from
# disabled_layers, which removes them from the search.
Expand Down Expand Up @@ -380,6 +425,7 @@ def _mtq_inputs_from_auto_quantize_config(
for search_space in aq_config.module_search_spaces
]
return {
"search_domain": "weight",
"constraints": constraints,
"quantization_formats": quantization_formats,
"fixed_quantization_config": fixed_quantization_config,
Expand Down Expand Up @@ -414,11 +460,7 @@ def auto_quantize(
)

if args.use_fsdp2:
warnings.warn(
"AutoQuantize with --use_fsdp2 has not been validated end-to-end yet "
"(distributed calibration, sensitivity scoring, and recipe/checkpoint "
"synchronization across ranks); use at your own risk."
)
raise NotImplementedError(_FSDP2_AUTOQUANT_ERROR)

inputs = _mtq_inputs_from_auto_quantize_config(
aq_config, args, fixed_quantize_config=fixed_quantize_config
Expand Down Expand Up @@ -461,14 +503,33 @@ def forward_step(model, batch):
output = model(**inputs_)
if is_base_model:
assert full_model is not None
return full_model.lm_head(output.last_hidden_state)
return output.logits
logits = full_model.lm_head(output.last_hidden_state)
else:
logits = output.logits
return _select_unpadded_logits(logits, batch)

else:
raise ValueError(
f"Invalid auto_quantize method: {inputs['method']}. Must be 'gradient' or 'kl_div'"
)

if inputs["search_domain"] == "kv_cache":
language_model, _ = mtq.auto_quantize_kv_cache(
language_model,
constraints=inputs["constraints"],
data_loader=calib_dataloader,
forward_step=forward_step,
quantization_formats=inputs["quantization_formats"],
num_calib_steps=len(calib_dataloader),
num_score_steps=min(
len(calib_dataloader), max(inputs["score_size"] // args.batch_size, 1)
),
verbose=True,
disabled_layers=inputs["disabled_layers"],
checkpoint=args.auto_quantize_checkpoint,
)
return language_model

language_model, _ = mtq.auto_quantize(
language_model,
constraints=inputs["constraints"],
Expand Down Expand Up @@ -512,6 +573,8 @@ def _recipe_is_auto_quantize(recipe: str | None) -> bool:
def load_model(args: argparse.Namespace):
# If low memory mode is enabled, we compress the model while loading the HF checkpoint.
calibration_only = False
if args.use_fsdp2 and _recipe_is_auto_quantize(args.recipe):
raise NotImplementedError(_FSDP2_AUTOQUANT_ERROR)
if args.use_fsdp2:
hf_config = AutoConfig.from_pretrained(
args.pyt_ckpt_path, trust_remote_code=args.trust_remote_code
Expand Down
61 changes: 55 additions & 6 deletions modelopt/recipe/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,22 @@ def _validate_active_moe_expert_ratio(cls, v: float | None) -> float | None:
class AutoQuantizeConstraints(ModeloptBaseConfig):
"""LP search constraints + cost model; matches the ``mtq.auto_quantize`` constraints dict."""

effective_bits: float = ModeloptField(
default=4.8,
effective_bits: float | None = ModeloptField(
default=None,
title="Effective bits per weight",
description="Average weight-storage bits target for the LP, in (0, 16].",
description=(
"Average weight-storage bits target for the LP, in (0, 16]. Defaults to 4.8 "
"when neither bit constraint is specified."
),
)
kv_effective_bits: float | None = ModeloptField(
default=None,
title="Effective bits per KV-cache scalar",
description=(
"Average KV-cache storage bits target across eligible layers for layer-wise KV "
"AutoQuant, in (0, 16]. Exactly one of effective_bits and kv_effective_bits may "
"be set."
),
)
cost_model: Literal["weight", "active_moe"] = ModeloptField(
default="weight",
Expand All @@ -165,13 +177,36 @@ class AutoQuantizeConstraints(ModeloptBaseConfig):
description="Extra cost-model parameters; omit for the 'weight' cost model.",
)

@field_validator("effective_bits")
@model_validator(mode="before")
@classmethod
def _validate_effective_bits(cls, v: float) -> float:
if not (0 < v <= 16):
def _default_weight_constraint(cls, data):
if isinstance(data, dict):
data = dict(data)
if "effective_bits" not in data and "kv_effective_bits" not in data:
data["effective_bits"] = 4.8
return data

@field_validator("effective_bits", "kv_effective_bits")
@classmethod
def _validate_effective_bits(cls, v: float | None) -> float | None:
if v is not None and not (0 < v <= 16):
raise ValueError(f"effective_bits must be in (0, 16], got {v}")
return v

@model_validator(mode="after")
def _exactly_one_bit_constraint(self):
if (self.effective_bits is None) == (self.kv_effective_bits is None):
raise ValueError(
"Exactly one of effective_bits and kv_effective_bits must be specified."
)
if self.kv_effective_bits is not None and (
self.cost_model != "weight" or self.cost is not None
):
raise ValueError(
"KV-cache AutoQuant does not support weight or active-MoE cost settings."
)
return self


class AutoQuantizeModuleSearchSpace(ModeloptBaseConfig):
"""Candidate formats selectable for modules matching one or more name patterns."""
Expand Down Expand Up @@ -272,6 +307,20 @@ def _has_search_space(self):
"auto_quantize requires candidate_formats or at least one module_search_spaces "
"entry. For uniform quantization, use a PTQ recipe instead."
)
if self.constraints.kv_effective_bits is not None:
if self.auto_quantize_method != "kl_div":
raise ValueError(
"KV-cache AutoQuant currently requires auto_quantize_method=kl_div."
)
if self.module_search_spaces:
raise ValueError(
"KV-cache AutoQuant uses one candidate space for all eligible attention "
"layers; module_search_spaces is not supported."
)
if self.kv_cache is not None:
raise ValueError(
"KV-cache AutoQuant candidate_formats replace the uniform kv_cache post-step."
)
return self


Expand Down
8 changes: 8 additions & 0 deletions modelopt/torch/export/convert_hf_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ def convert_hf_quant_config_format(input_config: dict[str, Any]) -> dict[str, An
if kv_cache_quant_algo:
if kv_cache_quant_algo == "FP8":
new_config["kv_cache_scheme"] = {"dynamic": False, "num_bits": 8, "type": "float"}
elif kv_cache_quant_algo == "MIXED_PRECISION":
new_config["kv_cache_quant_algo"] = kv_cache_quant_algo
new_config["kv_cache_quantized_layers"] = original_quantization_details.get(
"kv_cache_quantized_layers", {}
)
new_config["kv_cache_schema_version"] = original_quantization_details.get(
"kv_cache_schema_version", 1
)
else:
# TODO: Handle other kv cache quantization algorithms
new_config["kv_cache_scheme"] = kv_cache_quant_algo
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 @@ -45,6 +45,7 @@
QUANTIZATION_FP8_PC_PT = "fp8_pc_pt"

KV_CACHE_FP8 = "FP8"
KV_CACHE_FP8_K_NVFP4_V = "FP8_K_NVFP4_V"
KV_CACHE_INT8 = "INT8"
KV_CACHE_NVFP4 = "NVFP4"
KV_CACHE_NVFP4_AFFINE = "NVFP4_AFFINE"
Expand Down
7 changes: 6 additions & 1 deletion modelopt/torch/export/quant_aware_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def _map(name: str) -> str:


def revert_quant_config_names(quantization: dict, mapper) -> None:
"""Revert ``exclude_modules`` / ``quantized_layers`` keys to hub names, in place.
"""Revert layer-reference keys to hub names, in place.

``mapper`` is the callable from :func:`build_reverse_name_mapper` (a no-op when
``None``). Applies to the ModelOpt ``{"quantization": {...}}`` sub-dict before it is
Expand All @@ -293,6 +293,11 @@ def revert_quant_config_names(quantization: dict, mapper) -> None:
quantized_layers = quantization.get("quantized_layers")
if isinstance(quantized_layers, dict) and quantized_layers:
quantization["quantized_layers"] = {mapper(k): v for k, v in quantized_layers.items()}
kv_cache_quantized_layers = quantization.get("kv_cache_quantized_layers")
if isinstance(kv_cache_quantized_layers, dict) and kv_cache_quantized_layers:
quantization["kv_cache_quantized_layers"] = {
mapper(k): v for k, v in kv_cache_quantized_layers.items()
}


def _assert_experts_pre_expanded(
Expand Down
Loading
Loading