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
25 changes: 19 additions & 6 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,24 @@ For models without backprop support (e.g. Llama-4), use the `kl_div` scoring met
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.

To optimize GEMM and KV cache in one invocation, compose ordered stages in the same recipe. A fixed
`quantize` block followed by a KV-domain `auto_quantize` first calibrates the GEMM weight/activation
configuration, then searches K/V while the existing GEMM QDQ remains enabled with calibration
frozen. See `general/auto_quantize/fp8_ptq_then_kv_fp8_nvfp4_cast_kl_div_at_5p4bits`.

A weight-domain `auto_quantize` can instead add a `kv_auto_quantize` follow-up with its own method,
constraints, candidates, score size, and disabled layers. This supports, for example, a
gradient-based GEMM search followed by a KL-divergence KV search; see
`general/auto_quantize/nvfp4_fp8_gradient_then_kv_fp8_nvfp4_cast_kl_div_at_5p4bits`. When the
follow-up is present, the recipe owns KV configuration and suppresses the CLI's uniform
`--kv_cache_qformat` fallback. Use `--auto_quantize_checkpoint` for the weight search and
`--kv_auto_quantize_checkpoint` for the KV search.

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 recipe searches FP8-cast K/V (8.0 bits/scalar) and NVFP4-cast K/V
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:

Expand All @@ -441,9 +454,8 @@ python hf_ptq.py \
--export_path /path/to/qwen3-1.7b-mixed-kv
```

Each candidate uses an explicit constant scale, avoiding an additional calibration pass while
keeping persistent K/V scales in the unified HF checkpoint. Unified export records the selected
formats in `kv_cache_quantized_layers` and writes
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.

Expand All @@ -455,8 +467,9 @@ the JSON-safe sensitivity report to `kv_cache_auto_quantize_report.json`;
> 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):
For a single-stage search, `--auto_quantize_checkpoint` saves/restores the search state to resume an
interrupted search (skips re-scoring). Composed weight-plus-KV recipes additionally use
`--kv_auto_quantize_checkpoint` for the independent KV search state:

```bash
scripts/huggingface_example.sh --model $HF_PATH --recipe general/auto_quantize/nvfp4_fp8_at_5p4bits \
Expand Down
172 changes: 137 additions & 35 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ def _mtq_kv_candidate_formats(formats) -> list[tuple[dict, str]]:


def _mtq_inputs_from_auto_quantize_config(
aq_config, args: argparse.Namespace, fixed_quantize_config=None
aq_config,
args: argparse.Namespace,
fixed_quantize_config=None,
allow_uniform_kv: bool = True,
) -> dict:
"""Map a resolved AutoQuantizeConfig to mtq.auto_quantize inputs.

Expand All @@ -413,7 +416,9 @@ def _mtq_inputs_from_auto_quantize_config(
constraints.setdefault("cost", {})["excluded_module_name_patterns"] = (
aq_config.cost_excluded_layers
)
if aq_config.kv_cache is not None:
if not allow_uniform_kv:
kv_cache_quant_cfg = None
elif aq_config.kv_cache is not None:
kv_cache_quant_cfg = aq_config.kv_cache.model_dump()
elif args.kv_cache_qformat == KV_CACHE_NONE:
kv_cache_quant_cfg = None
Expand Down Expand Up @@ -449,13 +454,31 @@ def _mtq_inputs_from_auto_quantize_config(
}


def _assert_kv_autoquantize_input_is_clean(model: torch.nn.Module) -> None:
"""Fail closed if an upstream stage left actual K/V quantizers enabled."""
enabled = [
name
for name, module in model.named_modules(remove_duplicate=False)
if name.endswith(("k_bmm_quantizer", "v_bmm_quantizer"))
and getattr(module, "is_enabled", False)
]
if enabled:
raise ValueError(
"The preceding weight/activation stage left K/V quantizers enabled on the converted "
f"model: {enabled}. Disable them in that stage before running mixed-KV AutoQuant; "
"clearing them now would not undo its calibration or sensitivity measurements."
)


def auto_quantize(
args: argparse.Namespace,
language_model: torch.nn.Module,
calib_dataloader: DataLoader,
aq_config,
full_model: torch.nn.Module | None = None,
fixed_quantize_config=None,
allow_uniform_kv: bool = True,
checkpoint_attr: str = "auto_quantize_checkpoint",
):
"""Recipe-driven auto_quantize, organized around an AutoQuantizeConfig.

