Skip to content

fix(loss): compute causal-LM cross-entropy in float32 - #464

Merged
le1nux merged 2 commits into
mainfrom
fix/cross-entropy-fp32-upcast
Sep 14, 2026
Merged

le1nux merged 2 commits into
mainfrom
fix/cross-entropy-fp32-upcast

Conversation

@le1nux

@le1nux le1nux commented Sep 14, 2026

Copy link
Copy Markdown
Member

The problem

CLMCrossEntropyLoss passes the model's logits straight into CrossEntropyLoss:

loss = self.loss_fun(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))

Under mixed precision those logits are bfloat16, and nothing promotes them. FSDP2's
MixedPrecisionPolicy casts parameters; it does not install torch.autocast, so the
operator-level float32 autocast list — which normally covers log_softmax / cross_entropy
never applies.

This is about tensor precision, not accumulation. PyTorch's kernels already accumulate in
float32 for half-precision inputs. What is lost without the cast is the precision of the stored
tensors: the log-softmax output, the tensors its backward reads, and the returned loss scalar.
The scalar dominates — at a loss magnitude of ~36 the bfloat16 grid is 0.25 wide, and the measured
error before the cast is exactly that quantum.

What the reference implementations do

Both up-cast at exactly this point, unconditionally:

  • TorchTitan — every one of its cross-entropy paths (torchtitan/components/loss.py):
    return torch.nn.functional.cross_entropy(
        pred.flatten(0, 1).float(), labels.flatten(0, 1), reduction="sum", ignore_index=IGNORE_INDEX
    )
    It does not trade this away for memory either: the chunked path still does
    logits.float().reshape(B * L, V) and accumulates through GradAccumulator(..., dtype=torch.float32).
    Memory is solved by chunking the sequence, not by dropping the up-cast.
  • HF transformersForCausalLMLoss opens with logits = logits.float()
    (transformers/loss/loss_utils.py).

The fix

One .float() on the logits, plus a comment recording why it must not be removed.

Measured

Against an independent float64 evaluation of identical bfloat16-quantized logits, vocab 131072:

variant error vs float64 what it is
bfloat16 logits, loss returned in bfloat16 (before) 4.89e-2 the bfloat16 quantum at loss ≈ 36
bfloat16 logits, per-token losses summed in float32 2.98e-3 residual from bfloat16-stored log-probs
float32 logits (after) 2.36e-6

Reproduced on an A100: 3.18e-2 → 1.78e-7, and the 3.18e-2 is again exactly the bfloat16 quantum at
that magnitude — confirming the error is stored-tensor precision rather than an accumulator artefact.

On a real 8B checkpoint over 262,144 held-out tokens, the gradient into lm_head differs by
0.19% between the two paths, and the up-cast recovers about 1.8× of it. The remainder is
imposed by the bfloat16 logit boundary itself and is not reachable from the loss — stated so nobody
expects this to be exact.

After the change the loss and its gradient are bit-identical to TorchTitan's formulation
(max |diff| = 0.0) for bfloat16, float16 and float32 inputs at vocab 1024 and 131072.

Tests

Three new tests, each of which fails without the fix:

  1. the returned loss dtype is float32 for half-precision logits;
  2. the loss is within 1e-4 of a float64 reference (it was ~5e-2 off; it is now 2.4e-6). The reference
    is computed directly via F.cross_entropy, not by calling the loss with a float64 tensor —
    the implementation casts its input to float32, so that would silently compare the fix with itself;
  3. it matches TorchTitan's sum-reduction-over-valid-tokens formulation, including IGNORE_INDEX
    handling. The reference is inlined so the test carries no dependency on torchtitan.

tests/test_loss_functions.py passes (7 tests); 5 of them go red with the .float() removed. The
pinned pre-commit gate (isort 5.11.5 / black 23.9.1 / ruff 0.0.278) passes.
tests/instruction_tuning/test_e2e_instruction_tuning.py fails on this branch and on clean
main
— pre-existing and unrelated. The full 545-test suite has not been run end-to-end here.

Memory note for large-vocabulary training

Casting a [2, 8192, 131072] logit tensor materialises 8 GiB in float32, alongside the other
loss buffers. On a configuration already near its memory ceiling, either chunk the loss or reduce the
micro-batch. TorchTitan's chunked path is the reference approach.

Note for the in-flight refactor

nano_expert_parallelism refactors this into two free functions, clm_cross_entropy_loss and
clm_cross_entropy_loss_sum; both need the same .float(). Worth carrying over, not least
because ChunkedCLMCrossEntropyLoss's docstring already promises "the float32 up-cast inside
cross-entropy" — a claim that only becomes true once this lands.

🤖 Generated with Claude Code

CLMCrossEntropyLoss passed the model's logits straight into CrossEntropyLoss.
Under mixed precision those are bfloat16, and nothing promotes them: FSDP2's
MixedPrecisionPolicy casts parameters, it does not install torch.autocast, so
the operator-level float32 autocast list never applies. The log-softmax, its
backward, and -- because the reduction is "mean" -- the accumulation of the
per-token losses therefore all ran in bfloat16 over a 131k-entry vocabulary.

Both reference implementations up-cast at exactly this point:
TorchTitan does it on every one of its cross-entropy paths
(torchtitan/components/loss.py, e.g. `pred.flatten(0, 1).float()`), and HF
transformers opens ForCausalLMLoss with `logits = logits.float()`
(transformers/loss/loss_utils.py).

Measured against a float64 evaluation of identical bfloat16-quantized logits at
vocab 131072, the loss value was off by ~4.9e-2 and is now off by ~2.4e-6. The
accumulator dominates that figure; the log-softmax alone accounts for ~3e-3.
On a real 8B checkpoint and 262,144 held-out tokens the gradient into lm_head
differs by 0.19% between the two paths, and the up-cast recovers about 1.8x of
that -- the remainder is imposed by the bfloat16 logit boundary itself and is
not reachable from the loss.

After the change the loss and its gradient are bit-identical to TorchTitan's
formulation for bfloat16, float16 and float32 inputs at vocab 1024 and 131072.

Tests cover the three symptoms: the returned dtype, accuracy against a float64
reference, and agreement with TorchTitan's sum-reduction-over-valid-tokens
formulation. All three fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@le1nux
le1nux requested a review from BlueCrescent September 14, 2026 21:39
…tionale

Two review findings, both correct.

The accuracy test compared the implementation with itself. It built its
"float64 reference" by calling the loss with a float64 tensor, but the loss
casts its input to float32 -- so once the fix is in place both sides evaluated
the identical float32 tensor and the difference was exactly 0.0. It still went
red on the bug, but it was vacuous with the fix applied. The reference is now
computed directly via F.cross_entropy on float64 logits, and the assertion
measures a real 2.36e-6.

The rationale conflated tensor precision with accumulation. PyTorch's kernels
already accumulate in float32 for half-precision inputs, so the claim that the
log-softmax, its backward and the mean reduction "run in the logits' own dtype"
was wrong. What the cast preserves is the precision of the stored tensors: the
log-softmax output, the tensors its backward reads, and the returned loss
scalar. The scalar dominates -- measured, the ~5e-2 error before this cast is
exactly the bfloat16 quantum at a loss magnitude of ~36 (4.89e-2 on CPU,
3.18e-2 on an A100), not an accumulator artefact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@le1nux
le1nux merged commit 8b61a0e into main Sep 14, 2026
3 checks passed
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.

2 participants