Skip to content

Commit 2d274ad

Browse files
committed
[ckpt] feat: Add --auto-config for self-contained Megatron-to-HF checkpoint export
- Add --auto-config flag to convert_checkpoints.py for config-free HF export - Add filter_hf_config_fields utility for filtering reconstructed HF configs - Add megatron_to_hf_config to model bridges for Megatron→HF config mapping - Add auto-config round-trip test reusing Nemotron-3-Nano toy model fixture Signed-off-by: david <davidzhaiyang@gmail.com>
1 parent 856febf commit 2d274ad

8 files changed

Lines changed: 301 additions & 49 deletions

File tree

‎examples/conversion/convert_checkpoints.py‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@
5050
--megatron-path ./checkpoints/custom_model \
5151
--hf-path ./exports/custom_model_hf \
5252
--no-progress
53+
54+
# Export using auto-config (synthesizes config from checkpoint + HF model reference)
55+
uv run python examples/conversion/convert_checkpoints.py export \
56+
--hf-model nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 \
57+
--auto-config \
58+
--megatron-path ./checkpoints/nemotron_h_custom \
59+
--hf-path ./exports/nemotron_h_custom_hf
5360
"""
5461

5562
import argparse
@@ -140,16 +147,22 @@ def export_megatron_to_hf(
140147
hf_model: str,
141148
megatron_path: str,
142149
hf_path: str,
150+
auto_config: bool = False,
143151
show_progress: bool = True,
144152
strict: bool = True,
145153
) -> None:
146154
"""
147155
Export a Megatron checkpoint to HuggingFace format.
148156
149157
Args:
158+
hf_model: HuggingFace model ID or path (loads full model with weights).
150159
megatron_path: Directory path where the Megatron checkpoint is stored
151160
hf_path: Directory path where the HuggingFace model will be saved
161+
auto_config: If True, synthesizes the HF config from run_config.yaml using
162+
the model bridge class resolved from --hf-model, instead of loading
163+
the full pretrained weights from HuggingFace.
152164
show_progress: Display progress bar during weight export
165+
strict: Whether to perform strict validation during weight export
153166
"""
154167
print(f"🔄 Starting export: {megatron_path} -> {hf_path}")
155168

@@ -174,9 +187,26 @@ def export_megatron_to_hf(
174187

175188
print(f"📋 Found configuration: {config_files[0]}")
176189

177-
# For demonstration, we'll create a bridge from a known config
178-
# This would typically be extracted from the checkpoint metadata
179-
bridge = AutoBridge.from_hf_pretrained(hf_model, trust_remote_code=True)
190+
# Create bridge: auto-config (synthesize from run_config.yaml) or full pretrained
191+
if auto_config:
192+
from transformers import AutoConfig, PretrainedConfig
193+
194+
from megatron.bridge.models.conversion.utils import filter_hf_config_fields
195+
from megatron.bridge.training.model_load_save import load_model_config
196+
197+
megatron_cfg, _ = load_model_config(str(config_files[0].parent))
198+
config = AutoConfig.from_pretrained(hf_model, trust_remote_code=True)
199+
bridge = AutoBridge.from_hf_config(config)
200+
if "megatron_to_hf_config" not in type(bridge._model_bridge).__dict__:
201+
raise ValueError(f"--auto-config does not currently support {type(bridge._model_bridge).__name__}")
202+
hf_cfg_dict = bridge._model_bridge.megatron_to_hf_config(megatron_cfg)
203+
hf_cfg_dict = filter_hf_config_fields(hf_cfg_dict, config.to_dict())
204+
config = PretrainedConfig(**hf_cfg_dict)
205+
config._bridge_original_dict = dict(hf_cfg_dict)
206+
bridge = AutoBridge.from_hf_config(config)
207+
bridge._auto_config_source_repo = hf_model
208+
else:
209+
bridge = AutoBridge.from_hf_pretrained(hf_model, trust_remote_code=True)
180210

181211
# Export using the convenience method
182212
print("📤 Exporting to HuggingFace format...")
@@ -227,6 +257,11 @@ def main():
227257
# Export subcommand (Megatron -> HF)
228258
export_parser = subparsers.add_parser("export", help="Export Megatron checkpoint to HuggingFace format")
229259
export_parser.add_argument("--hf-model", required=True, help="HuggingFace model ID or path to model directory")
260+
export_parser.add_argument(
261+
"--auto-config",
262+
action="store_true",
263+
help="Export custom models with different architectures than the base --hf-model",
264+
)
230265
export_parser.add_argument(
231266
"--megatron-path", required=True, help="Directory path where the Megatron checkpoint is stored"
232267
)
@@ -237,7 +272,6 @@ def main():
237272
export_parser.add_argument(
238273
"--not-strict", action="store_true", help="Allow source and target checkpoint to have different keys"
239274
)
240-
241275
args = parser.parse_args()
242276

243277
if not args.command:
@@ -258,6 +292,7 @@ def main():
258292
hf_model=args.hf_model,
259293
megatron_path=args.megatron_path,
260294
hf_path=args.hf_path,
295+
auto_config=args.auto_config,
261296
show_progress=not args.no_progress,
262297
strict=not args.not_strict,
263298
)

‎src/megatron/bridge/models/conversion/auto_bridge.py‎

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import dataclasses
18+
import logging
1819
from functools import cached_property, partial
1920
from pathlib import Path
2021
from typing import TYPE_CHECKING, Any, Generic, Iterable, List, Optional, Type, TypeVar, Union
@@ -30,6 +31,7 @@
3031
from megatron.core.transformer.module import MegatronModule
3132
from megatron.core.transformer.transformer_config import MLATransformerConfig, TransformerConfig
3233
from modelopt.torch.quantization.utils import is_quantized
34+
from safetensors.torch import save_file
3335
from transformers.configuration_utils import PretrainedConfig
3436
from typing_extensions import Unpack
3537

@@ -47,6 +49,8 @@
4749
from megatron.bridge.models.model_provider import GetModelKwargs, ModelParallelKwargs, ModelProviderMixin
4850

4951

52+
logger = logging.getLogger(__name__)
53+
5054
MegatronModelT = TypeVar("MegatronModelT", bound=MegatronModule)
5155
DataclassT = TypeVar("DataclassT")
5256

@@ -577,29 +581,65 @@ def save_hf_pretrained(
577581
saves the configuration files, while weight saving is coordinated
578582
across all ranks.
579583
"""
580-
if not isinstance(self.hf_pretrained, PreTrainedCausalLM):
581-
raise ValueError(
582-
"save_hf_pretrained requires a pretrained HuggingFace model. "
583-
"AutoBridge.from_hf_config() creates a config-only bridge; "
584-
"use AutoBridge.from_hf_pretrained(...) instead."
585-
)
584+
if not isinstance(self.hf_pretrained, (PreTrainedCausalLM, PretrainedConfig)):
585+
raise ValueError("save_hf_pretrained requires a pretrained HuggingFace model or config.")
586+
is_config_only = isinstance(self.hf_pretrained, PretrainedConfig)
587+
588+
def _save_artifacts():
589+
if is_config_only:
590+
import json
591+
592+
# Config-only path: write config.json and download modeling files from Hub.
593+
Path(path).mkdir(parents=True, exist_ok=True)
594+
config_dict = getattr(self.hf_pretrained, "_bridge_original_dict", None)
595+
if config_dict is None:
596+
config_dict = self.hf_pretrained.to_dict()
597+
# Apply dtype/model_type fixups directly here
598+
if "model_type" in self.hf_pretrained.__dict__:
599+
config_dict["model_type"] = self.hf_pretrained.__dict__["model_type"]
600+
if "dtype" in config_dict and config_dict.get("torch_dtype") is None:
601+
config_dict["torch_dtype"] = config_dict.pop("dtype")
602+
603+
with open(Path(path) / "config.json", "w") as _f:
604+
json.dump(config_dict, _f, indent=2, sort_keys=True, allow_nan=True)
605+
606+
# Download custom modeling files so the output is loadable with from_pretrained().
607+
hub_repo = getattr(self, "_auto_config_source_repo", None)
608+
if hub_repo:
609+
try:
610+
from huggingface_hub import hf_hub_download, list_repo_files
611+
612+
repo_files = list_repo_files(hub_repo)
613+
py_files = [f for f in repo_files if f.endswith(".py")]
614+
for py_file in py_files:
615+
hf_hub_download(
616+
repo_id=hub_repo,
617+
filename=py_file,
618+
local_dir=path,
619+
)
620+
except Exception as exc:
621+
logger.error(
622+
"Could not download modeling files from %s: %s. "
623+
"The exported model may not be loadable with from_pretrained().",
624+
hub_repo,
625+
exc,
626+
)
586627

