Skip to content

Fix tensor-parallel gradient scaling in Top-K KL distillation - #2988

Open
wiederse wants to merge 2 commits into
NVIDIA:mainfrom
wiederse:codex/fix-topk-kl-tp-gradients
Open

wiederse wants to merge 2 commits into
NVIDIA:mainfrom
wiederse:codex/fix-topk-kl-tp-gradients

Conversation

@wiederse

@wiederse wiederse commented Oct 24, 2026 •

Copy link
Copy Markdown

What does this PR do?

Type of change: Bug fix

With Top-K logits distillation and tensor parallelism enabled, each TP rank evaluates the same global KL loss. The autograd all-gather and full-vocabulary normalizer all-reduce sum gradients from these replicated losses, multiplying student gradients by the TP size even though the reported loss is correct.

Scale only the Top-K loss's backward contribution by 1 / TP. Forward loss values, loss-balancer weights, and dense KL behavior remain unchanged. Extend the existing ghost-token reference test to compare each vocabulary shard's gradients against a full-vocabulary reference, including Top-P truncation, non-default temperature, and reverse KL.

Usage

No API or command-line changes. Existing logit_kl_topk / --logit_kl_top_k configurations automatically receive the corrected gradients.

Testing

  • A800/NCCL: 13 focused tests passed at each of TP=1, TP=2, and TP=4 (39 total), including real Megatron model forward/backward and the distributed gradient-reference tests.
  • Negative control: restoring the original loss implementation makes all 3 gradient-reference cases fail at TP=2, at the gradient assertions.
  • CPU/Gloo: 14 focused numerical tests passed, using a temporary TP=1/2/4 worker fixture; repeated successfully on this isolated PR branch.
  • pre-commit run --files modelopt/torch/distill/plugins/megatron.py tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py CHANGELOG.rst: passed.
  • A800 TP=2 forward/backward microbenchmark: BF16 logits, sequence length 4096, global vocabulary 32768, K=256, 3 warmups and 10 measured iterations. Peak allocated memory was unchanged at 1,086,358,528 bytes per rank; measured time was 8.44 ms before / 7.81 ms after. This short run checks overhead and does not establish a speedup.
  • Full training convergence was not evaluated.

GPU test selection:

python -m pytest tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py \
  -k 'logits_kl_loss or logits_kl_losses_temperature_scaling or topk_logits_kl_loss' \
  -q -o addopts=''

Run with 1, 2, or 4 visible GPUs.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ API and forward loss values are unchanged; incorrect TP gradient scaling is corrected.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A.
  • Did you write any new necessary tests?: ✅ Extended the existing reference test with distributed gradient assertions and parameterized cases.
  • Did you update Changelog?: ✅.
  • Did you get Claude approval on this PR?: ❌ Not run; this PR is a draft.

Additional Information

No additional dependencies or communication collectives.

Summary by CodeRabbit

  • Bug Fixes
    • Corrected Megatron Top-K/Top-P distillation gradient scaling when tensor parallelism is enabled. Reported loss values remain unchanged.
  • Tests
    • Expanded coverage across tensor-parallel workers, including top-P truncation, reverse KL, and temperature scaling.

Signed-off-by: wiederse <1477618732@qq.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 24, 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 Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 5ec737a and fd93cec.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 0638a27 and 5ec737a.

Files selected for processing: 3
  • CHANGELOG.rst
  • modelopt/torch/distill/plugins/megatron.py
  • tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

Megatron Top-K/Top-P distillation now divides loss gradients by the tensor-parallel size when multiple ranks are active, without changing the reported loss. A distributed test compares shard losses and student gradients with a full-vocabulary reference across several configurations.

Changes: Distillation gradient scaling

Layer / File(s) Summary
Scale gradients and validate distributed loss
modelopt/torch/distill/plugins/megatron.py, tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py, CHANGELOG.rst
For multiple tensor-parallel ranks, the loss adjustment scales gradients by the inverse of the tensor-parallel size while preserving the reported loss. Distributed tests compare shard losses and gradients to a full-vocabulary reference across top-P, reverse-KL, and temperature settings. The changelog records the fix.

Priority: Normal

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix

Suggested reviewers: aanoosheh

Merge Risk: Minimal · up to 5ec73

The change appears mergeable after normal checks. The reported distributed tests cover the intended gradient behavior, though they were not run independently here.

Pre-merge checks: Passed 5 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 5 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title names the main change: Top-K KL distillation under tensor parallelism now scales gradients by 1/TP, which the raw summary and PR objectives both describe. It is specific and concise, and it …
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 PASS. The pull request changes only CHANGELOG.rst, modelopt/torch/distill/plugins/megatron.py, and a Megatron test. The added Python code contains no torch.load(..., weights_only=False), `numpy.…
Full details: Title check

Explanation

The title names the main change: Top-K KL distillation under tensor parallelism now scales gradients by 1/TP, which the raw summary and PR objectives both describe. It is specific and concise, and it fits the changelog entry, the megatron.py loss change, and the updated test. No unrelated changes were reported.

Full details: Security Anti-Patterns

Explanation

PASS. The pull request changes only CHANGELOG.rst, modelopt/torch/distill/plugins/megatron.py, and a Megatron test. The added Python code contains no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or added # nosec comments. No pyproject.toml or requirements file changed, so no new dependency licensing issue applies.

Full details: Docstring Coverage

Explanation

Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@wiederse
wiederse marked this pull request as ready for review October 24, 2026 10:38
@wiederse
wiederse requested review from a team as code owners October 24, 2026 10:38

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (gpt-6.1-sol) — DM the bot to share feedback.

LGTM: backward-only scaling corrects replicated TP gradients without changing forward losses; the justified reference-test rewrite needs human sign-off.

Needs action:

  • Confirm the test rewrite in tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py: it retains forward and probability-mass checks while adding shard-gradient comparisons, Top-P truncation, temperature scaling, and reverse KL.

@kevalmorabia97

Copy link
Copy Markdown
Collaborator

/claude review

@kevalmorabia97

Copy link
Copy Markdown
Collaborator

/ok to test 5ec737a

parallel_state.initialize_model_parallel(tensor_model_parallel_size=size)
cfg = SimpleNamespace(tensor_model_parallel_size=size)
device = torch.device("cuda", rank) if torch.cuda.is_available() else torch.device("cpu")
student, teacher = _make_loss_inputs()

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.

[SUGGESTION] _make_loss_inputs() defaults to vocab=16, and size here is torch.cuda.device_count() (via dist_workers), so the sharding below only behaves well when the GPU count divides 16.

torch.chunk(16, n, dim=-1) is not "split into n equal parts" — it is "chunks of ceil(16/n)":

  • n=5 → [4,4,4,4], only 4 chunks, so .chunk(size, -1)[rank] raises IndexError on rank 4.
  • n=6 → [3,3,3,3,3,1], so local_top_k = min(k, shard) is 3 on ranks 0-4 but 1 on rank 5, and the dist_nn.functional.all_gather inside TopLogitsKLLoss gets mismatched shapes across ranks.
  • n=3 → [6,6,4], which runs but exercises an unequal vocab sharding Megatron never produces.

The PR was validated at TP=1/2/4 (and 8 divides 16), so this is latent rather than currently failing — but the failure mode on an odd runner is an opaque IndexError/NCCL shape error rather than a clear skip. Cheapest fix is to size the vocab off size so shards are always equal and >= k:

student, teacher = _make_loss_inputs(vocab=4 * size)

(or keep vocab=16 and add assert 16 % size == 0).

):
parallel_state.initialize_model_parallel(tensor_model_parallel_size=size)
cfg = SimpleNamespace(tensor_model_parallel_size=size)
device = torch.device("cuda", rank) if torch.cuda.is_available() else torch.device("cpu")

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.

[SUGGESTION] The torch.device("cpu") fallback is unreachable here. This helper only ever runs through dist_workers, whose pool is built with backend="nccl" and world_size=torch.cuda.device_count() (tests/gpu_megatron/conftest.py), so CUDA is always available. This looks like a leftover from the temporary Gloo TP=1/2/4 fixture mentioned in the PR description; CONTRIBUTING's "Remove dead code" applies.

Suggested change
device = torch.device("cuda", rank) if torch.cuda.is_available() else torch.device("cpu")
device = torch.device("cuda", rank)

Comment on lines +513 to +519
@pytest.mark.parametrize(
("top_p", "reverse", "temperature"),
[(None, False, 1.0), (0.5, False, 2.0), (0.5, True, 1.0)],
)
def test_topk_logits_kl_loss_numerics_ghost_token_reference(
dist_workers, top_p, reverse, temperature
):

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.

[SUGGESTION] This is the only regression test for the new tp_size > 1 branch, but dist_workers sizes the pool to torch.cuda.device_count(). On a single-GPU runner size == 1, the tp_size > 1 branch in TopLogitsKLLoss.forward is never entered, and all three parametrizations pass vacuously — green CI without having tested the fix. (The top_p/reverse/temperature numerics they also assert are already covered single-process by test_logits_kl_losses_temperature_scaling and test_topk_logits_kl_loss_top_p_masks_tail.)

dist_workers_size_2 / dist_workers_size_4 exist for exactly this case — they pytest.skip("Need at least N GPUs") rather than silently degrade, so a 1-GPU run reports "skipped" instead of "passed". Consider requesting dist_workers_size_2 (keeping dist_workers as an additional case if you also want TP=device_count coverage), so the branch this PR fixes can never be reported as passing untested.

@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.11%. Comparing base (0638a27) to head (5ec737a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/distill/plugins/megatron.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2988      +/-   ##
==========================================
- Coverage   79.82%   79.11%   -0.71%     
==========================================
  Files         661      661              
  Lines       74928    74930       +2     
==========================================
- Hits        59810    59284     -526     
- Misses      15118    15646     +528     
Flag Coverage Δ
examples-diffusers 19.90% <0.00%> (-0.04%) ⬇️
examples-gpt-oss 13.44% <0.00%> (-0.03%) ⬇️
examples-hf_ptq 23.34% <0.00%> (-0.08%) ⬇️
examples-llm_distill 13.52% <0.00%> (-0.03%) ⬇️
examples-llm_eval 17.17% <0.00%> (-0.04%) ⬇️
examples-llm_qat 17.34% <0.00%> (-0.07%) ⬇️
examples-llm_sparsity 16.76% <0.00%> (-0.04%) ⬇️
examples-megatron_bridge 26.39% <50.00%> (-0.26%) ⬇️
examples-specdec_bench 13.23% <0.00%> (-0.03%) ⬇️
examples-speculative_decoding 17.42% <0.00%> (-0.10%) ⬇️
examples-torch_onnx 21.21% <0.00%> (-0.07%) ⬇️
examples-torch_trt 15.15% <0.00%> (-0.03%) ⬇️
examples-vllm_serve 17.38% <0.00%> (-0.04%) ⬇️
gpu 58.91% <50.00%> (-1.17%) ⬇️
regression 15.04% <0.00%> (-0.04%) ⬇️
unit 61.11% <0.00%> (-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.

@claude

claude Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Claude review — PR #2988

Scope: full review (trigger comment was a bare /claude review). 3 changed files, all reviewed: modelopt/torch/distill/plugins/megatron.py, tests/gpu_megatron/torch/distill/plugins/test_distill_megatron.py, CHANGELOG.rst.

Findings: CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3 (all test-only, all posted inline)

The fix is correct — I verified the 1/tp_size factor rather than assuming it

The claim is that every TP rank computes the same global Top-K loss, and the autograd collectives then sum tp_size identical gradients. Traced end to end:

  • dist_nn.functional.all_gather backward is reduce_scatter(cat(grad_outputs)). Because every rank runs an identical graph over identically-gathered values, grad_outputs[r] is bit-identical on all ranks, so rank r receives exactly tp_size × its local gradient.
  • dist_nn.functional.all_reduce inside _tp_logsumexp backward is all_reduce(SUM) of the grad — again tp_size × local. Crucially, logits_max is .detach()ed before its (non-autograd) MAX all-reduce, so it does not add a second, unscaled path.
  • Both paths from loss back to predictions (via student_lse, and via gather → all_gather) cross exactly one collective, so the inflation factor is uniformly tp_size. A single scale on the loss is therefore exactly right, not an approximation — and skipping it at tp_size == 1 is correct since no collective runs.
  • loss.detach() + (loss - loss.detach()) / tp_size is bit-exact in the forward: loss - loss.detach() is exactly 0.0.

Dense LogitsKLLoss is correctly left alone, which is the part most likely to look like a missed case. It returns post_forward(loss, tp_reduce=True) on a partial per-shard vocab sum, so the sum over ranks really is the global loss and the summing autograd semantics are the intended ones there. TopLogitsKLLoss returns tp_reduce=False on an already-global loss — that asymmetry is precisely the bug. Root cause, not side effect.

Loss-balancer claim checked: LogitsAndIntermediatesLossBalancer derives dynamic_scale from logits_loss.detach() / intermediate_loss.detach(), so the preserved forward value keeps the balancer's weighting byte-identical. The intended consequence — the logits term's gradient now shrinks tp_size × relative to the never-inflated intermediate/LM terms — is the correction itself.

Test quality: the rewrite is a genuine strengthening, not a weakening. It keeps the original forward and K+1 probability-mass assertions (now keep-masked) and adds a real full-vocab gradient reference per shard. The reference correctly computes the Top-P mask from the teacher before the reverse swap, matching the implementation, and omitting the min_keep term is safe since top_p > 0 always keeps top-1. Global-Top-K recovery from per-rank local Top-K candidates is sound at every tested size. The author's negative control (reverting the loss change fails all 3 cases at TP=2) is the right evidence per CONTRIBUTING's "each fix has a test that fails without it".

Suggestions (non-blocking)

  1. vocab=16 vs torch.chunk(size) — torch.chunk means "chunks of ceil(n/size)", not "size equal parts". On a 5-GPU runner rank 4 hits IndexError; on 6 GPUs ranks disagree on local_top_k and all_gather sees mismatched shapes. Latent only (1/2/4/8 all divide 16), but the failure mode is opaque. Suggest vocab=4 * size.
  2. Unreachable torch.device("cpu") fallback — dist_workers is an NCCL pool, so this is dead code left over from the temporary Gloo fixture.
  3. dist_workers sizes to torch.cuda.device_count() — on a 1-GPU runner size == 1, the tp_size > 1 branch is skipped and all three cases pass vacuously. dist_workers_size_2 skips loudly instead.

Two minor notes not worth their own threads: the Top-P reference masking is now the third hand-written copy in this file (alongside test_topk_logits_kl_loss_top_p_masks_tail's reference() and test_logits_kl_losses_temperature_scaling) and could be consolidated; and the CHANGELOG entry is accurate, but users with existing TP>1 logit_kl_topk runs may appreciate the explicit hint that effective gradient magnitude drops by TP, so LR / kd_loss_alpha may need retuning.

Risk

Low. Five added source lines guarded by tp_size > 1, no API / config / modelopt_state / export surface touched, forward values provably unchanged, and the one behavioral change is the intended correction. All three suggestions are test hygiene and none affect shipped behavior.

@claude claude 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.

Claude review passed — no blocking issues found. LGTM

@wiederse
wiederse requested a review from cjluo-nv October 25, 2026 15:28

This branch has not been deployed

No deployments
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.

3 participants