Skip to content

[RL] Avoid full-vocab gather for TP policy statistics - #4685

Merged
tianyu-l merged 6 commits into
pytorch:mainfrom
yinli-systems:feature/vocab-parallel-policy-stats
Sep 16, 2026
Merged

tianyu-l merged 6 commits into
pytorch:mainfrom
yinli-systems:feature/vocab-parallel-policy-stats

Conversation

@yinli-systems

@yinli-systems yinli-systems commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • keep TP policy logits vocab-sharded when computing token logprobs and entropy
  • reuse vocab-parallel cross-entropy forward/backward for per-token gradients
  • compute exact entropy from shard-local sufficient statistics and small TP reductions
  • set decoder vocabulary size explicitly when constructing non-batch-invariant DAPO/GRPO recipe configs, including chunked losses
  • keep batch-invariant mode on the existing full-gather operation sequence, with an explicit runtime guard and warning for misconfigured recipes

Why

The RL trainer previously gathered every vocabulary shard on every TP rank before computing sampled-token logprobs and entropy. The new path uses shard-local values plus per-token reductions for the global maximum, softmax denominator, target contribution, and entropy-weighted sum. Entropy remains a no-grad metric.

Correctness and compatibility

Distributed parity tests compare logprobs, entropy, and non-uniformly weighted gradients against the full-vocabulary reference. They cover TP=2/4, DP=2 x TP=2, even/uneven vocabulary, FP32/BF16, ignored labels, partially masked logits, and strict SPMD type checking.

Batch-invariant training leaves global_vocab_size unset to retain the original trainer operation sequence. In the generator, VLLMModelWrapper.compute_logits() already gathers the LM-head vocabulary shards into the full logits tensor expected by vLLM, so the patched vLLM logprob call correctly receives full logits and does not select the trainer vocab-sharded path. A CPU regression test pins its compute_logprobs(logits, labels) call and multi-column token-id behavior. The generic RL loss default also remains unset, preserving the fallback for configurations that do not opt in. A runtime is_in_batch_invariant_mode() check forces that same gather path if a recipe accidentally supplies global_vocab_size; the selector is documented explicitly in compute_logprobs. GRPOLoss.Config now inherits the shared DAPO config instead of duplicating this field.

Recipe configuration regression tests assert that ordinary recipes use the model vocabulary size and batch-invariant recipes leave it unset. They are wired into the RL CI workflow, which installs vLLM and the example dependencies.

Performance evidence

A local forward+backward CPU/Gloo component benchmark with the Qwen3 vocabulary size (151,936) measured 2.66x at TP2 and 3.97x at TP4 after warmup. These are component-level CPU measurements, not GPU or end-to-end RL throughput claims.

Validation

  • 22 runnable loss tests previously passed, including the distributed parity matrix
  • batch-invariance CPU regressions on the current head: 2 passed; the new case verifies that a misconfigured vocab-parallel size warns and still gathers
  • config regression tests: 10 skipped locally because macOS lacks vLLM; scheduled in the RL CI job
  • modified-file AST, format, lint, docstring, and spelling checks: passed
  • targeted Pyrefly on changed files reports missing local vLLM and the existing torch.stack union diagnostic, with no new diagnostic from the guard; full-project checking is unavailable because this environment lacks nightly PyTorch, Monarch, TorchStore, Renderers, and the configured PyTorch search path

The local stable PyTorch environment cannot collect the mainline loss tests because it lacks the nightly DataParallelMeshDims API. Ten config tests skip locally for missing optional vLLM/renderers dependencies. The new-head Lint, CPU Unit Test, and GPU Unit Tests workflows are awaiting maintainer approval (action_required); only three Meta checks have passed on this head. Prior-head CI results are not being claimed for this revision. This change is orthogonal to the loss abstraction work in #4582.

Closes #4683

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 15, 2026
@pytorch-bot

pytorch-bot Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Workflows were awaiting approval. CI has now been triggered for the ciflow labels on this PR.

@yinli-systems

Copy link
Copy Markdown
Contributor Author

Thanks @tianyu-l -- I kept batch-invariant mode on the existing full-gather path and added a dedicated CPU regression test that pins the patched vLLM call to compute_logprobs(logits, labels) without global_vocab_size. The external-contributor workflows are currently awaiting maintainer approval.

@acisseJZhong

Copy link
Copy Markdown
Contributor

i've updated #4582, this should not affect your changes

Comment thread torchtitan/components/loss.py Resolved
@yinli-systems
yinli-systems force-pushed the feature/vocab-parallel-policy-stats branch from 571b6ce to 72bc39a Compare September 15, 2026 06:50
Comment thread torchtitan/components/loss.py Resolved
Comment thread torchtitan/components/loss.py Resolved
@tianyu-l

Copy link
Copy Markdown
Contributor

@claude I'm confused by the input sharding contract of the target function. See my latest comments. Please explain.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tianyu-l's task in 4m 20s —— View job


Input sharding contract of compute_logprobs

I traced the full trainer and generator paths. Short version: the contract is not static "always vocab-parallel". It is selected at runtime by two things -- whether a TP mesh is active on the SPMD stack, and whether global_vocab_size is passed. The patched vLLM call satisfies the unsharded branch, so there's no violation. Details below.

The three regimes compute_logprobs dispatches on

The gate is tp_size = spmd_mesh_size("tp") (loss.py:397), which reads the thread-local mesh stack via current_spmd_mesh() (spmd_types.py:148-156). If no SPMD mesh context is active, it returns 1 even when the process is running TP.

Regime tp_size global_vocab_size Expected logits Path
Trainer, opt-in (new) >1 set local shard [T, V_local] vocab-parallel CE + _VocabParallelEntropy, no gather
Trainer, legacy >1 None local shard [T, V_local] gather S(-1)->I, then plain F.cross_entropy on full logits
Generator / vLLM patch 1 None full [N, V] plain F.cross_entropy, no collectives

So the answer to "is this function assuming vocab parallel or not?" is: only when a TP mesh is on the stack. The tp_size > 1 branches assume local shards; the tp_size == 1 fallthrough assumes full logits. This was already true before the PR -- the PR only adds the first row (a no-gather branch guarded by global_vocab_size is not None). When global_vocab_size is None, behavior is byte-identical to the old code, so the legacy contract is preserved, not violated.

Why the patched vLLM path is the tp_size == 1 (full-logits) branch

Two independent reasons, either one sufficient:

  1. No active SPMD mesh around the sampler. The wrapper only enters a mesh context inside compute_logits/forward via with self.spmd_context(): (vllm_wrapper.py:491), and the mesh stack is pushed only inside that context manager (spmd_types.py:193). vLLM's v2 sampler calls the patched compute_token_logprobs (batch_invariance.py:59) downstream of compute_logits, outside any spmd_context. So current_spmd_mesh() is None -> spmd_mesh_size("tp") == 1 -> the plain branch. The patched call also never passes global_vocab_size, so even the row-1 guard is off.

  2. The logits are already gathered to full vocab. You're right that decoder_sharding.py:358 keeps the LM head vocab-parallel even in inference. But compute_logits gathers S(-1) -> R before returning to vLLM (vllm_wrapper.py:496-505), exactly to satisfy the "vllm expects unsharded logits" comment you cited. So by the time the patched kernel runs, the tensor is full [N, V] -- matching what vLLM's native compute_token_logprobs assumes (the logprob.py#L89 behavior you pointed at).

Both facts agree: the patched replacement receives full, unsharded logits, and compute_logprobs correctly routes it through the non-vocab-parallel branch. Passing global_vocab_size there would be the actual bug -- it would select the sharded algorithm on already-gathered full logits and produce wrong reductions -- which is precisely why the batch-invariant path leaves it unset.

On "it used to assume so, but this code violates it"

The pre-PR compute_logprobs was not "always vocab parallel" either -- under tp_size > 1 it gathered S(-1) -> I and then ran plain CE, and under tp_size == 1 it ran plain CE directly. The PR keeps both of those unchanged and inserts an opt-in shard-local branch ahead of them. The input each caller must supply is unchanged for every pre-existing caller.

Suggested doc fix

The one thing worth improving is the docstring: it currently describes the algorithm but not the input contract. Given the confusion, it would help to state the shape contract explicitly per regime, e.g.:

logits is the local vocab shard [T, V_local] when a TP mesh is active (global_vocab_size then selects gather-free vs. legacy-gather); it is the full [T, V] (or [N, V]) tensor when no TP mesh is active, as in the vLLM generator path where compute_logits has already all-gathered the vocab shards.

Add input-contract docs to compute_logprobs →


  • Read compute_logprobs and cross_entropy_loss in loss.py
  • Read vllm_wrapper.py compute_logits gather
  • Read batch_invariance.py patching path
  • Read spmd_types.py to confirm the runtime mesh gate
  • Explain the contract and answer @tianyu-l

Summary: the contract is runtime-selected, not static. TP mesh active -> local shards; no TP mesh (generator/vLLM patch) -> full logits. The vLLM path hits the full-logits branch both because the sampler runs outside any spmd_context and because compute_logits already all-gathers to Replicate, so nothing is violated. Only a docstring clarification is recommended.
• branch feature/vocab-parallel-policy-stats

Comment thread torchtitan/components/loss.py Resolved
@tianyu-l

Copy link
Copy Markdown
Contributor

@claude check my latest comment. In particular

  • Is my comment correct that under batch invariant patching, trainer is using _LossParallelCrossEntropy and _VocabParallelEntropy, whereas vllm generator is using F.cross_entropy and logsumexp + softmax, respectively?
  • If so, why could we still achieve batch invariance -- is it because reduction="none" and reduceOp=dist.ReduceOp.MAX.name itself makes numerics sharing invaraint?

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tianyu-l's task in 3m 11s —— View job


Batch invariance under the patched logprob path

Short answer: the premise is inverted. Under the shipped batch-invariant configs, the trainer does not use _LossParallelCrossEntropy / _VocabParallelEntropy. Both sides run F.cross_entropy and logsumexp+softmax on full logits, and that identity -- not a numerical property of the MAX all-reduce -- is what preserves batch invariance.

Q1: Does the trainer use the vocab-parallel path in batch-invariant mode?

No. The batch-invariant recipes deliberately leave global_vocab_size unset:

  • rl_grpo_qwen3_0_6b_varlen_batch_invariant -> loss_fn=GRPOLoss.Config(), default global_vocab_size=None (alphabet_sort/config_registry.py:1008)
  • rl_grpo_qwen3_0_6b_flex_batch_invariant -> dataclasses.replace(loss_config.loss_fn, global_vocab_size=None) (:255)
  • rl_grpo_qwen3_5_9b_varlen_batch_invariant -> same replace(..., global_vocab_size=None) (:1130)

So in compute_logprobs, the guard tp_size > 1 and global_vocab_size is not None (loss.py:398) is false on the trainer (TP mesh is active, but global_vocab_size is None). It falls through to the legacy branch (loss.py:416):

Side tp_size global_vocab_size logprobs op entropy op
Trainer (batch-inv) 2 None gather S(-1)->I, then F.cross_entropy logsumexp + softmax (line 440-442)
Generator (vLLM patch) 1 None F.cross_entropy on full logits logsumexp + softmax

The generator hits tp_size == 1 because the patched compute_token_logprobs runs downstream of compute_logits, outside any spmd_context, and compute_logits has already all-gathered the vocab shards to full [N, V]. So both sides execute the same PyTorch ops on the same full-vocab logits. _LossParallelCrossEntropy / _VocabParallelEntropy are on neither side.

Q2: So why is batch invariance preserved -- is it the MAX all-reduce / reduction="none"?

No -- it's preserved because the vocab-parallel path is simply not taken. It is not the case that the sharded reductions coincidentally match the single-tensor ops. They would not be bitwise identical:

  • The MAX all-reduce (amax per shard -> ReduceOp.MAX) is exact, since max is associative/idempotent -- the global max is bitwise identical regardless of sharding.
  • But the softmax denominator is a SUM: the vocab-parallel path computes sum(exp(shifted)) over each shard and then ReduceOp.SUM across ranks (loss.py:152-155), whereas the generator's F.cross_entropy / logsumexp sums over the full contiguous vocab in one reduction. Floating-point addition is not associative, so partial-sums-then-all-reduce differs from one contiguous sum in the low bits. Same for the entropy weighted-sum (_VocabParallelEntropy, loss.py:261-267).

That divergence is exactly why the batch-invariant recipes opt out: they keep the trainer on the full-gather + F.cross_entropy sequence so its summation grouping matches the generator's bit-for-bit (bit_wise/logprob_diff == 0).

Consequence for your review suggestion

This is the tension with your earlier ask to "when tp_size > 1, assert global_vocab_size is not None and maybe remove this all-gather path." Doing that would break batch invariance: the batch-invariant trainer runs TP=2 with global_vocab_size=None and depends on the legacy gather branch surviving. Your later comment ("we should still fallback to all-gather path under batch invariant mode") is the correct resolution, and this PR already achieves it -- via config (global_vocab_size unset) rather than via a runtime is_in_batch_invariant_mode() check.

One thing worth deciding explicitly: right now the fallback is keyed on global_vocab_size is None, which is a config-level convention, not an enforced invariant. If you'd prefer a hard guarantee, cross_entropy_loss/compute_logprobs could additionally check is_in_batch_invariant_mode() (distributed/utils.py:259) and force the gather path even if a config mistakenly set global_vocab_size. That would make the "batch-invariant => full-gather" contract robust to config drift rather than relying on every recipe author remembering to leave it unset.


  • Read compute_logprobs / cross_entropy_loss / _VocabParallelEntropy
  • Read batch_invariance.py patch path
  • Read DAPO/GRPO losses + global_vocab_size flow
  • Confirm batch-invariant configs leave global_vocab_size unset
  • Answer both questions
    • branch feature/vocab-parallel-policy-stats

@wwwjn wwwjn self-assigned this Sep 15, 2026
Comment thread torchtitan/components/loss.py Resolved
Comment thread torchtitan/components/loss.py Resolved
Comment thread torchtitan/components/loss.py Resolved
@pytorch-bot pytorch-bot Bot added the ciflow/fake-pg Run 1-GPU Fake PG integration tests label Sep 15, 2026
@yinli-systems

yinli-systems commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in b750d3f. GRPOLoss.Config now inherits DAPOLoss.Config instead of repeating global_vocab_size. compute_logprobs now names and documents the vocab-parallel selection explicitly, checks is_in_batch_invariant_mode(), and keeps the full-vocabulary gather path with a warning even if a recipe mistakenly sets global_vocab_size. I did not assert that every TP call must set it: batch-invariant TP depends on the existing unset-size gather path. The vLLM patch docstrings now state that its input is full logits and the size is intentionally omitted. A CPU regression covers the misconfigured batch-invariant case. Locally, the two runnable focused tests pass; ten optional-dependency cases skip. Formatting, AST, lint, docstring and spelling hooks pass. Project Pyrefly remains limited by missing local dependencies and an invalid configured search path; the targeted check reports only missing vLLM and the pre-existing torch.stack union diagnostic. The new-head Lint, CPU Unit Test, and GPU Unit Tests require maintainer workflow approval; only three Meta checks have passed on this head so far.

Comment thread torchtitan/components/loss.py Resolved
Comment thread torchtitan/components/loss.py Resolved
@yinli-systems

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in b0ac8d10. compute_logprobs now branches first on TP and runtime batch-invariant mode: batch-invariant TP always gathers full-vocabulary logits, while ordinary TP requires global_vocab_size and uses the vocab-parallel implementation. I removed the warning and stopped using global_vocab_size=None as a mode selector; all batch-invariant recipes now retain their model vocabulary size. The vLLM docstring also states why its full-logit call goes directly through the replicated path.

Added regression coverage for both runtime branches and expanded recipe checks. Focused result: 3 passed, 13 optional-dependency cases skipped. Formatting, AST, flake8, docstring, spelling, and link hooks pass. The broader test_loss.py still cannot collect with the local stable PyTorch because it lacks nightly DataParallelMeshDims; targeted Pyrefly remains limited by the repository’s invalid local search path and missing optional vLLM/renderers dependencies.

@yinli-systems

Copy link
Copy Markdown
Contributor Author

Also addressed the older test-placement threads in 143cff18: both policy-statistics tests now live under torchtitan/experiments/rl/tests/, and the RL integration workflow points to the new config-test path. Before the path-only move, the locally runnable focused cases passed; collection from the new package path requires the optional vLLM dependency, which the RL CI job installs.

@tianyu-l tianyu-l left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please address last comment

return grad_logits, None, None, None, None


class _VocabParallelEntropy(torch.autograd.Function):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we align the namings of vocab parallel and loss parallel? I think the _LossParallelCrossEntropy here is also vocab parallel CE?

furthermore, can we unify _VocabParallelEntropy and _LossParallelCrossEntropy? like have a return entropy flag in _LossParallelCrossEntropy, is this possible?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's easier if we do this in a separate PR, as this PR got a lot of back-n-forth already. So I'm merging

"global_vocab_size is required for vocab-parallel policy "
"statistics"
)
logprobs = -cross_entropy_loss(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this cross_entropy_loss is also checking tp_size > 1, can we deduplicate?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wwwjn is working on removing the hackiness

@tianyu-l
tianyu-l merged commit a3d59f3 into pytorch:main Sep 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/fake-pg Run 1-GPU Fake PG integration tests ciflow/rl CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RL] Avoid full-vocab gather for TP policy logprobs and entropy

4 participants