Skip to content

feat(rl): one paired GPU capture and one support gather for exact score centering (follow-up to #4176) - #4232

Open
faresobeid wants to merge 29 commits into
mainfrom
feat/sc-exact-support
Open

faresobeid wants to merge 29 commits into
mainfrom
feat/sc-exact-support

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Every score-centered or replayed request now captures the sampler's probabilities on the GPU, in vLLM's sampling step, with no host sync. vLLM returns no logprobs for these requests. Net +584 / −770 on current main.

What changes

  • Sync-free paired GPU capture. Replayed (prl_replay) requests get each kept id with its processed logprob. Untruncated requests get the sampled token plus the top ids, or with extra_args.prl_sampler_draws = K, K i.i.d. draws from q (torch.multinomial on the global generator, so the draws are independent of the sampled token). All host copies are pinned and non-blocking, so vLLM's async scheduling keeps overlapping.
  • Top-p-only replay with ragged CSR capture. Two Triton passes over vLLM's masked logits count each row's kept ids and pack (logprob << 32) | id into one flat device buffer. No top-k cap, no full-vocab bitmask, no injected top_k. The buffer size is inference.sampling_support_capacity (default 16M entries, 128 MB). A step whose supports do not fit fails those requests with an explicit error; nothing is truncated. The server logs the max per-step total every minute.
  • One support gather in the fused LM head. On replayed rows the support ids are the mask ids, so one gather gives the replay normalizer, the support logprobs and the backward. LossInputs takes trainer_support_logprobs, sampler_support_logprobs, support_valid and sampler_tail_mass.
  • Sampler draws for the sampled estimator (used by refactor(rl): one RL loss (ipo/icepop/reinforce) with shared exact score centering and sampler KL #4233), returned as compact logprobs with "samples": true.
  • Removed: top_p_before_top_k / sampling_order.py (the p→k reorder; exact centering doesn't need it, since mask and logprobs come from the same processed logits), and the vLLM logprob-return path for compact requests (compact_logprobs.py, logprobs_mode / max_logprobs setup). TRAIN_TOP_K_DEFAULT injection and the top-k bound are gone too.
  • Config: sampling-mask capture is enabled whenever train sampling is truncated or records sampler logprobs (score centering sets them).

Why

The old path computed logprobs in vLLM with a raised max_logprobs, synced GPU→CPU in capture (which stalled async scheduling), and padded supports to a fixed width, so top-p alone needed an injected top_k cap.

Numbers

Qwen3-1.7B (1×H200, 512 concurrent) and GLM-4.5-Air (TP 4, 256 concurrent), T=1, top_p 0.95, 1024 forced tokens, tok/s. replay = mask capture; sc = replay + sampler logprobs.

Qwen top_k 128 Qwen top-p only GLM top_k 128 GLM top-p only
no mask 23164 21699 9535 9543
replay 23165 20827 9337 8771
sc 24537 16761 9593 8980
  • Capture kernel: 0.87 ms for 512 rows × 151936 with 9.3M kept entries (vLLM's top-p masking itself: 3.1 ms).
  • Largest per-step total in a top-p-only Qwen run: 762k entries (4.5% of the default capacity). No overflows.
  • Qwen top-p-only sc is lower because the API server serializes full-vocab rows at forced lengths (mean 174 kept per token); on GLM it is −6%.
  • Fused LM head fwd+bwd (16k tokens, H=2048, K=128): replay + centering +1.6% over replay.
  • Paired capture matches vLLM's processed_logprobs to ≤ 8.3e-7 on identical seeded sequences.

Validation

  • CPU Slurm, on current main: pytest tests/unit/orchestrator tests/unit/test_configs.py tests/unit/inference tests/unit/train -m "not gpu": 660 passed. ruff check, ruff format --check, uv lock --check: clean.
  • GPU (H200): test_serving_tokens (capture equals the masked set, logprobs equal log_softmax over it, sampler rows merge, overflow fails), test_loss, test_fused_lm_head, test_nemotron_h_kl: 53 passed.
  • End to end (Qwen3-0.6B reverse-text, icepop + exact score centering, top_p 0.95 without top_k, 10 steps): reward 0.173 → 0.752, the same as with top_k 2048; every token centered over its full support.

Before merge

#4233 is stacked on this branch.


Note

High Risk
Changes the inference–trainer contract for truncated RL (logprobs, masks, capacity failures) and score-centered IcePop; misconfiguration or overflow now fails requests instead of silently bounding supports.

Overview
Sampling replay and IcePop score centering now get the sampler’s exact kept set and processed logprobs from GPU capture in vLLM’s sampling step, instead of raising max_logprobs and shipping logprobs through vLLM’s normal path. Replay and score-centered requests set logprobs = None on the server; supports are packed as CSR rows (logprob << 32) | id via Triton over the already masked processed logits, so top-p alone works without an injected top_k cap or fixed-width padding.

Removed the top_p_before_top_k engine patch (sampling_order.py), standalone compact_logprobs serialization for generic compact requests, orchestrator TRAIN_TOP_K_DEFAULT / 512 bound and mixed top-k validation, and RL auto-setup that forced processed_logprobs + max_logprobs. Added inference.sampling_support_capacity (default 16M entries); steps that exceed capacity fail explicitly rather than truncating.

The fused LM head uses one support gather for replay normalization, support logprobs, and backward; IcePop centering inputs are renamed to *_support_* with sampler_tail_mass instead of a “complete support” flag. Mask capture auto-enables when train sampling is truncated or records sampler logprobs (score centering). Optional prl_sampler_rows / prl_sampler_draws returns GPU-packed compact logprobs for untruncated score centering (including i.i.d. draws).

Reviewed by Cursor Bugbot for commit 606eaad. Bugbot is set up for automated code reviews on this repo. Configure here.

hallerite and others added 9 commits October 20, 2026 15:25
- Truncated train sampling without top_k now defaults to top_k = 128;
  explicit top_k is still capped at 512.
- When a request records a sampling mask, the inference server drops
  filtered (-inf) candidates from compact logprobs, so each row carries
  only the recorded support instead of top_k sentinel-padded entries.
- Masked tokens' sampler logprobs move onto the transport SamplingMask
  (new optional `logprobs`), so the support ids cross to the trainer once.
  TopLogprobs only carries heads of untruncated distributions, and the
  trainer builds the centering head from both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng-replay

# Conflicts:
#	src/prime_rl/inference/patches.py
)

Truncated training should not force eval requests on the same vLLM
engine to use a positive temperature and bounded top-k. This PR targets
`feat/icepop-sc-sampling-replay` (#4176), not main.

- Policy-training requests opt in to mask capture with
`sampling_params.extra_args.prl_replay`. Validation retains the
positive-temperature/top-k requirements only for opted-in requests.
Other request validation still runs against a private model-config view;
the shared engine config is never toggled.
- The V2 sampler tracks capture per request slot. Mixed batches zero
capture-only committed-token counts for eval rows, so the capture kernel
skips those rows before reading logits. Only replay rows set the compact
support width; eval-only batches allocate no sampling masks. Normal
sampled-token counts and logits are unchanged. Empty eval supports are
discarded before response assembly.
- Online eval retains independent top-p/top-k/greedy settings, disables
logprobs, and explicitly disables an omitted top-k instead of inheriting
it from the model generation config. Its existing chat endpoint strips
router payloads. Training's renderer client keeps its required logprobs.
- The parent's top-p-before-top-k ordering continues to apply to both
train and eval on the capture-enabled engine, independently of
per-request capture.

Limitations: request-scoped capture currently rejects speculative
decoding because its separate rejection sampler is not adapted here.
Router instrumentation and internal router transport remain engine-wide;
this removes eval response payloads, not that model-side overhead. This
is a vLLM 0.32 compatibility patch using private APIs, pending native
per-request support.

Validation:
- Seven CPU/GPU sampler tests passed on the devbox, including training
alongside unbounded eval supports and an eval-only capture bypass.
- Eight focused config tests passed, including resolved-config round
trips and independent top-p-only/greedy eval settings.
- Broader local config run: 210 passed; nine failed because optional
tasksets or `psutil` were not installed (39 example cases deselected).
- Real vLLM 0.32 V2 engine smoke test on an RTX PRO 6000 with a tiny
dummy-weight Llama: mixed, eval-only, training-only, and reordered
batches. This is an inference wiring test, not a full training or
throughput benchmark.
- Ruff and `git diff --check` pass.
@hallerite
hallerite force-pushed the feat/icepop-sc-sampling-replay branch 3 times, most recently from 04ef127 to dbeb8ce Compare October 24, 2026 14:36
Base automatically changed from feat/icepop-sc-sampling-replay to main October 24, 2026 14:38
# Conflicts:
#	docs/inference.md
#	packages/prime-rl-configs/src/prime_rl/configs/inference.py
#	packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py
#	src/prime_rl/inference/vllm/paired_capture.py
#	src/prime_rl/inference/vllm/serving_tokens.py
#	src/prime_rl/orchestrator/batch.py
#	src/prime_rl/orchestrator/metrics.py
#	src/prime_rl/orchestrator/trajectories.py
#	src/prime_rl/trainer/models/layers/lm_head.py
#	src/prime_rl/trainer/rl/loss.py
#	src/prime_rl/trainer/rl/train.py
#	tests/unit/orchestrator/test_batch.py
#	tests/unit/orchestrator/test_metrics.py
#	tests/unit/train/rl/test_loss.py
@faresobeid
faresobeid marked this pull request as ready for review October 24, 2026 18:33
… the GPU

Untruncated score-centered requests no longer use vLLM's logprob return. The patched sampler writes the sampled token plus the top ids (vLLM's logprob layout) or K i.i.d. draws from the processed distribution (extra_args.prl_sampler_draws) into the packed sampling-mask transport. The server returns them as compact logprobs. Removes compact_logprobs.py and the logprobs_mode/max_logprobs setup; score centering enables enable_return_sampling_mask instead. Bumps renderers for sampler-draw rows (PrimeIntellect-ai/renderers#202).
samsja pushed a commit that referenced this pull request Oct 26, 2026
…sks on (#4267)

With `enable_return_sampling_mask` on, vLLM generation throughput
dropped by about 45% (Qwen3-1.7B, H200). The cost was not the mask
transport. It came from GPU->CPU syncs inside the sampler step. Each
sync makes the engine thread wait for the current forward pass, which
removes vLLM's async-scheduling overlap. py-spy on the EngineCore showed
the busy loop 100% busy, with about 30% of it spent waiting in
`sampling_order.py:29` (`k.max().item()`). The sampler kernels
themselves cost about 1 ms per step.

Syncs removed:
- `apply_top_p_then_top_k` (on by default through `top_p_before_top_k`,
so it ran for every top-k/top-p request on a mask server, replayed or
not): `k.max().item()` and the tie-fallback `.any().item()`. The width
now comes from the host `top_k` array, set in a `Sampler._sample_random`
wrapper. Candidates are ranked by an int64 `(score, token id)` key, so
ties at the boundary resolve to the reference's stable order without a
data-dependent fallback. The result is bit-identical to
`reference_top_p_then_top_k`, including bf16-rounded logits with
boundary ties. The old fast path fell back to the full sort on those
logits.
- `capture_selected_supports` and `pack_support_logprobs`:
`torch.as_tensor(numpy, device=cuda)` (a pageable H2D copy that syncs)
becomes a pinned `non_blocking` copy.

Sampling semantics, masks, and logprobs are unchanged. There are no loss
changes.

## Numbers
Qwen3-1.7B, one H200 per server, 512 concurrent x 1024 tokens
(`min_tokens` = `max_tokens`), T=1, seeded; replay/top-k requests use
top_k=128, top_p=0.95. tok/s, two runs each. The columns come from
separate jobs. Untruncated requests have no syncs on either branch, so
that row shows the run-to-run noise (about 10%):

| request | main, masks off | main, masks on | this PR, masks on |
|---|---|---|---|
| untruncated, sampled logprob | 15.8-16.7k | 15.5-16.3k | 17.3-18.4k |
| top-k/top-p, no replay | 14.3-17.6k | 9.0-10.1k | 15.4-15.5k |
| replay | - | 8.2-9.6k | 14.4-14.9k |

On #4232's code, which deletes `sampling_order.py`, the same pinned
copies plus the matching fixes in `pack_sampler_rows` give:
- untruncated + 16 draws: 9.3-10.6k -> 17.1-17.4k (-6% vs untruncated)
- replay: 8.6-9.2k -> 14.4-16.9k

The rest of the replay gap on main is GPU time in the top-p-first
sampler: 4.1 ms vs vLLM's Triton top-k/top-p at 0.9 ms for 512 rows.
`apply_top_p_then_top_k` microbench, 512 x 151936 bf16-rounded logits:
old 9.5 ms (tie fallback to the full sort), new 4.1 ms, reference 8.4
ms.

With `VLLM_GPU_SYNC_CHECK=warn`, vLLM reports no syncs in
`execute_model`/`sample_tokens` for replay, top-k, and draws requests.

## Overlap
- #4232 (feat/sc-exact-support) deletes `sampling_order.py` and has the
same `as_tensor` syncs in `request_replay.py`/`paired_capture.py`, plus
four more in `pack_sampler_rows` (numpy-indexed CUDA tensors and
`as_tensor` copies). If #4232 lands first, only the two pinned-copy
lines here still apply, together with the `pack_sampler_rows` fixes on
that branch.
- #4213 (metrics) does not touch inference.

## Validation
- `pytest tests/unit/orchestrator tests/unit/test_configs.py
tests/unit/inference -m "not gpu"`: 445 passed, 1 skipped. `ruff check`
and `ruff format --check` are clean.
- GPU (H200): `pytest tests/unit/inference/test_sampling_order.py`: 14
passed.
- Bench scripts: `/home/fares/scratch/smt/bench` (client.py,
val.sbatch).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core top-k/top-p sampling and mask packing on every masked
request; behavior is intended to be bit-identical to the reference, but
the paths are performance-critical inference code.
> 
> **Overview**
> Restores vLLM throughput when **sampling masks** are enabled by
eliminating **GPU→CPU syncs** in the hot sampling and mask-capture
paths, without changing sampling semantics or logprobs.
> 
> **`apply_top_p_then_top_k`** no longer calls `k.max().item()` or a
tie-check `.any().item()`. Batch max top-k is supplied from host numpy
via a **`ContextVar`**, set in a **`Sampler._sample_random` wrapper**
during `patch_top_p_then_top_k`. The bounded path ranks candidates with
a composite **(score, token id)** key so boundary ties match the
reference stable order without a data-dependent fallback to full sort.
> 
> **`capture_selected_supports`** and **`pack_support_logprobs`**
replace syncing `torch.as_tensor(numpy, device=cuda)` with **pinned,
non-blocking** host→device copies for boolean masks.
> 
> Tests extend the patch test to preserve **`_sample_random`** under
monkeypatch.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1aee656. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

samsja added a commit that referenced this pull request Oct 27, 2026
## Summary

With IcePop score centering on, `validate_score_centering_data` ran on
the CPU, on the training loop's main thread, for every micro batch:
- boolean masking of the `[seq, 128]` head ids and values;
- a full sort of the ids to find duplicates;
- `exp`, sums and support checks.

The loop waits on the GPU at the end of every micro batch, so this time
added straight to step time. It took ~2.4 s per 262k-token micro batch
at `OMP_NUM_THREADS=1`.

This PR runs the same checks on the GPU, on the copies the step already
makes, with no host sync, and checks the result once per step.

## Changes

- `score_centering_data_errors` runs the six checks per token on the
step's GPU tensors. It returns one flag per check without syncing.
- `raise_score_centering_data_errors` syncs once, after the last micro
batch and before gradient scaling, clipping and `optimizer.step()`. It
raises the same message the CPU validation raised for the first failing
micro batch.
- `topk_valid` is computed on the GPU instead of the CPU.
- `validate_score_centering_data` keeps its signature. It reuses the
same checks, and the trainer still calls it on the CPU only when top
logprobs are missing.

**Behavior change:** an invalid micro batch now fails the step after the
forward/backward instead of before its forward. Gradients from bad
sampler evidence are still never applied. In between, out-of-vocabulary
head ids can't index out of bounds: the LM head maps them through
clamped, range-masked local indices.

## Benchmarks

Validation alone, on a synthetic micro batch (262,144 tokens, 128-wide
head, vocab 129,280):

| | per micro batch |
|---|---|
| CPU, `OMP_NUM_THREADS=1` (before) | 2,424 ms |
| CPU, `OMP_NUM_THREADS=16` (before) | 351 ms |
| GPU (this PR) | ~2 ms of kernels, 0.36 ms on the host, ~450 MiB of
temporaries |

End to end, on an 8-layer DSv4-Flash slice: 1×8 B300, CP=8, 4 micro
batches per rank, forward/backward averaged over steps 8–10:

| | s / micro batch |
|---|---|
| score centering on, before | 5.65 |
| score centering on, this PR | 1.84 |
| score centering off | 1.66 |

The remaining ~0.18 s per micro batch is the score-centering loss path
itself.

## Testing

- `tests/unit/orchestrator/test_batch.py` checks that the GPU path
raises the same error as the CPU validation for each corruption case
where top logprobs are present, on CPU and CUDA. On GPU, `test_batch`,
`tests/unit/train/rl/test_loss.py` and `test_fused_lm_head` pass. ruff
check and format pass.
- The old and new validation raise identical messages on 9 corruption
types, on clean data, and on a corrupted row outside the loss mask.

#4232 doesn't touch this validation. The two PRs only conflict
textually, in `train.py`'s top-k block.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Training-step timing and failure timing change for IcePop score
centering; invalid batches still abort before `optimizer.step()` but
after backward. Logic must stay equivalent to the prior CPU checks on RL
tokens only.
> 
> **Overview**
> Moves IcePop **score-centering** sampler-evidence checks off the CPU
micro-batch hot path and onto **GPU tensors**, deferring a **single host
sync** until after all micro-batches finish forward/backward.
> 
> **`data.py`** factors the six RL-token checks into
`score_centering_data_errors` (per-check flags, no sync) and
`raise_score_centering_data_errors` (one sync, same `ValueError`
messages via `SCORE_CENTERING_DATA_ERRORS`).
`validate_score_centering_data` still runs on CPU when top logprobs are
missing.
> 
> **`train.py`** drops per-micro-batch CPU
`validate_score_centering_data`; with logprobs present it appends GPU
flags each micro-batch and calls `raise_score_centering_data_errors`
before gradient scaling/`optimizer.step()`. `topk_valid` is computed on
CUDA with the other top-k tensors.
> 
> **Behavior:** bad sampler data now fails the step **after** that
micro-batch’s backward (gradients still not applied); missing-logprob
batches still fail immediately on CPU.
> 
> **Tests** assert the deferred GPU path raises the same errors as CPU
validation (CPU/CUDA).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9c23f38. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ed83b67. Configure here.

This branch has not been deployed

No deployments
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