Skip to content

Commit ba3eede

Browse files
committed
fix(training): sanitize masked sentinel and non-finite inputs in the DSpark loss
Padded teacher-forcing layouts fill unsupervised positions with sentinel target ids (-1 / -100) and non-finite logits. A sentinel id indexes out of bounds in the gather, and zero-weighting a non-finite row does not save the step: 0 * NaN is NaN in the forward, and the backward of a non-finite row poisons the gradient regardless of its weight. Replace the inputs at unsupervised positions instead of masking the outputs. torch.where on draft_logits selects exact zeros into those slots in the backward rather than multiplying by zero, so no NaN reaches draft_logits.grad; target_ids are filled with a valid row; the reduced L1 and the confidence logits are scrubbed for non-finite teacher rows without copying the full-vocab teacher. Keying off the combined supervision mask means dropped anchors get the same protection as eval-masked positions. A poisoned batch now produces bit-identical losses, metrics, and gradients to the equivalent clean one, which the tests assert term by term. Note in the head's docstring that the embedding lookup stays strict: it is the producer's job to replace sentinels, and clamping there would silently train on row 0 rather than fail. Signed-off-by: khazic <khazzz1c@gmail.com>
1 parent f7fd089 commit ba3eede

3 files changed

Lines changed: 75 additions & 7 deletions

File tree

‎src/megatron/bridge/training/post_training/dspark/heads.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,15 @@ def __init__(self, *, vocab_size: int, markov_rank: int):
5454
self.markov_w2 = nn.Linear(self.markov_rank, self.vocab_size, bias=False)
5555

5656
def get_prev_embeddings(self, token_ids: torch.Tensor) -> torch.Tensor:
57-
"""Look up ``markov_w1`` for ``token_ids`` (any shape) -> ``[..., markov_rank]``."""
57+
"""Look up ``markov_w1`` for ``token_ids`` (any shape) -> ``[..., markov_rank]``.
58+
59+
``token_ids`` must already be valid rows in ``[0, vocab_size)``. The
60+
teacher-forcing producer owns replacing sentinel pads (``-1`` / ``-100`` at
61+
positions outside the supervision mask) with any valid id before calling,
62+
mirroring how :func:`~.loss.dspark_loss` sanitizes ``target_ids``. The
63+
lookup stays strict on purpose: a sentinel that reaches it is a producer
64+
bug, and clamping here would silently train on row 0 instead of failing.
65+
"""
5866
return self.markov_w1(token_ids.long())
5967

6068
def project_bias(self, latent_states: torch.Tensor) -> torch.Tensor:

