Group GRPO reward normalization by prompt instead of reshaping the batch - #2
Group GRPO reward normalization by prompt instead of reshaping the batch#2FunJim wants to merge 1 commit into
Conversation
630502d to
02a48f3
Compare
Tsing-git
left a comment
There was a problem hiding this comment.
- The modified e2e fan-out test has never been run in the CI for this PR
- Wiring test: zero coverage in CI
_post_process_rewards built its groups by reshaping the flat reward vector to (-1, n_samples_per_prompt), falling back to view(-1, total) -- one group spanning the whole batch -- whenever the total did not equal n_samples_per_prompt * rollout_batch_size. A reshape cannot express uneven groups, and agent fan-out makes them uneven on every batch: one trajectory becomes several training samples through sub-agent dispatch, auto-compaction and token-drift forks, so a run with n_samples_per_prompt=8 produced group sizes like [8, 9, 10, 12, 28]. The fallback fired every time and silently replaced per-prompt centering with batch-wide centering, which is not GRPO -- the group mean is the prompt's difficulty baseline, and subtracting the batch mean instead leaves task difficulty in the advantage. Measured against per-prompt normalization on dumped rollouts: 80.0% of advantages had the wrong sign on one run (18.7% on a second, larger one), and non-zero advantages inflated from 454 to 1898. The inflation is the symptom -- prompts that solved nothing picked up advantages purely because other prompts in the batch solved something. Normalize by Sample.group_index, the data source's per-prompt counter, which survives the deepcopy that builds a prompt's samples and so identifies the prompt however many samples a trajectory emitted. Batches whose group_index is missing everywhere still fall back to fixed-size slices for custom rollouts that predate the field; a partially-missing group_index now raises rather than guessing a baseline. On the uniform layout the reshape handled, the result is elementwise identical. Grouping by prompt can produce a size-1 group, which reshaping never could. torch.std of one element is the sample std, i.e. NaN, so std normalization is skipped there; centering alone already sends a lone sample to zero. raw_reward_group_indices deliberately keeps its own permissive grouping: it feeds pass-rate logging, where a batch mixing samples with and without group_index should degrade to positional grouping rather than kill the run over a metric. The fan-out e2e test keeps its --custom-reward-post-process-path flag, now redundant with the fixed default. Dropping it would change what that test exercises, and this fork has no self-hosted runner to validate the swap, so it is left for a change that can be backed by an actual e2e run. grpo_normalize_by_group_index therefore stays wired in, and also serves as an oracle for the unit tests that is not the implementation under test. Three of the new tests guard the call site rather than the function: the bug was in _post_process_rewards, so the function passing its own tests would not catch a revert. They assert against rollout.py's source text instead of importing it -- that module pulls in sglang at module scope and cannot be imported in the CPU test job, which is also why there is no behavioural test of the manager here. All three fail against the pre-fix source. The normalization itself -- slime/rollout/reward_utils.py and the call site that replaces the reshape -- is taken from THUDM#2204 by morluto, which has been open since 2026-07-14 without review. Cherry-picked here rather than waited on because our agent-fanout training is affected on every batch. Added on top: the measurements above, the size-1 NaN rationale, seven more unit tests, and keeping grpo_normalize_by_group_index so a configured --custom-reward-post-process-path does not break. Upstream is the same fix, so this should drop out cleanly when THUDM#2204 lands. Prior reports of the same defect: THUDM#904, THUDM#1414 (closed), THUDM#2230, and PRs THUDM#487, THUDM#1415, THUDM#1918.
02a48f3 to
f843508
Compare
|
Both valid. Fixed in f843508. 1. e2e fan-out test — reverted, now byte-identical to 2. Wiring test — deleted. It can't be registered: Replaced with three call-site guards in CI coverage 9 → 12. PR body updated. |
Fixes GRPO reward normalization for the uneven per-prompt sample counts that agent fan-out produces on every batch.
The defect
_post_process_rewardsbuilt its groups by reshaping the flat reward vector to(-1, n_samples_per_prompt), falling back toview(-1, total)— one group spanning the whole batch — whenever the total did not equaln_samples_per_prompt * rollout_batch_size.A reshape cannot express uneven groups, and fan-out makes them uneven by construction: one trajectory becomes several training samples through sub-agent dispatch, auto-compaction and token-drift forks. With
n_samples_per_prompt=8we measured group sizes like[8, 9, 10, 12, 28], so the fallback fired and silently replaced per-prompt centering with batch-wide centering.How often it fires, per run:
That is not GRPO. The group mean is the prompt's difficulty baseline; subtracting the batch mean instead leaves task difficulty in the advantage.
Measured cost
Against per-prompt normalization, on dumped rollouts from two runs:
The 4–15x inflation of non-zero advantages is the clearest symptom: prompts that solved nothing received gradient purely because other prompts in the batch solved something.
Run B's agreement rate is higher only because 91 of its 102 dumps are all-zero and agree trivially; the divergence is concentrated in the 11 that matter. Both columns replay the pre-fix code faithfully, reshape branch included — not a blanket assumption that the fallback always fires.
After the fix, both runs match an independently computed per-prompt reference on 100% of samples, with no NaN.
Why it went unnoticed
Three layers of cover:
view(-1, total)is legal, and the fallback even carried a comment describing when it fires, so it read as handled.n_samples_per_promptsamples per prompt and takes the reshape branch.Worth noting
raw_reward_group_indices, ~50 lines below in the same file, already grouped bygroup_index. Pass-rate logging was correct; only normalization was not.Changes
Sample.group_index, the data source's per-prompt counter, which survives the deepcopy that builds a prompt's samples.group_indexmissing everywhere still fall back to fixed-size slices (custom rollouts predating the field); a partially missinggroup_indexnow raises rather than guessing a baseline.torch.stdof one element is the sample std, i.e. NaN, which would poison the gradient. Reshaping could never produce a size-1 group; grouping by prompt can.raw_reward_group_indicesdeliberately keeps its own permissive grouping — it feeds a metric, and a mixed batch should degrade to positional grouping rather than kill the run.--custom-reward-post-process-pathflag is now redundant with the fixed default, but dropping it would change what that test exercises and this fork has no self-hosted runner to validate the swap, so it is left for a change that can be backed by a real e2e run. Only its comment is updated, to stop describing the collapse as current behaviour.On the uniform layout the old reshape handled, the result is elementwise identical (pinned by a test).
Tests
12 CPU tests in
tests/test_reward_utils.py, registered in thecpu-unittestmatrix. It imports nothing beyond torch, so it runs there cleanly.Nine cover the grouping function: uneven groups, order preservation, singletons, legacy fallback, strictness, and elementwise equivalence to the old reshape on a uniform layout — plus the properties that make this a bug fix rather than a refactor (an unsolved prompt getting no signal from a solved one, size-1 groups staying finite).
Three guard the call site. The bug lived in
_post_process_rewards, not in the function, so the function passing its own tests would not catch a revert. These assert thatrollout.pyimports and callsnormalize_rewards_by_group, passes[sample.group_index for sample in samples]andfallback_group_size, and no longer contains the collapsingview(-1, rewards.shape[-1]). They check the source text rather than importing the module —slime.ray.rolloutpulls in sglang at module scope, which the CPU job does not install; this is the sameread_text()approachtests/plugin_contracts/test_plugin_runtime_hook_contracts.pyalready uses for call sites in that file.Verified all twelve pass against the fix and that the three call-site guards fail against the pre-fix source, in a venv built from the exact CI install commands (python 3.11, torch-cpu, no sglang) — a local dev environment has sglang and cannot reproduce that job.
Attribution
slime/rollout/reward_utils.pyand the call site replacing the reshape are taken from THUDM/slime#2204 by @morluto, open since 2026-07-14 without review. Cherry-picked rather than waited on because our agent-fanout training is affected on every batch.Added on top: the measurements above, the size-1 NaN rationale, seven more unit tests including the call-site guards, and keeping
grpo_normalize_by_group_indexso an already-configured--custom-reward-post-process-pathdoes not break (upstream deletes it, which is an undeclared breaking change given THUDM#904 recommended that flag as the official workaround).Upstream is the same fix, so this should drop out cleanly when THUDM#2204 lands.
Prior reports of the same defect: THUDM#904, THUDM#1414 (closed), THUDM#2230, and PRs THUDM#487, THUDM#1415, THUDM#1918.