Skip to content

Fix softmax collapse in CE, GRPO, KL/JSD and softmax - #1555

Open
kashif wants to merge 23 commits into
linkedin:mainfrom
kashif:fix-softmax-collapse
Open

kashif wants to merge 23 commits into
linkedin:mainfrom
kashif:fix-softmax-collapse

Conversation

@kashif

@kashif kashif commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the softmax collapse from A Fix for Softmax and Cross-Entropy in the kernels. Once the target logit leads the logsumexp of the rest by more than ~16.6 (fp32), softmax(x_y) rounds to 1, so softmax - 1 and lse - x_y become exactly 0 while the other classes keep their gradients. The gradient no longer sums to zero, and the loss reads 0. This happens for very confident tokens, which are common in LLM training.

  • CE / FLCE, CE+TVD, vocab-parallel CE: also accumulate the sum without the target in the first pass, then use dx_y = -sum_others / d and loss = softplus(log(sum_others) + m - x_y). Vocab-parallel all-reduces both sums in one call.
  • GRPO: the forward stores an accurate logp instead of lse (the backward rebuilds lse from it) and uses -expm1(logp) for 1 - p_y. fused_selective_log_softmax returns the accurate logp too and drops its unused lse= arg.
  • fused KL / JSD, softmax: the gradient at the argmax is taken from the other entries (paper Eq. 7). Mixed-beta JSD isn't affected.
  • softmax: the multi-block kernels didn't compile (tl.float32(...)) and were unreachable. Rows wider than 65536 now go through them instead of raising.

log1p / expm1 / target_nll helpers in ops/utils.py only use tl.log / tl.exp, so no libdevice is needed.

Details

New tests build rows with the target 5 to 40 above the rest and compare against an fp64 reference written as softplus(lse_others - x_y), since plain fp64 F.cross_entropy collapses too past ~36.7. All of them fail on main.

Overhead on a 3090 Ti, bf16, H=4096, fwd+bwd: fused KL is 2-3% slower, fused JSD 1-2%. I didn't benchmark the CE / GRPO / softmax changes.

Not touched: the cutile / cute / flydsl / ascend variants, and the chunked distillation losses (torch autograd, same as torch).

Testing Done

  • Hardware Type: RTX 3090 Ti (also ran on an AMD Radeon 890M / ROCm)
  • run make test to ensure correctness (ran the suites of the touched ops on both GPUs: CE, FLCE, GRPO, CE+TVD, selective log softmax, vocab-parallel CE, fused KL / JSD, JSD, KL, softmax, neighborhood attention)
  • run make checkstyle to ensure code style
  • run make test-convergence to ensure convergence