587-
# Get bridge-level ADDITIONAL_FILE_PATTERNS if configured
588-
additional_files = None
589-
if hasattr(self._model_bridge, "ADDITIONAL_FILE_PATTERNS") and self._model_bridge.ADDITIONAL_FILE_PATTERNS:
590-
additional_files = self._model_bridge.ADDITIONAL_FILE_PATTERNS
591-
592-
if dist.is_available() and dist.is_initialized():
593-
# Distributed training, only rank 0 saves artifacts
594-
if dist.get_rank() == 0:
628+
else:
629+
# Get bridge-level ADDITIONAL_FILE_PATTERNS if configured
630+
mb = self._model_bridge
631+
additional_files = None
632+
if hasattr(mb, "ADDITIONAL_FILE_PATTERNS") and mb.ADDITIONAL_FILE_PATTERNS:
633+
additional_files = mb.ADDITIONAL_FILE_PATTERNS
595634
self.hf_pretrained.save_artifacts(
596635
path, original_source_path=source_path, additional_files=additional_files
597636
)
637+
638+
if dist.is_available() and dist.is_initialized():
639+
if dist.get_rank() == 0:
640+
_save_artifacts()
598641
else:
599-
# No distributed training, save artifacts
600-
self.hf_pretrained.save_artifacts(
601-
path, original_source_path=source_path, additional_files=additional_files
602-
)
642+
_save_artifacts()
603643

