Conversation
|
Verified end to end on a GRPO run. The observable is The term is inert on mainGRPO, Qwen3-0.6B, gsm8k,
On main the configured weight has no effect whatsoever. Training runs to completion, no warning is emitted, and the term contributes exactly zero at every step. Anyone who sets As a control, two independent runs of the unpatched configuration reproduce each other exactly at step 1 ( Please merge this together with #3791This is the part I would ask reviewers to weigh, because merging this PR alone makes the situation worse rather than better. The term is currently dead, so the normalization bug in #3791 is latent. Merging this PR activates the term, and it comes back carrying that bug. Measured on the same run:
So the three possible states are:
The middle state is worse than the first, because a wrong contribution to the gradient is harder to notice than no contribution at all. I would suggest landing them together, or landing #3791 first. Note on the shape of the fixThe change mirrors what A separate improvement worth considering, outside this PR: |
ClippedPGLossFn's positive_example_nll_weight (VAPO) term is gated on "rewards" in data and silently contributes zero when the key is absent (loss_functions.py: if self.positive_example_nll_weight > 0 and "rewards" in data). PPO's train_data already carries this key (ppo.py), but both of GRPO's in-process training paths omit it: the synchronous grpo_train() builds its train_data dict without it, and the async no-TQ path's _build_async_grpo_train_data() does too. Setting loss_fn.positive_example_nll_weight on a GRPO run is therefore a silent no-op: the config accepts the value, no warning is printed, and the loss is identical to leaving it unset. Add "rewards": <the batch's total_reward, aligned row-for-row with the rest of train_data> to both dict-construction sites, mirroring PPO's existing pattern exactly. The data-plane (TQ) sync path in grpo_sync.py has the same gap (DP_TRAIN_FIELDS does not include a rewards-shaped field) but is architecturally distinct enough, a different fetch mechanism over a shared column store, that it needs its own follow-up rather than reusing this fix. Signed-off-by: khazic <khazzz1c@gmail.com>
cfe1c15 to
7b4b803
Compare
Problem
ClippedPGLossFn's VAPO positive-example NLL term is gated like this:If
"rewards"is absent from the data dict passed to the loss, the term is silently zero regardless ofpositive_example_nll_weight, with no warning.ppo.pyalready carries this key:"rewards": repeated_batch["total_reward"]is part of itstrain_datadict. GRPO's two in-process training paths do not:grpo_train()buildstrain_datainline without arewardskey._build_async_grpo_train_data()builds the same shape of dict, also without it.So setting
loss_fn.positive_example_nll_weighton a GRPO run is a silent no-op. The config accepts the value, training runs normally, and the loss is bit-identical to leaving the weight at 0.Fix
Add
"rewards"to both dict-construction sites, mirroring PPO's existing pattern exactly:_build_async_grpo_train_data():"rewards": repeated_batch["total_reward"].grpo_train()'s inlinetrain_datadict:"rewards": rewards, whererewardsis the same batch-aligned reward tensor already computed a few lines above (repeated_batch["total_reward"], orrepeated_batch["filtered_reward"]under DAPO dynamic sampling) and used to buildadvantages. It stays row-aligned withrepeated_batchthrough this section: dynamic sampling has already filteredrepeated_batchto match by the timetrain_datais built, and neither overlong filtering nor the mask-sample filter that follow drop rows, they only zeroloss_multiplier.Evidence
Three tests, from the API boundary up to the actual loss activating:
TestBuildAsyncGrpoTrainData::test_train_data_carries_rewardscalls_build_async_grpo_train_data()directly and asserts"rewards"is present and equalsrepeated_batch["total_reward"].TestBuildAsyncGrpoTrainData::test_rewards_row_matches_reward_hungry_loss_termfeeds the resultingtrain_datastraight intoClippedPGLossFnwithpositive_example_nll_weight=0.5and assertsmetrics["positive_nll_loss"] != 0, i.e. the fix actually turns the feature on end-to-end.test_grpo_train_sync_train_data_carries_rewardslocks the synchronous path's inline dict literal via source inspection (grpo_train()builds this dict inline rather than through an extracted, directly callable helper, so invoking the real function would require standing up rollout, policy, and generation actors).On the current
main, all three fail:With the fix applied, all three pass:
The full
test_grpo.pysuite (144 tests, covering dynamic sampling, mask/reward-penalty filtering, config validation, refit handshakes, and rollout-metric aggregation) passes on the fix branch, so the change does not regress any other GRPO path:Known related gap, out of scope here
The data-plane (TQ) synchronous path (
grpo_sync.py, a documented sibling ofgrpo_train()fordata_plane.enabled=true) has the identical gap, traced end to end:DP_TRAIN_FIELDS(nemo_rl/data_plane/schema.py), the column list workers fetch fortrain_from_meta, does not include a rewards-shaped field.nemo_rl/experience/sync_rollout_actor.py,bulk_batch = BatchedDataDict[Any](...)built fromDP_TRAIN_FIELDSplus multimodal extras) does not write one either, even thoughtotal_rewardis already one of thePROMOTE_1D_FIELDSthe schema knows how to carry through the Mooncake adapter, so the reward is available in the data plane, just not under a name or field list this fetch reads.write_to_dataplanecall ingrpo_sync.pywrites only{"advantages", "sample_mask"}.So
"rewards" in datais alwaysFalseinClippedPGLossFnon this path too. Fixing it means adding a fetched column and aliasing it torewardsfor the loss call, in a materially different fetch mechanism (a distributed column store, not a literal dict) that this PR does not touch and that deserves its own focused change and testing.Scope
This only adds the
rewardskey to the two in-process GRPO training paths. Everything else about howtrain_datais built, and the loss function itself, is unchanged.