‎src/megatron/bridge/training/post_training/dspark/loss.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ class DSparkForwardOutput:
8383
target_ids: Teacher-forced next token per position
8484
``[batch, num_blocks, block_size]``.
8585
eval_mask: Bool/float supervision mask ``[batch, num_blocks, block_size]``
86-
(a block is a contiguous, in-bounds, loss-enabled prefix).
86+
(a block is a contiguous, in-bounds, loss-enabled prefix). Positions
87+
outside the mask may carry sentinel ids or non-finite padded values;
88+
:func:`dspark_loss` sanitizes them before computing any term.
8789
block_keep_mask: Kept-anchor mask ``[batch, num_blocks]``. Dropped anchors
8890
are excluded from every loss term, from ``num_tokens``, and from the
8991
acceptance metrics, independently of what ``eval_mask`` says.
@@ -272,26 +274,38 @@ def dspark_loss(
272274
raise ValueError("aligned_target_logits is required for the L1 loss or the confidence head.")
273275
needs_teacher = l1_alpha > 0 or has_confidence
274276

277+
# Unsupervised positions may carry sentinel target ids (-1 / -100) or
278+
# non-finite padded logits. Zero-weighting them afterwards is not enough:
279+
# 0 * NaN is NaN, and a non-finite forward row still produces NaN gradients.
280+
# Replacing the inputs with torch.where is what makes this safe in both
281+
# directions, because where's backward *selects* zeros into the masked slots
282+
# rather than multiplying by zero, so no NaN reaches draft_logits.grad.
283+
supervised_bool = supervised > 0
284+
draft_logits = torch.where(supervised_bool.unsqueeze(-1), draft_logits, 0.0)
285+
target_ids = outputs.target_ids.long().masked_fill(~supervised_bool, 0)
286+
275287
# One FP32 normalization of the draft distribution serves both terms.
276288
ce_per_token, l1_dist = _ce_and_l1_per_token(
277-
draft_logits, outputs.target_ids, target_logits if needs_teacher else None, chunk_tokens
289+
draft_logits, target_ids, target_logits if needs_teacher else None, chunk_tokens
278290
)
279291
ce_num = (ce_per_token * weight).sum()
280292

281293
zero = ce_num.new_zeros(())
282294
l1_num = conf_num = zero
283295
accept_rate = None
284296
if needs_teacher:
297+
# A non-finite teacher row at an unsupervised position surfaces as NaN in
298+
# the reduced distance; scrub it there rather than copying the full-vocab
299+
# teacher tensor just to mask a few rows.
300+
l1_dist = torch.where(supervised_bool, l1_dist, 0.0)
285301
accept_rate = (1.0 - 0.5 * l1_dist).clamp(0.0, 1.0)
286302
if l1_alpha > 0:
287303
# Raw weighted L1 (paper Eq. 10). The 1/2 lives in accept_rate above.
288304
l1_num = (l1_dist * weight).sum()
289305
if has_confidence:
306+
confidence_pred = torch.where(supervised_bool, outputs.confidence_pred.float(), 0.0)
290307
conf_num = (
291-
F.binary_cross_entropy_with_logits(
292-
outputs.confidence_pred.float(), accept_rate.detach(), reduction="none"
293-
)
294-
* weight
308+
F.binary_cross_entropy_with_logits(confidence_pred, accept_rate.detach(), reduction="none") * weight
295309
).sum()
296310

297311
loss = ce_alpha * ce_num + l1_alpha * l1_num + confidence_alpha * conf_num

‎tests/unit_tests/training/post_training/test_dspark_loss.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,52 @@ def test_accept_rate_and_tau_exclude_dropped_blocks():
242242
assert _den(report, "dspark tau") == pytest.approx(B * (N - 1))
243243

244244

245+
def _masked_outputs(*, poison: bool):
246+
out = _outputs(with_target=True, with_confidence=True)
247+
out.eval_mask[:, :, -1] = False # trailing position unsupervised
248+
if poison:
249+
with torch.no_grad():
250+
out.draft_logits[:, :, -1] = float("-inf")
251+
out.aligned_target_logits[:, :, -1] = float("nan")
252+
out.target_ids[:, :, -1] = -1
253+
out.confidence_pred[:, :, -1] = float("nan")
254+
return out
255+
256+
257+
def test_masked_positions_tolerate_sentinel_ids_and_nonfinite_padding():
258+
# Padded layouts fill unsupervised positions with sentinel ids (-1 / -100) and
259+
# non-finite logits; they must neither crash nor leak NaN into loss or grads.
260+
clean = _masked_outputs(poison=False)
261+
ref_loss, _, ref_report = dspark_loss(clean, ce_alpha=0.1, l1_alpha=0.9, confidence_alpha=1.0)
262+
poisoned = _masked_outputs(poison=True)
263+
loss, _, report = dspark_loss(poisoned, ce_alpha=0.1, l1_alpha=0.9, confidence_alpha=1.0)
264+
assert torch.isfinite(loss)
265+
assert loss.item() == pytest.approx(ref_loss.item(), rel=1e-6)
266+
for key in ref_report:
267+
assert _num(report, key) == pytest.approx(_num(ref_report, key), rel=1e-6), key
268+
assert _den(report, key) == pytest.approx(_den(ref_report, key), rel=1e-6), key
269+
loss.backward()
270+
assert torch.isfinite(poisoned.draft_logits.grad).all()
271+
assert torch.all(poisoned.draft_logits.grad[:, :, -1] == 0)
272+
273+
274+
def test_dropped_anchors_also_tolerate_poisoned_padding():
275+
# The sanitizer keys off the combined mask, so a poisoned position inside a
276+
# dropped anchor is covered even when its eval_mask says supervised.
277+
out = _outputs(with_target=True, with_confidence=True)
278+
out.block_keep_mask[:, 0] = False
279+
with torch.no_grad():
280+
out.draft_logits[:, 0] = float("-inf")
281+
out.aligned_target_logits[:, 0] = float("nan")
282+
out.target_ids[:, 0] = -1
283+
out.confidence_pred[:, 0] = float("nan")
284+
loss, _, _ = dspark_loss(out, ce_alpha=0.1, l1_alpha=0.9, confidence_alpha=1.0)
285+
assert torch.isfinite(loss)
286+
loss.backward()
287+
assert torch.isfinite(out.draft_logits.grad).all()
288+
assert torch.all(out.draft_logits.grad[:, 0] == 0)
289+
290+
245291
def test_eval_mask_zero_positions_do_not_contribute():
246292
out = _outputs(with_target=True, with_confidence=False)
247293
out.eval_mask = torch.zeros(B, N, L, dtype=torch.bool)

0 commit comments

Comments
 (0)