danielhanchen added a commit to unslothai/unsloth-zoo that referenced this pull request Oct 25, 2026
…hunked JSD, TRL and Liger (#2079)

## Summary

Adds `fused_linear_jsd`, a fused two-head generalized JSD for TRL's
`GKDTrainer`, as an opaque custom op
(`unsloth_zoo::fused_linear_jsd_v1`). Rows are processed in chunks: each
chunk projects the student and the teacher head with fp32 logits, takes
the closed-form student gradient (hidden, plus head and bias when they
train) in the forward, and frees the chunk before the next one, so
neither model ever holds tokens x vocab logits and the backward only
rescales the stored gradient.

- Matches TRL's `generalized_jsd_loss` (0.22.2, 0.25.1, 1.15.0): logits
/ T, log-softmax on both, beta = 0 forward KL, beta = 1 reverse KL,
logsumexp mixture in between, `labels != -100` mask, batchmean or
`num_items_in_batch`.
- The teacher can have its own hidden size, head, bias, device, logit
scale and softcap; it is never differentiated.
- CUDA runs a Triton body (rows split over vocab blocks); without Triton
an Inductor-compiled torch body, then eager.
- Peaked rows stay correct: the gradient term is shifted by its value at
the student's argmax, so a row whose top logit leads by 20-80 still has
a zero-sum fp32 gradient (the softmax collapse described in
linkedin/Liger-Kernel#1555).
- GradScaler `scaling` is supported; engagement is counted in
`CALLS["jsd"]`; chunk budget `UNSLOTH_FUSED_LINEAR_JSD_CHUNK_MB`.

The unsloth side that routes `GKDTrainer` here follows in a separate
unsloth PR; with an older unsloth nothing calls this op.

## Numbers

Exclusive B200, N = 2048 rows, student hidden 2048, teacher hidden 2560,
V = 151936, bf16, forward + backward, median of 3 (spreads do not
overlap):

| beta | this op | existing chunked JSD | TRL dense | Liger
`LigerFusedLinearJSDLoss` (compiled) |
|---|---|---|---|---|
| 0 | 389 MiB, 4.96 ms | 406 MiB, 6.42 ms | 4162 MiB, 7.24 ms | 1500
MiB, 7.44 ms |
| 0.5 | 389 MiB, 6.58 ms | 1148 MiB, 11.20 ms | 7724 MiB, 23.22 ms |
3276 MiB, 9.96 ms |
| 1 | 389 MiB, 5.96 ms | 406 MiB, 6.25 ms | 4671 MiB, 7.79 ms | 1500
MiB, 7.73 ms |

Student gradient relative error vs an fp64 oracle: 2.2e-3 for this op,
1.5e-2 for the existing chunked path, 1.5e-2 to 8.3e-2 for Liger, 1.6e-2
to 1.5e-1 for TRL dense in bf16.

## Tests

`tests/test_fused_linear_jsd.py`: fp64 TRL oracle for beta in {0, 0.1,
0.5, 1} x T in {1, 0.9} with masks, bias + scale + softcap, trainable
head and `num_items_in_batch`, cross-checked against the installed TRL
function; chunking, fully masked batch, GradScaler scaling, peaked rows,
Triton vs fp64 body, 0 graph breaks under `torch.compile(fullgraph =
True)`. On this branch with main: the new file plus
`test_distillation_chunked_jsd.py`, `test_fused_linear*.py`,
`test_fused_loss_fullgraph.py`, `test_chunked_ce.py`,
`test_chunked_logprob.py`: 545 passed, 3 skipped on GPU. The new tests
fail on main.
danielhanchen added a commit to unslothai/unsloth-zoo that referenced this pull request Oct 25, 2026
…tmax collapse) (#2080)

## Summary

The fused linear CE / log-prob kernels lose all gradient signal on
confident rows. When the target logit leads every other class by more
than about 16.6 (fp32), `exp(z_t - m) = 1` absorbs the sum of the other
classes, so `lse == z_t`, `log p_t` reads exactly 0 and the target term
of the gradient, `1 - p_t`, reads exactly 0. The hidden-state gradient
of such a row is then 100% wrong (relative), and on the Triton log-prob
routes with a temperature the log-prob can even come out positive (up to
+1.8e-6, from the target logit being recomputed after the sweep). This
is the softmax collapse described in arXiv 2610.31642, section 2; Liger
has the same issue open (linkedin/Liger-Kernel#1555).

Fix, in `unsloth_zoo/fused_losses/fused_linear.py`:

- Forward (`_fl_forward_kernel`, `_fl_partial_kernel`,
`_fl_combine_kernel`): the running sum excludes the target. The sweep
skips the target's block as a whole (one scalar choice per block, no
per-element mask in the hot loop) and the other classes of that block
are added back by one re-read after the sweep. Then `log p_t =
-softplus(log(s_others) + m - z_t)`, which never adds `e^0 = 1` to
`e^-margin` and is never positive. The exact-target-logit mode uses the
same form. Entropy takes `log s` from the same term when the target is
the row max; the split merge still rescales with each split's full sum.
- Backward (`_fl_backward_kernel`, `_torch_dz`) and the flash `u`
kernel: the target column uses `-expm1(log p_t)` (label smoothing
included).
- Torch fallback (`_torch_forward`): same split with `logaddexp` /
`softplus`.
- Op schema bumps (Inductor / AOTAutograd caches key on neither):
`fused_linear_logprobs_v5 -> _v6` (one extra output, `log p_t` of the
logits the gradient reads; equal to the log-probs unless the
exact-target mode is on) and `fused_linear_logprobs_bwd_v3 -> _v4`. The
CE op is unchanged. Nothing outside this file references the op names.
- Triton builds without libdevice get Kahan forms of `expm1` / `log1p`
(exact to an ulp for tiny arguments with `tl.exp` / `tl.log` only).

## Accuracy (fp64 oracle, margins 17 / 20 / 30 / 50, V in {1003, 40001,
151936})

| | before | after |
|---|---|---|
| hidden-grad, worst row, fp32 logits | 1.0 relative | 1.8e-5 |
| hidden-grad, worst row, bf16 | 1.0 relative | 3.9e-3 (bf16 rounding
itself 1.8e-3) |
| log p_t, collapsed rows | 0 (100% relative), positive on Triton +
temperature | 1.7e-5 relative, never positive |
| entropy of collapsed rows | 5.3% (Triton) / 6.0% (torch) | 1.5e-5 |
| CE loss, absolute | 2.3e-7 of loss read as 0 | 9e-13 |

