[Perf] Fuse log-softmax into fused_linear_jsd kernel - #1352
Open
hiwuhgds-pixel wants to merge 2 commits into
Open
[Perf] Fuse log-softmax into fused_linear_jsd kernel#1352hiwuhgds-pixel wants to merge 2 commits into
hiwuhgds-pixel wants to merge 2 commits into
Conversation
The forward path used to materialize two fp32 log-probability tensors with torch.log_softmax, hand them to _jsd_kernel, then apply the log-softmax Jacobian back in PyTorch. This replaces that with a single Triton kernel that derives both log-softmaxes from the logits with a 3-pass online softmax and applies the chain rule inline. - loss_1d shrinks from (BT, V) fp32 to (BT,); the kernel reduces over V. - The GEMM keeps the input dtype and the cast to fp32 happens at tl.load, following the fused_linear_cross_entropy pattern, instead of casting the whole logits tensor after the matmul. - The student logits buffer is reused in place as the gradient buffer. - e^x is emitted as exp2(x * log2 e) to hit the hardware ex2.approx path. ops/jsd.py is untouched: _jsd_kernel takes log-probabilities and is public API (LigerJSDFunction, functional.py), with cutile and Ascend backends holding the same signature. Adds a V=40960 case to test_correctness so the multi-block path (V > BLOCK_SIZE, with a partial trailing block) is covered; every existing case was single-block.
hiwuhgds-pixel
marked this pull request as ready for review
August 7, 2026 16:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
fused_linear_jsdcomputed its forward in two steps: twotorch.log_softmaxcalls materialized fp32 log-probability tensors,
_jsd_kernelconsumed them, andthe log-softmax Jacobian was then applied back in PyTorch. This PR replaces that
with a single Triton kernel that derives both log-softmaxes from the logits
in-kernel and applies the chain rule inline.
On an H100 with the default chunking (H=4096, V=128256, bf16): 1.50-2.29x
faster on the full pass and 9.5-44.2% lower peak memory, both growing with
B*T. At
num_chunks=1the same change is 2.37-2.68x faster and 26.3-71.2%lighter.
Math and dtype contract are unchanged — same expression, same fp32 compute, and
@amp_custom_fwd/@amp_custom_bwdare kept.Details
The technique is the
fused_linear_cross_entropypattern applied to JSD:algorithm (Milakov & Gimelshein, Algorithm 3)
liger_cross_entropy_kerneluses.JSD needs 3 passes rather than CE's 2: pass 1 builds
m/dfor student andteacher together, pass 2 accumulates the loss and
dX_sum, pass 3 recomputesdXand writes the gradient.dX_sumis a cross-column term, so it has to beknown before any gradient is stored.
tl.loadrather than on the whole logits tensor after the matmul.
loss_1dshrinks from(BT, V)to(BT,)— the kernel reduces over Vitself.
same trick FLCE uses.
e^xis emitted asexp2(x * log2 e)to reach the hardwareex2.approxpath.Peak memory drops from three structural changes, per chunk of
Crows at vocabVwiths= bytes per element of the input dtype:loss_1d(unchunked, scales withBT)BT · V · 4BT · 42 · C · V · 42 · C · V · 42 · C · V · sThe PyTorch Jacobian expression also allocated several more
C · Vtemporaries(
softmax, the broadcast product, the subtraction, the dtype cast) that thekernel no longer needs, so the measured saving exceeds what the table accounts
for. Only
loss_1dscales withBTrather thanC, which is why the reductionis largest when chunking is coarsest — at
num_chunks=1, −26.3% at BT=1024rising to −71.2% at BT=8192.
ops/jsd.pyis deliberately untouched — see the open questions.Benchmarks
Hidden size: 4096, Vocab size: 128256, bf16, NVIDIA H100 80GB HBM3. "Liger old"
is the current implementation; the default
num_chunksfor this shape is 32.Speed is the median over the benchmark harness's runs, memory is peak allocated.
Speed — full pass (ms, median)
Memory — full pass (MB, peak)
Open questions
1. The fp32 rounding point moved, and #336 is the reason to ask.
#336 ("Fix FusedLinearJSD
precision issue when using AMP") made the path cast logits to fp32 right after the
matmul, so that "all the computation between logit to final JSD loss happen on
FP32", guarded by
test_amp. This PR still does all logit→loss computation infp32 — but in registers rather than in HBM, so the GEMM output itself now rounds
to the input dtype.
chunked_loss/jsd_loss.pyalready runsF.log_softmaxonunconverted bf16 logits, so the two paths were inconsistent before this change and
are consistent after it.
test_amppasses. Still, this narrows the intent of#336, so it should be a maintainer call rather than mine.
2. Should temperature scale the loss by T² rather than T?
Hinton et al. 2015 multiply the soft-target objective by T², because soft-target
gradients scale as 1/T² and the factor keeps gradient magnitude stable as T
changes. Liger divides by T once, everywhere: here, and in
chunked_loss/fused_linear_distillation.py. There is notemperature ** 2in therepo and no mention of temperature in
docs/, so users currently have to know topre-scale themselves. This PR keeps the existing behaviour — changing semantics
does not belong in a perf PR. Worth deciding: fold T² into the kernel, or document
that the caller owns it.
3. Why the kernel is here and not in
ops/jsd.py._jsd_kerneltakes log-probabilities and is public API —LigerJSDFunctionisexported from
ops/__init__.py, reached viatransformers/jsd.pyandfunctional.py, and its docstring states the log-space contract. The cutile andAscend backends carry the same signature. Rewriting it to take logits would break
all of that, so the fused kernel lives in
fused_linear_jsd.pyandjsd.pyisunmodified. The cost is two code paths for the same JSD formula. Dedupe or accept
is a maintainer decision.
4. Aside: the chunking heuristic looks mis-tuned for JSD.
While benchmarking I swept
num_chunksfrom 1 to 32. Speed scales far morestrongly with chunk size than FLCE's heuristic assumes. torch does not chunk, so
its column is the same series throughout and serves as the fixed reference:
Speed — full pass (ms, median), new kernel
Memory — full pass (MB, peak), new kernel
Against torch the new kernel is 2.73x / 3.02x / 3.27x / 3.26x faster at
nc=1,but 0.31x / 0.57x / 1.09x / 1.99x of torch's speed at the default — so with the
current heuristic it is slower than plain torch below BT≈4096, and only the
coarse-chunk configuration beats torch everywhere. Part of the reason is
structural: the heuristic pins
num_chunksatcdiv(V, H) = 32for every BT inthis sweep — the chunk size is grown to absorb a larger BT instead, so even at
BT=1024 the sequence is still split into 32 pieces, fragmenting work that would
run faster as a single pass. At B*T=1024,
nc=1buys 8.9x the speed for 9.5%more memory, and still sits at roughly half of torch's 10609 MB.
inc_factor = cdiv(V, H)is inherited from FLCE, but JSD holds more live buffersper chunk (two distributions plus M, against CE's one), so the formula does not
transfer and currently splits too finely. Notably
chunked_loss/jsd_loss.pyalready uses a flat
chunk_size = 1024instead of a formula, so there isprecedent for JSD not following FLCE here. I have left the heuristic alone — it is
independent of this kernel and wants its own PR, but the effect is larger than
this one.
Testing Done
test_correctnessgained one shape,(1, 4, 64, 40960). Every pre-existing casehad V ≤ 4096, so with
BLOCK_SIZE = min(32768, next_pow2(V))they all ransingle-block. The old kernel did not care — blocks were independent — but the new
one carries
m/drescaling anddX_sumacross blocks, which is the pathproduction V=128256 takes. V=40960 also leaves a partial trailing block (8192 of
32768 lanes valid), covering the mask × multi-block interaction. 58 → 66 cases.
make test: 3914 passed, 942 skipped, 14 xfailed of 4870 collected. Within thatrun,
test_fused_linear_jsd.py66/66 andtest_jsd.py67/67 —jsd.pyisunmodified but no longer imported from here, so it is worth confirming. The eight
new cases cover both dtypes across all four beta branches (0.0 forward KL, 0.1 and
0.5 generalized, 1.0 reverse KL).
make checkstyle: clean, no diff.make testto ensure correctnessmake checkstyleto ensure code stylemake test-convergenceto ensure convergence