Skip to content

refactor(rl): one RL loss (ipo/icepop/reinforce) with shared exact score centering and sampler KL - #4233

Open
faresobeid wants to merge 36 commits into
feat/sc-exact-supportfrom
feat/rl-loss-core
Open

faresobeid wants to merge 36 commits into
feat/sc-exact-supportfrom
feat/rl-loss-core

Conversation

@faresobeid

@faresobeid faresobeid commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

One RL loss for ipo, icepop and the new reinforce, with score centering always on and an optional sampler KL, over one ragged (CSR) per-token support from #4232:

loss_t = -adv_tau * A_t * (w(a) * log p(a) - sum_v sg[u_v * w_v] * log p_v) + beta_t * KL(q || p)

sum_v u_v f(v) estimates E_q[f] with one rule per token:

  • Truncated sampling (top-p and/or top-k, replayed): the replayed support S with u_v = q_v. Centering is exact.
  • Untruncated sampling: K = score_centering_k (default 16) i.i.d. sampler draws with u_v = 1/K, captured on the GPU next to the sampled token.

What changes

  • One RLLoss replaces IPOLoss / IcePopLoss. Types differ only in w: ipo 1(|p-q| <= eps) * min(p/q, max_importance_ratio), icepop 1(low <= p/q <= high) * p/q, reinforce 1.
  • Score centering is always on for these types; custom is unaffected. Config: score_centering_k only (replaces score_centering / score_centering_topk). The top-K head + tail estimator and its top-id capture are gone: compact-logprob requests now need replay or prl_sampler_draws.
  • KL over the same support (kl_coeff, default 0). kl_estimator = exact | k1; kl_staleness = none | inv_sqrt uses per-token policy age from the engines' weight-version labels (main's per-token staleness stream).
  • RLConfig requests the support for every policy-sampled train source: replay masks with sampler probabilities when truncated, sampling.sampler_draws = K when not (kept across live config updates). Train sampling needs temperature > 0.
  • Ragged support end to end. TokenSupport (int32 ids, host int64 offsets, replayed flag, sampler logprobs) replaces the padded [N, K] mask / top-k tensors in data.py, CP sharding, the model forwards and the loss. The fused LM head slices fixed token chunks from the CSR, with no padding, sort or device-to-host sync.
  • Sparse LM-head backward. When every RL token's logit gradient lies on its label and support (replay, or reinforce without KL), the head skips the dense softmax backward and uses a deterministic CSR matmul.
  • Sampler-evidence validation runs on the GPU on the CSR entries (main's perf(trainer): validate score-centering data on the GPU #4284 checks), checked once per step before the optimizer.
  • Metrics: score_centering/support_mass (E_q[p/q]), score_centering/mask_tv, and with the KL kl, kl/beta, kl/age0.
  • Behavior change: every RL run is now centered. Untruncated runs capture K draws per token.

Why

Each token needs the sampler distribution over its support. Padding every support to the micro batch's widest row wasted more than 99% of those tensors on chat with uncapped top-p (about 1.5 GiB at 64k tokens, K=2048). The per-type losses duplicated the same centering math.

Numbers

Head + RLLoss fwd+bwd, H200, H=2048, V≈152k, real chat kept counts at top_p 0.97 (ms / peak GiB):

16k tokens 64k tokens
no support 148 / 2.08 602 / 2.5
padded, K=2048 (before) 170 / 3.06 664 / 8.4
ragged, uncapped 170 / 2.21 674 / 2.6
ragged, sparse backward (replay or K8 draws, no KL) 72 / 0.94 259 / 1.14
  • Ragged vs padded: bitwise on non-replayed logprobs and entropy; replayed logprobs within 1e-6; gradients ≤ 1 bf16 ulp. Sparse vs dense backward: rounding level in fp32.

Validation

  • CPU Slurm: pytest tests/unit -m "not gpu": 1024 passed. ruff check, ruff format --check, uv lock --check (uv 0.13.5): clean.
  • GPU (H200): test_serving_tokens, test_loss, test_fused_lm_head (incl. sparse vs dense backward), test_batch with -m gpu: 50 passed.
  • Unit tests cover dense-reference gradients for each weight on a replayed support, the KL (exact and k1) against a dense KL, the K-draw estimator against the exact center, and the config capture rules.
  • End to end (Qwen3-1.7B reverse-text, ipo, 4+4 H200): untruncated sampling with K=16 draws and top_p 0.95 replay with exact centering both train without errors. A/B with 2 runs per arm, 50 steps: exact centering at top_p 0.95 and K=16 / K=8 draws (simulated from the replayed q) reach the same reward (0.26–0.27 over the last 10 steps), and so does untruncated sampling with K=16 / K=8. Decode inter-token latency is about 10% higher for untruncated sampling with draws than for top_p 0.95 replay (3.4–3.5 vs 3.05–3.17 ms), the same for K=8 and K=16.

Before merge


Note

Medium Risk
Large refactor of RL loss math, batch wire format, and LM-head autograd on the hot training path; truncated runs now center by default, so regressions would show up as wrong gradients or KL rather than silent UI bugs.

Overview
Unifies RL policy losses into one RLLoss for ipo, icepop, and new reinforce, all sharing the same per-token objective: importance weight w, optional score centering under the sampler, and optional KL(q‖p) with exact / k1 estimators and inv_sqrt staleness from token policy age.

Replaces padded mask/top-k tensors with TokenSupport (CSR ids, host offsets, replay flag, sampler logprobs) from batch packing through model forward, CP gather, validation, and loss. IcePop-only centering knobs (score_centering_topk, IcePop-only flag) move to shared fields: score_centering (auto/true/false), score_centering_estimator (sampled/topk), score_centering_k. auto now exactly centers replayed (truncated) tokens by default—a behavior change vs IcePop-only centering on main.

Inference/orchestrator wiring: RLConfig auto-requests logprobs and, for untruncated sampled centering/KL, sampler_draws → prl_sampler_draws; vLLM rejects draws without compact logprobs. Fused LM head computes support_logprobs over CSR rows and can take a sparse backward (Triton segment_reduce / CSR matmul) when RL gradients lie only on label + support—large memory/time win for replay or REINFORCE+sampled centering without KL.

Docs and tests are updated for REINFORCE, StableReinforce mapping, GPU-side CSR validation, and dense-vs-sparse gradient parity.

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

@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.

Comment thread src/prime_rl/trainer/rl/loss.py Resolved
faresobeid and others added 6 commits October 24, 2026 18:53
## Summary

- `node_policy_versions` counts sampled tokens with
`np.count_nonzero(node.mask)` instead of `sum(node.mask)`.
`MessageNode.mask` is a numpy bool array since
PrimeIntellect-ai/verifiers#2961, which #4226 pulled into main. Python's
`sum()` walks that array one token at a time and returns an `np.int64`.
`np.count_nonzero` is vectorized, accepts lists too, and the `int()`
cast keeps the metric values as plain ints.
- `test_token_policy_ages` builds its masks as numpy arrays, which is
the type main now produces.

Targets #4141.

Checks: `ruff check` and `ruff format --check` pass. I also ran
`token_policy_ages` on the test case with list masks and with numpy
masks. Both returned `(20, 8, 3)` as plain ints. I did not run pytest,
because the import chain needs `torch` and the test machine (macOS) does
not have it.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Small metric-calculation fix for numpy masks; behavior unchanged aside
from correct int types and performance.
> 
> **Overview**
> **Fixes sampled-token counting** now that `MessageNode.mask` is a
numpy bool array (from verifiers): `node_policy_versions` uses
`int(np.count_nonzero(node.mask))` instead of `sum(node.mask)`, avoiding
slow element-wise summation and keeping metrics as plain Python `int`s
rather than `np.int64`.
> 
> The unit test builds trace masks with `np.array(...)` to match
production types.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5a96878. 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>
# Conflicts:
#	src/prime_rl/orchestrator/train_sink.py
#	tests/unit/orchestrator/test_train_sink.py
# Conflicts:
#	docs/algorithms.md
#	packages/prime-rl-configs/src/prime_rl/configs/rl.py
…t 1/K) as the untruncated default

score_centering_estimator = sampled | topk (default sampled), score_centering_k = 16 (replaces score_centering_topk). Without replay, sampled centers with mean_k w(v_k) grad log p(v_k) and estimates the KL as mean_k (log q - log p) over K i.i.d. draws v_k ~ q; topk keeps the head plus proportional tail. The loss takes per-token support_replayed instead of sampler_tail_mass and derives the per-entry weights and the tail itself. The launcher asks policy sources for the draws via extra_args.prl_sampler_draws.

@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.

Replace the padded [N, K] sampling-mask / top-logprobs tensors with one token-major CSR support
(TokenSupport: int32 ids, host int64 offsets, replayed flag, sampler logprobs). The fused LM head
slices each fixed token chunk's entries from host offsets (no sort, padding or device sync), gathers
support logits through flat indices and renormalizes replayed rows with a deterministic Triton segment
logsumexp. RLLoss reduces per-entry terms with segment sums; CP shards and gathers entry slices.
kl_estimator = 'exact' (the support sum, default) or 'k1' (log q(a) - log p(a), differentiated directly as in
Tinker's stable_reinforce); k1 needs no sampler support. Document the StableReinforce-equivalent config.
…bel and support

Replayed tokens normalize over their support, and REINFORCE centered over K draws without a KL has weights that
sum to one, so in both cases each token's logit gradient lives on its label and support entries. train.py
decides this per micro batch and the fused head then skips the vocab loop in backward: the gradient is one CSR
matrix multiplied into the weight and hidden states by a deterministic Triton kernel.

@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.

Comment thread src/prime_rl/trainer/rl/loss.py Resolved
# Conflicts:
#	docs/algorithms.md
#	packages/prime-rl-configs/src/prime_rl/configs/rl.py
#	src/prime_rl/trainer/rl/data.py
#	src/prime_rl/trainer/rl/train.py
#	tests/unit/orchestrator/test_batch.py
#	tests/unit/test_configs.py

@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.

Comment thread src/prime_rl/trainer/rl/train.py Resolved
Comment thread src/prime_rl/trainer/rl/train.py Resolved
# Conflicts:
#	src/prime_rl/orchestrator/batch.py
#	src/prime_rl/orchestrator/train_sink.py
#	src/prime_rl/trainer/rl/data.py
#	src/prime_rl/trainer/rl/loss.py
#	src/prime_rl/trainer/rl/train.py
#	src/prime_rl/transports/batch/types.py
#	tests/unit/orchestrator/test_batch.py
… updates

Score centering now sets a frozen sampling.sampler_draws; the sampling config's validator derives
extra_body.extra_args.prl_sampler_draws from it, so every validation (launch, reload, live
control-API update, resume merge) re-derives it and a live extra_body update cannot drop it.

@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 fc8dc13. Configure here.

Comment thread src/prime_rl/trainer/rl/train.py Resolved

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