|
2 | 2 | import torch |
3 | 3 |
|
4 | 4 | from modalities.batch import InferenceResultBatch |
5 | | -from modalities.loss_functions import NCELoss, nce_loss |
| 5 | +from modalities.loss_functions import CLMCrossEntropyLoss, NCELoss, nce_loss |
6 | 6 |
|
7 | 7 |
|
8 | 8 | @pytest.fixture |
@@ -36,3 +36,68 @@ def test_nce_loss_correctness(embedding1, embedding2): |
36 | 36 | bidirectional_loss = nce_loss(embedding1, embedding2, device="cpu", is_asymmetric=False, temperature=1.0) |
37 | 37 | assert unidirectional_loss == pytest.approx(1.1300, 0.0001) |
38 | 38 | assert bidirectional_loss == pytest.approx(2.2577, 0.0001) |
| 39 | + |
| 40 | + |
| 41 | +# --------------------------------------------------------------------------- |
| 42 | +# Causal-LM cross-entropy must be computed in float32 even when the model hands |
| 43 | +# it bfloat16 logits. FSDP2's MixedPrecisionPolicy casts parameters but does not |
| 44 | +# install torch.autocast, so nothing promotes them on the way into the loss. |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | + |
| 47 | + |
| 48 | +@pytest.fixture |
| 49 | +def clm_loss() -> CLMCrossEntropyLoss: |
| 50 | + return CLMCrossEntropyLoss(target_key="target", prediction_key="logits") |
| 51 | + |
| 52 | + |
| 53 | +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) |
| 54 | +def test_clm_cross_entropy_returns_float32_for_half_precision_logits(clm_loss, dtype): |
| 55 | + """The returned dtype is the giveaway: cross-entropy returns its input's dtype.""" |
| 56 | + torch.manual_seed(0) |
| 57 | + logits = torch.randn(2, 16, 512, dtype=dtype) |
| 58 | + labels = torch.randint(0, 512, (2, 16)) |
| 59 | + assert clm_loss(logits, labels).dtype == torch.float32 |
| 60 | + |
| 61 | + |
| 62 | +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) |
| 63 | +def test_clm_cross_entropy_is_accurate_for_half_precision_logits(clm_loss, dtype): |
| 64 | + """Half-precision logits must not drag the loss down with them. |
| 65 | +
|
| 66 | + Not about accumulation -- the kernels already accumulate in float32 for half-precision inputs. |
| 67 | + What the up-cast preserves is tensor precision: the log-softmax output and the returned scalar, |
| 68 | + which would otherwise be stored in the logits' dtype. The scalar dominates; at a loss magnitude |
| 69 | + of ~36 the bfloat16 grid is 0.25 wide. Against a float64 reference the un-upcast path lands |
| 70 | + around 5e-2 and the float32 path around 2e-6. |
| 71 | +
|
| 72 | + The reference is computed directly rather than through ``clm_loss``: the implementation casts |
| 73 | + its input to float32, so passing it a float64 tensor would silently compare the fix with itself. |
| 74 | + """ |
| 75 | + torch.manual_seed(0) |
| 76 | + vocab_size = 131072 |
| 77 | + logits = (torch.randn(2, 64, vocab_size) * 8.0).to(dtype) |
| 78 | + labels = torch.randint(0, vocab_size, (2, 64)) |
| 79 | + |
| 80 | + reference = torch.nn.functional.cross_entropy(logits.double().reshape(-1, vocab_size), labels.reshape(-1)) |
| 81 | + assert abs(clm_loss(logits, labels).double() - reference) < 1e-4 |
| 82 | + |
| 83 | + |
| 84 | +def test_clm_cross_entropy_matches_torchtitan_formulation(clm_loss): |
| 85 | + """Mean reduction over valid tokens == sum reduction / valid-token count. |
| 86 | +
|
| 87 | + Mirrors ``torchtitan/components/loss.py::cross_entropy_loss``, which up-casts the logits and |
| 88 | + sum-reduces for token-based normalization. Kept inline so the test carries no dependency on |
| 89 | + torchtitan. |
| 90 | + """ |
| 91 | + torch.manual_seed(0) |
| 92 | + vocab_size = 1024 |
| 93 | + logits = torch.randn(2, 64, vocab_size, dtype=torch.bfloat16) |
| 94 | + labels = torch.randint(0, vocab_size, (2, 64)) |
| 95 | + labels[0, :7] = -100 # ignored tokens must leave both sides unchanged |
| 96 | + |
| 97 | + flat_labels = labels.view(-1).long() |
| 98 | + summed = torch.nn.functional.cross_entropy( |
| 99 | + logits.view(-1, vocab_size).float(), flat_labels, reduction="sum", ignore_index=-100 |
| 100 | + ) |
| 101 | + torchtitan_style = summed / (flat_labels != -100).sum() |
| 102 | + |
| 103 | + torch.testing.assert_close(clm_loss(logits, labels), torchtitan_style, rtol=1e-6, atol=1e-6) |
0 commit comments