Expand All @@ -475,8 +498,14 @@ def auto_quantize(
raise NotImplementedError(_FSDP2_AUTOQUANT_ERROR)

inputs = _mtq_inputs_from_auto_quantize_config(
aq_config, args, fixed_quantize_config=fixed_quantize_config
aq_config,
args,
fixed_quantize_config=fixed_quantize_config,
allow_uniform_kv=allow_uniform_kv,
)
if inputs["search_domain"] == "kv_cache":
_assert_kv_autoquantize_input_is_clean(language_model)
checkpoint = getattr(args, checkpoint_attr, None)

# base-model lm_head handling (mirrors the CLI helper)
is_base_model = (
Expand Down Expand Up @@ -538,7 +567,7 @@ def forward_step(model, batch):
),
verbose=True,
disabled_layers=inputs["disabled_layers"],
checkpoint=args.auto_quantize_checkpoint,
checkpoint=checkpoint,
)
return language_model

Expand All @@ -556,7 +585,7 @@ def forward_step(model, batch):
verbose=True,
disabled_layers=inputs["disabled_layers"],
method=inputs["method"],
checkpoint=args.auto_quantize_checkpoint,
checkpoint=checkpoint,
)

# KV cache quantization is uniform; applied after the LP search.
Expand Down Expand Up @@ -843,6 +872,86 @@ def mono_quantize(
warnings.warn("Skipping quantization: model is already quantized.")


def _prepare_quant_cfg(
args: argparse.Namespace, quant_cfg: dict[str, Any], full_model: torch.nn.Module
) -> dict[str, Any]:
"""Apply shared checkpoint-local adjustments to a PTQ configuration."""
mtp_layer_prefixes = getattr(full_model, "_mtp_layer_prefixes", None)
if mtp_layer_prefixes:
quant_cfg = copy.deepcopy(quant_cfg)
for prefix in mtp_layer_prefixes:
pattern = f"*{prefix}*"
quant_cfg["quant_cfg"].append({"quantizer_name": pattern, "enable": False})
print(f"Excluding MTP layer from quantization: {pattern}")

if needs_checkpoint_path_update(quant_cfg):
quant_cfg, resolved_dir = resolve_checkpoint_dir(quant_cfg, args.pyt_ckpt_path)
print(f"Auto-resolved layerwise checkpoint_dir: {resolved_dir}")

if args.cast_mxfp4_to_nvfp4:
quant_cfg = copy.deepcopy(quant_cfg)
force_weight_quantizers_static(quant_cfg["quant_cfg"])
return quant_cfg


def _run_auto_quantize_recipe(
args: argparse.Namespace,
recipe: ModelOptAutoQuantizeRecipe,
full_model: torch.nn.Module,
language_model: torch.nn.Module,
model_type: str | None,
calibration_only: bool,
calib_dataloader: DataLoader,
is_nemotron_vl_model: bool,
) -> None:
"""Run the recipe's fixed PTQ, weight search, and KV search in order."""
primary = recipe.auto_quantize
followup_kv = recipe.kv_auto_quantize
primary_is_kv = primary.constraints.kv_effective_bits is not None
fixed_quantize_config = recipe.quantize
primary_uses_kv_checkpoint = primary_is_kv and fixed_quantize_config is not None

if primary_is_kv and fixed_quantize_config is not None:
quant_cfg = _prepare_quant_cfg(args, fixed_quantize_config.model_dump(), full_model)
mono_quantize(
args,
quant_cfg,
full_model,
language_model,
model_type,
calibration_only,
calib_dataloader,
is_nemotron_vl_model,
)
fixed_quantize_config = None

auto_quantize(
args,
full_model,
calib_dataloader,
aq_config=primary,
full_model=full_model,
fixed_quantize_config=fixed_quantize_config,
allow_uniform_kv=followup_kv is None,
checkpoint_attr=(
"kv_auto_quantize_checkpoint"
if primary_uses_kv_checkpoint
else "auto_quantize_checkpoint"
),
)

if followup_kv is not None:
auto_quantize(
args,
full_model,
calib_dataloader,
aq_config=followup_kv,
full_model=full_model,
allow_uniform_kv=False,
checkpoint_attr="kv_auto_quantize_checkpoint",
)


def export_quantized(
args: argparse.Namespace,
full_model: torch.nn.Module,
Expand Down Expand Up @@ -1198,10 +1307,8 @@ def quantize_main(
# AutoQuantize is recipe-driven: everything downstream reads the resolved AutoQuantizeConfig.
if isinstance(recipe, ModelOptAutoQuantizeRecipe):
aq_config = recipe.auto_quantize
fixed_quantize_config = recipe.quantize
else:
aq_config = None
fixed_quantize_config = None

def _is_layerwise(obj):
if isinstance(obj, ModelOptPTQRecipe):
Expand Down Expand Up @@ -1277,7 +1384,11 @@ def _is_layerwise(obj):
device,
model_type,
autoquant_gradient_recipe=(
aq_config is not None and aq_config.auto_quantize_method == "gradient"
isinstance(recipe, ModelOptAutoQuantizeRecipe)
and any(
config is not None and config.auto_quantize_method == "gradient"
for config in (recipe.auto_quantize, recipe.kv_auto_quantize)
)
),
)

Expand All @@ -1289,16 +1400,16 @@ def _is_layerwise(obj):
)

if aq_config is not None:
# AutoQuantize (recipe-driven). For VL models the search walks the OUTER CausalLM (which
# carries lm_head and the LM-head forward path); architecture-specific exclusions come
# from aq_config.disabled_layers.
auto_quantize(
assert isinstance(recipe, ModelOptAutoQuantizeRecipe)
_run_auto_quantize_recipe(
args,
recipe,
full_model,
language_model,
model_type,
calibration_only,
calib_dataloader,
aq_config,
full_model=full_model,
fixed_quantize_config=fixed_quantize_config,
is_nemotron_vl_model,
)

else:
Expand Down Expand Up @@ -1333,25 +1444,7 @@ def _is_layerwise(obj):
KV_QUANT_CFG_CHOICES[args.kv_cache_qformat]["quant_cfg"],
)

# Exclude MTP layers from quantization if detected (e.g., GLM-4.7's layer 92).
# These layers are typically speculative decoding layers that should be exported as-is.
# Complementary to recipe `*mtp*` wildcards (name-match); this catches MTP layers
# identified by index.
mtp_layer_prefixes = getattr(full_model, "_mtp_layer_prefixes", None)
if mtp_layer_prefixes:
quant_cfg = copy.deepcopy(quant_cfg)
for prefix in mtp_layer_prefixes:
pattern = f"*{prefix}*"
quant_cfg["quant_cfg"].append({"quantizer_name": pattern, "enable": False})
print(f"Excluding MTP layer from quantization: {pattern}")

if needs_checkpoint_path_update(quant_cfg):
quant_cfg, resolved_dir = resolve_checkpoint_dir(quant_cfg, args.pyt_ckpt_path)
print(f"Auto-resolved layerwise checkpoint_dir: {resolved_dir}")

if args.cast_mxfp4_to_nvfp4:
quant_cfg = copy.deepcopy(quant_cfg)
force_weight_quantizers_static(quant_cfg["quant_cfg"])
quant_cfg = _prepare_quant_cfg(args, quant_cfg, full_model)

if quant_cfg:
mono_quantize(
Expand Down Expand Up @@ -1417,7 +1510,7 @@ def parse_args() -> argparse.Namespace:
"general/ptq/nvfp4_default-kv_fp8_cast, general/auto_quantize/nvfp4_fp8_at_4p8bits). "
"KV cache source depends on the recipe type: PTQ recipes bake KV cache into quant_cfg "
"and --kv_cache_qformat is ignored; AutoQuantize recipes fall back to --kv_cache_qformat "
"unless the recipe sets an explicit kv_cache field."
"unless the recipe sets an explicit kv_cache or kv_auto_quantize field."
),
default=None,
)
Expand Down Expand Up @@ -1591,6 +1684,15 @@ def parse_args() -> argparse.Namespace:
"(sensitivity scores, costs, etc.). Used with an AutoQuantize --recipe."
),
)
parser.add_argument(
"--kv_auto_quantize_checkpoint",
type=str,
default=None,
help=(
"Path for saving/restoring the KV-cache search checkpoint in a composed recipe. "
"Use a new path whenever the preceding weight/activation quantization stage changes."
),
)
parser.add_argument(
"--moe_calib_experts_ratio",
type=float,
Expand Down
32 changes: 26 additions & 6 deletions modelopt/recipe/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,32 +333,52 @@ class ModelOptAutoQuantizeRecipe(ModelOptRecipeBase):
quantize: QuantizeConfig | None = ModeloptField(
default=None,
title="Fixed PTQ baseline",
description="Optional normal PTQ QuantizeConfig for modules outside the explicit "
"AutoQuantize module_search_spaces. Fixed and searched modules are calibrated, scored, "
"costed, and exported in one integrated AutoQuantize operation.",
description="Optional normal PTQ QuantizeConfig. A weight AutoQuantize stage uses it for "
"modules outside explicit module_search_spaces; a KV AutoQuantize stage applies it first "
"as the fixed GEMM weight/activation configuration.",
)

auto_quantize: AutoQuantizeConfig = Field(
title="AutoQuantize config",
description="AutoQuantize search configuration. Required.",
)

kv_auto_quantize: AutoQuantizeConfig | None = ModeloptField(
default=None,
title="Follow-up KV-cache AutoQuantize config",
description="Optional KV-cache search run after the primary weight AutoQuantize search.",
)

@model_validator(mode="after")
def _validate_fixed_and_searched_spaces(self):
primary_is_kv = self.auto_quantize.constraints.kv_effective_bits is not None
if self.kv_auto_quantize is not None:
if primary_is_kv:
raise ValueError(
"kv_auto_quantize cannot follow an auto_quantize stage that already searches "
"the KV cache."
)
if self.kv_auto_quantize.constraints.kv_effective_bits is None:
raise ValueError("kv_auto_quantize must use a kv_effective_bits constraint.")
if self.auto_quantize.kv_cache is not None:
raise ValueError(
"A weight AutoQuantize stage followed by kv_auto_quantize must omit the "
"uniform auto_quantize.kv_cache post-step."
)
has_fixed_baseline = self.quantize is not None
has_global_search = bool(self.auto_quantize.candidate_formats)
if has_fixed_baseline and has_global_search:
if not primary_is_kv and has_fixed_baseline and has_global_search:
raise ValueError(
"An AutoQuantize recipe with a fixed quantize baseline must omit top-level "
"auto_quantize.candidate_formats and explicitly list searched modules under "
"auto_quantize.module_search_spaces."
)
if has_fixed_baseline and not self.auto_quantize.module_search_spaces:
if not primary_is_kv and has_fixed_baseline and not self.auto_quantize.module_search_spaces:
raise ValueError(
"An AutoQuantize recipe with a fixed quantize baseline requires at least one "
"auto_quantize.module_search_spaces entry."
)
if not has_fixed_baseline and not has_global_search:
if not primary_is_kv and not has_fixed_baseline and not has_global_search:
raise ValueError(
"An AutoQuantize recipe without a fixed quantize baseline requires top-level "
"auto_quantize.candidate_formats for unmatched modules."
Expand Down
Loading