-
Notifications
You must be signed in to change notification settings - Fork 555
perf(loss): defer the fp32 upcast in the distillation top-k path #3496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
53e9557
4b2d8fd
972ae77
006a4eb
69e222e
06954fe
43c6059
5c99bf8
44c86a1
5a1140a
c73efdf
87874cb
4983328
92f193f
e153943
7534040
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ | |
| from_parallel_logits_to_logprobs, | ||
| from_parallel_logits_to_logprobs_packed_sequences, | ||
| gather_logits_at_global_indices, | ||
| get_distillation_topk_logprobs_from_logits, | ||
| ) | ||
| from nemo_rl.distributed.named_sharding import NamedSharding | ||
| from nemo_rl.distributed.ray_actor_environment_registry import ( | ||
|
|
@@ -1583,3 +1584,176 @@ def test_distributed_vocab_topk_ops( | |
| worker_group.shutdown(force=True) | ||
| finally: | ||
| cluster.shutdown() | ||
|
|
||
|
|
||
| def test_distillation_topk_non_tp_defers_fp32_upcast(): | ||
| """The non-TP top-k gather must not need a full-vocab fp32 copy. | ||
|
|
||
| Only K columns are read on this path, and ``gather`` then ``to(float32)`` | ||
| is equivalent to ``to(float32)`` then ``gather``. Pin that the returned | ||
| log-probs and the gradient w.r.t. the student logits stay bitwise identical | ||
| to the previous full-vocab-upcast formulation, and that the result is still | ||
| fp32. | ||
| """ | ||
| torch.manual_seed(0) | ||
| batch, seq, vocab, k = 1, 8, 512, 4 | ||
| logits = torch.randn(batch, seq, vocab, dtype=torch.bfloat16) | ||
| teacher_topk_logits = torch.randn(batch, seq, k) | ||
| # distinct per position, as torch.topk returns: with duplicate indices the | ||
| # gather backward would accumulate into the (now bf16) input rather than an | ||
| # fp32 copy, which is a different computation | ||
| teacher_topk_indices = torch.stack( | ||
| [ | ||
| torch.stack([torch.randperm(vocab)[:k] for _ in range(seq)]) | ||
| for _ in range(batch) | ||
| ] | ||
| ) | ||
|
|
||
| student = logits.clone().requires_grad_(True) | ||
| topk_logprobs, _, h_all = get_distillation_topk_logprobs_from_logits( | ||
| student_logits=student, | ||
| teacher_topk_logits=teacher_topk_logits, | ||
| teacher_topk_indices=teacher_topk_indices, | ||
| zero_outside_topk=False, | ||
| calculate_entropy=False, | ||
| ) | ||
| topk_logprobs.sum().backward() | ||
|
|
||
| reference = logits.clone().requires_grad_(True) | ||
| expected = torch.nn.functional.log_softmax( | ||
| reference.to(torch.float32).gather(dim=-1, index=teacher_topk_indices), | ||
| dim=-1, | ||
| )[:, :-1, :] | ||
| expected.sum().backward() | ||
|
|
||
| assert h_all is None | ||
| assert topk_logprobs.dtype == torch.float32 | ||
| assert torch.equal(topk_logprobs, expected) | ||
| assert torch.equal(student.grad, reference.grad) | ||
|
|
||
|
|
||
| def _topk_grad_with(indices, upstream, logits): | ||
| """Gradient w.r.t. ``logits`` under the deferred-upcast implementation.""" | ||
| student = logits.clone().requires_grad_(True) | ||
| topk_logprobs, _, _ = get_distillation_topk_logprobs_from_logits( | ||
| student_logits=student, | ||
| teacher_topk_logits=torch.zeros_like(upstream), | ||
| teacher_topk_indices=indices, | ||
| zero_outside_topk=False, | ||
| calculate_entropy=False, | ||
| ) | ||
| (topk_logprobs * upstream[:, :-1, :]).sum().backward() | ||
| return student.grad | ||
|
|
||
|
|
||
| def _topk_grad_reference(indices, upstream, logits): | ||
| """Gradient under the previous full-vocab-upcast formulation.""" | ||
| reference = logits.clone().requires_grad_(True) | ||
| expected = torch.nn.functional.log_softmax( | ||
| reference.to(torch.float32).gather(dim=-1, index=indices), | ||
| dim=-1, | ||
| )[:, :-1, :] | ||
| (expected * upstream[:, :-1, :]).sum().backward() | ||
| return reference.grad | ||
|
|
||
|
|
||
| def test_deferred_upcast_matches_only_because_padded_positions_are_masked(): | ||
| """The equivalence is conditional, and this pins the condition. | ||
|
|
||
| ``gather``'s backward is a scatter-add, and padded positions carry K copies | ||
| of index 0 -- ``dtensor_policy_worker.py`` pads the sequence axis with | ||
| ``value=0``. There the old formulation accumulates in fp32 and this one | ||
| accumulates in bf16, which is a different number. | ||
|
|
||
| They agree because ``DistillationLossFn`` masks those positions to exactly | ||
| 0.0, so the incoming gradient is zero and there is nothing to accumulate. | ||
| Both directions are checked: masked agrees, unmasked does not. The second | ||
| half is why the loss now raises on missing masks instead of falling back to | ||
| an unmasked mean. | ||
|
|
||
| Two things would make this test pass without testing anything: | ||
| - top-k indices are distinct within a position, so only the padded tail | ||
| has duplicates at all -- ``torch.randperm`` everywhere reproduces | ||
| nothing; | ||
| - a uniform upstream gradient makes the unmasked case agree too, since | ||
| ``log_softmax`` over K duplicated logits is uniform and its gradient | ||
| for an all-ones upstream is exactly zero. | ||
| """ | ||
| torch.manual_seed(0) | ||
| batch, seq, vocab, k = 2, 8, 512, 4 | ||
| pad_from = 6 | ||
|
|
||
| logits = torch.randn(batch, seq, vocab, dtype=torch.bfloat16) | ||
| indices = torch.stack( | ||
| [ | ||
| torch.stack([torch.randperm(vocab)[:k] for _ in range(seq)]) | ||
| for _ in range(batch) | ||
| ] | ||
| ) | ||
| indices[:, pad_from:, :] = 0 # the padded tail, as the worker writes it | ||
|
|
||
| upstream = torch.randn(batch, seq, k) # non-uniform, deliberately | ||
| mask = torch.ones(batch, seq, 1) | ||
| mask[:, pad_from:, :] = 0.0 | ||
|
|
||
| assert torch.equal( | ||
| _topk_grad_with(indices, upstream * mask, logits), | ||
| _topk_grad_reference(indices, upstream * mask, logits), | ||
| ), "masked: the deferred upcast must be bitwise equal" | ||
|
|
||
| assert not torch.equal( | ||
| _topk_grad_with(indices, upstream, logits), | ||
| _topk_grad_reference(indices, upstream, logits), | ||
| ), ( | ||
| "unmasked: the two must DIFFER -- if they do not, this test has stopped " | ||
| "reproducing the duplicate-index accumulation and the ValueError in " | ||
| "DistillationLossFn is no longer pinned by anything" | ||
| ) | ||
|
|
||
|
|
||
| def test_distillation_topk_gathers_before_upcasting(monkeypatch): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: An equivalent refactor to torch.gather(...) / index_select would keep the optimization but fail this test as a false alarm. Maybe note this in the docstring so a future refactorer knows to update the spy rather than suspect a real regression.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken. The spy is bound to |
||
| """The full-vocab tensor must still be bf16 when ``gather`` runs. | ||
|
|
||
| The equivalence test above cannot catch a revert: its reference is the | ||
| old ``to(float32).gather(...)`` formulation, so it holds whether or not | ||
| the upcast was deferred. This one observes the deferral itself. | ||
|
|
||
| It spies on ``Tensor.gather``, so it is coupled to that specific call. An | ||
| equivalent rewrite to ``torch.gather(...)`` or ``index_select`` would keep | ||
| the deferral but stop tripping the spy, failing here as a false alarm -- | ||
| update the spy in that case rather than reading it as a regression. | ||
| """ | ||
| torch.manual_seed(0) | ||
| batch, seq, vocab, k = 1, 8, 512, 4 | ||
| logits = torch.randn(batch, seq, vocab, dtype=torch.bfloat16) | ||
| teacher_topk_logits = torch.randn(batch, seq, k) | ||
| teacher_topk_indices = torch.stack( | ||
| [ | ||
| torch.stack([torch.randperm(vocab)[:k] for _ in range(seq)]) | ||
| for _ in range(batch) | ||
| ] | ||
| ) | ||
|
|
||
| seen_dtypes = [] | ||
| real_gather = torch.Tensor.gather | ||
|
|
||
| def spy(self, dim, index): | ||
| # only the full-vocab gather is interesting; K-wide ones are downstream | ||
| if self.shape[-1] == vocab: | ||
| seen_dtypes.append(self.dtype) | ||
| return real_gather(self, dim, index) | ||
|
|
||
| monkeypatch.setattr(torch.Tensor, "gather", spy, raising=True) | ||
|
|
||
| get_distillation_topk_logprobs_from_logits( | ||
| student_logits=logits, | ||
| teacher_topk_logits=teacher_topk_logits, | ||
| teacher_topk_indices=teacher_topk_indices, | ||
| zero_outside_topk=False, | ||
| calculate_entropy=False, | ||
| ) | ||
|
|
||
| assert seen_dtypes, "expected a gather over the vocab axis" | ||
| assert all(d == torch.bfloat16 for d in seen_dtypes), ( | ||
| f"the vocab-wide tensor was upcast before the gather: {seen_dtypes}" | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The loss also has an unmasked fallback (kl_loss = per_token_kl.mean()) when token_mask/sample_mask are absent, where padded duplicate-index positions would carry nonzero grads and the accumulation order would differ (bf16 post-rounding vs fp32 pre-rounding). Consider asserting that the masks are present in DistillationLossFn, so the invariant this comment relies on is enforced rather than assumed.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, and that was the weak part of the comment.
DistillationLossFnfell back tokl_loss = per_token_kl.mean()when the masks were absent, and on that branch the padded positions get real gradient. Those are exactly the positions carrying K duplicate copies of index 0, sincedtensor_policy_worker.pypadstopk_indiceswithvalue=0. So the gather backward accumulates there instead of scattering, and it rounds in bf16 rather than fp32. That is the one case where deferring the upcast stops being bitwise equivalent. My comment claimed the equality held because the loss masks those positions — true only on the branch that has masks.Enforced it the way you suggested: it raises now instead of falling back. Nothing reached the fallback anyway —
distillation.pyalways setstoken_mask, andsetup_distillation_test_datasets both — so this turns a dead path into a loud one rather than changing behaviour. Added a CPU test that the raise fires for either mask missing. Put the silentmean()back and it goes red :)