Repository navigation
[fsdp] feat: chunked gather-logsumexp for top-K loss to avoid OOM at long context - #6593
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a memory optimization in compute_forward_kl_topk by avoiding the materialization of the large [B, T, V] student log probabilities tensor. It implements a chunked helper, _chunked_topk_log_probs, to compute gathered log probabilities, and adds corresponding unit tests. The reviewer identified a critical issue in the helper where pre-allocating a tensor and performing in-place slice assignments can disrupt PyTorch's autograd gradient propagation, and provided a code suggestion to accumulate chunks and use torch.cat instead.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
โฆOOM at long context
The student-side log_softmax + gather pattern in
verl/trainer/distillation/fsdp/losses.py:compute_forward_kl_topk
materializes a [B, T, V] log_probs tensor that persists across two reads
(one torch.topk, one torch.gather). For Qwen-class models with V=152064
in bf16, this allocation peaks at ~28 GB at 64K context and dominates
GPU memory budget, causing OOM in long-context distillation runs.
This change:
* Replaces F.log_softmax(student_logits, ...) materialization with two
operations that do not need the full [B, T, V] copy:
- student_topk_ids: log_softmax is monotonic, so
topk(log_softmax(x)) == topk(x); use student_logits directly.
- student_topk_log_probs: chunked gather - logsumexp using the
identity log_softmax(x).gather(idx) == x.gather(idx) - logsumexp(x).
Streamed in chunks of 4096 tokens along (B*T) with fp32 logsumexp
for numerical stability; result cast back to logits.dtype.
* Adds tests/workers/test_chunked_topk_log_probs_on_cpu.py with:
- Numerical equivalence vs F.log_softmax(...).gather(...) across
fp32/bf16/fp16 and several (B, T, V, K) shapes.
- chunk_size invariance (1, 64, 256, 1024, 4096, 16384, no-chunk).
- Backward gradient correctness.
Math equivalence: log_softmax(x).gather(idx) = x.gather(idx) - logsumexp(x, keepdim=True).
Verified: fp32 max diff ~1.9e-6 (PyTorch fused log_softmax kernel vs
explicit logsumexp; within mantissa precision); bf16/fp16 max diff = 0;
backward gradient max diff ~2.4e-7.
The same chunked gather-logsumexp pattern is already used elsewhere in
verl (verl/utils/torch_functional.py); this PR brings it to the
distillation top-K path which previously did not benefit from it.
|
Thanks for the careful review! I want to address the autograd concern, since I think there's a misconception worth clarifying for anyone reading this thread later. The autograd claim is incorrect. I verified this on CUDA (PyTorch 2.8.0+cu126): This is also covered by:
Why I prefer
Since this PR exists specifically to reduce memory at long context, the lower-peak version is preferred. The Updated commit: |
Default to F.log_softmax + gather (pre-PR baseline) to preserve short-context performance, since the chunked path has ~6x time overhead at short context (N=14K, V=152K, bf16). Long-context users opt in via distillation.distillation_loss.use_chunked_topk=True. This addresses reviewer concerns about short-context regression while keeping the OOM relief for long-context users that motivated the PR. Trade-offs measured on H20 (V=152064, K=64, fwd+bwd): - short (N=14K): baseline 21.8 GB / 16.3 ms; chunked 30.5 GB / 94.8 ms - mid (N=64K): baseline OOM; chunked 79.8 GB / 556.9 ms - long (N=96K): both OOM at upstream student_logits allocation (tier-1) Adds: - distillation.distillation_loss.use_chunked_topk: bool = False - distillation.distillation_loss.chunked_topk_chunk_size: int = 4096 Existing _chunked_topk_log_probs implementation and 16 unit tests unchanged (all still pass).
Update: chunked path is now opt-in (commit 73dd434)Following the discussion above re: the tier-2 / tier-1 split, I've made the chunked path opt-in via a config flag, defaulting to distillation:
distillation_loss:
use_chunked_topk: true
chunked_topk_chunk_size: 4096 # tunable; default 4096Why opt-inBenchmarked the chunked vs baseline paths on H20 (V=152064, K=64, bf16, fwd+bwd):
A note on the +482% short-context time figure -- it's the function in In our production OPD setup (Qwen-7B, FSDP-8, real prompt length 64K):
So In the current agent scenario, long trajectory situations often exceed 64k, which is very necessary, and it only increases the total training time by 1% to 3%. For default users ( The 6x time overhead at short context is too high to make the chunked path the default, but the OOM relief at mid context (~64K) is exactly what some users need. Opt-in solves both: short-context users keep their current performance; long-context users get a path that runs. What I added
What I didn't change
Re: tier-1 (
|
Address review comments on commenting verbosity: - Drop the autograd defense paragraph in _chunked_topk_log_probs docstring (the meta-discussion is no longer needed; behaviour is verified by tests and self-evident from the code). - Slim the 22-line dispatch comment block in compute_forward_kl_topk to 5 lines; benchmark numbers and tier-1/tier-2 scope live in the PR description and DistillationLossConfig docstring. - Drop the 'Baseline path -- unchanged from pre-PR' dev comment. No behaviour change; 16 unit tests still pass.
|
Hi Tianle,
#6593
Thank you for the feedback. I have updated the comments in the PR as
suggested, streamlining the inline notes and cleaning up the developer
comments to improve readability.
Could you please take another look at the latest changes? If everything
looks good on your end, I would appreciate it if you could invite the lead
reviewer to move forward with the final review so we can complete the
process.
Best regards,
Zifeng Wang
โฆOn Sun, Jun 7, 2026 at 7:49โฏAM ้ๅคฉๆฅฝ ***@***.***> wrote:
***@***.**** commented on this pull request.
------------------------------
In verl/trainer/distillation/fsdp/losses.py
<#6593 (comment)>:
> @@ -63,12 +115,47 @@ def compute_forward_kl_topk(
assert teacher_topk_log_probs.shape[:2] == teacher_topk_ids.shape[:2] == student_logits.shape[:2]
# 2. compute token-wise KL divergence across sp groups
- student_log_probs = F.log_softmax(student_logits, dim=-1)
- student_topk_ids = torch.topk(student_log_probs, k=teacher_topk_ids.shape[-1], dim=-1).indices
- student_topk_log_probs = torch.gather(student_log_probs, dim=-1, index=teacher_topk_ids)
+ # Two paths, controlled by ``loss_config.use_chunked_topk``:
we can concise the inline comments to make it easier to read
------------------------------
In verl/trainer/distillation/fsdp/losses.py
<#6593 (comment)>:
> + # upstream ``student_logits`` allocation (tier-1, ~28 GB at N=96K) is
+ # still materialised by the caller; eliminating that requires a separate
+ # ``forward``-side refactor (skip LM head, chunk over hidden_states),
+ # tracked as future work.
+ loss_config: DistillationLossConfig = config.distillation_loss
+ if loss_config.use_chunked_topk:
+ # log_softmax is monotonic, so topk(log_softmax(x)) == topk(x); use
+ # student_logits directly to avoid the extra [B, T, V] log_softmax.
+ student_topk_ids = torch.topk(student_logits, k=teacher_topk_ids.shape[-1], dim=-1).indices
+ student_topk_log_probs = _chunked_topk_log_probs(
+ student_logits,
+ teacher_topk_ids,
+ chunk_size=loss_config.chunked_topk_chunk_size,
+ )
+ else:
+ # Baseline path -- unchanged from the pre-PR implementation. Lowest
clean up this dev comment
------------------------------
In verl/trainer/distillation/fsdp/losses.py
<#6593 (comment)>:
> @@ -23,6 +23,58 @@
from verl.workers.config import DistillationConfig, DistillationLossConfig
+
+def _chunked_topk_log_probs(
+ logits: torch.Tensor,
+ topk_ids: torch.Tensor,
+ chunk_size: int = 4096,
+) -> torch.Tensor:
+ """Compute log_softmax(logits).gather(topk_ids) without materializing [B, T, V].
+
+ Uses the identity:
+ log_softmax(x).gather(idx) == x.gather(idx) - logsumexp(x, keepdim=True)
+ Streams the reduction in chunks of `chunk_size` tokens along (B*T) with fp32
+ logsumexp for numerical stability. Mathematically equivalent to the direct
+ formulation; max diff ~1.9e-6 in fp32, exact (max diff = 0) in bf16.
+
+ Note on autograd: `out = torch.empty(...)` followed by `out[s:e] = ...` is
not necessary comments
โ
Reply to this email directly, view it on GitHub
<#6593?email_source=notifications&email_token=BHZJA3VYFKBJCSORRT7F5BD46SUXXA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINBUGQYDGOBUGY22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#pullrequestreview-4444038465>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/BHZJA3VPUNPHCMKCJG354AL46SUXXAVCNFSM6AAAAACZY2HVJSVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHM2DINBUGAZTQNBWGU>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/BHZJA3ULMMJL3IFTFFJV2KT46SUXXA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINBUGQYDGOBUGY22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG>
and Android
<https://github.com/notifications/mobile/android/BHZJA3UB25CY66VXOIBRZKL46SUXXA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINBUGQYDGOBUGY22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
|
@kekellllll please fix pre-commit. |
- Sort imports in verl/trainer/distillation/fsdp/losses.py (I001). - Reformat assert message line in test_chunked_topk_log_probs_on_cpu.py. - Cosmetic whitespace fixes (single-space inline comments, blank line) per ruff format. No behavior change; 16 unit tests still pass.
|
Done in e088d91 โ applied |
|
Hi Luosuu,
Thank you for the reminder. I have fixed the pre-commit issues as
requested. Could you please approve and trigger the tests again?
Best regards,
Zifeng Wang
***@***.***
โฆOn Tue, Jun 9, 2026 at 2:34โฏPM ้ๅคฉๆฅฝ ***@***.***> wrote:
*Luosuu* left a comment (verl-project/verl#6593)
<#6593 (comment)>
@kekellllll <https://github.com/kekellllll> please fix pre-commit.
โ
Reply to this email directly, view it on GitHub
<#6593?email_source=notifications&email_token=BHZJA3UTBXV4AJXHWAWGEYT466VXLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTINRVGY3TSOBZHAYKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-4656798980>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/BHZJA3T6BJVYOSIRU5NT7QL466VXLAVCNFSM6AAAAACZY2HVJSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DMNJWG44TQOJYGA>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/BHZJA3W63J53GQE2HYL7ZGD466VXLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTINRVGY3TSOBZHAYKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJKTGN5XXIZLSL5UW64Y>
and Android
<https://github.com/notifications/mobile/android/BHZJA3XYH2JBXORM4OFD47D466VXLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTINRVGY3TSOBZHAYKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>.
Download it today!
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
## Summary - Preserve backward compatibility for lightweight distillation loss configs that do not define `use_chunked_topk`. - Default missing `chunked_topk_chunk_size` to the existing 4096 value when the chunked path is enabled. ## Root Cause PR #6593 added optional chunked top-k configuration fields, but `compute_forward_kl_topk` accessed them directly. Existing CPU tests use a `SimpleNamespace` distillation config with only `log_prob_min_clamp`, so the default path raised `AttributeError` before it could fall back to the non-chunked implementation. ## Validation - `/root/modelchef/.venv/bin/pytest tests/workers/test_distillation_topk_symmetry_on_cpu.py -q` - `/root/modelchef/.venv/bin/pytest tests/workers/test_chunked_topk_log_probs_on_cpu.py -q` - `/root/modelchef/.venv/bin/ruff check verl/trainer/distillation/fsdp/losses.py`
Summary
The student
log_softmax + gatherpattern inverl/trainer/distillation/fsdp/losses.pymaterializes a full
[N, V]tensor before reducing to top-K. For long-context distillation(64K+ tokens with
V=152064Qwen vocab), this single line allocates ~28 GB persistentmemory in bf16, dominating the activation budget and causing OOM.
This PR replaces the line with a mathematically equivalent chunked computation that:
log_softmax(x).gather(idx) โก x.gather(idx) - logsumexp(x, keepdim=True)The optimization pattern is already used elsewhere in verl (see
verl/utils/torch_functional.pywhich has a chunked log-probs implementation with the comment "avoid creating full log_softmax
tensors in memory"). This PR brings the same optimization to the distillation top-K path,
which was missed when the distillation module was added.
Opt-in design (added in latest commit): the chunked path is gated behind
distillation.distillation_loss.use_chunked_topk=True(defaultFalse) to preserve thepre-PR latency at short context. Short-context users see no behavior change; long-context
users opt in to trade time for memory headroom.
Scope (which OOM tier this PR addresses)
compute_forward_kl_topkallocates two[B, T, V]peaks. This PR eliminates one of them:student_logitsprepare_model_outputs(caller)log_softmax(logits)bufferuse_chunked_topk=True)Users hitting OOM at the
log_softmaxallocation get full relief. Users hitting OOM at the upstreamstudent_logitsallocation itself will need the follow-up.Benchmark (single H20, V=152064, K=64, bf16, fwd+bwd, chunk_size=4096)
Measured with
compute_forward_kl_topk's inner_chunked_topk_log_probsin isolation, comparing to the pre-PR baseline (F.log_softmax + gather):use_chunked_topk=True)student_logits)Reading the table:
False) -- short-context users see zero behavior change.student_logitsallocation itself, before either log_softmax path runs. This requires a separate refactor (_forward_skip_lm_head+ chunk over hidden_states) tracked as future work.Numerical equivalence (vs fp64 reference, bf16 input)
Same inputs as benchmark, B=1 T=512 to keep memory in fp64 reference small:
F.log_softmax + gather)_chunked_topk_log_probs, fp32 cast inside chunk)Chunked path matches baseline exactly at bf16 precision because the explicit
.float()cast inside each chunk preserves the same fp32 reduction PyTorch'sF.log_softmaxdoes internally. This is consistent with theverl/utils/torch_functional.pychunked log-probs convention.Motivation
Background
In OPD (On-Policy Distillation) with top-K teacher logits, the student-side loss computation
needs both:
student_log_probs.gather(target_ids)โ used in policy gradientstudent_log_probs.gather(teacher_topk_ids)โ used in KL distillationBoth gathers reuse the same
student_log_probs, forcing the[N, V]tensor to persist inmemory rather than be freed as a transient.
The OOM
For Qwen-class models with
V = 152064and remove-padding enabled:This single allocation dominates a 96 GB H20 / 141 GB H200 GPU when combined with FSDP
weight shards, gradient shards, and activation memory.
Why this matters
Long-context distillation is becoming standard for agent/coding/long-trace tasks (32K-256K
context). Without this fix, the only workarounds are:
Solution
Mathematical equivalence
Since softmax reduces only along the vocab dimension, computation is independent across
the (B*T) dimension. We can chunk along this axis without affecting numerics.
Implementation
The change in
losses.pyreplaces a 2-linelog_softmax + gatherwith a single call tothis helper.
Related work
This optimization belongs to the well-established online softmax family
(Milakov & Gimelshein, NVIDIA 2018):
FusedLinearCrossEntropychunks LM head lossutils/torch_functional.py: chunked log-probs already implemented, just notused in the distillation path (this PR's motivation)
This PR is the conservative middle ground: minimal code change, no Triton kernel needed,
fully PyTorch-native, drop-in compatible.
Testing
Numerical equivalence (vs
torch.log_softmax(...).gather(...))Tested on a single H20 GPU (PyTorch 2.8.0+cu126):
Notes:
log_softmaxkernel vs explicitlogsumexp(mantissa ~1.2e-7 scaled by logitsmagnitude ~10).
and our chunked implementation internally upcast to fp32, then cast back to
bf16 (7-bit mantissa truncates to identical bits).
long-sequence cases due to fp16 range limits (max ยฑ65504), where PyTorch's
fused vs explicit logsumexp paths produce slightly different rounding at
edge values. Mean absolute diff remains ~3e-8, indicating only a handful of
affected indices. This is orthogonal to chunking (same diff appears with
chunk_size = N). Modern LLM training uses bf16, so fp16 edge behavior should
not affect production runs.
Chunk size invariance
Verified that chunk_size in {1, 64, 256, 1024, 4096, 16384} all produce identical
results vs the un-chunked baseline (max diff <= 1.91e-6).
Backward / gradient correctness
Compared
out.sum().backward()gradients oflogits.grad:Memory benchmark (B=1, T=N, V=152064, K=64, bf16, double-gather pattern)
Reproducing the OPD memory pattern (
log_probs_allmaterialized for two gathers):Honest caveat: with the default chunk_size=4096, fp32 chunk buffer (~2.5 GB) is
fixed overhead. For T < 64K under this benchmark shape, chunked is slightly worse.
Real-world distillation runs hit the savings because: (a)
Nis the concatenatedvalid token count after remove-padding, often much larger than per-sample T; (b) the
double-gather pattern forces the reference path to persist
log_probs_all, whilechunked never materializes it.
A larger chunk_size (e.g. 16384) makes chunked match reference at all seq lengths.
We picked 4096 as a balance; users can tune via configuration if needed.
Speed
In end-to-end distillation training, this is <0.5% of step time (typical step
time 30-70s with vLLM rollout + FSDP backward + optimizer).
Real-world validation
We ran the patched verl in two production setups:
7B-yarn 125K context, 2-node setup: 5/5 step completed, 207 tok/s throughput,
no OOM. Without chunked, the same configuration cannot start training (vLLM init
passes but first step's distillation forward exceeds memory budget).
72B + 64K context, 2-node colocated rollout: chunked path passes; the run later
hit a separate OOM caused by colocated vLLM sibling process (8.58 GB) plus FSDP
weight unsharding, unrelated to chunked patch. Without chunked, the run wouldn't
even reach step 1 because
log_softmax's 70+ GB allocation alone would fail.This is consistent with the math: chunked patch removes the
[N, V]persistentallocation; downstream memory pressure (FSDP unshard, vLLM colocate) is orthogonal.
Backward compatibility
losses.pyAPIThe change is a drop-in internal optimization. Users do not need to update anything.
Future work (out of scope)
student_logitselimination (follow-up PR): skip the LM head inprepare_model_outputsand compute log-probs chunked directly from(hidden_states, lm_head_weight)-- avoids the upstream[B, T, V]materialisationand brings the loss-side peak from ~28 GB to ~0. Touches the FSDP unshard
lifecycle, so kept separate from this PR.
chunk_sizeconfigurable viadistillation.distillation_loss.chunk_size(currently hardcoded at 4096)
loss_mode=k2introduces a similar bottleneckChecklist
Files changed
(Optional second commit, if maintainer prefers: extract
_chunked_topk_log_probstoverl/utils/torch_functional.pyto live alongside the existing chunked log-probshelper. Happy to do this in a follow-up.)
Reproducer (for reviewer)