Files
Model-Optimizer/examples/minimax_m3/hf_ptq_mixed_mxfp8_nvfp4.py
T
Shengliang Xu c7ed23a103 Rename modelopt_recipes/huggingface to model_type with backward-compat alias (#2328)
### What does this PR do?

Type of change: Refactor + deprecation (recipe-library restructure,
backward compatible), plus an unrelated transformers-compat test fix.

Rename the architecture-specific recipe tier
`modelopt_recipes/huggingface/` to
`modelopt_recipes/model_type/`, making explicit that it holds recipes
**shared across
every checkpoint of a Hugging Face `model_type`** — as opposed to the
checkpoint-mirror
`models/<org>/<model_id>/` tier. The old `huggingface/` path keeps
working as a
deprecated backward-compat alias (a source-tree symlink plus a loader
alias), so no
saved `--recipe` path breaks.

- **Loader alias** (`modelopt/recipe/loader.py`): generalized so saved
`--recipe huggingface/<model_type>/...` paths rewrite to
`model_type/...`, alongside
the existing `huggingface/models/... -> models/...` rewrite (checked
first as the more
specific prefix). This keeps old paths resolving for pip-installed
wheels, where the
  source-tree symlinks don't survive.
- **Internal `$import`s**: rewritten from `huggingface/... ->
model_type/...` inside the
shipped recipes so they resolve without the symlink — mandatory for
wheels, since
  `$import` resolution goes through `config_loader` (no alias there).
- **Packaging** (`pyproject.toml`, `MANIFEST.in`): extended the
symlink-exclusion globs
so the recursive `**/*.yaml` package-data glob doesn't double-ship
recipes through the
`huggingface -> model_type` and `model_type/models -> ../models`
symlinks.
- **Docs / examples / skills / tests**: migrated all internal references
to the canonical
`model_type/`; `huggingface/` remains only in the deprecated-alias tests
and explanatory
  notes.
- **Unrelated fix (2nd commit):**
`tests/unit/torch/export/test_quant_aware_conversion.py`
  failed on transformers>=5.9, which dropped `base_model_prefix` from
`WeightTransform.__slots__` (the scoped-rule tests assigned it on the
now-slotted
object). Production `_scope_prefixes` already reads it via `getattr(...,
None)` and
degrades correctly, so there is no runtime change — the tests now set it
through a
helper that suppresses `AttributeError` across the supported
transformers range.

### Usage

```bash
# New canonical path
python examples/hf_ptq/hf_ptq.py --model <ckpt> \
    --recipe model_type/qwen3_vl/ptq/fp8_vision-kv_none

# Old path still works (deprecated backward-compat alias)
python examples/hf_ptq/hf_ptq.py --model <ckpt> \
    --recipe huggingface/qwen3_vl/ptq/fp8_vision-kv_none
```

```python
from modelopt.recipe import load_recipe

load_recipe("model_type/vit/ptq/fp8")    # canonical
load_recipe("huggingface/vit/ptq/fp8")   # deprecated alias, resolves to the same recipe
```

### Testing

- `tests/unit/recipe/` — **336 passed**, including the new
`test_load_recipe_huggingface_arch_backward_compat_alias` and the
updated
  structural/doc tests (`test_recipe_docs.py`).
- `tests/unit/torch/export/test_quant_aware_conversion.py` — **16
passed** (was 4 failed
  on transformers 5.9.0).
- Built an sdist **and** a wheel and inspected both manifests: each
recipe ships exactly
once (29 `model_type/`, 13 `models/`, 2 `timm/`, 162 total) with
**zero** `huggingface/` or
  `model_type/models/` duplicates and no build error on the symlinks.
- Simulated a wheel install (symlink-free extracted tree) and confirmed
`huggingface/<arch>/...`, `model_type/...`, and `huggingface/models/...`
all resolve via
  the loader alias — including a recipe that pulls internal `$import`s.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅ — old `huggingface/...` recipe
paths keep resolving via the symlink + loader alias.
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: ✅ — backward-compat alias test
added; structural/doc tests updated to the new layout.
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅ — Deprecations entry under 0.48.0. (The transformers-compat test fix
is not changelog-worthy.)
- Did you get Claude approval on this PR?: ❌ — not yet.

### Additional Information

The `model_type/models -> ../models` symlink is kept purely as a
backward-compat alias for
old `huggingface/models/<org>/<model_id>/...` paths; `model_type/` is
otherwise
architecture-only. If we ever want it strictly architecture-only, that
symlink can be
dropped later without breaking anything, since the loader rewrites
`huggingface/models/...`
straight to the top-level `models/` tier.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added post-training quantization recipes for Gemma, Gemma 4,
MiniMax-M3, Nemotron, Qwen, Step-3.7, ViT, and other architectures.
- Added vision, multimodal, mixed-precision, and experts-only
quantization options.

- **Documentation**
- Standardized architecture-specific recipes under `model_type/` and
updated examples and guidance.

- **Compatibility**
- Legacy `huggingface/` recipe paths remain supported with deprecation
warnings.
  - Local recipe files now take precedence over built-in recipes.
  - Deprecated quantization-format flags warn when explicitly provided.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
2026-09-15 12:16:12 -07:00

355 lines
13 KiB
Python

# 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.
"""Export MiniMax-M3 with an MXFP8 base and NVFP4 routed experts.
Non-routed-expert tensors are copied unchanged from the vendor MXFP8
checkpoint. Routed experts are quantized directly from the BF16 checkpoint,
one MoE layer at a time, to avoid loading the complete model or double
quantizing the vendor weights.
Usage:
python hf_ptq_mixed_mxfp8_nvfp4.py \\
--mxfp8_ckpt /models/minimax-m3-mxfp8 \\
--bf16_ckpt /models/minimax-m3-bf16 \\
--recipe model_type/minimax_m3_vl/ptq/nvfp4_experts_only \\
--output_ckpt /workspace/quant/minimax-m3-mxfp8-nvfp4-mixed \\
--device cuda
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Any
import torch
import torch.nn as nn
from safetensors import safe_open
from safetensors.torch import save_file
import modelopt.torch.quantization as mtq
from modelopt import __version__
from modelopt.recipe import load_recipe
from modelopt.torch.quantization.qtensor.nvfp4_tensor import NVFP4QTensor
BLOCK_SIZE = 16
_EXPERT_WEIGHT_RE = re.compile(
r"^language_model\.model\.layers\.(?P<layer>\d+)\.block_sparse_moe\.experts\."
r"(?P<expert>\d+)\.(?P<projection>w[123])\.weight$"
)
_EXPERT_TENSOR_RE = re.compile(
r"^language_model\.model\.layers\.\d+\.block_sparse_moe\.experts\.\d+\.w[123]\."
)
def _log(message: str) -> None:
print(message, flush=True)
def _load_index(checkpoint: Path) -> dict[str, str]:
index = json.loads((checkpoint / "model.safetensors.index.json").read_text())
return index["weight_map"]
def _expert_projection(weight: torch.Tensor) -> nn.Linear:
output_features, input_features = weight.shape
linear = nn.Linear(
input_features,
output_features,
bias=False,
device=weight.device,
dtype=weight.dtype,
)
with torch.no_grad():
linear.weight.copy_(weight)
return linear
class _ExpertLayerModel(nn.Module):
"""One MoE layer with module paths matching the expert-only recipe."""
def __init__(self, weights: dict[tuple[int, str], torch.Tensor]):
super().__init__()
self.block_sparse_moe = nn.Module()
self.block_sparse_moe.experts = nn.ModuleDict()
for (expert, projection), weight in weights.items():
expert_key = str(expert)
if expert_key not in self.block_sparse_moe.experts:
self.block_sparse_moe.experts[expert_key] = nn.Module()
setattr(
self.block_sparse_moe.experts[expert_key],
projection,
_expert_projection(weight),
)
def projection(self, expert: int, name: str) -> nn.Linear:
return getattr(self.block_sparse_moe.experts[str(expert)], name)
def _pack_nvfp4(
weight: torch.Tensor,
quantizer: nn.Module,
weight_scale_2: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
weight_scale, weight_scale_2 = NVFP4QTensor.get_weights_scaling_factor_from_quantizer(
quantizer, weight, weight_scale_2
)
result = NVFP4QTensor.quantize(weight, BLOCK_SIZE, weight_scale, weight_scale_2)
quantized = result[0] if isinstance(result, tuple) else result
return quantized._quantized_data, weight_scale, weight_scale_2
def _quantize_layer(
weights: dict[tuple[int, str], torch.Tensor],
quantize_config: dict[str, Any],
) -> dict[str, torch.Tensor]:
model = _ExpertLayerModel(weights)
mtq.quantize(model, quantize_config, forward_loop=lambda _: None)
output: dict[str, torch.Tensor] = {}
for expert in sorted({expert for expert, _ in weights}):
w1_quantizer = model.projection(expert, "w1").weight_quantizer
w3_quantizer = model.projection(expert, "w3").weight_quantizer
w1_scale_2 = NVFP4QTensor.get_weights_scaling_factor_2_from_quantizer(w1_quantizer)
w3_scale_2 = NVFP4QTensor.get_weights_scaling_factor_2_from_quantizer(w3_quantizer)
shared_w13_scale_2 = torch.maximum(w1_scale_2.reshape(()), w3_scale_2.reshape(()))
for projection in ("w1", "w2", "w3"):
linear = model.projection(expert, projection)
quantizer = linear.weight_quantizer
if projection in ("w1", "w3"):
weight_scale_2 = shared_w13_scale_2
else:
weight_scale_2 = NVFP4QTensor.get_weights_scaling_factor_2_from_quantizer(
quantizer
).reshape(())
packed, weight_scale, weight_scale_2 = _pack_nvfp4(
linear.weight, quantizer, weight_scale_2
)
key = f"experts.{expert}.{projection}"
output[f"{key}.weight"] = packed.cpu()
output[f"{key}.weight_scale"] = weight_scale.cpu()
output[f"{key}.weight_scale_2"] = weight_scale_2.cpu().reshape(())
output[f"{key}.input_scale"] = torch.tensor(1.0, dtype=torch.float32).reshape(())
del model
if torch.cuda.is_available():
torch.cuda.empty_cache()
return output
def _is_routed_expert_tensor(key: str) -> bool:
return bool(_EXPERT_TENSOR_RE.match(key))
def _build_quant_config(
mxfp8_map: dict[str, str],
nvfp4_expert_modules: list[str],
exclude_modules: list[str],
) -> dict[str, Any]:
quantized_layers: dict[str, dict[str, Any]] = {}
for key in mxfp8_map:
if not key.endswith(".weight_scale_inv"):
continue
module = key.removesuffix(".weight_scale_inv")
if "block_sparse_moe.experts." not in module:
quantized_layers[module] = {"quant_algo": "MXFP8"}
for module in nvfp4_expert_modules:
quantized_layers[module] = {"quant_algo": "NVFP4", "group_size": BLOCK_SIZE}
return {
"producer": {"name": "modelopt", "version": __version__},
"quant_method": "modelopt",
"quantization": {
"quant_algo": "MIXED_PRECISION",
"quant_method": "modelopt",
"kv_cache_quant_algo": None,
"exclude_modules": exclude_modules,
"quantized_layers": quantized_layers,
},
}
def _load_layer_weights(
checkpoint: Path,
weight_map: dict[str, str],
keys: list[str],
device: str,
) -> dict[tuple[int, str], torch.Tensor]:
weights: dict[tuple[int, str], torch.Tensor] = {}
keys_by_shard: dict[str, list[str]] = defaultdict(list)
for key in keys:
keys_by_shard[weight_map[key]].append(key)
for shard, shard_keys in keys_by_shard.items():
with safe_open(str(checkpoint / shard), framework="pt", device="cpu") as handle:
for key in shard_keys:
match = _EXPERT_WEIGHT_RE.match(key)
if match is None:
continue
expert = int(match.group("expert"))
projection = match.group("projection")
weights[(expert, projection)] = handle.get_tensor(key).to(device)
return weights
def _quantize_experts(
bf16: Path,
destination: Path,
weight_map: dict[str, str],
quantize_config: dict[str, Any],
device: str,
) -> tuple[dict[str, str], list[str]]:
keys_by_layer: dict[int, list[str]] = defaultdict(list)
for key in weight_map:
match = _EXPERT_WEIGHT_RE.match(key)
if match:
keys_by_layer[int(match.group("layer"))].append(key)
if not keys_by_layer:
raise ValueError(f"No routed-expert weights found in {bf16}")
new_index: dict[str, str] = {}
expert_modules: list[str] = []
layers = sorted(keys_by_layer)
_log(f"[mixed] quantizing {len(layers)} BF16 MoE layers to NVFP4")
for layer_index, layer in enumerate(layers, start=1):
weights = _load_layer_weights(bf16, weight_map, keys_by_layer[layer], device)
quantized = _quantize_layer(weights, quantize_config)
tensors = {
f"language_model.model.layers.{layer}.block_sparse_moe.{key}": tensor
for key, tensor in quantized.items()
}
shard_name = f"experts-layer-{layer:03d}.safetensors"
save_file(tensors, str(destination / shard_name))
for key in tensors:
new_index[key] = shard_name
if key.endswith(".weight"):
expert_modules.append(key.removesuffix(".weight"))
_log(
f"[mixed] layer {layer} ({layer_index}/{len(layers)}): "
f"wrote {len(tensors)} NVFP4 tensors"
)
return new_index, expert_modules
def _copy_mxfp8_base(
checkpoint: Path,
destination: Path,
weight_map: dict[str, str],
new_index: dict[str, str],
) -> None:
for shard_index, shard in enumerate(sorted(set(weight_map.values()))):
tensors = {}
with safe_open(str(checkpoint / shard), framework="pt", device="cpu") as handle:
for key in handle.keys(): # noqa: SIM118
if not _is_routed_expert_tensor(key):
tensors[key] = handle.get_tensor(key)
if not tensors:
continue
output_name = f"base-mxfp8-{shard_index:05d}.safetensors"
save_file(tensors, str(destination / output_name))
for key in tensors:
new_index[key] = output_name
_log(f"[mixed] base shard {shard} -> {output_name}: {len(tensors)} tensors")
def _copy_ancillary_files(source: Path, destination: Path) -> None:
generated_files = {
"config.json",
"hf_quant_config.json",
"model.safetensors.index.json",
}
for item in source.iterdir():
if item.name in generated_files or item.name.endswith(".safetensors"):
continue
if item.is_file():
shutil.copy2(item, destination / item.name)
def _rename_checkpoint_shards(destination: Path, weight_map: dict[str, str]) -> dict[str, str]:
shard_names = list(dict.fromkeys(weight_map.values()))
renamed_shards = {
name: f"model-{index:05d}-of-{len(shard_names):05d}.safetensors"
for index, name in enumerate(shard_names, start=1)
}
for old_name, new_name in renamed_shards.items():
(destination / old_name).replace(destination / new_name)
return {key: renamed_shards[shard] for key, shard in weight_map.items()}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--mxfp8_ckpt", required=True, help="vendor MiniMax-M3-MXFP8 checkpoint")
parser.add_argument("--bf16_ckpt", required=True, help="BF16 source for routed experts")
parser.add_argument("--recipe", required=True, help="expert-only NVFP4 recipe")
parser.add_argument("--output_ckpt", required=True, help="mixed checkpoint output")
parser.add_argument("--device", default="cuda")
return parser.parse_args()
def main() -> None:
args = parse_args()
mxfp8 = Path(args.mxfp8_ckpt)
bf16 = Path(args.bf16_ckpt)
destination = Path(args.output_ckpt)
destination.mkdir(parents=True, exist_ok=True)
recipe = load_recipe(args.recipe)
quantize_config = recipe.quantize.model_dump()
mxfp8_map = _load_index(mxfp8)
bf16_map = _load_index(bf16)
new_index, expert_modules = _quantize_experts(
bf16,
destination,
bf16_map,
quantize_config,
args.device,
)
_copy_mxfp8_base(mxfp8, destination, mxfp8_map, new_index)
new_index = _rename_checkpoint_shards(destination, new_index)
mxfp8_config = json.loads((mxfp8 / "config.json").read_text())
vendor_quantization = mxfp8_config.get("quantization_config", {})
mixed_quant_config = _build_quant_config(
mxfp8_map,
expert_modules,
list(vendor_quantization.get("ignored_layers", []) or []),
)
mxfp8_config["quantization_config"] = mixed_quant_config["quantization"]
(destination / "config.json").write_text(json.dumps(mxfp8_config, indent=2))
(destination / "hf_quant_config.json").write_text(json.dumps(mixed_quant_config, indent=2))
(destination / "model.safetensors.index.json").write_text(
json.dumps({"metadata": {"format": "pt"}, "weight_map": new_index}, indent=2)
)
_copy_ancillary_files(mxfp8, destination)
_log(f"[mixed] done -> {destination}")
if __name__ == "__main__":
main()