feat(megatron-bridge): SFT-masked data support in distillation - #2113
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe distillation example adds SFT options, validates input combinations, loads prompt/completion JSONL data with response-only masking, uses HuggingFace tokenization, calculates per-token loss, and applies SFT-specific distributed gradient reduction. ChangesSFT distillation flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant FinetuningDatasetConfig
participant HuggingFaceTokenizer
participant ModelProviders
participant DistributedGradientReduction
CLI->>FinetuningDatasetConfig: configure SFT dataset
FinetuningDatasetConfig->>HuggingFaceTokenizer: tokenize prompt and completion records
HuggingFaceTokenizer->>ModelProviders: provide tokenized inputs with per-token loss
ModelProviders->>DistributedGradientReduction: reduce SFT gradients without pre-averaging
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/megatron_bridge/distill.py`:
- Around line 275-279: Update the existing pretraining data-input validation
before the SFT-specific check so it does not require --data_paths or
--use_mock_data when args.sft is enabled. Preserve the current requirement for
non-SFT runs, and keep the --sft_dataset_root validation in the SFT argument
flow unchanged.
- Around line 403-417: Before constructing FinetuningDatasetConfig in the --sft
flow, validate args.sft_dataset_root, required split files, each JSONL record’s
schema, and its size against the approved limits; reject invalid input before
the dataset builder parses it. Do not rely on seq_length truncation to constrain
parsing, and only pass validated data into the existing dataset construction
path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 14bae0c7-1e6d-41f6-9d65-0a89b9ce393b
📒 Files selected for processing (1)
examples/megatron_bridge/distill.py
|
/claude review |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2113 +/- ##
==========================================
- Coverage 78.76% 78.74% -0.03%
==========================================
Files 522 522
Lines 60461 60461
==========================================
- Hits 47621 47607 -14
- Misses 12840 12854 +14
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| dataset_kwargs={ | ||
| "prompt_template": "{input}{output}", | ||
| "label_key": "output", | ||
| "truncation_field": "input", | ||
| "answer_only_loss": True, | ||
| "add_bos": False, | ||
| "add_eos": True, | ||
| }, |
There was a problem hiding this comment.
[IMPORTANT Compatibility] add_bos: False is hardcoded, and prompt_template="{input}{output}" applies no chat template. Together these mean every training sequence starts directly at the first token of input — no BOS, no role markers.
For a model family whose tokenizer/chat template always prepends BOS (Llama <|begin_of_text|>, Gemma <bos>, Mistral <s>), that is a train/inference skew: distillation runs on sequences the model never sees at serving time, and nothing warns about it. The PR body's stated intent is the opposite — "matching how the model was fine-tuned" — and this silently doesn't for those models. It happens to be correct for the Nemotron-Nano-3 run in the Testing section, which is why the run looked clean.
The escape hatch (bake the full chat-formatted prompt, including BOS and role markers, into the "input" field) is real but undocumented — neither the --sft_dataset_root help text nor the README says the text is fed verbatim, so the natural reading of {"input": <prompt>, "output": <response>} is plain instruction text.
Two options, either is fine:
- Derive it from the tokenizer instead of hardcoding, so BOS-requiring models get BOS:
"add_bos": AutoTokenizer.from_pretrained( args.student_hf_path, trust_remote_code=args.trust_remote_code ).bos_token is not None,
- Keep
add_bos=False(verbatim is a defensible contract) but state the requirement where users will read it — in the--sft_dataset_roothelp string and the README:inputmust contain the fully templated prompt, including any BOS and role/turn markers the model expects; no chat template or BOS is added.
There was a problem hiding this comment.
Accepted — took option 2, in 080f4de.
Deriving add_bos from the tokenizer is the more automatic fix, but it would silently change tokenization for the runs already validated against this path, and it only covers BOS while leaving the role/turn-marker half of the skew unaddressed. "The fields are tokenized verbatim" is the contract I actually want; it was just undocumented.
So the requirement is now stated in all three places a user could look:
- the
--sft_dataset_roothelp text, - a comment next to the
dataset_kwargsthat implement it, - the Data Preparation section of
examples/megatron_bridge/README.md.
Each says the same thing: both fields are tokenized verbatim, no chat template is applied and no BOS is prepended, so bake in any role/turn markers and BOS the model expects.
There was a problem hiding this comment.
Following up on this thread: I took option 2 (document the contract), and after digging further I think option 1 as written would have been actively wrong for this model.
bos_token is not None tests whether a BOS exists in the vocab, not whether the tokenizer uses one. Nemotron-3.5's config is:
bos_token : <s> # exists
add_bos_token : False # but is never prepended
and its chat_template.jinja never emits one either — the first thing it produces is <|im_start|>system. So option 1 would have set add_bos=True and trained the model with a BOS it never sees at serving, creating exactly the skew the suggestion was meant to prevent.
Even the corrected predicate (tokenizer.add_bos_token) conflicts with the contract, because a fully-templated input may already contain a BOS and would then get a second one. The two designs are mutually exclusive: either the caller owns the whole prompt (verbatim, what this PR does) or the framework does (apply_chat_template).
What your comment did expose is a real gap the documentation alone does not close: a model with add_bos_token=True whose data lacks a BOS is silently skewed. That case is now detected (6160a69):
def _warn_if_bos_missing(tokenizer, dataset_root: str) -> None:
bos = getattr(tokenizer, "bos_token", None)
if not bos or not getattr(tokenizer, "add_bos_token", False):
return
...
if not first_input.startswith(bos):
warn_rank_0(f"This tokenizer prepends {bos!r} at inference, but the first record ... ")Verified across five cases: silent for add_bos_token=False, silent when no BOS token exists, silent when the caller already baked the BOS in, fires when the tokenizer adds one and the data lacks it, and silent on a missing/malformed file (left to the dataset builder to report).
For data that is not pre-templated at all, the right answer is Bridge's GPTSFTChatDataset with use_hf_tokenizer_chat_template=True, which handles BOS, role markers and multi-turn together. Tracking that as a follow-up rather than expanding this PR.
| if args.sft: | ||
| # The SFT loss mask covers only the response tokens, so the reduction must be | ||
| # per-token for it to combine correctly across context-parallel ranks. | ||
| provider.calculate_per_token_loss = True |
There was a problem hiding this comment.
[SUGGESTION] _build_model_provider is called for both student and teacher, so calculate_per_token_loss = True lands on both providers. That's harmless (the teacher's LM loss is zeroed out in adjust_distillation_model_for_mcore, and the flag has to agree with the average_in_collective=not args.sft setting on the single shared DistributedDataParallelConfig anyway), but it reads as if it were a student-only knob.
Since this and line 450 are two halves of one decision that must stay in sync, a one-line note here pointing at the DDP setting — or moving the comment to mention both — would keep a future edit from flipping one without the other.
There was a problem hiding this comment.
Accepted — 8c19f38.
Kept the flag where it is (it does have to agree with the single shared DistributedDataParallelConfig, so scoping it to the student would be wrong) and made the coupling explicit instead: the comment now says it lands on both providers, why that is harmless, and that it must stay in sync with average_in_collective=not args.sft below.
There was a problem hiding this comment.
Claude review — feat(megatron-bridge): SFT-masked data support in distillation
Scope: full review (trigger comment was bare /claude review). 1 file changed (+62/−4): examples/megatron_bridge/distill.py, reviewed in full, plus surrounding context in _distillation_provider.py, modelopt/torch/distill/plugins/megatron.py, and examples/megatron_bridge/README.md.
Findings
CRITICAL: 1 · IMPORTANT: 2 · SUGGESTION: 3
| # | Severity | Location | Issue |
|---|---|---|---|
| 1 | CRITICAL Algorithm | distill.py:275-278 |
The pre-existing --data_paths/--use_mock_data check runs first and doesn't know about --sft, so the documented invocation raises before the SFT branch is reached |
| 2 | IMPORTANT Compatibility | distill.py:410-417 |
Hardcoded add_bos=False with no chat template — train/inference skew for BOS-requiring model families; the verbatim-text contract is undocumented |
| 3 | IMPORTANT Compatibility | distill.py:462-470 |
SFT tokenizes with the student's tokenizer while KD targets come from the teacher — mismatched vocabularies silently produce garbage KD targets instead of erroring |
| 4 | SUGGESTION | distill.py:403-409 |
do_validation=True hardcoded ignores --eval_iters 0, forcing a dummy validation.jsonl |
| 5 | SUGGESTION | distill.py:303-306 |
calculate_per_token_loss and average_in_collective are two halves of one decision, ~145 lines apart, with no cross-reference |
| 6 | SUGGESTION | README.md |
The Distillation section documents only pre-tokenized --data_paths and mock data; --sft has no README entry and no note on the expected JSONL schema |
Most impactful
Finding 1 blocks the feature. With --sft --sft_dataset_root ... and nothing else, distill.py:266 raises Must provide either --data_paths or set --use_mock_data. before the new dataset branch at line 396 is ever evaluated. --use_mock_data defaults to False and --data_paths to None, so the only way to reach the SFT path today is to also pass one of them — after which its value is silently discarded, since if args.sft: wins the branch. The usage block in the PR description does not run as written. This is worth confirming with an actual launch before merge; the Testing section's Nemotron-Nano-3 run presumably passed --data_paths too, which would mask it.
Findings 2 and 3 share a shape: both are silent-wrong-result paths rather than crashes, and both are invisible in the one model configuration that was tested. The Nemotron-Nano-3 run has a shared teacher/student tokenizer and no mandatory BOS, so neither would have shown up in the reported loss curve. A vocab-size assertion and a documented prompt-format contract are cheap relative to debugging a distillation run that trains cleanly but produces a subtly wrong model.
The parts I checked and found correct:
- Loss reduction.
calculate_per_token_loss=Truepaired withaverage_in_collective=Falseis the right combination for a response-only mask under CP>1 — a pre-averaged collective would weight ranks equally regardless of how many unmasked tokens each holds. Consistent with the# zero when one CP rank has only context tokensguard already inLogitsAndIntermediatesLossBalancer.forward. - Opt-in isolation. Every behavioral change is gated on
args.sft; the mock and blend paths,NullTokenizer, andaverage_in_collective=Trueare byte-for-byte unchanged without the flag. Nomodelopt_stateschema, mode registration, or public API surface is touched, so there is no checkpoint-compat or restore-fidelity exposure. - Dataset kwargs.
prompt_template="{input}{output}"+label_key="output"+answer_only_loss=Truedoes mask to the response (answer_start_idx == len(context_ids)), andtruncation_field="input"correctly truncates the context rather than the labels.
Risk
Medium. The blast radius is one example script and fully opt-in, so no existing user workflow can regress — but the new path does not currently run as documented, and two of its silent-failure modes are outside what the reported test exercised. Finding 1 is a small, self-contained fix; findings 2 and 3 are a guard plus a docs sentence each.
Not blocking, but noted: the PR checklist marks no tests and no docs. tests/examples/megatron_bridge/test_distill.py exists and a --sft smoke case over a two-record JSONL fixture would have caught finding 1 outright.
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/megatron_bridge/README.md`:
- Around line 135-138: Update the validation.jsonl tokenization description near
the “Both fields are tokenized” text to clarify that inputs and outputs are
tokenized verbatim before the dataset automatically appends an EOS token via
add_eos=True. Preserve the existing guidance about manually including chat
markers or BOS tokens.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 997a1191-cc96-4369-ac17-019ddc8ccd91
📒 Files selected for processing (2)
examples/megatron_bridge/README.mdexamples/megatron_bridge/distill.py
🚧 Files skipped from review as they are similar to previous changes (1)
- examples/megatron_bridge/distill.py
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
examples/megatron_bridge/distill.py (1)
141-145: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winMake
validation.jsonlconditional in the SFT documentation.When
--eval_iters 0,do_validationis false, so onlytraining.jsonlis required. Update the CLI help, error text, and README to state thatvalidation.jsonlis required only when--eval_iters > 0.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@examples/megatron_bridge/distill.py` around lines 141 - 145, Update the SFT dataset documentation near the CLI help for the training/validation JSONL files, plus the corresponding validation/error text and README, to state that training.jsonl is always required while validation.jsonl is required only when --eval_iters > 0. Keep the existing record format and tokenization guidance unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/megatron_bridge/distill.py`:
- Around line 341-350: Update the SFT validation near the existing
student_provider.vocab_size check to compare the actual tokenizer mappings used
by args.student_hf_path and the teacher, including token IDs, special-token IDs,
and added-token IDs. Reject distillation with a clear ValueError when mappings
are not equivalent, while preserving training only when both tokenizers
interpret every batch ID identically.
---
Outside diff comments:
In `@examples/megatron_bridge/distill.py`:
- Around line 141-145: Update the SFT dataset documentation near the CLI help
for the training/validation JSONL files, plus the corresponding validation/error
text and README, to state that training.jsonl is always required while
validation.jsonl is required only when --eval_iters > 0. Keep the existing
record format and tokenization guidance unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 93033df1-1caa-48a1-9b2f-ad8ba8609c92
📒 Files selected for processing (1)
examples/megatron_bridge/distill.py
48e9547 to
6160a69
Compare
|
/claude review |
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 2
🧹 Nitpick comments (1)
examples/megatron_bridge/distill.py (1)
329-329: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueKeep
AutoTokenizerin the module import block.Transformers is already imported for the non-SFT
AutoConfigcall. MoveAutoTokenizerto the module imports and remove this local import.As per coding guidelines: “Keep imports at the top of Python source and test files; use local imports only for justified circular dependencies, optional dependencies, or unusually heavy imports, with a brief explanatory comment.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@examples/megatron_bridge/distill.py` at line 329, Move AutoTokenizer into the module-level transformers import block and remove the local import near the non-SFT distillation flow. Keep the existing AutoConfig import and behavior unchanged; no local import is needed here.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/megatron_bridge/distill.py`:
- Line 289: Update the argument logging around print_args(args) to avoid
emitting raw path-bearing values, including sft_dataset_root. Log a redacted
namespace containing only safe fields, or omit all path fields while preserving
non-sensitive argument logging.
- Around line 142-147: Update the CLI help text for the training data directory
argument in the argument parser to state that validation.jsonl is required only
when validation is enabled, such as when --eval_iters is greater than zero;
retain the existing descriptions of training.jsonl and record formatting.
---
Nitpick comments:
In `@examples/megatron_bridge/distill.py`:
- Line 329: Move AutoTokenizer into the module-level transformers import block
and remove the local import near the non-SFT distillation flow. Keep the
existing AutoConfig import and behavior unchanged; no local import is needed
here.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 96ad575c-df4d-43c5-87ea-4a431fdee3ad
📒 Files selected for processing (1)
examples/megatron_bridge/distill.py
There was a problem hiding this comment.
Claude review — feat(megatron-bridge): SFT-masked data support in distillation
Scope: full review (trigger was a bare /claude review). 2 files changed (+139/−5): examples/megatron_bridge/distill.py reviewed in full, README.md skimmed for the docs contract, plus surrounding context in _distillation_provider.py and modelopt/torch/distill/plugins/megatron.py.
This is a re-review — the six findings from the previous pass all landed:
| Prior finding | Status |
|---|---|
| 1 · CRITICAL — sanity check raised before the SFT branch | Fixed (distill.py:269, not args.sft and ...), plus a new mutual-exclusion guard at 283 so --data_paths/--use_mock_data cannot be silently discarded |
| 2 · IMPORTANT — undocumented BOS / verbatim-text contract | Addressed — _warn_if_bos_missing, arg help, README paragraph. One residual issue, see below |
| 3 · IMPORTANT — student/teacher tokenizer mismatch | Fixed well — get_vocab() mapping comparison at 335, not just size, with a message that distinguishes the two cases. Runs before the providers are built, so a mismatch costs seconds |
4 · SUGGESTION — do_validation ignored --eval_iters 0 |
Fixed (477) |
| 5 · SUGGESTION — split loss-reduction decision | Fixed — both halves now cross-reference each other (362-369, 519) |
| 6 · SUGGESTION — no README entry | Fixed |
Findings this round
CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 1
| # | Severity | Location | Issue |
|---|---|---|---|
| 1 | IMPORTANT Compatibility | distill.py:479-486 |
The "verbatim, no BOS" contract is assumed rather than enforced: add_bos=False suppresses the dataset prepend, not the tokenizer wrapper's add_special_tokens=True |
| 2 | SUGGESTION | distill.py:301-303 |
getattr(tokenizer, "add_bos_token", False) no-ops for tokenizers that prepend BOS via the post-processor — a behavioural probe is more robust |
Most impactful
Finding 1 is the residue of prior finding 2, one layer down. The docs now make an unconditional promise ("no BOS token is prepended… include them in the input field yourself"), but the only thing backing it is add_bos=False in dataset_kwargs — and that flag governs the explicit prepend inside GPTSFTDataset._process_example, not what the Bridge HuggingFaceTokenizer wrapper does when it calls self._tokenizer(text).input_ids. If the wrapper tokenizes with specials on, then (a) a Llama-3/Mistral/Gemma user who follows the README gets a doubled BOS — with _warn_if_bos_missing staying quiet, because their input does start with the BOS string — and (b) because _process_example tokenizes each prompt_template segment independently, every record gets a second BOS injected at the response boundary, exactly where answer_only_loss begins scoring.
I could not verify the wrapper from this checkout (megatron-bridge is not installed in the review env), so I have flagged it as verify then enforce rather than as a confirmed defect. The check is a two-line print(tok("hello").input_ids); the fix, if needed, is add_bos_token=False in the hf_tokenizer_kwargs you already thread through. Either way the outcome belongs in that comment, since the next reader will otherwise have to re-derive it. Neither variant would have appeared in the Nemotron-Nano-3 run if that tokenizer does not auto-add BOS — the same blind spot the earlier findings sat in.
What I checked and found correct
- Loss reduction.
calculate_per_token_loss=True+average_in_collective=Falseremains the right pairing for a response-only mask under CP>1: a pre-averaged collective weights ranks equally regardless of how many unmasked tokens each holds. Consistent with the# zero when one CP rank has only context tokensguard atplugins/megatron.py:507, and the flag survivesconvert_to_distillation_provider(which mutates the student provider in place). Setting it on the teacher too is genuinely harmless —_compute_teacher_lm_lossreturnszeros_like(labels). - KD loss is masked, not just the LM loss.
LogitsKLLoss.post_forwardreshapes[s, b] → [b, s]"for upcoming loss masking", so the response-only mask reaches the distillation term as well. Withskip_lm_loss=True(the default) that is the only live term, so this is what makes--sftmean anything. - Vocab guard. Comparing
get_vocab()mappings rather than sizes is the correct strictness — same-size/different-mapping is exactly the case that produces a cleanly-training, subtly-wrong model. - Opt-in isolation. Every behavioural change is gated on
args.sft.NullTokenizer,average_in_collective=True, the mock/blend branches, andcalculate_per_token_lossare byte-for-byte unchanged without the flag. Nomodelopt_stateschema, mode registration, or public API is touched — no checkpoint-compat or restore-fidelity exposure. - Arg validation.
--sftwithout--sft_dataset_rooterrors;--sftwith either pretraining data source errors rather than silently discarding it;--validate_onlystill forceseval_iters > 0, which now also impliesdo_validation=True, so the validation file is required exactly when it is used.
Risk
Low-medium, down from medium. The feature now runs as documented and the two silent-wrong-result paths from last round are guarded. What remains is one docs/behaviour question whose worst case is a train/inference BOS skew for model families outside the one that was tested — a config-level concern, not a code-structure one, in a fully opt-in example script that cannot regress any existing workflow.
Still worth noting (raised last round, unchanged): the checklist marks no tests, and tests/examples/megatron_bridge/test_distill.py exists. A --sft smoke case over a two-record JSONL fixture would have caught last round's CRITICAL outright, and would pin the arg-validation matrix that now has four branches.
|
/claude review |
| # prompt_template="{input}{output}" tokenizes input+output verbatim (adjacent placeholders, | ||
| # no separator); label_key="output" with answer_only_loss=True masks the loss to the | ||
| # response only (answer_start_idx == len(context_ids)); truncation_field="input" truncates | ||
| # the context when the pair exceeds seq_length. |
There was a problem hiding this comment.
[SUGGESTION] The "verbatim" contract stated here (and in --sft_dataset_root help and the README) is slightly stronger than what GPTSFTDataset actually does: _process_example applies .strip(' ') to each prompt_template field value before tokenizing, so leading/trailing ASCII spaces of "input" and "output" are dropped. (Other whitespace such as \n survives, which is why most templates are unaffected.)
Why it matters: each template string is tokenized separately, so the first token of "output" is a BPE token boundary the loss starts scoring at. {"input": "... [/INST] ", "output": "..."} (Llama-2/Mistral style, trailing space significant) and {"input": "Q: ...\nA:", "output": " 42"} both lose that space, so the model is trained on "42" where the served format produces "▁42". That is the same class of train/inference skew the BOS warning above was added to catch, and it is invisible in the loss curve.
Suggest softening the claim in all three places to name the exception — e.g. "tokenized as written, except that leading/trailing spaces on each field are stripped; put any significant separator inside the text (a newline, not a space)". Worth a quick check of _process_example in your container's megatron.bridge.data.datasets.sft to confirm the .strip(' ') carried over from NeMo.
There was a problem hiding this comment.
Accepted — 3b9eb9d. Confirmed in the source: prompt_template_values.append(example[c].strip(" ")) (gpt_sft.py:513). Spaces go, newlines survive.
I had actually noticed the newline surviving when tracing this path earlier, but did not follow it to the consequence you draw: with each field tokenized separately, a stripped trailing space changes the first token of "output" from ▁42 to 42, and the loss starts scoring at that boundary. Silent, and invisible in the loss curve.
Softened in all three places, with the concrete recommendation:
tokenized as written, except that leading and trailing spaces on each field are stripped ... express any significant separator as a newline rather than a trailing space.
| # Enforce the verbatim contract here rather than relying on the dataset's | ||
| # add_bos/add_eos: text_to_ids adds special tokens when this is left at its | ||
| # default of True, and prompt_template tokenizes "{input}" and "{output}" | ||
| # separately -- so a BOS-adding tokenizer would inject one at the answer | ||
| # boundary, where answer_only_loss starts scoring. | ||
| "include_special_tokens": False, |
There was a problem hiding this comment.
[SUGGESTION] Two things worth verifying about this kwarg, because the entire verbatim guarantee rests on it and nothing here fails loudly if it doesn't land:
- Is it consumed?
hf_tokenizer_kwargsmay be forwarded toAutoTokenizer.from_pretrained, which accepts (and silently retains) unknown kwargs rather than raising. If Bridge'sHuggingFaceTokenizerdoesn't interceptinclude_special_tokensas its own__init__parameter, this becomes a no-op and a BOS-adding tokenizer does inject a BOS at the answer boundary — the exact bug the comment says is being prevented, still present but now believed fixed. - The stated default. The comment asserts the default is
True. In the NeMoAutoTokenizerthis class is ported from,include_special_tokensdefaults toFalseandtext_to_idsgoes throughtext_to_tokens/tokens_to_ids(no special tokens added). If Bridge kept that default, the comment's rationale is inverted — harmless behaviorally, but it's now the primary documentation of a non-obvious constraint, so a reader can't trust it.
Given the failure mode is silent, an assertion beats a comment. You already load student_tokenizer at the top of main(); a two-line check there would pin the invariant regardless of what the Bridge layer does:
# The SFT dataset tokenizes "{input}" and "{output}" separately, so a BOS injected by
# text_to_ids would land at the answer boundary where answer_only_loss starts scoring.
if student_tokenizer("x").input_ids[:1] == [student_tokenizer.bos_token_id]:
print_rank_0("Tokenizer adds BOS; relying on include_special_tokens=False to suppress it.")At minimum, correct the stated default once you've checked it.
There was a problem hiding this comment.
Not changing this one — both concerns are already disproven by measurement, which I should have put in the comment rather than leaving as an assertion.
I probed the actual call chain in the container: _tokenize -> DefaultTokenizerText.tokenize -> megatron.core.tokenizers.text.libraries.huggingface_tokenizer.HuggingFaceTokenizer.text_to_ids, which is
if self.include_special_tokens:
return self.tokenizer(text).input_ids
tokens = self.text_to_tokens(text)
return self.tokens_to_ids(tokens)and then measured both of your questions directly:
default include_special_tokens = True
hf_tokenizer_kwargs include_special_tokens=False -> False
hf_tokenizer_kwargs include_special_tokens=True -> True
So (1) the kwarg is consumed — passing it changes the value, it is not silently retained by from_pretrained; and (2) the default in this Bridge version is True, so the comment's rationale stands. The NeMo AutoTokenizer default of False you are reasoning from did not carry over to megatron.core.tokenizers.
Your underlying point is fair though — the guarantee rested on a comment for a silent failure mode. It now rests on a measurement, and the two probes are reproducible from the container if a future Bridge version changes the default.
| if student_vocab != teacher_vocab: | ||
| detail = ( | ||
| f"{len(student_vocab)} vs {len(teacher_vocab)} tokens" | ||
| if len(student_vocab) != len(teacher_vocab) | ||
| else f"both {len(student_vocab)} tokens, but different token->id mappings" | ||
| ) | ||
| raise ValueError( | ||
| "--sft tokenizes with the student's tokenizer and scores the teacher on those " | ||
| f"same ids, so student and teacher must share a tokenizer ({detail})." | ||
| ) |
There was a problem hiding this comment.
[SUGGESTION] Full dict equality is stricter than the invariant that actually matters, and there's no escape hatch when it over-triggers.
What KD needs is that every id the dataset can emit means the same thing to both models. A teacher whose get_vocab() is a strict superset of the student's satisfies that — e.g. a teacher fine-tune that added a few reserved/special tokens, or a student tokenizer directory that dropped unused added_tokens. get_vocab() includes added_tokens_encoder, so a single extra token on the teacher side fails this check and the run is unlaunchable, with an error message ("must share a tokenizer") that points at the wrong thing.
Consider testing agreement on the shared tokens plus student ⊆ teacher, and reserving the hard error for genuine mapping conflicts:
conflicts = {t for t, i in student_vocab.items() if teacher_vocab.get(t, i) != i}
missing = student_vocab.keys() - teacher_vocab.keys()
if conflicts or missing:
raise ValueError(...) # ids genuinely disagree
if len(teacher_vocab) != len(student_vocab):
warn_rank_0(
f"Teacher vocabulary has {len(teacher_vocab) - len(student_vocab)} extra token(s) not in "
"the student's; ids agree on all shared tokens, so distillation is well-defined."
)Also minor: AutoTokenizer.from_pretrained runs twice here (plus a third time inside Bridge for the training tokenizer), on every rank. Not hot-path, but discarding the teacher tokenizer immediately after get_vocab() makes the cost easy to miss.
There was a problem hiding this comment.
Accepted — 3b9eb9d. You are right that full equality is stricter than the invariant. Only student ids ever reach the teacher (the dataset tokenizes with the student's tokenizer), so what matters is that the teacher maps every student token to the same id.
conflicts = {t for t, i in student_vocab.items() if teacher_vocab.get(t, i) != i}
missing = student_vocab.keys() - teacher_vocab.keys()
if conflicts or missing:
raise ValueError(...)
if len(teacher_vocab) > len(student_vocab):
warn_rank_0(...)Checked against five cases before pushing: identical -> OK; teacher superset -> warn; id conflict at equal size -> raise; token absent from teacher -> raise; different family at equal size -> raise. So the Llama-2/Mistral case that motivated the guard still fails, and a teacher with extra reserved tokens no longer does.
On the double from_pretrained: agreed it is not free, but it is once per run at startup against a local path, and holding the teacher tokenizer alive only to avoid a second load would trade clarity for little. Left as is.
| # the mappings. Checked before the providers are built so a mismatch costs seconds. | ||
| # The pretraining path is structurally immune: NullTokenizer plus pre-tokenized | ||
| # --data_paths means one tokenization feeds both models. | ||
| from transformers import AutoTokenizer |
There was a problem hiding this comment.
[SUGGESTION] Function-local import without one of the sanctioned reasons. CONTRIBUTING.md ("Keep imports at the top of the file") allows in-function imports only for circular imports, optional-dependency guards, or an unusually heavy import with explicit justification — and transformers is already imported unconditionally at module top (from transformers import AutoConfig, line 53), so none of the three applies and there is no cost to deferring.
| from transformers import AutoTokenizer |
…and extend line 53 to from transformers import AutoConfig, AutoTokenizer.
There was a problem hiding this comment.
Accepted — 3b9eb9d. from transformers import AutoConfig is already at module scope, so none of the three sanctioned reasons applied. Extended to AutoConfig, AutoTokenizer and removed the deferred import.
There was a problem hiding this comment.
Claude review — feat(megatron-bridge): SFT-masked data support in distillation
Scope: full review (trigger comment was bare /claude review). 2 files changed (+159/−5); both reviewed in full — examples/megatron_bridge/distill.py and examples/megatron_bridge/README.md — plus surrounding context in _distillation_provider.py and modelopt/torch/distill/plugins/megatron.py.
Prior round: all six findings addressed
| Prior finding | Status |
|---|---|
| 1 CRITICAL — pretraining data check fired before the SFT branch | ✅ not args.sft guard + explicit mutual-exclusion error for --data_paths/--use_mock_data |
| 2 IMPORTANT — undocumented BOS / verbatim-text contract | ✅ documented in help + README, plus a _warn_if_bos_missing probe |
| 3 IMPORTANT — student/teacher tokenizer mismatch silently corrupts KD targets | ✅ vocab-mapping comparison before providers are built |
4 SUGGESTION — do_validation ignored --eval_iters 0 |
✅ do_validation=args.eval_iters > 0 |
5 SUGGESTION — calculate_per_token_loss / average_in_collective split with no cross-reference |
✅ both comments now point at each other |
6 SUGGESTION — no README entry for --sft |
✅ added |
Findings 2 and 3 were fixed above the bar I asked for — the vocab check compares mappings rather than sizes and names the Llama-2/Mistral collision, and the BOS probe reads the tokenizer's actual output instead of trusting add_bos_token, with its own bounded-scope caveat documented.
This round
CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 4
| # | Severity | Location | Issue |
|---|---|---|---|
| 1 | IMPORTANT Algorithm | distill.py:494 |
truncation_method unset → default 'right' cuts the tail of the prompt, destroying the prompt→response seam answer_only_loss scores from |
| 2 | SUGGESTION | distill.py:474-477 |
"Verbatim" overstates it: GPTSFTDataset._process_example applies .strip(' ') per field, so significant leading/trailing spaces are dropped |
| 3 | SUGGESTION | distill.py:552-557 |
include_special_tokens=False may be silently swallowed by from_pretrained, and the stated default of True is False in the NeMo class this is ported from |
| 4 | SUGGESTION | distill.py:347-356 |
Full vocab-dict equality rejects a legitimate teacher-superset vocabulary with no escape hatch |
| 5 | SUGGESTION | distill.py:341 |
Function-local from transformers import AutoTokenizer — transformers is already imported at line 53 |
Most impactful
Finding 1 is the one to act on. truncation_field="input" correctly protects the labels, but which end of the context gets cut is a separate decision that this config inherits rather than makes. In the NeMo GPTSFTDataset lineage, truncation_method defaults to 'right', so an over-length pair loses the tail of the prompt — the actual question, and any role/turn markers the caller baked in (which, with no chat template applied, is the only thing marking where the response begins). The example is then trained to start a response at a boundary that never occurs at serving time. It is silent, loss keeps falling, and it affects only records exceeding seq_length — at seq_length=32768 the validation run likely truncated nothing, so the reported curve cannot rule it out. One kwarg: "truncation_method": "left".
Findings 2 and 3 share a shape with the previous round's 2 and 3: the verbatim contract is now asserted in three places (code comment, --sft_dataset_root help, README) but still enforced only by a kwarg whose consumption is not observable from this file, and the contract has one real exception (.strip(' ')) that none of the three mention. Both are cheap to close, and worth closing precisely because the guarantee is now load-bearing documentation others will rely on.
Verified correct
- Per-token loss plumbing.
provider.calculate_per_token_loss = Trueis set inside_build_model_provider, i.e. beforeconvert_to_distillation_provider, so it reachesdistill_provider(rebased from the student provider) and thus theTransformerConfigthe pipeline schedules read — andDistillationProvider.__setattr__'s teacher-mirroring makes the "lands on both providers" note in the comment literally true. Paired withaverage_in_collective=Falsethis is the right combination for a response-only mask under CP>1, and it lines up with the# zero when one CP rank has only context tokensguard already inLogitsAndIntermediatesLossBalancer.forward(megatron.py:507) — the losses entering that balancer are already mask-reduced scalars, so a CP rank holding only context contributes zero rather than a NaN scale factor. - Dataset kwargs.
prompt_template="{input}{output}"splits to exactly['{input}', '{output}']under_separate_template's( *?{.+?})regex (adjacent placeholders, no injected separator);label_key="output"is last, satisfying the label-position assertion;answer_only_loss=Truemasks fromanswer_start_idx == len(context_ids); andadd_eos=Truegoes throughtokenizer.eos_id, so it is unaffected byinclude_special_tokens=Falseand the EOS lands inside the scored span — the model still learns to stop. - Opt-in isolation. Every behavioral change is gated on
args.sft. The mock and blend branches,NullTokenizer, andaverage_in_collective=Trueare byte-for-byte unchanged without the flag. Nomodelopt_stateschema, mode registration, or public API is touched, so there is no checkpoint-compat or restore-fidelity exposure. - Argument validation.
--sftrequires--sft_dataset_root; conflicting data sources now error instead of being silently discarded;--validate_only/--eval_itersstay consistent withdo_validation.warn_rank_0→dist.is_master()is safe before distributed init.
Risk
Low-to-medium. One example script, fully opt-in, no existing workflow can regress — and the previous round's launch-blocking bug is fixed. The residual exposure is entirely in the "trains cleanly but on subtly wrong data" category, concentrated in over-length records (finding 1) and space-sensitive prompt formats (finding 2), neither of which the reported Nemotron-Nano-3 run would have exercised. Finding 1 is a one-line change.
Still worth noting: the checklist marks no tests. A --sft smoke case in tests/examples/megatron_bridge/test_distill.py over a two-record JSONL fixture would pin the argument wiring and the tokenizer/dataset contract cheaply — and would have caught last round's finding 1 outright.
|
/claude review |
…them Calibration tolerates MTP: process_mtp_loss early-returns when labels is None (multi_token_prediction.py) and megatron_prefill passes none, so mtp_num_layers=0 was never required. Dropping the heads is what forced distill to rebuild the student without them and export to recover the shape from run_config.yaml, so all three compensations go with it. MTP heads are kept and simply not quantized -- 'mtp.*' is disabled in default_disabled_quantizers and in the model recipes (#2146). Verified on Nemotron-3.5-Lightning with main + #2112 + #2113 + this branch: PTQ mtp_num_layers=1, 6660 quantizers, all 278 mtp.* quantizers disabled export "Successfully loaded 270 MTP tensors", 18487 keys QAD 900 quantizers, trained past iteration 50 with no KeyError: "mtp.layers.0.enorm.weight from model not in state dict" Signed-off-by: James Shen <yueshen@nvidia.com>
4e0dd3a to
41afb58
Compare
|
So this is a single turn SFT support right? I recalled that MBridge should have multi-turn OpenAI message style support with answer_only masking. |
|
Megatron Bridge current SFT support appears to handle OpenAI/HF-style multi-turn messages / conversation rows, with ChatSFTPreprocessingConfig(loss_mode="assistant") masking every assistant turn. This PR instead restricts the input to a single {input, output} pair and uses the legacy FinetuningDatasetConfig path. Is there a reason we are not supporting the multi-turn OAI message schema here, with answer-only masking over all assistant responses? If the older materialized JSONL path is a deliberate constraint, could we document that and/or consider the direct-HF chat SFT configuration so distillation matches the dataset formats Bridge already supports? |
9554a5c to
eb2585a
Compare
eb2585a to
eede76f
Compare
The distillation example only consumes pretraining-style data (GPTDataset over pre-tokenized
blends, NullTokenizer), so the loss is computed over every token. For distilling an
instruction-tuned model it is usually preferable to train on prompt/response pairs and mask
the loss to the response, matching how the model was fine-tuned.
Adds --sft and --sft_dataset_root, which switch the data path to Bridge's
FinetuningDatasetConfig (NeMo-style GPTSFTDataset) reading training.jsonl / validation.jsonl of
{"input": <prompt>, "output": <response>} records.
Details:
* prompt_template="{input}{output}" tokenizes input+output verbatim (adjacent placeholders,
no separator), label_key="output" with answer_only_loss=True masks the loss to the response
(answer_start_idx == len(context_ids)), and truncation_field="input" truncates the context
when a pair exceeds seq_length.
* SFT reads raw text, so it uses the model's real HuggingFace tokenizer; the pretraining path
consumes pre-tokenized data and keeps NullTokenizer.
* The response-only loss mask requires per-token loss reduction to combine correctly across
context-parallel ranks, so calculate_per_token_loss is enabled and average_in_collective is
disabled under --sft. Both are untouched on the pretraining path.
Opt-in: without --sft the existing mock/blend data path is unchanged.
Signed-off-by: James Shen <yueshen@nvidia.com>
… document verbatim SFT format --sft supplies its own data via --sft_dataset_root, but the pretraining sanity check still demanded --data_paths or --use_mock_data, so a valid "--sft --sft_dataset_root <dir>" invocation raised before reaching the SFT branch. Exempt SFT from that check. Also state the SFT record contract where users will read it (--sft_dataset_root help, the dataset_kwargs comment, and the README): add_bos=False plus a placeholder-only prompt_template means "input"/"output" are tokenized verbatim -- no chat template, no BOS, no role markers -- so models that expect those need them baked into the fields. Addresses CodeRabbit and claude[bot] review comments. Signed-off-by: James Shen <yueshen@nvidia.com>
- Reject `--sft` combined with `--data_paths` / `--use_mock_data`. The SFT branch wins the dataset selection, so those inputs were silently ignored -- a stale `--data_paths` in a launch script looked like it was in use. - Fail loudly when `--sft` is used with a teacher and student that do not share a vocabulary. SFT tokenizes raw text with the student's tokenizer and the KD target comes from the teacher's logits over those same ids, so a cross-family pair produced a garbage target rather than an error. The pretraining path was structurally immune (NullTokenizer + pre-tokenized data means one tokenization feeds both). - Derive `do_validation` from `--eval_iters` instead of hardcoding True, so a training-only `dataset_root` no longer has to carry a dummy `validation.jsonl` just to satisfy the dataset builder. - Cross-reference `calculate_per_token_loss` and `average_in_collective`, which are two halves of one decision that must stay in sync. Signed-off-by: James Shen <yueshen@nvidia.com>
…d note the appended EOS The `--sft` guard compared `student_provider.vocab_size` against the teacher's, but equal sizes do not mean equal token->id mappings: Llama-2 and Mistral are both 32000 tokens with different vocabularies, so the check passed while the teacher scored ids it never saw. Compare `get_vocab()` instead. Moved it ahead of provider construction so a mismatch fails in seconds rather than after both models are built. Also note in the help text and README that an EOS token is appended after the response — "tokenized verbatim" was true of the two fields but did not mention `add_eos=True`. Signed-off-by: James Shen <yueshen@nvidia.com>
… adds at inference `--sft` tokenizes both fields verbatim (`add_bos=False`), so the caller owns the BOS. We cannot add one for them — their text may already contain it, and prepending would double it — but a model whose tokenizer sets `add_bos_token=True` and whose data does not carry a BOS is trained without the token it is served with, which is silent train/inference skew. The mismatch is now detected and reported: if the tokenizer prepends a BOS and the first training record does not start with it, warn and name the fix. A missing or malformed file is left to the dataset builder to report. Signed-off-by: James Shen <yueshen@nvidia.com>
…zer, not just the dataset
`add_bos=False` only suppresses the dataset's own prepend. The tokenization
itself goes through Bridge's HuggingFaceTokenizer, whose `text_to_ids` returns
`self.tokenizer(text).input_ids` — i.e. add_special_tokens=True — whenever
`include_special_tokens` is set, and it defaults to True. Verified in the
container: `build_tokenizer(...)._tokenizer.include_special_tokens` is `True`
by default and follows `hf_tokenizer_kwargs`.
Because `prompt_template="{input}{output}"` tokenizes the two fields
separately, a BOS-adding tokenizer would produce `[BOS, *input, BOS, *output]`
— a BOS injected exactly where `answer_only_loss` starts scoring — and the
documented "no BOS is prepended" contract would be false. Nemotron-Nano-3.5
never hit this because its tokenizer adds no special tokens either way, so the
validated run says nothing about it.
Set `include_special_tokens: False`, making the contract true by construction
rather than assumed.
Also:
- `_warn_if_bos_missing` now probes behaviour (`tokenizer("x").input_ids[:1]`)
instead of reading `add_bos_token`, which many fast tokenizers never expose
even when their post-processor prepends BOS — a missing warning being the
worse failure. Docstring records that only the first training record is
inspected.
- Help text, error message and README: `validation.jsonl` is required only when
`--eval_iters > 0`.
Signed-off-by: James Shen <yueshen@nvidia.com>
…he tokenizer check
- `truncation_method` was left at GPTSFTDataset's default of "right", which
truncates the END of "input" — the question and whatever turn marker the
caller baked in, i.e. exactly the boundary `answer_only_loss` starts scoring
at. An over-length record would train the model to begin responding at a
position that never occurs at inference. Set "left" explicitly so the oldest
context is dropped and the prompt->response seam survives. Confirmed against
Bridge's GPTSFTDataset: `truncation_method: str = "right"` with
`"right" -> ids[:expect_length]`.
- The "verbatim" contract was slightly stronger than the truth: GPTSFTDataset
applies `.strip(" ")` to each template field, so a significant leading or
trailing SPACE is lost (a newline is not). Stated in the help text, the code
comment and the README, with the recommendation to express separators as
newlines.
- `AutoTokenizer` moved to the module import block; `transformers` is already
imported there, so none of the sanctioned reasons for a deferred import
applied.
- The teacher/student tokenizer check required full `get_vocab()` equality,
which is stricter than what KD needs. Only student ids ever reach the
teacher, so the invariant is that the teacher maps every student token to the
same id; a teacher whose vocabulary is a strict superset (extra reserved
tokens) is fine and now warns instead of failing. Cross-family pairs still
raise.
Signed-off-by: James Shen <yueshen@nvidia.com>
…ect a stray --sft_dataset_root The teacher-superset case was downgraded to a warning claiming distillation is well-defined. It is not: LogitsKLLoss ends in F.kl_div(p, q) with p=[s, b, V_student] and q=[s, b, V_teacher], and TopKLogitsKLLoss gathers student logits with the teacher's topk indices — neither tolerates a width mismatch. Token-id agreement is necessary but not sufficient. len(get_vocab()) is also the wrong quantity: the losses operate on the models' padded vocab dimension, not the tokenizer's surface. Two tokenizers of different size can pad to the same width, and the reverse is possible too. So: keep the strict conflicts/missing id check (it still catches cross-family pairs early, before two 31B providers are built), drop the misleading warning, and compare the providers' padded vocab_size once they exist. Also reject --sft_dataset_root without --sft, which was accepted and silently ignored — the mirror of the mutual-exclusion check right above it. Signed-off-by: James Shen <yueshen@nvidia.com>
…FT input The teacher/student token-mapping check moves to get_args() and runs on every run, not just --sft: the KD losses score the teacher on the student's ids whatever the data path, so a disagreement is always fatal, and the pretraining path previously reached it only as a shape error from inside the loss. Loading a tokenizer is warned about rather than fatal, so VLM repos that ship only a processor keep working. The logits-width guard likewise drops its --sft gate, and now compares the padded vocab size it always claimed to check; provider.vocab_size is the raw HF config value, so the old message overstated what it verified. It is not redundant with the mapping check: two models can share a tokenizer and still declare different vocab sizes, and equal sizes do not imply equal mappings. --sft now fails at argparse time when training.jsonl (or validation.jsonl, when evaluating) is absent from --sft_dataset_root, rather than after both checkpoints have loaded onto GPUs. Documents that a record over --seq_length is truncated from the start of "input", dropping any BOS baked in there, and records why truncation_method is "left": "right" would cut the answer boundary and then the answer itself. Notes where Bridge consumes include_special_tokens. Adds an SFT case to tests/examples/megatron_bridge/test_distill.py. Signed-off-by: James Shen <yueshen@nvidia.com>
…d changelog The teacher/student check now compares the two vocabularies for equality rather than counting conflicting and missing tokens. Distillation requires the same tokenizer on both sides -- the KD losses reduce elementwise over the vocab dimension -- so tolerating supersets implied a cross-tokenizer capability that does not exist. Shortens the comments and helper docstrings, and adds a 0.47 changelog entry for the SFT data support. Signed-off-by: James Shen <yueshen@nvidia.com>
… framework Review follow-ups on --sft: - Drop the three try/except blocks that turned a failed tokenizer load or a malformed record into a warning. A broken tokenizer is fatal, and a warning scrolls past in a multi-day run. - Move the BOS handling into get_args() with the other sanity checks. - Set add_bos from a tokenizer probe instead of hardcoding False. GPTSFTDataset prepends it after truncation and budgets for it in total_ids, so the BOS survives a record that had to be cut; baking it into 'input' by hand did not, because truncation_method='left' drops the front of that field. The probe is needed because bos_token_id alone is not decisive: Nemotron 3.5 Lightning declares bos_token_id=1 with add_bos_token=False and prepends nothing. - README and --sft_dataset_root help no longer tell users to add the BOS themselves, which would now double it. Signed-off-by: James Shen <yueshen@nvidia.com>
eede76f to
8fe9543
Compare
…otron-3.5-Lightning-30B-A3B (#2142) ## What does this PR do? Adds `mbridge_qad.yaml`, a launcher example running NVFP4 quantization-aware distillation for **Nemotron-3.5-Lightning-30B-A3B** through the Megatron-Bridge scripts in `examples/megatron_bridge/`, alongside the existing `mbridge_prune.yaml` / `mbridge_quantize.yaml`. `megatron_lm_qad.yaml` (#2146) runs the same recipe and the same data through Megatron-LM. This is the Megatron-Bridge counterpart: the same `huggingface/models/nvidia/Nemotron-3.5-Lightning-30B-A3B-BF16/ptq/w4a16_nvfp4_4o6` recipe and the same `nvidia/Nemotron-Post-Training-Dataset-v2` chat data, with the training hyperparameters from our public-data QAD run. Four tasks: tokenize the training data, PTQ the student, distill it against the frozen BF16 teacher, export to unified HF. ### Why the extra tokenize task Megatron-LM's finetune path reads an HF parquet shard directly. Megatron-Bridge trains from pre-tokenized data, so `distill.py` consumes Megatron `.bin`/`.idx` via `--data_paths`. The chat split is therefore tokenized once with `modelopt.torch.utils.plugins.megatron_preprocess_data`. `--hf_streaming` avoids the Arrow cast errors this dataset's nested tool-call fields trigger in non-streaming mode, and `--append_eod` is omitted because chat rows already terminate each conversation via the chat template. ### Details - Training topology 8 nodes x 4 GPUs, TP=1 PP=1 CP=4 EP=16 -> DP=8; `gbs` 64 at `mbs` 1 is 8 gradient-accumulation microbatches. 200 iters x 64 x 32768 = 419M training tokens. - PTQ runs TP=EP=PP=1 across 4 ranks (pure DP), so each rank calibrates on its own shard. `--calib_dataset_name` is left unset, selecting the default public `cnn_nemotron_v2_mix` (cnn_dailymail + Nemotron-Post-Training-Dataset-v2). - Export uses TP=1 (the HF writer does not gather TP shards) and PP=4, splitting 52 layers 13/stage. - Pins `nvcr.io/nvidia/nemo:26.06` like the other `mbridge_*` examples. ## Dependencies Based on `main`; the PTQ recipe ships in #2146 (merged). No other PR required. Nemotron-3.5-Lightning has `tie_word_embeddings: false`, so a correct quantized `lm_head` in the exported checkpoint also depends on #2112. ## Testing The PTQ -> export -> QAD flow and these hyperparameters were run end to end on Nemotron-3.5-Lightning (`main` + #2112 + #2113): - PTQ completed, 6660 quantizers, MTP heads retained (`mtp_num_layers: 1`) with all 278 `mtp.*` quantizers disabled by the recipe. - Export produced a unified-HF checkpoint (18487 keys, including 270 MTP tensors). - QAD trained with 900 quantizers through a validation pass at iteration 50. The YAML itself is validated against the launcher's conventions (`ntasks_per_node == gpus_per_node` on Slurm, single-line `inline`, no `args` alongside `inline`, all `<<global_vars.X>>` resolve, output prefix matches `megatron_preprocess_data`'s naming) and by the repo's `validate launcher YAML references` pre-commit hook. Topology arithmetic checked: EP divides world/(TP*PP), `gbs` divisible by DP*mbs. ## Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ (new example file only) - Did you write any new necessary tests?: N/A - Did you update [Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: N/A 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an example workflow for NVFP4 quantization-aware distillation of NVIDIA Nemotron 3.5 Lightning 30B-A3B. * Supports dataset tokenization, post-training quantization, teacher-student distillation, and export of a unified Hugging Face checkpoint. * Includes configurable model, dataset, and checkpoint paths, distributed execution settings, and support for local or Slurm-based workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: James Shen <yueshen@nvidia.com>
This current path is targeting the private data that uses the legacy FinetuningDatasetConfig path. As for the the new HF data-config API (DirectHFSFTDatasetConfig /ChatSFTPreprocessingConfig / HFDatasetSourceConfig) that you referred to, I am also ready to push a new a PR supporting that as that could be used for public HF dataset. The reason it's not in this PR is that the new HF data-config API is not supported in 26.06 container, they should be supported in 26.08 which will be release tmr. And by then my new PR will be out for review. |
|
What does this PR do ?
Type of change: New feature
Overview: Adds SFT-masked data support to the Megatron-Bridge distillation example, so a
model can be distilled on prompt/response pairs with the loss masked to the response.
Today
examples/megatron_bridge/distill.pyonly consumes pretraining-style data —GPTDatasetover pre-tokenized blends with
NullTokenizer— so the loss is computed over every token. Whendistilling an instruction-tuned model it is usually preferable to train on prompt/response pairs
and mask the loss to the response, matching how the model was fine-tuned.
Usage
where
/path/to/dataholdstraining.jsonl/validation.jsonlof records:{"input": "<prompt>", "output": "<response>"}How it works
Switches the data path to Bridge's
FinetuningDatasetConfig(NeMo-styleGPTSFTDataset):prompt_template="{input}{output}"tokenizes input+output verbatim — adjacent placeholders,no separator — so the text is fed exactly as provided
label_key="output"withanswer_only_loss=Truemasks the loss to the response(
answer_start_idx == len(context_ids))truncation_field="input"truncates the context when a pair exceedsseq_lengthTwo supporting changes, both scoped to
--sft:pretraining path consumes pre-tokenized data and keeps
NullTokenizer.context-parallel ranks, so
calculate_per_token_lossis enabled andaverage_in_collectiveis disabled. Both are untouched on the pretraining path.
Testing
Used for quantization-aware distillation of Nemotron-Nano-3 (W4A16 NVFP4) at
seq_length=32768with CP>1: 200 iterations, logits-distillation loss
3.37e-2 -> 1.91e-2monotonically, routerseq_load_balancing_losssteady, and the resulting checkpoint exports and serves correctly.Opt-in: without
--sftthe existing mock/blend data path is unchanged.Before your PR is "Ready for review"
--sft.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation