feat: top-p/top-k train sampling with sampling replay - #3235
Draft
mikasenghaas wants to merge 3 commits into
Draft
feat: top-p/top-k train sampling with sampling replay#3235mikasenghaas wants to merge 3 commits into
mikasenghaas wants to merge 3 commits into
Conversation
Squash of feat/top-p-mask-replay (PR #2979) onto current main, adapting to the vllm pass-through inference config, multi-tenant removal, and the v0 env compat drop. Co-authored-by: fares <fares@primeintellect.ai> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing between sampler and API process pairs logprob ids and values column-wise, so the -inf float filler rows were pure IPC overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The enable flag rides vLLM's additional_config as enable_return_sampling_mask (named after the in-flight native vLLM flag, vllm-project/vllm#49577), snapshotted at Sampler.__init__ like the fp32 patches. The API-process patches are data-driven off the separator id and install unconditionally. The capture width is a fixed constant; the orchestrator rejects train-sampling top_k above it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikasenghaas
added a commit
that referenced
this pull request
Aug 29, 2026
Truncated train sampling (top_p < 1, top_k) renormalizes the rollout distribution over the surviving kept set; rollout logprobs reflect that (processed_logprobs) while the trainer normalizes over the full vocab, biasing every importance ratio. Record the kept set at sampling time and renormalize trainer logprobs over the same set (DeepSeek V3.2's Keep Sampling Mask, arXiv:2512.02556 3.1). Same user API as #3235: [orchestrator.train.sampling] top_p/top_k, no replay flags. Truncating policy sampling auto-enables inference.enable_return_sampling_mask, bounds top_k to 512 (trainer mask tensors pad to the largest kept set), and rejects opd/opsd and temperature 0. Unlike #3235 the capture is vLLM's native --return-sampling-mask (>= 0.28, V2 model runner) instead of custom engine patches: the /generate response carries sampling_mask natively, renderers parse it (PrimeIntellect-ai/renderers#144) and verifiers carry it as KeptTokens arrays (PrimeIntellect-ai/verifiers#2460). Capture is engine-wide: vLLM rejects requests with temperature <= 0 or top_k <= 0 while it is on, and it is incompatible with router replay (V1-only).
mikasenghaas
added a commit
that referenced
this pull request
Sep 1, 2026
## Summary - Add typed top-p and top-k settings for policy rollouts. - Capture vLLM 0.28 sampling masks and replay the truncated distribution in the trainer. - Bound sampling masks with a default `top_k = 512` when truncation is enabled. - Use Model Runner V2 for router replay on standard deployments. - Support router replay and sampling replay together on Model Runner V2. - Keep NIXL P/D router replay on V1 and reject the unsupported combined P/D mode. - Use `SamplingMask` and `sampling_mask` across Renderers, Verifiers, and prime-rl. - Pin merged Renderers and require its published `0.1.12.dev2` build. - Pin Verifiers to the latest `main` after its sampling-mask merge. - Document standalone capture, the NIXL replay matrix, and sampling-mask layout examples. - Shard sampling masks with labels for multimodal context-parallel training. - Reject mixed enabled and disabled top-k settings across live-policy train sources. This builds on vLLM 0.28.0 from #3430. It supersedes #3235. The capture side uses vLLM's native sampling-mask support from [vllm#49577](vllm-project/vllm#49577). ## Usage ```toml [orchestrator.train.sampling] top_p = 0.95 top_k = 20 ``` There are no replay flags for sampling replay. Truncated policy sampling enables mask capture and trainer replay automatically. Set `trainer.enable_router_replay = true` to combine router replay with sampling replay. Standard deployments use Model Runner V2 for both captures. Disaggregated NIXL deployments keep router replay on V1 and cannot combine both replay modes. ## Behavior - Policy sampling with `top_p < 1` or `top_k` gets a bounded sampling mask. - Truncation without an explicit `top_k` defaults to 512. - Different truncating sources can use different top-p values; each gets a positive top-k. - Mixed top-k capture modes across live-policy sources are rejected. - Values above 512 are rejected to bound trainer memory. - The trainer renormalizes each sampled token over the same mask as inference. - Frozen-source environments do not require masks. - `opd` and `opsd` reject truncated policy sampling because their reference scores use the full vocabulary. - vLLM rejects sampling-mask requests with `temperature <= 0` or without an effective `top_k > 0`. ## Runs GLM-4.5-Air on `scaleswe` on 4 nodes | Run | Runner | Router replay | Top-p | Effective top-k | Nodes | | --- | --- | --- | --- | --- | --- | | `glm-air-v1-baseline` | V1 | Off | 1.0 | Off | 6 | | `glm-air-v2-baseline` | V2 | Off | 1.0 | Off | 6 | | `glm-air-v2-router-replay` | V2 | On | 1.0 | Off | 6 | | `glm-air-v2-top-p-0.95` | V2 | Off | 0.95 | 512 | 6 | | `glm-air-v2-router-replay-top-p-0.95` | V2 | On | 0.95 | 512 | 6 | <img width="279" height="238" alt="Screenshot 2026-09-01 at 3 45 20 PM" src="https://github.com/user-attachments/assets/bbbf5334-4b06-4573-9232-80d9207a97e6" /> <img width="286" height="226" alt="Screenshot 2026-09-01 at 3 46 03 PM" src="https://github.com/user-attachments/assets/8d048fb1-3e2a-4484-a437-8084d845d47e" /> <img width="285" height="238" alt="Screenshot 2026-09-01 at 3 45 39 PM" src="https://github.com/user-attachments/assets/ca5c21c7-6355-497f-a553-adade238dc1a" /> Sampling replay leads to - no reduction in step 0 KL mismatch - more stable KL mismatch in conjunction with router replay - more stable entropy (not rising) - slower step time (follow-up perf investigation TBD) ## Verification - `uv run pytest -q tests/unit/test_configs.py -k 'policy_sources'`: 2 passed. - `uv run pytest -q tests/unit/test_configs.py -k 'not test_load_configs'`: 69 passed and 67 deselected. - Pre-commit checks passed for the changed config and test files. - `uv run pytest tests/unit/train/rl/test_fused_lm_head.py tests/unit/train/rl/test_loss.py -q`: 19 passed and 1 skipped after the context-parallel fix. - `uv run pytest tests/unit/test_configs.py::test_combined_replay_uses_v2_runner -q`: passed after the documentation review. - `uv sync --all-extras`: passed with merged Renderers and Verifiers dependencies. - `uv run pytest tests/unit/orchestrator tests/unit/inference tests/unit/train/rl/test_loss.py tests/unit/train/rl/test_fused_lm_head.py --ignore=tests/unit/orchestrator/test_qwen3_vl_e2e.py -q`: 108 passed and 1 skipped. - The excluded Qwen3-VL test has an existing fake-response fixture mismatch. - `uv run rl @ examples/basic/reverse-text/rl.toml --max-steps 5 --orchestrator.train.sampling.top-p 0.95`: completed five trainer and orchestrator steps on the final dependency chain. - The end-to-end run used Model Runner V2, effective `top_k = 512`, and sampling-mask capture. Final mismatch KL was 0.0035 with no rollout errors. - Renderers: `uv run pytest tests/test_client.py -q`: 21 passed and 5 skipped. Ruff and format checks pass. - Latest Verifiers main: `uv run pytest deps/verifiers/tests/v1/test_graph.py deps/verifiers/tests/v1/test_trace.py -q`: 21 passed. Ruff, format, type, and pre-commit checks pass. - Reverse-text baseline, top-p 0.95, and top-p 0.95 plus top-k 20 runs completed with 100% sampling-mask coverage. W&B: `reverse-text/reverse-text-native-replay-{baseline,topp095,topp095-topk20}`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes core RL logprob/importance-ratio math and vLLM runner selection; misconfiguration or missing masks can bias training or fail at runtime, though validation and runtime checks aim to catch incompatible modes. > > **Overview** > Adds **sampling replay** so policy rollouts with `top_p < 1` or `top_k` stay aligned with trainer importance ratios: vLLM 0.28 returns per-token sampling masks (`enable_return_sampling_mask` / `--return-sampling-mask`), and the trainer renormalizes logprobs over the same mask instead of the full vocabulary. > > **Config & orchestration:** `TrainSamplingConfig` gains typed `top_p` and `top_k` (truncation via `extra_body` is rejected). The `rl` entrypoint auto-enables mask capture when policy sampling truncates; unbounded truncation defaults `top_k = 512`, values above 512 are rejected, `temperature = 0` and `opd`/`opsd` are blocked. Policy train sources must agree on top-k capture mode (engine-wide). **Inference:** new `enable_return_sampling_mask`; vLLM env setup prefers **V2** for sampling capture and for router replay on standard deployments, while **disaggregated NIXL P/D** keeps routed-expert capture on V1 and **rejects** router + sampling replay together. > > **Data path:** `SamplingMask` on `TrainingSample` / `MicroBatch`, encoding in trajectories, packing/padding in the trainer batch builder, and a hard error in `TrainSink` if truncated rollouts lack masks. **Trainer:** masks flow through CP sharding; fused `lm_head` and vanilla-path `selective_log_softmax_with_sampling_mask` compute mask-renormalized logprobs (Gemma softcap heads explicitly unsupported). Docs cover sampling replay; `renderers` is bumped for mask support in the rollout stack. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cee517e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
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.
Supersedes #2979 (same feature, re-cut from current
main; see What changed vs #2979).Adds top-p and top-k sampling support for train rollouts (both were hardcoded off). Truncated sampling renormalizes the rollout distribution over the surviving "kept set" of tokens; our rollout logprobs already reflect that (
logprobs_mode = "processed_logprobs"), but the trainer normalizes over the full vocabulary — so every importance ratio is biased, and runs with truncated sampling collapse. This PR makes truncation safe by recording the kept set at sampling time and renormalizing trainer logprobs over the same set: DeepSeek V3.2's "Keep Sampling Mask" (arXiv:2512.02556 §3.1), also described in Cognition's SWE-1.7 post as "sampling distribution replay".Usage
That's the whole config — there are no replay flags. Truncated train sampling (
top_p < 1and/ortop_k) implies sampling replay end to end:top_krespected if set, else defaulted to 512; values above 512 are rejected — see below), so kept sets are never larger than the capture width. Truncation knobs must be the typed fields — smuggling them viaextra_bodyis rejected. Frozen-source envs are exempt (external endpoints, no importance ratios).inference.enable_return_sampling_mask(bool, named after vLLM's in-flight native flag) turns on capture at a fixed width of 512; the orchestrator rejects train-samplingtop_k > 512so no kept set ever overflows — replay is exact at every position. The flag is auto-set and persisted into per-node configs; hand-setting is only for standalone-launched servers.opd/opsd(reference logprobs are full-vocab prefill scores), and the gibberish/repetition filters (removed from the default lists, rejected if explicitly configured — their full-softmax thresholds misfire when singleton kept sets read as probability 1.0).How it works
Inference (
src/prime_rl/inference/vllm/kept_tokens.py, monkey patches over the stock vLLM 0.26 wheel):-1-separated extension on the id tensor (ids only — nothing between sampler and API process pairs ids and logprob values column-wise), at a fixed device-side width (no host syncs). An API-process patch splits the extension back off before vLLM builds logprob dicts — chat/eval consumers see byte-identical logprobs — and/inference/v1/generatereturns base64{ids, counts}per choice, likerouted_experts. Kept sets are decode-only, so PD-disaggregated serving needs no router changes.additional_configasenable_return_sampling_mask, snapshotted atSampler.__init__where vLLM guarantees a config context (the fp32_lm_head mechanism). The API-process patches are data-driven off the separator id and install unconditionally — rows without extensions pass through untouched.logprobs_modeoverrides,VLLM_USE_V2_MODEL_RUNNER=1(prime-rl pins the V1 runner anyway).Upstream path: vLLM is adding native support with the same semantics and constraints — vllm-project/vllm#49577
enable_return_sampling_mask, near-merge, earliest release ~0.28 (built for the V2 model runner). Once it ships in a release we pin, the two engine patches here reduce to therouted_experts-style API-layer glue (KeptTokensCapture+ serializer). On released vLLM the only patch-free alternative today is requestinglogprobs = top_kper token, which ships k ids+floats per position through vLLM's per-position logprob-dict machinery — orders of magnitude more transport and API-process work than this extension (~32 B/token measured).Trainer (data-driven — replays masks whenever the batch carries them):
logprob = logits[label]/T - logsumexp(logits[kept]/T)in both the chunked fused LM head (backward restricted to kept ids) and the vanilla path. Positions without a mask (context tokens, non-policy samples) use full-vocab logprobs.Transport:
KeptTokens {ids, counts}(int32 bytes, CSR-style) onTrainingSample/MicroBatch, appended last to keep the positional wire layout stable; packed/truncated/padded alongside the other per-token streams; tensorized as[1, seq, max_kept]with-1padding.What changed vs #2979
main, adapted to the vLLM 0.26 bump, the pass-through[inference.vllm]config, the multi-tenant removal, and the v0 env-compat drop — which also retires feat: top-p/top-k train sampling with sampling replay #2979's known gap (kept tokens were v1-only; v0 envs no longer exist).-inffloat filler rows were pure IPC overhead (halves the extension's engine→API traffic).enable_return_sampling_mask) with a hardcoded capture width, transported viaadditional_configinstead of env vars; the orchestrator rejectstop_k > 512instead of deriving a width.Paired dep PRs (already merged and pinned)
kept_tokensfrom/generateresponses (base64 splice fast path).KeptTokensdataclass:TurnTokenscarrier → graph attribution →Branch.kept_tokens.Both are ancestors of main's current submodule pins — this PR does not touch submodules.
Verification
Checks on this branch:
uv run ruff check/ruff format --check,uv lock --checkuv run pytest tests/unit/test_configs.py tests/unit/inference/(120 passed)uv run pytest tests/unit/train/ tests/unit/orchestrator/(165 passed; the one failure,test_qwen3_vl_e2e.py, fails identically onmain— pre-existing, fix: token_id-formatted logprob tokens in the qwen3-vl fake engine #3161)top_p 0.97on reverse-text resolves totop_k = 512(with warning) andinference.toml: kept_tokens = 512.selective_log_softmax_with_keptand the fused_SequenceChunkedLogProbEntropyFn(forward + backward) match a dense masked-renormalization reference (float32 error ≤ 5e-7; misaligned-mask fallback; singleton kept set → logprob 0, exactly zero grad).End-to-end on reverse-text (Qwen3-0.6B-Reverse-Text-SFT, 20 steps, 1 trainer + 1 inference GPU), both runs from this branch:
mismatch_kl0.0007–0.0153. Traces carrytop_p = 1.0; capture stays off (nocapture ENABLEDengine log), logprobs unaffected.top_p 0.97(replay): reward 0.19 → 0.75, 0% rollout errors,mismatch_klbounded 0.0006–0.0143, entropy healthy. Traces carrytop_p = 0.97/top_k = 512; the engine logsKept-set sampling-mask capture ENABLED for this Sampler instance (cap=512)(viaadditional_config); and since the orchestrator raises on any truncating sample without masks, completing 20/20 steps means every trainable sample shipped its kept sets. W&B:reverse-text/reverse-text-{baseline,topp0.97}-pr3235.top_k = 1024with truncation fails config validation with the fixed-capture-width error.Prior validation on #2979 (same logic; CPU tests were out-of-band, GPU runs on H200):
mismatch_kl0.0003–0.0004 flat from step 1 to 200 — below an untruncated control's noise floor; train reward 0.49 → 0.68; AIME2024 eval 0.1875 → 0.2458; entropy flat; replay ≈5% MFU vs control.top_k = 512never binds, 100.00% mask coverage on sampled tokens, ~32 B/token on the wire.🤖 Generated with Claude Code