mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-02 03:14:52 +08:00
### 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>
217 lines
8.6 KiB
Python
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()
|