Files
Model-Optimizer/examples/megatron_bridge/export_quantized_megatron_to_hf.py
Keval MorabiaandClaude Opus 5 0058a15537 [2/2] Track every Megatron-Bridge script with MLflow (#2514)
### What does this PR do?

Type of change: new feature

**[2/2] of a split. Based on #2544 — merge that first; this PR's diff is
only the Megatron-Bridge half.**

#2477 added MLflow tracking to `examples/megatron_bridge/quantize.py`.
It was one of five scripts in that directory that write a checkpoint;
the other four recorded nothing, so the provenance chain stopped at the
PTQ checkpoint and a deployed model could not be traced back to the run
that produced it.

All five now take the same `--mlflow` / `--mlflow_experiment` /
`--mlflow_run_name` flags, and **each declares what it records as a
`Tool` beside its own flags** — the shared `mlflow_utils.py` knows none
of them:

| Script | Records |
| --- | --- |
| `prune_minitron.py` | command, arguments, log, `prune_score` metric,
pointer |
| `quantize.py` (#2477, moved onto the shared `Tool` in #2544) | +
resolved recipe, quantizer summary |
| `distill.py` | + Megatron-Bridge's per-iteration metrics and resolved
config |
| `export_quantized_megatron_to_hf.py` | command, arguments, log,
pointer |
| `export_distilled_megatron_to_hf.py` | same, one pointer per exported
checkpoint |

Each writes `.experiment.json` into the checkpoint it produced, and each
tags what it consumed, so `prune → quantize → distill → export` is
walkable both from disk and by tag query on the server.

**`distill.py` opens the run and Megatron-Bridge joins it.** Its
`LoggerConfig` records per-iteration metrics and the full resolved
config — which a wrapper around `main()` cannot see — but nothing of
`distill.py`'s own arguments and no invocation. Megatron-Bridge takes
`mlflow.active_run()` when one exists, applies the tags and logs into
it, so `distill_run()` opens the run on the rank Megatron-Bridge looks
at (the **last** one) and the two share it. Its early exit is handled
explicitly: `train()` leaves through `sys.exit(0)` on `--exit_interval`,
which a blanket handler would record as `FAILED`.

**The library pieces that exist for that shared run land here with their
first caller**, rather than in [1/2] where they would have none:
`split_tracking_credentials`, so a URI handed to something which
*records* it carries no credential; `log_active_run_experiment_json`,
for pointing a checkpoint at a run this process did not open; and
`MlflowRunLogger._reattach`, because a co-owner can end the run first —
Megatron-Bridge does, as `KILLED`, when SIGTERM arrives mid-training.

Two of Megatron-Bridge's defaults are deliberately not inherited:
**checkpoint artifact upload stays off** unless
`--mlflow_log_checkpoints` (it pushes the whole checkpoint over HTTP
after every save), and **an untracked run passes no `mlflow_*` fields at
all**, since they landed in Megatron-Bridge 0.6 and sending them
unconditionally would break an untracked run on an older one.

### Usage

```bash
# Any of the five, same flags:
torchrun --nproc_per_node 8 prune_minitron.py  ... --mlflow https://<server>/
torchrun --nproc_per_node 8 quantize.py        ... --mlflow https://<server>/
torchrun --nproc_per_node 8 distill.py         ... --mlflow https://<server>/
torchrun --nproc_per_node 8 export_quantized_megatron_to_hf.py ... --mlflow https://<server>/

# Each checkpoint names the run that wrote it:
cat /output/qad/checkpoints/.experiment.json
```

Experiments default to
`$USER/megatron_bridge_{prune,quantize,distill,export,distill_export}/<model
basename>-<variant>`.

### Testing

- Real runs on a toy Qwen3 in one MLflow experiment covering all five
Megatron-Bridge scripts and `hf_ptq` — prune, quantize, QAD
distillation, quantized export, BF16 distillation, distilled export, HF
PTQ — each closing `FINISHED` with the invocation, its arguments as
params, its log, and a matching `.experiment.json` on disk. The chain
tags line up: each stage's `source_checkpoint_path` is the previous
stage's `checkpoint_path`.
- `tests/examples/megatron_bridge` in `nvcr.io/nvidia/nemo:26.08`, the
only lane that runs it: **76 passed**. Plus the three suites from #2544:
**195 pass**.
- `pre-commit run --files <changed>`: all hooks pass.
- Each fix from the review rounds has a test that fails with the fix
reverted: the resumed run, the foreign active run, the percent-decoded
credential, the credential that cannot be moved, the rank-dependent
`LoggerConfig`, the exit-callback guard, and the `iter_*` join.

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

- Is this change backward compatible?: ✅
- 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?: ✅
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅
- Did you get Claude approval on this PR?: several rounds; re-requested
on this head.

### Additional Information

Split from a single ~1150-line PR at review's request; #2544 carries the
library consolidation this builds on, and this branch is based on it.
Earlier review threads here show as outdated after the rebases — they
are all resolved and their fixes are in this branch.

One known gap, stated in the README rather than implied: `distill.py
--hf_export_path` writes a second HuggingFace checkpoint from rank 0,
which is not the rank that owns the run, so it carries no pointer yet.
For the same reason the uploaded `logs/distill.log` holds the last
rank's output — `print_rank_0` keeps the script's own lines on rank 0 —
which the README now says outright; carrying rank 0's log into a run
owned by another rank needs cross-rank upload and is a follow-up.

Two defects found on shared-run paths during review, both verified
against the installed Megatron-Bridge 0.6 rather than its docs.
Megatron-Bridge ends the run it shares with `distill.py` as `KILLED`
from its SIGTERM handler (`train.py:1413`) and then leaves through
`sys.exit()` (`train.py:805`), i.e. before `distill_run`'s `finally` —
and MLflow's fluent calls resolve their target by *opening* a run when
none is active, so a preempted distillation's log and metrics went to a
second, empty run and its `KILLED` status was overwritten. Separately,
an unreachable server disabled our logger but `logger_kwargs` still
handed Megatron-Bridge the same URI, and `state.py` calls
`set_experiment` unguarded from inside the training loop — so a
best-effort `$MLFLOW_TRACKING_URI` aborted the training instead of
degrading to untracked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:29:25 +00:00

217 lines
8.6 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2024 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 a quantized Megatron checkpoint (produced by quantize.py) to a HuggingFace (unified)
checkpoint that can be deployed directly with TensorRT-LLM, vLLM, or SGLang.
The process is as follows:
1. Build the Megatron-Core model structure + tokenizer from the original HuggingFace model.
2. Load the quantized Megatron checkpoint (ModelOpt state + weights are restored automatically).
3. Export the model to a HuggingFace (unified) checkpoint via ModelOpt.
The HuggingFace unified exporter does not gather tensor-parallel-sharded weights, so this script
always loads the checkpoint at tensor_model_parallel_size=1 (re-sharding from whatever TP was used
during quantization). Use --pp_size to shard a large model across GPUs for export.
Example usage to export an FP8 checkpoint produced by quantize.py:
torchrun --nproc_per_node 2 export_quantized_megatron_to_hf.py \
--hf_model_name_or_path Qwen/Qwen3-8B \
--megatron_path /tmp/Qwen3-8B-FP8-megatron \
--pp_size 2 \
--export_unified_hf_path /tmp/Qwen3-8B-FP8-hf
See `README.md` in this directory for more details.
"""
import argparse
import torch
from megatron.bridge.models.hf_pretrained.utils import is_safe_repo
from megatron.core.utils import unwrap_model
from mlflow_utils import (
NON_PARAMS,
add_mlflow_args,
checkpoint_name,
checkpoint_root,
mlflow_run,
resolve_mlflow_args,
)
import modelopt.torch.utils.distributed as dist
from modelopt.torch.export import export_mcore_gpt_to_hf
from modelopt.torch.utils import print_args, print_rank_0
from modelopt.torch.utils.mlflow import Tool, masked_args
from modelopt.torch.utils.plugins.mbridge import (
load_mbridge_model_from_hf,
load_modelopt_megatron_checkpoint,
use_moe_grouped_gemm,
)
EXPORT = Tool(
name="megatron_bridge_export",
tracks=(
"Track this export on an MLflow server, uploading the command and the run log, and "
"writing .experiment.json into --export_unified_hf_path."
),
variant_help="the Megatron checkpoint's directory name",
variant=lambda args: checkpoint_name(args.megatron_path),
model=lambda args: args.hf_model_name_or_path,
checkpoint=lambda args: args.export_unified_hf_path,
source=lambda args: checkpoint_root(args.megatron_path),
non_params=NON_PARAMS,
)
def get_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
"--hf_model_name_or_path",
type=str,
required=True,
help="Original HuggingFace model (used for the model structure, tokenizer, and config).",
)
parser.add_argument(
"--megatron_path",
type=str,
required=True,
help="Path to the quantized Megatron checkpoint produced by quantize.py.",
)
parser.add_argument(
"--export_unified_hf_path",
type=str,
required=True,
help="Directory to write the exported HuggingFace (unified) checkpoint to.",
)
parser.add_argument("--trust_remote_code", action="store_true")
parser.add_argument(
"--no_moe_grouped_gemm",
action="store_true",
help=(
"Force SequentialMLP for MoE experts instead of the fused TEGroupedMLP (grouped GEMM). "
"By default grouped GEMM is used unless the architecture cannot export it to "
"HuggingFace, in which case SequentialMLP is selected automatically."
),
)
parser.add_argument(
"--export_extra_modules",
action="store_true",
help="Export extra modules such as Medusa heads, EAGLE, or MTP.",
)
# Only Pipeline parallelism is supported for export
parser.add_argument("--pp_size", type=int, default=1, help="Pipeline parallel size")
parser.add_argument(
"--num_layers_in_first_pipeline_stage",
type=int,
default=None,
help="Number of layers in the first pipeline stage (Uneven Pipeline Parallelism)",
)
parser.add_argument(
"--num_layers_in_last_pipeline_stage",
type=int,
default=None,
help="Number of layers in the last pipeline stage (Uneven Pipeline Parallelism)",
)
add_mlflow_args(parser, EXPORT)
args = parser.parse_args()
resolve_mlflow_args(args, parser, EXPORT)
print_args(masked_args(args))
# Flipped by main() once the HuggingFace checkpoint is on disk, which is what the MLflow
# provenance pointer claims authorship of.
args.checkpoint_exported = False
return args
def main(args: argparse.Namespace):
trust_remote_code = is_safe_repo(
trust_remote_code=args.trust_remote_code, hf_path=args.hf_model_name_or_path
)
# Build the model structure from HF
_bridge, _provider, model, _unwrapped_model, _tokenizer = load_mbridge_model_from_hf(
hf_model_name_or_path=args.hf_model_name_or_path,
trust_remote_code=trust_remote_code,
moe_grouped_gemm=use_moe_grouped_gemm(
args.hf_model_name_or_path,
trust_remote_code=trust_remote_code,
force_sequential=args.no_moe_grouped_gemm,
),
provider_overrides={
"tensor_model_parallel_size": 1, # Tensor parallelism is not supported
"pipeline_model_parallel_size": args.pp_size,
"expert_model_parallel_size": 1, # Expert parallelism is not supported
"expert_tensor_parallel_size": 1, # Expert tensor parallelism is not supported
"num_layers_in_first_pipeline_stage": args.num_layers_in_first_pipeline_stage,
"num_layers_in_last_pipeline_stage": args.num_layers_in_last_pipeline_stage,
"pipeline_dtype": torch.bfloat16,
},
init_model_parallel=True,
load_weights=False, # The weights come from the Megatron checkpoint, so HF weights are not loaded
)
# Load the quantized checkpoint (with the correct layer spec) rather than reconstructing it from the checkpoint
# config, which avoids the non-serializable layer-spec issue for MoE / Mamba models.
print_rank_0(f"Loading quantized Megatron checkpoint from {args.megatron_path}...")
load_modelopt_megatron_checkpoint(model, args.megatron_path)
unwrapped_model = unwrap_model(model[0])
# Extra modules (Medusa / EAGLE / MTP) only exist on the last pipeline stage. Use an all-reduce
# MAX over all ranks (rather than a broadcast from a hard-coded source rank) so the decision is
# correct regardless of pipeline placement / global rank ordering.
has_extra_modules = hasattr(unwrapped_model, "eagle_module") or hasattr(
unwrapped_model, "medusa_heads"
)
if torch.distributed.is_initialized():
flag = torch.tensor(
[int(has_extra_modules)], dtype=torch.int, device=torch.cuda.current_device()
)
torch.distributed.all_reduce(flag, op=torch.distributed.ReduceOp.MAX)
has_extra_modules = bool(flag.item())
export_extra_modules = has_extra_modules and args.export_extra_modules
print_rank_0(
f"Exporting to HuggingFace (unified) checkpoint at {args.export_unified_hf_path}..."
)
# TODO: Gemma3-VL is not in export_mcore_gpt_to_hf's per-arch mappings yet.
export_mcore_gpt_to_hf(
unwrapped_model,
args.hf_model_name_or_path,
export_extra_modules=export_extra_modules,
dtype=torch.bfloat16,
export_dir=args.export_unified_hf_path,
trust_remote_code=trust_remote_code,
)
args.checkpoint_exported = True
print_rank_0(f"Exported HuggingFace checkpoint to {args.export_unified_hf_path}")
if __name__ == "__main__":
dist.setup()
args = get_args()
try:
# Entered inside the try: opening the run is fatal by design, and the peers of a rank
# that exits without dist.abort() stay blocked on the first collective.
with mlflow_run(args, EXPORT):
main(args)
except BaseException:
dist.abort() # peers may be stuck in a collective this rank will never reach
finally:
dist.cleanup()