Skip to content

Commit 9d360af

Browse files
authored
Add ModelOpt QAD skill for Slurm workflows (#2010)
### What does this PR do? Type of change: new feature Adds a general Slurm-only QAD skill based on the supported Megatron Bridge workflow. The skill: - starts from a measured BF16-to-PTQ benchmark gap and preserves the preceding PTQ configuration or recipe; - gates QAD on exact Megatron Bridge model support and successful Megatron PTQ, using its master-rank quantizer summary as a scoped `amax` sanity check; - requires model- and hardware-derived TP/PP/CP/EP/ETP topology selection; - streams and randomly samples only the required `nvidia/Nemotron-Cascade-2-SFT-Data` token budget and uses Megatron sequence packing; - defaults to 32K sequences, LR `1e-5` with cosine decay, a 1000-step cap, and GBS 512; - requires explicit user authorization because QAD is costly, validates two batches every 25 steps, saves every 50 steps, and monitors a decreasing smoothed loss trend; - evaluates an early checkpoint around step 150 and continues only when benchmark recovery and the loss trend justify more training; - follows the established common Slurm and remote-execution guidance instead of duplicating mutable commands from the Megatron Bridge README. Also exposes Megatron Bridge `save_interval`, `exit_interval`, and `exit_duration_in_mins` through `examples/megatron_bridge/distill.py`, with example-test coverage for checkpoint and ModelOpt-state preservation at an early exit. ### Usage ```text Use the QAD skill to recover the measured BF16-to-PTQ benchmark gap for <model> on <Slurm cluster>, preserving the validated PTQ recipe. ``` ### Testing - `PYTHONPATH=$PWD pre-commit run --all-files` - Passed every hook on the rebased branch, including Ruff, Ruff format, mypy, YAML/recipe validation, launcher reference validation, Bandit, generated arguments, symlink synchronization, and Markdown lint. - `python ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/qad` - `Skill is valid!` Qwen3-0.6B result-bearing validation: - Resources: one exclusive node, 8 H100 GPUs - Container: `nvcr.io/nvidia/nemo:26.06` - Quantization: NVFP4, group size 16, embedding excluded - QAD topology: TP=1, PP=1, CP=4, EP=1, DP=2 - Training validation configuration: sequence length 32768, MBS=1, GBS=8, `train_iters=1000`, LR `1e-5` / minimum LR `1e-6`, 50 warmup iterations, cosine decay, `eval_interval=150`, `exit_interval=150`, `exit_duration_in_mins=220` - This result-bearing run used the then-current coupled eval/save cadence. The final skill now validates two batches every 25 steps and saves every 50; the example test covers the independent checkpoint cadence. - The reduced GBS 8 is intentionally validation-only; the skill retains GBS 512 as the production default. - Data: exactly 10,000,000 sampled tokens from four `nvidia/Nemotron-Cascade-2-SFT-Data` configs: - math: 2,306,011 tokens / 364 documents - science: 1,191,257 tokens / 285 documents - chat: 6,142,077 tokens / 1,800 documents - instruction following: 360,655 tokens / 411 documents - Megatron built packed 32K GPT samples from the materialized prefixes; the full dataset was not downloaded. - QAD loss was finite and decreased from `0.2640341` at iteration 10 to `0.1060580` at iteration 150. Final gradient norm was `0.747`, with zero skipped and zero NaN iterations. Validation distillation loss was `0.09715855`. - The iteration-150 checkpoint saved successfully with `modelopt_state`, and both PTQ and QAD-150 exported to unified Hugging Face format. - Identical full MMLU 0-shot comparison through the Megatron evaluator: | Model | Accuracy | | --- | ---: | | BF16 | 0.39517164 | | PTQ | 0.32851446 | | QAD-150 | 0.38740921 | QAD-150 recovered `0.05889475 / 0.06665718 = 88.35%` of the measured PTQ gap, so validation stopped at the early evidence gate rather than continuing blindly toward 1000 iterations. ### 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?: N/A — this adds an agent skill and example-only lifecycle flags. - Did you get Claude approval on this PR?: N/A ### Additional Information All seven branch commits are cryptographically signed and include a `Signed-off-by` trailer. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Updated the QAD skill documentation with a clear “Execute in this order” workflow, including a revised default recovery training policy. * Added a new `nemotron-cascade-2` dataset blend configuration with an increased token budget. * Enhanced the MeGatron Bridge distillation CLI with stricter interval argument validation and support for configurable save-and-exit controls. * **Documentation** * Expanded Megatron Bridge README guidance for dataset preparation, token-budget recalculation, and resume expectations. * **Tests** * Improved distillation and QAD tests to validate early-exit behavior and checkpoint expectations. * Added unit tests covering distillation CLI interval validation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Meng Xin <mxin@nvidia.com>
1 parent a23390d commit 9d360af

6 files changed

Lines changed: 215 additions & 9 deletions

File tree

.agents/skills/qad/SKILL.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
---
2+
name: qad
3+
description: >-
4+
Run explicitly requested ModelOpt Quantization-Aware Distillation (QAD) on
5+
Slurm through Megatron Bridge to recover a measured BF16-to-PTQ accuracy gap.
6+
Use only when the user explicitly asks for QAD, including its topology, data
7+
preparation, Slurm launch, resume, checkpoint export, or recovery decisions.
8+
---
9+
10+
# ModelOpt Quantization-Aware Distillation
11+
12+
QAD is expensive. Run it only when the user explicitly authorizes QAD for the
13+
target model or run. A Day-0, PTQ, evaluation, comparison, or recipe-search
14+
request alone is not authorization to start QAD.
15+
16+
## Follow the supported workflow
17+
18+
Before constructing commands, read:
19+
20+
- `examples/megatron_bridge/README.md`, especially PTQ, data preparation, QAD,
21+
export, and Slurm usage
22+
- `examples/megatron_bridge/{quantize.py,distill.py}` via `--help`
23+
- `skills/common/{environment-setup,workspace-management,slurm-setup}.md`; also
24+
`skills/common/remote-execution.md` for remote Slurm
25+
26+
Treat the example README and `--help` output as authoritative for mutable flags,
27+
commands, containers, and checkpoint formats. This skill supports Slurm only.
28+
29+
## Execute in this order
30+
31+
1. **Confirm the gap.** Reuse only validated, comparable BF16/PTQ results and
32+
the exact benchmark configuration from preceding evaluation or recipe
33+
search; run missing, invalid, or non-comparable baselines. Confirm the target
34+
benchmarks and their context-length needs. Stop if the PTQ gap to BF16 is
35+
already below 1%.
36+
2. **Reproduce PTQ and verify compatibility.** In the target runtime, require
37+
`AutoBridge.can_handle()` for the target model and PTQ through `quantize.py`
38+
to succeed while preserving the exact preceding PTQ config or recipe:
39+
format, layer selection, calibration data/count, sequence length, and seed.
40+
A changed quantization setting is a new PTQ candidate and must be evaluated
41+
before QAD. In the master-rank `.quant_summary.txt`, require finite positive
42+
`amax` for enabled static quantizers; accept `dynamic`/format-defined `None`
43+
only when the recipe intends it. Treat the summary as rank-local under model
44+
parallelism.
45+
3. **Choose topology explicitly.** Derive the smallest fitting node count and
46+
TP/PP/CP/EP from student and teacher architecture, the chosen sequence length,
47+
and available GPU memory. Prefer CP before TP for small long-context models;
48+
keep EP=1 for dense models and ETP=1 because the current `distill.py`
49+
workflow does not support expert tensor parallelism. For MoE require:
50+
51+
- `DP = world_size / (TP * PP * CP)`
52+
- `EDP = world_size / (EP * PP)`
53+
- integral DP/EDP, `num_experts % EP == 0`, and
54+
`GBS % (MBS * DP) == 0`
55+
56+
4. **Prepare the full capped dataset once.** Use suitable user-provided data, or
57+
copy `examples/megatron_bridge/data/nemotron-cascade-2-blend.yaml` as the
58+
default. Set the target tokenizer and workspace path, then materialize the
59+
randomly sampled subset before training. Pack the chosen sequence length;
60+
Megatron's `99,1,0` split creates the 1% validation holdout from the same
61+
data.
62+
5. **Run and monitor QAD.** Run one QAD training job at a time and fold startup
63+
validation into it; do not submit separate GPU preflight jobs or split at
64+
recovery iterations. Let training continue while evaluating saved
65+
checkpoints, and cancel it when a stop condition below is met.
66+
67+
## Default training policy
68+
69+
| Setting | Default |
70+
| --- | --- |
71+
| Sequence length | 32768; adjust for target benchmarks |
72+
| Peak / minimum LR | `1e-5` / `1e-6` |
73+
| LR schedule | cosine |
74+
| Training cap | 1000 iterations |
75+
| Global batch size | 512 |
76+
| Dataset | `nvidia/Nemotron-Cascade-2-SFT-Data` by default |
77+
| Materialized token budget | 17.3B at 32K; cover the full cap at the chosen length |
78+
| Training validation | every 25 iterations; deterministic 1% holdout; 2 batches |
79+
| Checkpoint interval | 50 iterations |
80+
| Loss logging | every 10 iterations |
81+
| Recovery benchmark | 150, then every 100 iterations while training runs |
82+
| Slurm duration exit | 220 minutes for a 4-hour allocation |
83+
84+
## Run policy
85+
86+
- Keep `train_iters=1000` and leave `exit_interval` unset.
87+
- From initial step timing, submit only enough sequential jobs to reach
88+
checkpoint 150; never submit through iteration 1000 upfront. At each recovery
89+
checkpoint, submit to the next only after its targeted evaluation and any
90+
triggered full suite, and only if the BF16 gap remains at least 1% and
91+
recovery has neither plateaued nor regressed.
92+
- Give all training jobs the same run-specific job name and
93+
`--dependency=singleton`; record job IDs and, on any stop, cancel pending jobs
94+
before the active job.
95+
- Cancel on non-finite loss, repeated skipped iterations, or a sustained spike.
96+
At iteration 50, require the loss aggregate to be lower than at iteration 10.
97+
- At each recovery checkpoint, first evaluate the one to three benchmarks with
98+
the largest PTQ drops. Run the remaining original PTQ suite at that checkpoint
99+
only after recovery beyond run noise.
100+
- Cancel when the full-suite gap to BF16 is below 1%, benchmark recovery
101+
regresses beyond run noise, or benchmark recovery and loss both plateau.
102+
- After a duration exit, resume the latest QAD checkpoint in the same output
103+
directory with unchanged prepared data paths/cache, seed, topology, optimizer,
104+
scheduler, iteration, and consumed-sample state; do not restart from PTQ.
105+
- Report the PTQ recipe, data sample, Slurm topology, loss/state, checkpoints,
106+
and comparable BF16/PTQ/QAD results.

.claude/skills/qad

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.agents/skills/qad

examples/megatron_bridge/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,16 @@ To export selected iterations instead, use `--export_iterations 200 400 600`.
250250

251251
### Quantization Aware Distillation (QAD)
252252

253-
To recover the accuracy lost during [Post-Training Quantization](#post-training-quantization), distill the quantized model (student) from the original, unquantized model (teacher). Pass the quantized **Megatron checkpoint** produced by `quantize.py` via `--student_megatron_path` (the ModelOpt quantizers are restored automatically, so distillation trains the fake-quantized student), while `--student_hf_path` provides the student architecture and `--teacher_hf_path` points to the original unquantized model. We also use a smaller learning rate for QAD:
253+
To recover the accuracy lost during [Post-Training Quantization](#post-training-quantization), distill the quantized model (student) from the original, unquantized model (teacher). Pass the quantized **Megatron checkpoint** produced by `quantize.py` via `--student_megatron_path` (the ModelOpt quantizers are restored automatically, so distillation trains the fake-quantized student), while `--student_hf_path` provides the student architecture and `--teacher_hf_path` points to the original unquantized model.
254+
255+
If you do not already have a suitable QAD dataset, start with
256+
[data/nemotron-cascade-2-blend.yaml](data/nemotron-cascade-2-blend.yaml). It defines a general-purpose
257+
mixture of SFT data for QAD. Copy it, set the tokenizer for the target model, and adjust the output directory,
258+
sources, and weights as needed before preparing data. Its default 17.3-billion-token budget covers 1000
259+
iterations at global batch size 512 and sequence length 32768, including a 1% validation holdout and margin.
260+
Recalculate the budget when changing those settings, and keep the prepared data unchanged when resuming.
261+
262+
We also use a smaller learning rate for QAD:
254263

255264
```bash
256265
torchrun --nproc_per_node 8 distill.py \
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Set to the target model's Hugging Face ID or local tokenizer path.
2+
tokenizer: <target-model-tokenizer>
3+
output_dir: <session-model-workspace>/data/nemotron-cascade-2-through-1000
4+
target_tokens: 17_300_000_000
5+
sources:
6+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
7+
config: math
8+
split: train
9+
content_field: messages
10+
weight: 21.1
11+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
12+
config: science
13+
split: train
14+
content_field: messages
15+
weight: 10.9
16+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
17+
config: chat
18+
split: train
19+
content_field: messages
20+
weight: 56.2
21+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
22+
config: instruction_following
23+
split: train
24+
content_field: messages
25+
weight: 3.3
26+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
27+
config: safety
28+
split: train
29+
content_field: messages
30+
weight: 0.02
31+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
32+
config: conversational_agent
33+
split: train
34+
content_field: messages
35+
weight: 3.3
36+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
37+
config: swe
38+
split: train
39+
content_field: messages
40+
weight: 1.8
41+
- hf_dataset: nvidia/Nemotron-Cascade-2-SFT-Data
42+
config: terminal_agent
43+
split: train
44+
content_field: messages
45+
weight: 3.3

examples/megatron_bridge/distill.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@
5959
import modelopt.torch.puzzletron.plugins.mbridge # noqa: F401
6060

6161

62+
def _positive_int(value: str) -> int:
63+
parsed = int(value)
64+
if parsed <= 0:
65+
raise argparse.ArgumentTypeError("must be a positive integer")
66+
return parsed
67+
68+
69+
def _nonnegative_int(value: str) -> int:
70+
parsed = int(value)
71+
if parsed < 0:
72+
raise argparse.ArgumentTypeError("must be a non-negative integer")
73+
return parsed
74+
75+
6276
def get_args():
6377
"""Parse command-line arguments."""
6478
parser = argparse.ArgumentParser(description="Distillation for Megatron-Bridge.")
@@ -162,10 +176,31 @@ def get_args():
162176
"Allowed: core_attn, mlp, moe, moe_act, layernorm, mla_up_proj, shared_experts.",
163177
)
164178
parser.add_argument(
165-
"--eval_interval", type=int, default=100, help="Validate + checkpoint every <N> steps"
179+
"--eval_interval", type=_positive_int, default=100, help="Validate every <N> steps"
166180
)
167181
parser.add_argument(
168-
"--eval_iters", type=int, default=32, help="Number of batches per validation stage"
182+
"--eval_iters",
183+
type=_nonnegative_int,
184+
default=32,
185+
help="Number of batches per validation stage; set to 0 to disable validation",
186+
)
187+
parser.add_argument(
188+
"--save_interval",
189+
type=_positive_int,
190+
default=None,
191+
help="Checkpoint every <N> steps; defaults to --eval_interval",
192+
)
193+
parser.add_argument(
194+
"--exit_interval",
195+
type=_positive_int,
196+
default=None,
197+
help="Save a checkpoint and exit when the iteration is divisible by this value",
198+
)
199+
parser.add_argument(
200+
"--exit_duration_in_mins",
201+
type=_positive_int,
202+
default=None,
203+
help="Save a checkpoint and exit after this many minutes",
169204
)
170205
parser.add_argument(
171206
"--validate_only",
@@ -218,8 +253,8 @@ def get_args():
218253
args.student_hf_model = args.student_hf_path
219254
if args.checkpoint_keep_last < -1:
220255
raise ValueError("--checkpoint_keep_last must be >= -1.")
221-
if args.validate_only and (args.eval_interval <= 0 or args.eval_iters <= 0):
222-
raise ValueError("--validate_only requires --eval_interval > 0 and --eval_iters > 0.")
256+
if args.validate_only and args.eval_iters == 0:
257+
raise ValueError("--validate_only requires --eval_iters > 0.")
223258

224259
print_args(args)
225260

@@ -347,6 +382,8 @@ def _restore_student_hook(model_chunks):
347382
train_iters=args.train_iters,
348383
global_batch_size=args.gbs,
349384
micro_batch_size=args.mbs,
385+
exit_interval=args.exit_interval,
386+
exit_duration_in_mins=args.exit_duration_in_mins,
350387
manual_gc=True,
351388
manual_gc_interval=100,
352389
),
@@ -379,7 +416,9 @@ def _restore_student_hook(model_chunks):
379416
tokenizer_type="NullTokenizer", vocab_size=distill_provider.vocab_size
380417
),
381418
checkpoint=CheckpointConfig(
382-
save_interval=args.eval_interval,
419+
save_interval=(
420+
args.save_interval if args.save_interval is not None else args.eval_interval
421+
),
383422
save=checkpoint_dir,
384423
load=checkpoint_dir, # Resume from this directory (if exists)
385424
most_recent_k=args.checkpoint_keep_last, # Keeps most recent checkpoints (-1 keeps all)

tests/examples/megatron_bridge/test_qad.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ def test_qad(tmp_path: Path, num_gpus, create_student, is_vlm, is_moe):
6868
hf_model_path = create_student(tmp_path)
6969
quantized_megatron_path = tmp_path / "quantized_megatron"
7070
distill_output_dir = tmp_path / "qad_output"
71-
train_iters = 2
71+
train_iters = 3
72+
early_exit_iter = 2
7273

7374
# TODO: VLMs disable sequence parallelism, so tensor parallelism can't be used here.
7475
# Flip to tp_size=num_gpus in nemo:26.08 container
@@ -109,13 +110,18 @@ def test_qad(tmp_path: Path, num_gpus, create_student, is_vlm, is_moe):
109110
gbs=4,
110111
train_iters=train_iters,
111112
lr_warmup_iters=2,
112-
eval_interval=train_iters,
113+
eval_interval=early_exit_iter,
113114
eval_iters=1,
115+
save_interval=1,
114116
log_interval=1,
117+
exit_interval=early_exit_iter,
118+
exit_duration_in_mins=10,
115119
)
116120
run_example_command(distill_cmd, example_path="megatron_bridge", setup_free_port=True)
117121
distilled_megatron_path = distill_output_dir / "checkpoints"
118-
assert (distilled_megatron_path / "latest_checkpointed_iteration.txt").exists()
122+
tracker = distilled_megatron_path / "latest_checkpointed_iteration.txt"
123+
assert tracker.read_text(encoding="utf-8").strip() == str(early_exit_iter)
124+
assert (distilled_megatron_path / "iter_0000001").is_dir()
119125
assert list(distilled_megatron_path.rglob("modelopt_state")), (
120126
"Expected modelopt_state to be preserved in the distilled (QAD) checkpoint"
121127
)

0 commit comments

Comments
 (0)