604644
self.save_hf_weights(
605645
model,
@@ -701,7 +741,29 @@ def _filter_quant(gen):
701741
save_every_n_ranks=save_every_n_ranks,
702742
)
703743
else:
704-
raise ValueError("The state source is not a SafeTensorsStateSource, cannot save in streaming mode.")
744+
# Config-only path: shard and write safetensors directly
745+
import json
746+
747+
from huggingface_hub import split_torch_state_dict_into_shards
748+
749+
# NOTE: Collects the full state dict into CPU memory before sharding.
750+
# For very large models (>70B), this may require significant host RAM.
751+
state_dict = {name: tensor.contiguous().cpu() for name, tensor in generator}
752+
753+
is_distributed = dist.is_available() and dist.is_initialized()
754+
rank = dist.get_rank() if is_distributed else 0
755+
756+
if rank == 0:
757+
plan = split_torch_state_dict_into_shards(state_dict)
758+
safe_dir = Path(path)
759+
safe_dir.mkdir(parents=True, exist_ok=True)
760+
for filename, tensors in plan.filename_to_tensors.items():
761+
shard = {k: state_dict[k] for k in tensors}
762+
save_file(shard, safe_dir / filename)
763+
if plan.is_sharded:
764+
index = {"metadata": plan.metadata, "weight_map": plan.tensor_to_filename}
765+
with open(safe_dir / "model.safetensors.index.json", "w") as f:
766+
json.dump(index, f, indent=2)
705767

