Skip to content

Commit 8b61a0e

Browse files
authored
Merge pull request #464 from Modalities/fix/cross-entropy-fp32-upcast
fix(loss): compute causal-LM cross-entropy in float32
2 parents a275808 + 70cbb83 commit 8b61a0e

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎src/modalities/loss_functions.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,20 @@ def __call__(self, *args, **kwargs) -> torch.Tensor:
4848
shift_logits = lm_logits.contiguous()
4949
shift_labels = labels.contiguous().long()
5050
# Flatten the tokens. We compute here, the loss per token.
51-
loss = self.loss_fun(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))
51+
# The up-cast to float32 is deliberate and must not be removed. Under mixed precision the
52+
# model hands us bfloat16 logits: FSDP2's MixedPrecisionPolicy casts *parameters*, it does
53+
# not install torch.autocast, so nothing else promotes them on the way in.
54+
#
55+
# This is about *tensor* precision, not accumulation -- PyTorch's kernels already accumulate
56+
# in float32 for half-precision inputs. What the cast preserves is the log-softmax output,
57+
# the tensors its backward reads, and the returned loss scalar, each of which would
58+
# otherwise be stored in the logits' dtype. The scalar matters most: at a loss magnitude of
59+
# ~36 the bfloat16 grid is 0.25 wide, which is the whole of the ~5e-2 error measured before
60+
# this cast was added.
61+
#
62+
# TorchTitan up-casts on every one of its cross-entropy paths for the same reason
63+
# (torchtitan/components/loss.py), as does HF transformers (transformers/loss/loss_utils.py).
64+
loss = self.loss_fun(shift_logits.view(-1, shift_logits.size(-1)).float(), shift_labels.view(-1))
5265
return loss
5366

5467
def _parse_arguments(

‎tests/test_loss_functions.py‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import torch
33

44
from modalities.batch import InferenceResultBatch
5-
from modalities.loss_functions import NCELoss, nce_loss
5+
from modalities.loss_functions import CLMCrossEntropyLoss, NCELoss, nce_loss
66

77

88
@pytest.fixture
@@ -36,3 +36,68 @@ def test_nce_loss_correctness(embedding1, embedding2):
3636
bidirectional_loss = nce_loss(embedding1, embedding2, device="cpu", is_asymmetric=False, temperature=1.0)
3737
assert unidirectional_loss == pytest.approx(1.1300, 0.0001)
3838
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

Comments
 (0)