Skip to content

Commit 8fc569d

Browse files
committed
MSE fixes for mcore and fix HF export and add Super NVFP4 yaml recipe
Signed-off-by: Jennifer Chen <jennifchen@nvidia.com>
1 parent 8eec6d4 commit 8fc569d

10 files changed

Lines changed: 569 additions & 73 deletions

File tree

modelopt/torch/export/plugins/hf_checkpoint_utils.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,23 @@
2222

2323
import torch
2424
from huggingface_hub import snapshot_download
25+
from huggingface_hub.errors import LocalEntryNotFoundError
2526
from safetensors.torch import safe_open
2627
from tqdm import tqdm
2728

2829

30+
_HF_HUB_OFFLINE_TRUE_VALUES = {"1", "ON", "YES", "TRUE"}
31+
32+
33+
def _is_hf_hub_offline() -> bool:
34+
return os.environ.get("HF_HUB_OFFLINE", "").strip().upper() in _HF_HUB_OFFLINE_TRUE_VALUES
35+
36+
37+
def _copy_python_files(source_dir: Path, save_dir: Path) -> None:
38+
for py_file in source_dir.glob("*.py"):
39+
shutil.copy2(py_file, save_dir / py_file.name)
40+
41+
2942
def copy_hf_ckpt_remote_code(
3043
pretrained_model_path: str | os.PathLike, save_directory: str | os.PathLike
3144
):
@@ -36,7 +49,10 @@ def copy_hf_ckpt_remote_code(
3649
frameworks.
3750
3851
If ``pretrained_model_path`` is a local directory, Python files are copied directly.
39-
If it's a HF Hub model ID (e.g. ``nvidia/NVIDIA-Nemotron-Nano-12B-v2``), files are downloaded from the Hub.
52+
If it's a HF Hub model ID (e.g. ``nvidia/NVIDIA-Nemotron-Nano-12B-v2``), the Hub
53+
snapshot is resolved first and Python files are copied from that snapshot. When
54+
``HF_HUB_OFFLINE`` is set, the snapshot must already be available in the local
55+
Hugging Face cache.
4056
4157
Args:
4258
pretrained_model_path: Local path to the pretrained model or HuggingFace Hub model ID.
@@ -47,14 +63,28 @@ def copy_hf_ckpt_remote_code(
4763
save_dir.mkdir(parents=True, exist_ok=True)
4864

4965
if hf_checkpoint_path.is_dir():
50-
for py_file in hf_checkpoint_path.glob("*.py"):
51-
shutil.copy2(py_file, save_dir / py_file.name)
66+
_copy_python_files(hf_checkpoint_path, save_dir)
5267
else:
53-
snapshot_download(
54-
repo_id=str(pretrained_model_path),
55-
local_dir=str(save_dir),
56-
allow_patterns=["*.py"],
57-
)
68+
local_files_only = _is_hf_hub_offline()
69+
try:
70+
source_dir = Path(
71+
snapshot_download(
72+
repo_id=str(pretrained_model_path),
73+
allow_patterns=["*.py"],
74+
local_files_only=local_files_only,
75+
)
76+
)
77+
except LocalEntryNotFoundError as exc:
78+
if local_files_only:
79+
raise RuntimeError(
80+
f"Could not copy Python sidecar files for {pretrained_model_path!r} because "
81+
"HF_HUB_OFFLINE is enabled and the files are not available in the local "
82+
"Hugging Face cache. Populate the cache with the model's *.py files or pass "
83+
"a local pretrained model directory."
84+
) from exc
85+
raise
86+
87+
_copy_python_files(source_dir, save_dir)
5888

5989

6090
def load_multimodal_components(

modelopt/torch/export/unified_export_megatron.py

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
get_weight_block_size,
6262
get_weight_scaling_factor,
6363
get_weight_scaling_factor_2,
64+
process_layer_quant_config,
6465
to_quantized_weight,
6566
)
6667

@@ -169,6 +170,7 @@ def __init__(
169170
self.all_rules = self._populate_rule_book()
170171
self.rules = self.all_rules[self.arch]
171172
self.exclude_modules = []
173+
self.layer_config_dict = {}
172174

173175
if not hasattr(model, "_modelopt_state"):
174176
return
@@ -324,22 +326,32 @@ def save_pretrained(
324326
print(f"Successfully loaded {len(mtp_state_dict)} MTP tensors")
325327

326328
combined_exclude_modules = self._gather_exclude_modules()
329+
combined_layer_config_dict = self._gather_layer_config_dict()
327330

328331
if is_last_stage_main_rank and quantization is not None:
329-
self._hf_quant_config = {
332+
if combined_layer_config_dict:
333+
quantization_config = process_layer_quant_config(combined_layer_config_dict)
334+
quantization_config["exclude_modules"] = combined_exclude_modules
335+
else:
336+
quantization_config = {
337+
"quant_algo": quantization,
338+
"exclude_modules": combined_exclude_modules,
339+
}
340+
if quantization == "NVFP4": # update block size
341+
quantization_config["group_size"] = 16
342+
343+
if hasattr(self, "kv_cache_dtype"):
344+
quantization_config["kv_cache_quant_algo"] = self.kv_cache_dtype
345+
346+
raw_hf_quant_config = {
330347
"producer": {
331348
"name": "modelopt",
332349
"version": __version__,
333350
},
334-
"quantization": {
335-
"quant_algo": quantization,
336-
"exclude_modules": combined_exclude_modules,
337-
},
351+
"quantization": quantization_config,
338352
}
339-
if quantization == "NVFP4": # update block size
340-
self._hf_quant_config["quantization"]["group_size"] = 16
341-
if hasattr(self, "kv_cache_dtype"):
342-
self._hf_quant_config["quantization"]["kv_cache_quant_algo"] = self.kv_cache_dtype
353+
# Use one serving-facing config for both hf_quant_config.json and config.json.
354+
self._hf_quant_config = convert_hf_quant_config_format(raw_hf_quant_config)
343355
with open(save_directory + "/hf_quant_config.json", "w") as f:
344356
json.dump(self._hf_quant_config, f, indent=4)
345357

@@ -359,10 +371,9 @@ def save_pretrained(
359371
# Newer versions of VLLM expect config.json with hf_quant_config
360372
config_json_file = save_directory + "/config.json"
361373
if self._hf_quant_config and os.path.exists(config_json_file):
362-
converted_quant_config = convert_hf_quant_config_format(self._hf_quant_config)
363374
with open(config_json_file) as f:
364375
config_dict = json.load(f)
365-
config_dict["quantization_config"] = converted_quant_config
376+
config_dict["quantization_config"] = self._hf_quant_config
366377
with open(config_json_file, "w") as f:
367378
json.dump(config_dict, f, indent=4)
368379

@@ -803,9 +814,7 @@ def _get_quantized_state(
803814
name_to_value = {}
804815
qformat: str = self._get_quantization_format(module)
805816
if qformat is None and "norm" not in prefix:
806-
# Add exclude layers for hf_quant_config. Note that if the prefix is not an empty
807-
# string then it usually ends with "." which needs to be removed.
808-
self.exclude_modules.append(prefix.removesuffix("."))
817+
self._record_excluded_module(prefix)
809818
block_size = get_weight_block_size(module)
810819

811820
name_to_value = self._get_weight_bias(module, dtype, name_to_value)
@@ -850,6 +859,27 @@ def _get_weight_scales(self, quantized_state: dict[str, Any], qformat: str):
850859

851860
return weight_scale, weight_scale_2
852861

862+
def _record_layer_quant_config(self, prefix: str, qformat: str | None, block_size: int):
863+
"""Record per-HF-layer quantization metadata for mixed precision exports."""
864+
if qformat in (None, QUANTIZATION_NONE):
865+
return
866+
867+
layer_name = prefix.removesuffix(".")
868+
if "{" in layer_name or not layer_name:
869+
return
870+
871+
self.layer_config_dict[layer_name + ".quantization"] = qformat
872+
self.layer_config_dict[layer_name + ".awq_block_size"] = block_size
873+
874+
def _record_excluded_module(self, prefix: str):
875+
"""Record an unquantized HF module prefix for hf_quant_config."""
876+
layer_name = prefix.removesuffix(".")
877+
if "{" in layer_name or not layer_name:
878+
return
879+
880+
if layer_name not in self.exclude_modules:
881+
self.exclude_modules.append(layer_name)
882+
853883
def _name_remapping(
854884
self,
855885
module: torch.nn.Module | torch.Tensor,
@@ -866,6 +896,7 @@ def _name_remapping(
866896
return
867897

868898
name_to_value, qformat, block_size = self._get_quantized_state(module, dtype, prefix=prefix)
899+
self._record_layer_quant_config(prefix, qformat, block_size)
869900

870901
weight = name_to_value.pop("weight")
871902
weight_scale, weight_scale_2 = self._get_weight_scales(name_to_value, qformat)
@@ -906,6 +937,8 @@ def _gated_mlp_slicing(
906937

907938
gate_proj_prefix = prefix + gate_proj_name + "."
908939
up_proj_prefix = prefix + up_proj_name + "."
940+
self._record_layer_quant_config(gate_proj_prefix, qformat, block_size)
941+
self._record_layer_quant_config(up_proj_prefix, qformat, block_size)
909942

910943
ffn_hidden_size = module.config.ffn_hidden_size
911944
gate_proj_weight = weight[:ffn_hidden_size, :]
@@ -986,6 +1019,7 @@ def _grouped_mlp_slicing(self, module, prefix, parallel_config=None):
9861019

9871020
for expert_id in range(num_experts):
9881021
expert_prefix = prefix.format(expert_id) + "."
1022+
self._record_layer_quant_config(expert_prefix, qformat, block_size)
9891023
weight_key = f"weight{expert_id}"
9901024

9911025
if weight_key not in state_dict:
@@ -1030,6 +1064,18 @@ def _qkv_slicing(
10301064
q_proj_prefix = prefix + q_proj_name + "."
10311065
k_proj_prefix = prefix + k_proj_name + "."
10321066
v_proj_prefix = prefix + v_proj_name + "."
1067+
self._record_layer_quant_config(q_proj_prefix, qformat, block_size)
1068+
self._record_layer_quant_config(k_proj_prefix, qformat, block_size)
1069+
self._record_layer_quant_config(v_proj_prefix, qformat, block_size)
1070+
if qformat in (None, QUANTIZATION_NONE):
1071+
# MCore stores Q/K/V as one fused linear_qkv module, but HF exports them
1072+
# as separate q_proj/k_proj/v_proj modules. Record the HF names so
1073+
# runtime quant configs do not miss excluded fused-QKV projections.
1074+
fused_prefix = prefix.removesuffix(".")
1075+
self.exclude_modules = [m for m in self.exclude_modules if m != fused_prefix]
1076+
self._record_excluded_module(q_proj_prefix)
1077+
self._record_excluded_module(k_proj_prefix)
1078+
self._record_excluded_module(v_proj_prefix)
10331079

10341080
config = module.config
10351081
hidden_size = config.hidden_size
@@ -1179,6 +1225,7 @@ def _pack_name_remapping(self, module, prefix, layer_type=None):
11791225
weight_scale_list.append(weight_scale)
11801226
weight_scale_2_list.append(weight_scale_2)
11811227
input_scale_list.append(input_scale)
1228+
self._record_layer_quant_config(prefix, qformat, block_size)
11821229

11831230
merged_weight = torch.stack(weight_list, dim=0)
11841231

@@ -1247,6 +1294,7 @@ def _pack_name_remapping_gpt_oss(self, module, prefix, layer_type=None):
12471294
weight_scale_2_list.append(weight_scale_2)
12481295
input_scale_list.append(input_scale)
12491296
bias_list.append(bias)
1297+
self._record_layer_quant_config(prefix, qformat, block_size)
12501298

12511299
merged_weight = torch.stack(weight_list, dim=0)
12521300

@@ -1349,6 +1397,19 @@ def _gather_exclude_modules(self):
13491397
combined_exclude_modules.update(modules)
13501398
return sorted(combined_exclude_modules)
13511399

1400+
def _gather_layer_config_dict(self):
1401+
"""Get per-layer quantization metadata from all ranks for hf_quant_config."""
1402+
if not torch.distributed.is_initialized():
1403+
return dict(sorted(self.layer_config_dict.items()))
1404+
1405+
all_layer_config_dicts = [None] * torch.distributed.get_world_size()
1406+
torch.distributed.all_gather_object(all_layer_config_dicts, self.layer_config_dict)
1407+
combined_layer_config_dict = {}
1408+
for layer_config_dict in all_layer_config_dicts:
1409+
if layer_config_dict:
1410+
combined_layer_config_dict.update(layer_config_dict)
1411+
return dict(sorted(combined_layer_config_dict.items()))
1412+
13521413

13531414
def export_mcore_gpt_to_hf(
13541415
model: torch.nn.Module,

modelopt/torch/quantization/model_calib.py

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -110,26 +110,44 @@ def _has_expert_parallelism(module: nn.Module) -> bool:
110110
return ps is not None and ps.expert_model_parallel_group.is_initialized()
111111

112112

113-
def _check_moe_calibration_complete(quantizer, parallel_state):
114-
"""Raise error if MoE calibration is incomplete (some ranks have amax, others don't)."""
113+
def _is_dynamic_block_quantizer(quantizer) -> bool:
114+
block_sizes = getattr(quantizer, "block_sizes", None)
115+
if isinstance(block_sizes, dict):
116+
return block_sizes.get("type") == "dynamic"
117+
return getattr(block_sizes, "type", None) == "dynamic"
118+
119+
120+
def _iter_leaf_quantizers(quantizer):
115121
if isinstance(quantizer, SequentialQuantizer):
116122
for _q in quantizer:
117-
_check_moe_calibration_complete(_q, parallel_state)
123+
yield from _iter_leaf_quantizers(_q)
118124
return
119-
for group in [
120-
parallel_state.data_parallel_group,
121-
parallel_state.expert_model_parallel_group,
122-
parallel_state.tensor_parallel_group,
123-
]:
124-
if not group.is_initialized():
125+
yield quantizer
126+
127+
128+
def _check_moe_calibration_complete(quantizer, parallel_state):
129+
"""Raise error if MoE calibration is incomplete across distributed MoE ranks."""
130+
for leaf_quantizer in _iter_leaf_quantizers(quantizer):
131+
if _is_dynamic_block_quantizer(leaf_quantizer):
125132
continue
126-
has_amax = getattr(quantizer, "_amax", None) is not None
127-
amax_states = DistributedProcessGroup.get_dist_syncd_obj(has_amax, group, lambda objs: objs)
128-
if any(amax_states) and not all(amax_states):
129-
raise RuntimeError(
130-
"MoE calibration incomplete: some experts received no tokens during calibration. "
131-
"Increase --calib-size to ensure all experts see calibration data."
133+
134+
has_amax = getattr(leaf_quantizer, "_amax", None) is not None
135+
for group in [
136+
parallel_state.data_parallel_group,
137+
parallel_state.expert_model_parallel_group,
138+
parallel_state.tensor_parallel_group,
139+
]:
140+
if not group.is_initialized():
141+
continue
142+
amax_states = DistributedProcessGroup.get_dist_syncd_obj(
143+
has_amax, group, lambda objs: objs
132144
)
145+
if any(amax_states) and not all(amax_states):
146+
raise RuntimeError(
147+
"MoE calibration incomplete: some experts received no tokens during "
148+
"calibration. Increase --calib-size to ensure all experts see calibration "
149+
"data."
150+
)
133151

134152

135153
@torch.no_grad()
@@ -175,13 +193,13 @@ def max_calibrate(
175193

176194
def sync_quantizer_amax_across_dp_ep(quantizer, parallel_state):
177195
"""Synchronize the amax across all ranks in the data parallel and expert parallel groups."""
178-
if isinstance(quantizer, SequentialQuantizer):
179-
for _q in quantizer:
180-
sync_quantizer_amax_across_dp_ep(_q, parallel_state)
181-
return
182-
if getattr(quantizer, "_amax", None) is not None:
183-
quantizer.sync_amax_across_distributed_group(parallel_state.data_parallel_group)
184-
quantizer.sync_amax_across_distributed_group(parallel_state.expert_model_parallel_group)
196+
for leaf_quantizer in _iter_leaf_quantizers(quantizer):
197+
if _is_dynamic_block_quantizer(leaf_quantizer):
198+
continue
199+
leaf_quantizer.sync_amax_across_distributed_group(parallel_state.data_parallel_group)
200+
leaf_quantizer.sync_amax_across_distributed_group(
201+
parallel_state.expert_model_parallel_group
202+
)
185203
# TODO: create sync_bias_across_distributed_group
186204

187205
# Step 2:Sync amax across data parallelism
@@ -226,7 +244,7 @@ def sync_quantizer_amax_across_tp(
226244
)
227245
# Skip amax sync for INT4 / W4A8 block quantization
228246
# Sync amax for NVFP4 (dynamic per-block, static per-tensor quantized scale)
229-
if getattr(quantizer.block_sizes, "type", None) == "dynamic":
247+
if _is_dynamic_block_quantizer(quantizer):
230248
return
231249

232250
if quantizer.axis in axes_for_sync and quantizer.amax is not None:
@@ -314,6 +332,7 @@ def mse_calibrate(
314332
start_multiplier: float = 0.25,
315333
stop_multiplier: float = 4.0,
316334
fp8_scale_sweep: bool = False,
335+
fp8_scale_sweep_stride: int = 1,
317336
):
318337
"""Calibrate the model using MSE-based amax search.
319338
@@ -333,6 +352,8 @@ def mse_calibrate(
333352
for NVFP4 per-block quantization instead of using multipliers.
334353
This is specifically designed for optimizing the FP8-quantized
335354
per-block scales in NVFP4 format (default: False).
355+
fp8_scale_sweep_stride: Subsample every Nth FP8 E4M3 candidate when
356+
fp8_scale_sweep is enabled. A value of 1 preserves exhaustive sweep.
336357
337358
See :class:`MseCalibConfig <modelopt.torch.quantization.config.MseCalibConfig>` for
338359
details on the remaining arguments.
@@ -388,6 +409,7 @@ def mse_calibrate(
388409
axis=module._calibrator._axis,
389410
global_amax=module.global_amax,
390411
quant_func=partial(_mse_quant_func, quantizer=module),
412+
fp8_scale_sweep_stride=fp8_scale_sweep_stride,
391413
)
392414
continue
393415

0 commit comments

Comments
 (0)