706768
# Save quantizer/amax sidecar after the main generator is consumed (rank 0 only).
707769
if quant_tensors:
@@ -899,6 +961,11 @@ def import_ckpt(
899961
hf_tokenizer_kwargs = None
900962
if hasattr(bridge._model_bridge, "get_hf_tokenizer_kwargs"):
901963
hf_tokenizer_kwargs = bridge._model_bridge.get_hf_tokenizer_kwargs()
964+
# Forward trust_remote_code to the tokenizer (needed for repos with custom code)
965+
if kwargs.get("trust_remote_code"):
966+
if hf_tokenizer_kwargs is None:
967+
hf_tokenizer_kwargs = {}
968+
hf_tokenizer_kwargs.setdefault("trust_remote_code", True)
902969
bridge.save_megatron_model(
903970
megatron_model,
904971
megatron_path,
@@ -921,8 +988,6 @@ def export_ckpt(
921988
This is a convenience method that loads a Megatron checkpoint and
922989
exports it to HuggingFace format. This is useful for sharing trained
923990
models or deploying them with HuggingFace inference tools.
924-
Requires a bridge created with `from_hf_pretrained` so the tokenizer
925-
and other HuggingFace artifacts can be saved alongside the weights.
926991
927992
Args:
928993
megatron_path: Directory path where the Megatron checkpoint is stored
@@ -941,7 +1006,6 @@ def export_ckpt(
9411006
... "./megatron_checkpoints/my_model",
9421007
... "./hf_exports/my_model"
9431008
... )
944-
9451009
>>> # Export with specific settings
9461010
>>> bridge.export_ckpt(
9471011
... "./megatron_checkpoints/my_model",
@@ -953,12 +1017,6 @@ def export_ckpt(
9531017
>>> from transformers import AutoModelForCausalLM
9541018
>>> hf_model = AutoModelForCausalLM.from_pretrained("./hf_exports/my_model")
9551019
"""
956-
if not isinstance(self.hf_pretrained, PreTrainedCausalLM):
957-
raise ValueError(
958-
"export_ckpt requires a pretrained HuggingFace model. "
959-
"AutoBridge.from_hf_config() creates a config-only bridge; "
960-
"use AutoBridge.from_hf_pretrained(...) instead."
961-
)
9621020
try:
9631021
from megatron.bridge.training.model_load_save import temporary_distributed_context
9641022
except ImportError:
@@ -1295,7 +1353,7 @@ def mla_transformer_config(self) -> MLATransformerConfig:
12951353

12961354
return self._create_config_from_provider(_model_provider, MLATransformerConfig)
12971355

1298-
@property
1356+
@cached_property
12991357
def _model_bridge(self) -> "MegatronModelBridge":
13001358
hf_config = getattr(self.hf_pretrained, "hf_config", None)
13011359
if hf_config is None:

‎src/megatron/bridge/models/conversion/model_bridge.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
unwrap_model,
4343
)
4444
from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
45+
from transformers.configuration_utils import PretrainedConfig
4546
from transformers.modeling_utils import PreTrainedModel
4647

4748
from megatron.bridge.models.conversion.mapping_registry import MegatronMappingRegistry
@@ -1109,6 +1110,9 @@ def stream_weights_megatron_to_hf(
11091110
expected_keys = hf_pretrained.state.source.get_all_keys()
11101111
if "lm_head.weight" in expected_keys:
11111112
yield HFWeightTuple("lm_head.weight", final_tensor.clone().detach())
1113+
elif isinstance(hf_pretrained, PretrainedConfig):
1114+
# Always emit lm_head.weight for config-only
1115+
yield HFWeightTuple("lm_head.weight", final_tensor.clone().detach())
11121116
elif embeddings_are_tied and hf_name == "lm_head.weight":
11131117
# This should not happen when embeddings are tied - assert error
11141118
raise ValueError(
@@ -1310,12 +1314,11 @@ def build_conversion_tasks(
13101314
populated.
13111315
"""
13121316

1313-
# Ensure hf_pretrained has the required state structure
1314-
if not (hasattr(hf_pretrained, "state") and hasattr(hf_pretrained.state, "source")):
1317+
has_hf_state = hasattr(hf_pretrained, "state") and hasattr(hf_pretrained.state, "source")
1318+
if not isinstance(hf_pretrained, PretrainedConfig) and not has_hf_state:
13151319
raise ValueError("hf_pretrained.state.source is required for weight ordering")
13161320

1317-
hf_keys: Iterable[str] = hf_pretrained.state.source.get_all_keys()
1318-
1321+
hf_keys: Optional[Iterable[str]] = hf_pretrained.state.source.get_all_keys() if has_hf_state else None
13191322
mapping_registry = self.mapping_registry()
13201323
unwrapped_model = unwrap_model(megatron_model)[0]
13211324
model_config = unwrapped_model.config
@@ -1351,8 +1354,8 @@ def build_conversion_tasks(
13511354
logger.warning(f"WARNING: No mapping found for megatron_param: {global_name}")
13521355
continue
13531356

1354-
# ensure hf weights exist
1355-
if not mapping.allow_hf_name_mismatch:
1357+
# Ensure hf weights exist (skip for config-only export where hf_keys is None)
1358+
if hf_keys is not None and not mapping.allow_hf_name_mismatch:
13561359
if isinstance(mapping.hf_param, str):
13571360
if mapping.hf_param not in hf_keys:
13581361
logger.warning(f"WARNING: Can't find {mapping.hf_param} in hf_keys")
@@ -1546,7 +1549,7 @@ def _megatron_to_hf_registered_impl(
15461549
) -> Iterable[HFWeightTuple]:
15471550
bridge = bridge_class()
15481551

1549-
# allow bridge to access model config (config-only shims or raw configs lack .config)
1552+
# Allow bridge to access model config (config-only shims or raw configs lack .config)
15501553
bridge.hf_config = hf_pretrained.config if hasattr(hf_pretrained, "config") else hf_pretrained
15511554

15521555
return bridge.stream_weights_megatron_to_hf(

‎src/megatron/bridge/models/conversion/utils.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ def get_causal_lm_class_name_via_auto_map(
273273
return None
274274

275275

276+
def filter_hf_config_fields(hf_cfg_dict: dict[str, object], reference_cfg: dict[str, object]) -> dict[str, object]:
277+
"""Filter an HF config dict to only contain keys present in a reference config."""
278+
reference_cfg_keys = set(reference_cfg.keys())
279+
return {key: value for (key, value) in hf_cfg_dict.items() if key in reference_cfg_keys}
280+
281+
276282
def persistent_buffers(model: torch.nn.Module) -> Iterable[Tuple[str, torch.Tensor]]:
277283
"""Return an iterator over persistent module buffers, yielding both the name of the buffer as well as the buffer itself."""
278284

0 commit comments

Comments
 (0)