New `tests/test_fused_linear_collapse.py` (Triton and torch, logits
modes 0 / 1 / 2, temperature 1 / 0.7, softcap 0 / 30, CE, log-probs,
entropy, trainable head; a negative control checks ordinary rows are
unchanged): 24 failed / 1 passed (the control) on main, 25 passed with
this PR (14 skipped are torch-fallback combinations the file does not
run).

## Speed

B200, exclusive, 2560 x 151936 frozen head, 3 rounds, median ratio (this
PR / main): CE 1.004, GRPO 0.999, GRPO + entropy 1.017, DPO 1.068 with
min-ratio 0.995 (A/A noise on the same harness: 0.97 to 1.006 at 8192
rows, up to 20% at 2048). Kernel profile of the CE flash route:
`_fl_partial_kernel` 0.84 -> 0.87 ms of a 9.3 ms step.

## Tests

GPU, all fused-linear / chunked CE / chunked log-prob / GRPO recompute /
fullgraph / preference / JSD / distillation suites plus the new file:
733 passed, 17 skipped. The fullgraph tests confirm no new graph breaks.

Not covered here: a ROCm / HIP run (only the half-logit mode ROCm
defaults to was exercised, on CUDA) and pre-Volta GPUs.
@sniper35

Copy link
Copy Markdown

Some independent measurements of the near-optimum JSD gradient error this PR removes on the fused Triton path, plus two places where the same residual survives — in case they help the review. The numbers below come from an op-for-op CPU emulation of the kernel arithmetic (fp32; the same formulas as ops/jsd.py and the host-side log-softmax backward in fused_linear_jsd.py), not from running the Triton kernels.

  • Mechanism on main. ops/jsd.py L59–70 (and the identical forms in every jsd_loss_and_grad backend) emit dX = loss + Q (β = 1) / dX = −P (β = 0) from an in-kernel exp(log_softmax), and fused_linear_jsd.py L229–232 then applies the log-softmax backward with a second softmax computed by torch.softmax outside the kernel. The "+Q" and "−Q·ΣdX" terms cancel exactly in exact arithmetic but not for two differently rounded Q's, leaving ≈ 2–3 ulp·Q per entry even when student_logits == teacher_logits. With that emulation on Qwen2.5-0.5B logits (fp32 inputs, i.e. after fix: request float32 logits from the fused JSD projection GEMM #1433): spurious gradient norm 1.5e-5 per token at the optimum (max 1e-4, true value 0); at a 1e-2 logit gap 23 % of tokens have > 10 % relative gradient error for β ∈ {0, 1}; synthetic margin-8 rows: 100 % error with 56 % sign flips. β ∈ (0, 1) is benign (≤ 1e-4), consistent with "Mixed-beta JSD isn't affected".
  • Not covered by this PR (as its description notes): the standalone LigerJSDFunction path, where PyTorch's own log_softmax backward supplies the second softmax against the kernel's Q·(X − Y) + Q; and the CuTe DSL / cuTile / Ascend jsd_loss_and_grad backends (_cutedsl/jsd.py L245–264, _cutile/jsd.py L100–125, _ascend/ops/jsd.py L64–92), which carry the same dx = loss + q / dx = −p forms — CuTe DSL is the default on sm_90+ when nvidia-cutlass-dsl is installed. A student == teacher ⇒ grad == 0 test on LigerJSDLoss (log-probs in, through autograd) would catch the standalone case.
  • Adjacent, unrelated to softmax collapse: ops/tvd.py L90–98 loads p/q without the .to(tl.float32) cast that kl_div.py, jsd.py, fused_linear_kl_div.py and fused_ce_tvd.py apply, so bf16 inputs are differenced in bf16 (loss only; the gradient is sign-based). Happy to send a one-line PR if wanted.

Comment thread test/transformers/test_cross_entropy.py Resolved
Comment thread test/transformers/test_cross_entropy.py Resolved
kashif and others added 2 commits November 5, 2026 18:47
Co-authored-by: Tcc0403 <76503978+Tcc0403@users.noreply.github.com>
@vaibhavjindal

Copy link
Copy Markdown
Collaborator

This branch has not been deployed

No deployments
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.

4 participants