feat: add FusedLinearKLDivLoss (fused linear + KL divergence for distillation) - #1423
Conversation
…nsients eagerly The historical inc_factor = cdiv(V, H) formula (memory budget C=1) forces tiny 32-row chunks at LLM vocab shapes, making the chunk loop launch-bound: measured full fwd+bwd at llama-3 head shape (H=4096, V=128256, bf16, BT=1024) was 5.1x slower than the unfused baseline. Adopt fused_linear_cross_entropy's _CHUNK_MEM_CONST = 16 budget, which reaches speed parity (0.89x). Also compute the log-softmax backward in place and drop chunk_size x V fp32 temporaries (logits/softmax/log-prob) as soon as they are consumed, so the peak during the trailing GEMMs drops. Peak memory for full fwd+bwd (max_memory_allocated, RTX 4060 Laptop, bf16, llama-3 head shape): BT=2048 6537 -> 4310 MB (1.52x), BT=4096 12072 -> 6594 MB (1.83x), and at BT=1024 the fused op now also wins (4793 -> 4244 MB via the benchmark harness). Per-row loss is partition-invariant across chunk sizes; gradients only differ at the GEMM reduction-order level, same class as existing chunked fused ops.
53aeedf to
2a1a964
Compare
|
Rebased onto latest I also reviewed the new multi-DSL dispatcher architecture from #1416 and the Follow-up plan: register the inner KL primitive via Tested on latest main (RTX 4060 Laptop 8GB, torch 2.11.0+cu128):
Could a maintainer approve the workflow run and take a look when you get a |
Adopt the same three-branch weight-gradient accumulation that fused_linear_cross_entropy uses after linkedin#1454: on CUDA SM80+ with dtype-matched low-precision params (accum_dtype=None), accumulate straight into grad_weight via addmm(out=grad_weight) instead of materializing a parameter-sized product + cast per token chunk. The fp32-accumulator out_dtype fast path and the legacy fallback are unchanged in behavior. Also expose accum_dtype in LigerFusedLinearKLDivLoss.extra_repr.
|
@arde171 @kolehma8 @BYHsu — friendly ping for a review when you get a chance. One update since my last comment: New commit — dW accumulation aligned with #1454. The low-precision weight-gradient accumulation now mirrors the three-branch Validation (same three suites as before:
As before, the PR is complementary to the JSD backends from #1419/#1420 (KL vs JSD divergence; the teacher distribution is given explicitly, e.g. cached soft labels for offline distillation), and registering the inner KL primitive via Could a maintainer approve the workflow run (checks are pending approval for fork PRs) and take a look when you get a chance? Thanks! |
| chunk_n_rows = logits_chunk.shape[0] | ||
|
|
||
| # log-softmax with temperature | ||
| log_prob_chunk = torch.log_softmax(logits_chunk, dim=-1).contiguous() |
There was a problem hiding this comment.
Could this be fused with the main kernel (_kl_div_kernel)?
| # For anything starting from logits to the final KL loss, we do computation | ||
| # in FP32 to avoid losing numerical stability. | ||
| logits_chunk = (input_chunk @ student_weight.t()).to(torch.float32) | ||
| logits_chunk.div_(temperature) |
There was a problem hiding this comment.
Can this be moved to fused kernel?
| # shape: chunk_size x V | ||
| # For anything starting from logits to the final KL loss, we do computation | ||
| # in FP32 to avoid losing numerical stability. | ||
| logits_chunk = (input_chunk @ student_weight.t()).to(torch.float32) |
There was a problem hiding this comment.
could you do the casting to FP32 inside the kernel (e.g. registers or SMEM) to avoid HBM bloat?
| # (log_prob_chunk now holds g = dL/dlog_prob; done in place to avoid | ||
| # materializing extra chunk_size x V temporaries) | ||
| softmax_chunk = torch.softmax(logits_chunk, dim=-1) | ||
| del logits_chunk # free chunk_size x V fp32 before the GEMMs below |
There was a problem hiding this comment.
I believe these will become unnecessary once you move some of the operations inside the fused kernel?
| @@ -0,0 +1,163 @@ | |||
| import torch | |||
There was a problem hiding this comment.
Can you include some performance numbers for the kernel you added compared with vanilla torch implementation?
@Yulong-Cauli thank you for your contribution. I left few comments about the kernel itself and could you provide also some performance numbers? We want to ensure that all the kernels in Liger are faster (or more memory efficient) than plain torch implementations. |
… KL kernel Address review feedback: the GEMM output now stays in its native precision in HBM and _kl_div_kernel upcasts to FP32 in registers, computing the temperature scaling, the log-softmax, the KL loss and the log-softmax backward in-kernel, then overwrites the logits buffer in place with dL/dlogits. This removes the fp32 logits cast, the log_softmax materialization and the torch-side softmax-recompute/backprop passes around the kernel: each token chunk now has a single native-precision transient (previously three fp32 ones) and far fewer HBM round trips. The loss is accumulated in the algebraically equal factored form sum(q*(log(max(q,eps)) - x/T)) + lse*sum(q) so it completes within the same two-pass structure.
|
@kolehma8 thanks for the review! All five points are addressed in The GEMM output now stays in its native precision in HBM. Benchmark (full fwd+bwd, median of 3×30 timed iterations, incremental peak-allocated memory; methodology as in #1454): A100-SXM4-80GB, bf16, torch 2.11.0:
Tesla T4, fp16, torch 2.10.0 (bf16 is unsupported on SM75):
fp32 is ~parity (0.94x–1.16x depending on shape and GPU). The fused kernel also validates on three GPUs (SM75/SM80/SM89): the full PR suite plus the sibling JSD and upstream Raw run logs: A100-SXM4-80GB (SM80), torch 2.11.0+cu130, repo @
|
Summary
Add
FusedLinearKLDivLoss, which fuses the vocabulary-head linear projection withKL(Q || P) loss against an explicit target distribution, for knowledge distillation
workflows where teacher probabilities are available directly (e.g. cached / offline
soft labels), complementing
LigerKLDIVLoss(element-wise, no fusion) andLigerFusedLinearJSD(teacher side needs its own projection).The kernel follows the chunked gradient-in-forward structure of
fused_linear_jsd.py,so the full
BT x Vlogits tensor is never materialized, and adopts the_CHUNK_MEM_CONST = 16token-chunk geometry offused_linear_cross_entropy.py.Details
reduction("batchmean"/"mean"/"sum", same semantics astorch.nn.KLDivLoss),ignore_indexviashift_labels, softmaxtemperature,and an
epsclamp so that0 * log(0)contributes 0.accum_dtypefor fp32 weight-gradient accumulation across chunkedlow-precision GEMMs (same semantics as
LigerFusedLinearJSD/LigerFusedLinearCrossEntropyLoss), including thetorch.addmm(out_dtype=fp32)fast path on torch >= 2.8 / sm80+ and a fallback elsewhere.
chunk_size x Vfp32 temporaries are freed eagerly, so the peak during thetrailing GEMMs stays low.
reduction="none"is intentionally unsupported (gradient-in-forward requires ascalar loss); a
ValueErrorpoints users toLigerKLDIVLossinstead.reduction="sum"test tolerances follow the existing convention intest_fused_linear_cross_entropy.py.max_memory_allocated, bf16, llama-3 head shape H=4096 / V=128256, RTX 4060Laptop GPU): BT=2048 6537 -> 4310 MB (1.52x), BT=4096 12072 -> 6594 MB (1.83x);
at BT=1024 the benchmark harness reports 4793 -> 4244 MB. Runtime is at parity
with the baseline at BT=1024 (0.89x).
Testing Done
Hardware Type: NVIDIA GeForce RTX 4060 Laptop GPU (sm89); NVIDIA Tesla T4 (sm75)
run
make testto ensure correctness (32 pre-existing failures unrelated tothis change: 18
test_mlp.pyshared-memory OOR on sm89 and 14test_grpo_loss.pyLUSPO numerics; both reproduce on
mainwithout this change)run
make checkstyleto ensure code stylerun
make test-convergenceto ensure convergence (does not exercise the newstandalone loss)
Test logs (RTX 4060 Laptop, torch 2.11.0+cu128, triton 3.7.1)
Test logs (Kaggle Tesla T4, torch 2.10.0+cu128, triton 3.6.0)