Skip to content

feat: top-p/top-k train sampling with kept-set mask replay#2979

Draft
faresobeid wants to merge 13 commits into
mainfrom
feat/top-p-mask-replay
Draft

feat: top-p/top-k train sampling with kept-set mask replay#2979
faresobeid wants to merge 13 commits into
mainfrom
feat/top-p-mask-replay

Conversation

@faresobeid

@faresobeid faresobeid commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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, top_k, or min_p > 0) implies replay end to end:

  • Every truncating policy-sourced sampling config gets a top-k bound (top_k respected if set, else defaulted to 512, which rarely binds at typical top_p), so kept sets are never larger than k. 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.kept_tokens (None = capture off, int = capture width) is derived as the largest configured top_k, so no kept set ever overflows — replay is exact at every position. It's only set by hand for standalone-launched inference 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.24 wheel — same pattern as router replay; no upstream support exists):

  • 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 instead recompute the nucleus and must force-keep the sampled token against kernel boundary disagreements.)
  • vLLM's inter-process output structs are fixed msgspec schemas, so the kept ids ride the existing logprobs channel as a -1-separated row extension, 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.
  • Incompatible setups fail at startup instead of running silently biased: speculative decoding, logprobs_mode overrides, VLLM_USE_V2_MODEL_RUNNER=1.

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.

Paired dep branches

Submodule pointers in this PR pin:

  • PrimeIntellect-ai/renderers feat/kept-tokens-parse — parse kept_tokens from /generate responses (base64 splice fast path).
  • PrimeIntellect-ai/verifiers feat/kept-tokens-branchTurnTokens → graph attribution → Branch.kept_tokens.

PRs for both still need to be opened.

Verification

All CPU-level, against the real installed vLLM 0.24:

  • 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.
  • The patched Sampler.forward produces kept sets matching an independent implementation of vLLM's top-p semantics; the returned sampled-token logprob equals the renormalized reference; greedy/untruncated steps stay position-aligned.
  • Serialize → parse → branch spreading → transport → pack/truncate/pad → tensorize round-trips, with mixed masked/maskless samples.
  • Config policy: top-k defaulting (explicit, per-env, inherited, min_p-triggered), derived inference.kept_tokens (including explicit-value override), extra_body truncation rejection, opd/filter guards, frozen-source exemption, standalone-server warning.

End-to-end GPU validation (H200, top_p 0.97 / top_k 512):

  • reverse-text (Qwen3-0.6B, 20 steps): reward 0.10 → 0.78, mismatch_kl bounded 0.0005–0.0094, entropy rising, 0% errors.
  • hendrycks sanity (R1-Distill-Qwen-1.5B, 200 steps, batch 512 × 8k ctx, W&B hendrycks-sanity/topp0.97-topk512-replay): mismatch_kl 0.0003–0.0004 at every step, flat from step 1 to 200below an untruncated control run's noise floor (0.00042–0.00046), since singleton kept sets contribute exactly zero mismatch. Train reward 0.49 → 0.68; AIME2024 eval 0.1875 → 0.2458; entropy flat (no collapse); DPPO trust-region masking 0.0000 in both runs; replay costs ≈5% MFU vs control.
  • Measured mask load (2.5M-token steps): kept-set sizes mean ~8 / median 2 / p99 ~82, 45–48% singletons (matching Cognition's observation), top_k=512 never binds, 100.00% mask coverage on sampled tokens, ~32 B/token on the wire (~40% of the trainer payload, trivial in absolute bandwidth).

Known gap from the ablation: legacy v0 envs are not wired for kept tokens — truncated sampling on a v0 env fails loudly at the first group (the orchestrator's missing-mask guard). Needs the v0 ResponseTokens path wired for parity (routed_experts supports v0) or documented v1-only support.

🤖 Generated with Claude Code

faresoPrime and others added 7 commits July 9, 2026 02:00
…r renorm)

Sampling with truncation (top_p < 1) renormalizes the rollout
distribution over the surviving kept set; a trainer normalizing over
the full vocab then computes biased importance ratios and runs collapse
(DeepSeek V3.2 'Keep Sampling Mask' arXiv:2512.02556 §3.1, Cognition
SWE-1.7 'sampling distribution replay').

Inference: capture the kept-set token ids in the sampler (finite
entries of the processed logprobs — logprobs_mode=processed_logprobs
already forces the mask-materializing native sampling path) and ride
them through the existing logprobs pipeline as a -1-separated row
extension, so no fixed msgspec/dataclass schema needs new fields
across the worker->engine->API process boundaries. An API-side patch
splits the extension off before vLLM builds logprob dicts (stock
logprobs stay byte-identical for chat/eval consumers) and attaches it
to the finished CompletionOutput; /inference/v1/generate serializes it
as base64 {ids, counts} per choice, mirroring routed_experts. Kept
sets are decode-only, so the PD router passes them through unchanged.

Trainer: enable_sampling_mask_replay renormalizes logprobs over the
kept set — logprob = logits[label] - logsumexp(logits[kept]) — in both
the chunked fused LM head (online kept logsumexp + backward softmax
restricted to kept ids) and the vanilla path. Positions without a mask
(context tokens, kept sets above inference.kept_tokens_max) fall back
to full-vocab logprobs; the bias is bounded by -log(top_p). Singleton
kept sets give logprob 0 and zero gradient. Entropy stays full-vocab.

Config: orchestrator.train.sampling.top_p (was hardcoded 1.0),
inference.enable_return_kept_tokens/kept_tokens_max,
trainer.enable_sampling_mask_replay with auto-setup + a loud warning
for truncated sampling without replay.

Pairs with kept-token commits in deps/renderers (parse + strip fast
path) and deps/verifiers (TurnTokens -> graph -> Branch.kept_tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fused backward: mask non-kept logits to -inf before exp — a non-kept
  logit above the kept-set logZ overflowed exp() to inf and poisoned
  grads with inf * 0 = NaN via the indicator multiply (reproduced)
- sampler capture: fixed cap+1 extension width chosen device-side, with
  overflow detected via the extra column — removes the per-step host
  syncs (.item()/.any()) that stalled the engine loop; cap default
  2048 -> 512 to bound the per-step D2H and trainer padding
- fail fast instead of running silently biased: reject speculative
  decoding and logprobs_mode overrides at server start (both bypass or
  invalidate the capture), reject VLLM_USE_V2_MODEL_RUNNER=1 in the
  patch, reject softcapped (Gemma) lm_heads at trainer start, and raise
  in the trainer when replay is on but an rl-bearing micro batch
  carries no kept sets (pure-ce and dummy batches legitimately don't)
- warn on truncation smuggled via extra_body (top_k/min_p/top_p) and on
  opd/opsd + replay (ref logprobs stay full-vocab prefill scores)
- kept_tokens_max: Field(ge=1); transport: kept_tokens moved to the end
  of TrainingSample/MicroBatch so the positional (array_like) layout of
  earlier fields stays stable across versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fused fn: collect kept logits into a persistent [tokens, K] buffer with
one logsumexp at the end instead of the online accumulator (deletes the
-inf-guarded helper), hoist the int64 cast to once per token chunk, use
an int8 indicator + in-place masked_fill_ in backward (drops ~200MiB of
fp32 temporaries per inner iteration), save kept_tokens/replay as None
instead of sentinel empties, and share the replay-eligibility predicate
(kept_replay_mask) with the vanilla path so the two can't drift.

Sampler patch: mirror the stock forward signature instead of *args
index arithmetic; stop masking extension logprob values nobody reads
(one full(-inf) block, no per-step device-scalar tensors). Processor
patch: always track kept rows from the first call, killing the position
counter and mid-request backfill; split rows with 2D slices at the
uniform separator column instead of per-row nonzero + np.stack.

Trainer wiring: gate the kept H2D copy on the replay flag, run the
missing-masks check on the CPU-side tensors (no per-micro-batch device
sync), express the kept shift through shift_tensor_left (generalized
with pad_value via full_like), null kept_tokens on dummy batches, and
drop the no-op _copy_kept_tokens.

Configs: TrainSamplingConfig.truncates_distribution() owns the
extra_body sentinel knowledge; the RL validator just calls it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Truncated train sampling without replay has no legitimate use (every
source — DeepSeek V3.2 §3.1, Cognition SWE-1.7, Miles' ablation — reports
collapse, and slime hard-requires masks when top_p != 1), and the top_p
field is new in this branch so no existing config depends on the naive
combination. The rl entrypoint now flips trainer.enable_sampling_mask_replay
(and with it inference.enable_return_kept_tokens) whenever any train
sampling config truncates; an explicit enable_sampling_mask_replay = false
opts out with a loud warning (naive-top-p baselines). The inference-flag
override warning now only fires when the user explicitly set it false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y exact

top_k at sampling time bounds every kept set to k, so replay never
overflows into the full-vocab fallback — but only if the capture cap
covers it. A cap below k would silently overflow every truncated
position, and the fallback's -log(top_p) bias bound doesn't hold when
top-k does the trimming (with top_p=1 it's log of the top-k mass,
unbounded). Add orchestrator.train.sampling.top_k (emitted top-level,
whitelisted through verifiers; extra_body still works), include it in
truncates_distribution, and have the rl entrypoint raise
inference.kept_tokens_max to the configured top_k with a warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When sampling-mask replay is on, every truncating train sampling config
gets a top-k bound (defaulted to 512 when only top-p/min-p truncate —
rarely binding at typical top_p, so the sampling policy is essentially
unchanged; explicit top_k wins). Kept sets are then bounded by k and
inference.kept_tokens_max is derived as the largest configured top_k,
so the overflow fallback is unreachable for policy tokens and replay is
exact everywhere. kept_tokens_max is no longer meant to be set by hand
(only standalone-launched servers need it, to cover their clients'
top_k); an explicit value below the derived bound is bumped with a
warning, a larger one is respected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- top_k rides extra_body as the single carrier (mirroring
  EvalSamplingConfig), with the field overriding any smuggled value —
  previously it was emitted top-level while extra_body kept the stamped
  -1 sentinel, leaving precedence to per-client merge order.
  effective_top_k becomes a precedence read and truncates_distribution
  composes with it, so top-k sentinel decoding lives in one place.
- auto_setup_sampling_mask_replay: per-env sampling configs are
  authoritative (group values are merged into envs by
  resolve_env_defaults; the group only stands in when no envs exist) —
  this also fixes a false positive where group-level truncation with
  every env overriding to top_p=1 enabled replay that no rollout would
  feed, crashing the trainer's missing-masks check. Flatten the accreted
  branches (compute truncating/unbounded lists once, drop the no-op
  branch), derive kept_tokens_max unconditionally (warn when overriding
  an explicit value — matching what the docstring already claimed), and
  document all four actions in the validator docstring.
- warn_mask_replay_with_ref_kl: envs inherit the top-level algo before
  RL validators (inherit_env_algorithms), so check env algos directly —
  drops a false positive when every env overrides an opd top-level algo.
- Single source for the coupled 512s: DEFAULT_KEPT_TOKENS_MAX in
  inference.py is both the standalone capture-width default and the
  injected replay top-k. Field docstrings trimmed to field-local facts
  pointing at docs/inference.md, which now also mentions the injection
  warning users will see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@faresobeid faresobeid changed the title feat: top-p kept-set sampling-mask replay feat: top-p/top-k train sampling with kept-set mask replay Jul 9, 2026
faresoPrime and others added 6 commits July 9, 2026 18:36
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Truncated train sampling now requires sampling-mask replay outright —
an explicit enable_sampling_mask_replay=false with truncation is a
config error instead of a warned opt-out (the naive combination has no
use beyond reproducing a known collapse). opd/opsd with replay is
rejected: reference logprobs are full-vocab prefill scores and would
mix normalizations in the ref_kl term. The gibberish/repetition
filters read rollout logprobs against full-softmax thresholds that
misfire under renormalization (singleton kept sets read as probability
1.0): the default-list instances are removed automatically, explicitly
configured ones are rejected; zero_advantage is unaffected. Also cover
env-less configs in the algo check (top-level algo fallback, mirroring
the sampling-config fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfig

Four decisions that collapse the config policy from ~160 lines across
four files to ~60 in two:

1. trainer.enable_sampling_mask_replay deleted. Replay is data-driven:
   the trainer replays kept masks whenever a batch carries them, like
   every other per-token stream (temperatures, rl_weights, mm_kwargs).
   The missing-masks guard moves to the orchestrator, which knows
   sampling was truncated: TrainEnv.requires_kept_masks (truncating +
   policy-sourced) is enforced per sample at group finalization. Gemma
   softcapped heads fail on their existing first-batch assert.
2. Truncation policy lives on OrchestratorConfig (it reads only
   orchestrator state): top-k defaulting, opd/opsd rejection, and
   logprob-filter pruning in one setup_truncated_sampling validator —
   scoped to policy-sourced envs, since frozen sources sample on
   external endpoints and never train importance ratios. RLConfig keeps
   only the cross-config wiring: derive the capture width.
3. inference.enable_return_kept_tokens + kept_tokens_max merge into one
   field: kept_tokens (None = capture off, int = width). Enable-without-
   width and width-without-enable are no longer representable.
4. Truncation knobs in train extra_body are rejected (top_p/top_k/min_p
   are typed fields now, incl. new min_p); the sentinel-decoding helper
   effective_top_k and the two-carrier precedence reasoning disappear.
   resolve_env_config's own post-validation sentinel stamping is
   unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop implementation internals (capture transport, width mechanics,
FlashInfer note, bias bounds) and the stale limitations paragraph;
keep what a user needs — why, the config, what gets rejected, the
standalone-server note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop reviewer-facing narration accreted across the feature's passes —
repeated citations, comparisons to other implementations, multi-line
hazard essays — keeping one-to-two-line comments only where the code
can't show the constraint (separator alignment, pre-exp -inf masking,
wire-layout stability, sentinel stamping order). Bumps the verifiers
pointer for the same trim there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not keys

The SLURM path re-parses the resolved config, which carries the
disabled top_k=-1/min_p=0.0 sentinels resolve_env_config stamps into
extra_body post-validation; rejecting on key presence broke the
round-trip. Reject only values that actually truncate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants