Skip to content

feat(megatron-bridge): SFT-masked data support in distillation - #2113

Open
yueshen2016 wants to merge 3 commits into
mainfrom
feat/mbridge-sft-distillation
Open

feat(megatron-bridge): SFT-masked data support in distillation#2113
yueshen2016 wants to merge 3 commits into
mainfrom
feat/mbridge-sft-distillation

Conversation

@yueshen2016

@yueshen2016 yueshen2016 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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.py only consumes pretraining-style data — GPTDataset
over pre-tokenized blends with NullTokenizer — so the loss is computed over every token. When
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.

Usage

python examples/megatron_bridge/distill.py \
  --teacher_hf_path <teacher> --student_hf_path <student> \
  --sft --sft_dataset_root /path/to/data \
  ...

where /path/to/data holds training.jsonl / validation.jsonl of records:

{"input": "<prompt>", "output": "<response>"}

How it works

Switches the data path to Bridge's FinetuningDatasetConfig (NeMo-style GPTSFTDataset):

  • prompt_template="{input}{output}" tokenizes input+output verbatim — adjacent placeholders,
    no separator — so the text is fed exactly as provided
  • label_key="output" with answer_only_loss=True masks the loss to the response
    (answer_start_idx == len(context_ids))
  • truncation_field="input" truncates the context when a pair exceeds seq_length

Two supporting changes, both scoped to --sft:

  • Tokenizer. SFT reads raw text, so it uses the model's real HuggingFace tokenizer. The
    pretraining path consumes pre-tokenized data and keeps NullTokenizer.
  • Loss reduction. A response-only mask requires per-token loss to combine correctly across
    context-parallel ranks, so calculate_per_token_loss is enabled and average_in_collective
    is disabled. Both are untouched on the pretraining path.

Testing

Used for quantization-aware distillation of Nemotron-Nano-3 (W4A16 NVFP4) at seq_length=32768
with CP>1: 200 iterations, logits-distillation loss 3.37e-2 -> 1.91e-2 monotonically, router
seq_load_balancing_loss steady, and the resulting checkpoint exports and serves correctly.

Opt-in: without --sft the existing mock/blend data path is unchanged.

Before your PR is "Ready for review"

  • Make sure you read and follow Contributor guidelines and your commits are signed.
  • Is this change backward compatible?: Yes — purely additive and opt-in behind --sft.
  • Did you write any new necessary tests?: No
  • Did you add or update any necessary documentation?: No
  • Did you update Changelog?: No

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added supervised fine-tuning (SFT) support for distillation workflows.
    • Added configuration and validation for SFT dataset locations.
    • Added support for raw prompt/completion JSONL datasets with response-only loss masking.
    • Added input truncation, end-of-sequence handling, and student tokenizer support without automatic chat templates or BOS tokens.
    • Preserved existing mock and GPT dataset modes for non-SFT runs.
  • Documentation

    • Documented required filenames, record format, tokenizer behavior, and completion-only loss masking.

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>
@yueshen2016
yueshen2016 requested a review from a team as a code owner August 7, 2026 15:32
@yueshen2016
yueshen2016 requested a review from ChenhanYu August 7, 2026 15:32
@copy-pr-bot

copy-pr-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The distillation example adds SFT options, validates the dataset root and 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.

Changes

SFT distillation flow

Layer / File(s) Summary
SFT options and validation
examples/megatron_bridge/distill.py, examples/megatron_bridge/README.md
The CLI adds --sft and --sft_dataset_root. SFT requires the dataset root and rejects data paths or mock data. The documentation describes the required JSONL files and record fields.
SFT data and loss configuration
examples/megatron_bridge/distill.py
SFT uses FinetuningDatasetConfig for prompt/completion records, response-only masking, truncation, EOS insertion, per-token loss, vocabulary-size validation, and HuggingFace tokenization. Non-SFT paths retain their existing dataset and tokenizer behavior.
SFT distributed gradient reduction
examples/megatron_bridge/distill.py
SFT disables collective loss pre-averaging during distributed gradient reduction. Other modes retain loss averaging.

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/completion records
  HuggingFaceTokenizer->>ModelProviders: provide tokenized inputs with per-token loss
  ModelProviders->>DistributedGradientReduction: reduce SFT gradients without pre-averaging
Loading

Suggested reviewers: chenhanyu

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding SFT-masked data support to Megatron-Bridge distillation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed The PR changes only distill.py and README; no unsafe torch/numpy loads, eval/exec, nosec, or dependency changes were added, and trust_remote_code remains caller-controlled with default false.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mbridge-sft-distillation

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

👉 Steps to fix this

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

📥 Commits

Reviewing files that changed from the base of the PR and between 43e9d15 and 8abcdb4.

📒 Files selected for processing (1)
  • examples/megatron_bridge/distill.py

Comment thread examples/megatron_bridge/distill.py
Comment thread examples/megatron_bridge/distill.py
@yueshen2016

Copy link
Copy Markdown
Contributor Author

/claude review

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.60%. Comparing base (99116c3) to head (8c19f38).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2113   +/-   ##
=======================================
  Coverage   78.60%   78.60%           
=======================================
  Files         522      522           
  Lines       60167    60167           
=======================================
  Hits        47294    47294           
  Misses      12873    12873           
Flag Coverage Δ
unit 55.38% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment on lines +275 to +278
if args.sft and not args.sft_dataset_root:
raise ValueError(
"--sft requires --sft_dataset_root (a directory with training.jsonl / validation.jsonl)."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL Algorithm] The pre-existing data-source check at line 265-266 runs before this one and does not know about --sft, so the invocation documented in the PR description fails immediately:

python distill.py --sft --sft_dataset_root /path/to/data ...
ValueError: Must provide either --data_paths or set --use_mock_data.

--use_mock_data defaults to False and --data_paths to None, so with --sft alone line 266 raises and the SFT branch at line 396 is never reached. The only way to run the feature today is to also pass --data_paths <anything> or --use_mock_data — whose values are then silently ignored, because if args.sft: wins the dataset branch. That makes the headline feature unreachable as documented, and reachable only via a misleading incantation.

Fix: teach the existing check about the new source, and reject the ignored combination so a stale --data_paths in a launch script doesn't look like it's in use:

    # Sanity checks
    if not args.sft and not args.use_mock_data and not args.data_paths:
        raise ValueError("Must provide one of --data_paths, --use_mock_data, or --sft.")
    if args.sft and (args.data_paths or args.use_mock_data):
        raise ValueError("--sft is mutually exclusive with --data_paths / --use_mock_data.")

(CodeRabbit raised the same line; repeating because it blocks the feature end-to-end.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted, both parts — 080f4de and 8c19f38.

Verified this was a genuine regression rather than a theoretical one: the working branch these commits were ported from carried the exemption, and the port onto main dropped it, so the PR as first pushed could not run its own documented invocation.

if not args.sft and not args.use_mock_data and not args.data_paths:
    raise ValueError("Must provide either --data_paths or set --use_mock_data.")
if args.sft and (args.data_paths or args.use_mock_data):
    raise ValueError(
        "--sft is mutually exclusive with --data_paths / --use_mock_data: the SFT branch wins "
        "the dataset selection, so those inputs would be silently ignored."
    )

Took the mutual-exclusion check too — that is the more valuable half, since it turns the misleading incantation into an error instead of leaving it as a silent no-op.

Comment on lines +410 to +417
dataset_kwargs={
"prompt_template": "{input}{output}",
"label_key": "output",
"truncation_field": "input",
"answer_only_loss": True,
"add_bos": False,
"add_eos": True,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. 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,
  2. Keep add_bos=False (verbatim is a defensible contract) but state the requirement where users will read it — in the --sft_dataset_root help string and the README: input must contain the fully templated prompt, including any BOS and role/turn markers the model expects; no chat template or BOS is added.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_root help text,
  • a comment next to the dataset_kwargs that 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.

Comment on lines 462 to +470
),
tokenizer=TokenizerConfig(
tokenizer_type="NullTokenizer", vocab_size=distill_provider.vocab_size
tokenizer=(
# SFT reads raw text, so it needs the model's real tokenizer; the pretraining path
# consumes pre-tokenized data and keeps NullTokenizer.
TokenizerConfig(
tokenizer_type="HuggingFaceTokenizer",
tokenizer_model=args.student_hf_path,
hf_tokenizer_kwargs={"trust_remote_code": args.trust_remote_code},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPORTANT Compatibility] The SFT path tokenizes with --student_hf_path's tokenizer, but the KD loss is computed against teacher logits over the same token ids. If the teacher and student come from different families with different vocabularies (which the existing --teacher_hf_path / --student_hf_path interface permits, and which the README's "distill a 4B student from an 8B teacher" framing invites), the teacher receives the student's ids and its logits are meaningless — silently producing a garbage KD target rather than an error.

The pretraining path was structurally immune to this: NullTokenizer + pre-tokenized --data_paths meant the user chose one tokenization for both. Picking the student's tokenizer here quietly introduces the coupling.

Worth a guard next to the other --sft sanity checks, since both configs are already loaded for the VLM detection:

if args.sft and student_provider.vocab_size != teacher_provider.vocab_size:
    raise ValueError(
        "--sft tokenizes with the student's tokenizer; student and teacher must share a "
        f"vocabulary (got {student_provider.vocab_size} vs {teacher_provider.vocab_size})."
    )

A warn_rank_0 would be acceptable too — the point is that this failure mode is currently invisible. Also worth stating in the help text / README that --sft requires teacher and student to share a tokenizer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted — added the guard in 8c19f38, as a hard error rather than a warning.

The failure is silent and total (the teacher scores ids it never saw, so the KD target is noise), and the check cannot produce a false positive: both providers are built with the same make_vocab_size_divisible_by and TP here, so their padded vocab_size values are equal iff the base vocabularies are.

if args.sft and student_provider.vocab_size != teacher_provider.vocab_size:
    raise ValueError(
        "--sft tokenizes with the student's tokenizer, so student and teacher must share a "
        f"vocabulary (got {student_provider.vocab_size} vs {teacher_provider.vocab_size})."
    )

Your framing of why this is new — the pretraining path was structurally immune because NullTokenizer plus pre-tokenized data means one tokenization feeds both — is now the comment above it.

Comment on lines +403 to +409
dataset_config = FinetuningDatasetConfig(
seq_length=args.seq_length,
dataset_root=args.sft_dataset_root,
seed=args.seed,
dataloader_type="batch",
do_validation=True,
do_test=False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] do_validation=True is hardcoded, but --eval_iters accepts 0 (_nonnegative_int) and the rest of the script honors that as "no validation". Under --sft --eval_iters 0, FinetuningDatasetConfig will still build the validation split and therefore still require validation.jsonl to exist — so a user with training data only has to fabricate a dummy validation file to run a config the script otherwise supports.

Deriving it keeps the two knobs consistent:

Suggested change
dataset_config = FinetuningDatasetConfig(
seq_length=args.seq_length,
dataset_root=args.sft_dataset_root,
seed=args.seed,
dataloader_type="batch",
do_validation=True,
do_test=False,
dataset_config = FinetuningDatasetConfig(
seq_length=args.seq_length,
dataset_root=args.sft_dataset_root,
seed=args.seed,
dataloader_type="batch",
do_validation=args.eval_iters > 0,
do_test=False,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted — applied verbatim in 8c19f38.

do_validation=args.eval_iters > 0. You are right that the rest of the script already treats --eval_iters 0 as "no validation", so requiring a dummy validation.jsonl to run a supported configuration was an inconsistency, not a constraint.

Comment on lines +303 to +306
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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=True paired with average_in_collective=False is 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 tokens guard already in LogitsAndIntermediatesLossBalancer.forward.
  • Opt-in isolation. Every behavioral change is gated on args.sft; the mock and blend paths, NullTokenizer, and average_in_collective=True are byte-for-byte unchanged without the flag. No modelopt_state schema, 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=True does mask to the response (answer_start_idx == len(context_ids)), and truncation_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.

… 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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

👉 Steps to fix this

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8abcdb4 and 080f4de.

📒 Files selected for processing (2)
  • examples/megatron_bridge/README.md
  • examples/megatron_bridge/distill.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/megatron_bridge/distill.py

Comment on lines +135 to +138
`validation.jsonl` of `{"input": <prompt>, "output": <response>}` records, which are tokenized with
the model's own HuggingFace tokenizer. Both fields are tokenized **verbatim** — no chat template is
applied and no BOS token is prepended — so if your model expects role/turn markers or a BOS token,
include them in the `"input"` field yourself.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the automatic EOS token.

Line 136 says both fields are tokenized verbatim. However, examples/megatron_bridge/distill.py:398-423 sets add_eos=True, so the dataset adds an EOS token automatically. Clarify that the fields are tokenized verbatim before this automatic EOS addition.

🤖 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/README.md` around lines 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.

- 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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

👉 Steps to fix this

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 win

Make validation.jsonl conditional in the SFT documentation.

When --eval_iters 0, do_validation is false, so only training.jsonl is required. Update the CLI help, error text, and README to state that validation.jsonl is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 080f4de and 8c19f38.

📒 Files selected for processing (1)
  • examples/megatron_bridge/distill.py

Comment on lines +341 to +350
if args.sft and student_provider.vocab_size != teacher_provider.vocab_size:
# The pretraining path is structurally immune to this: NullTokenizer plus pre-tokenized
# --data_paths means one tokenization feeds both models. SFT tokenizes raw text with the
# student's tokenizer, so a teacher from another family would score ids it never saw and
# silently produce a garbage KD target instead of an error.
raise ValueError(
"--sft tokenizes with the student's tokenizer, so student and teacher must share a "
f"vocabulary (got {student_provider.vocab_size} vs {teacher_provider.vocab_size})."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 --type py \
  'convert_to_distillation_provider|HuggingFaceTokenizer|input_ids|student_provider|teacher_provider' \
  examples/megatron_bridge tests

Repository: NVIDIA/Model-Optimizer

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- distill.py: argument and provider setup ---'
sed -n '80,180p' examples/megatron_bridge/distill.py
sed -n '280,410p' examples/megatron_bridge/distill.py
sed -n '450,515p' examples/megatron_bridge/distill.py

printf '%s\n' '--- distillation provider implementation ---'
sed -n '1,125p' examples/megatron_bridge/_distillation_provider.py

printf '%s\n' '--- focused tokenizer and distillation references ---'
rg -n -C 5 --type py \
  'TokenizerConfig|tokenizer_model|teacher_provider|student_provider|DistillationProvider|kd_loss|teacher' \
  examples/megatron_bridge \
  -g 'distill.py' \
  -g '_distillation_provider.py'

Repository: NVIDIA/Model-Optimizer

Length of output: 39297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

distill = Path("examples/megatron_bridge/distill.py").read_text()
provider = Path("examples/megatron_bridge/_distillation_provider.py").read_text()

checks = {
    "sft_uses_student_tokenizer": (
        'tokenizer_model=args.student_hf_path' in distill
        and '"HuggingFaceTokenizer"' in distill
    ),
    "guard_checks_only_vocab_sizes": (
        'if args.sft and student_provider.vocab_size != teacher_provider.vocab_size:' in distill
        and 'student_provider.vocab_size' in distill
        and 'teacher_provider.vocab_size' in distill
    ),
    "teacher_model_enters_kd_wrapper": (
        'teacher = unwrap_model(' in provider
        and '"teacher_model": teacher' in provider
        and 'mtd.convert(student, mode=[("kd_loss", modelopt_cfg)])' in provider
    ),
    "no_teacher_tokenizer_is_configured": (
        'tokenizer_model=args.teacher_hf_path' not in distill
    ),
}
for name, passed in checks.items():
    print(f"{name}: {'PASS' if passed else 'FAIL'}")
if not all(checks.values()):
    raise SystemExit(1)
PY

printf '%s\n' '--- distillation README tokenizer/model constraints ---'
rg -n -C 4 --type md \
  'distill|student_hf_path|teacher_hf_path|tokenizer|vocab' \
  examples/megatron_bridge README.md

Repository: NVIDIA/Model-Optimizer

Length of output: 50380


Validate tokenizer ID compatibility for SFT distillation.

SFT uses args.student_hf_path as the only tokenizer, and the KD wrapper supplies the same batch IDs to the teacher. Equal vocab_size values do not guarantee matching token, special-token, or added-token IDs. Require equivalent tokenizer mappings before training.

🤖 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 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant