Skip to content

feat: top-p/top-k train sampling with sampling replay - #3235

Draft
mikasenghaas wants to merge 3 commits into
mainfrom
feat/sampling-replay
Draft

feat: top-p/top-k train sampling with sampling replay#3235
mikasenghaas wants to merge 3 commits into
mainfrom
feat/sampling-replay

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 11, 2026

Copy link
Copy Markdown
Member

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

[orchestrator.train.sampling]
top_p = 0.95
top_k = 512   # optional — defaulted to 512 when truncation is on, since it bounds the kept sets

That's the whole config — there are no replay flags. Truncated train sampling (top_p < 1 and/or top_k) implies sampling replay end to end:

  • Every truncating policy-sourced sampling config gets a top-k bound (top_k respected 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 via extra_body is 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-sampling top_k > 512 so 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.
  • The trainer is data-driven: it replays masks whenever a batch carries them, like any other per-token stream. The orchestrator enforces that truncating envs actually produce masks (fails fast if the server isn't capturing).
  • Consumers of rollout logprobs that break under renormalization are rejected at config time: 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):

  • The kept set is read off the sampler's processed logprobs — the exact tensor the token was sampled from — so membership holds by construction. (slime and SGLang's original patches instead recompute the nucleus and must force-keep the sampled token against kernel boundary disagreements.)
  • vLLM's inter-process output structs are fixed positional msgspec schemas, so the kept ids ride the existing logprobs channel as a -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/generate returns base64 {ids, counts} per choice, like routed_experts. Kept sets are decode-only, so PD-disaggregated serving needs no router changes.
  • No env vars: the enable flag rides vLLM's additional_config as enable_return_sampling_mask, snapshotted at Sampler.__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.
  • Incompatible setups fail at startup instead of running silently biased: speculative decoding, logprobs_mode overrides, 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 the routed_experts-style API-layer glue (KeptTokensCapture + serializer). On released vLLM the only patch-free alternative today is requesting logprobs = top_k per 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):

  • Masked positions compute 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.
  • Singleton kept sets (top token above the top-p threshold) give logprob 0 and exactly zero gradient — the entropy-preserving property from the SWE-1.7 post.
  • Entropy stays full-vocab (it's a collapse diagnostic; matches slime).
  • Gemma-family softcapped lm_heads don't implement kept-set renormalization and fail loudly on their head assert.

Transport: KeptTokens {ids, counts} (int32 bytes, CSR-style) on TrainingSample/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 -1 padding.

What changed vs #2979

  • Re-cut as a single commit on current 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).
  • The engine-side extension now rides the logprob id tensor only; the -inf float filler rows were pure IPC overhead (halves the extension's engine→API traffic).
  • The config knob is a bool named after vLLM's proposed flag (enable_return_sampling_mask) with a hardcoded capture width, transported via additional_config instead of env vars; the orchestrator rejects top_k > 512 instead of deriving a width.
  • Documented the upstream alignment path (vLLM #49577, above).

Paired dep PRs (already merged and pinned)

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 --check
  • uv 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 on main — pre-existing, fix: token_id-formatted logprob tokens in the qwen3-vl fake engine #3161)
  • Config auto-wiring dry-run: top_p 0.97 on reverse-text resolves to top_k = 512 (with warning) and inference.toml: kept_tokens = 512.
  • CPU numeric check: selective_log_softmax_with_kept and 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:

  • Baseline (no truncation) — regression check with the API-side patches installed unconditionally: reward 0.18 → 0.73, 0% rollout errors, 100% trainable, mismatch_kl 0.0007–0.0153. Traces carry top_p = 1.0; capture stays off (no capture ENABLED engine log), logprobs unaffected.
  • top_p 0.97 (replay): reward 0.19 → 0.75, 0% rollout errors, mismatch_kl bounded 0.0006–0.0143, entropy healthy. Traces carry top_p = 0.97 / top_k = 512; the engine logs Kept-set sampling-mask capture ENABLED for this Sampler instance (cap=512) (via additional_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.
  • Rejection dry-run: top_k = 1024 with 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):

  • Fused and vanilla logprob paths match a dense masked-renormalization reference, forward and backward — including singleton zero-grad, misaligned-mask fallback, temperature ≠ 1, and an exp-overflow regression case.
  • hendrycks sanity (R1-Distill-Qwen-1.5B, 200 steps, batch 512 × 8k ctx): mismatch_kl 0.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.
  • Measured mask load (2.5M-token steps): kept-set sizes mean ~8 / median 2 / p99 ~82, 45–48% singletons, top_k = 512 never binds, 100.00% mask coverage on sampled tokens, ~32 B/token on the wire.

🤖 Generated with Claude Code

mikasenghaas and others added 2 commits August 11, 2026 17:13
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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant