fix(grpo): reject epoch bounds that train zero steps - #3986
Open
bzantium wants to merge 2 commits into
Open
Conversation
Three follow-ups to NVIDIA-NeMo#3248. `max_num_epochs <= 0` was skipped by that commit's `> 0` guard, so an async run went to `max_num_steps` unbounded — while both synchronous trainers gate on `while current_epoch < max_num_epochs` and fall straight through, returning 0 having trained nothing. And an empty training dataloader made the bound 0, so a fresh async run satisfied `step >= max_num_steps` before the collector started and printed "already complete": `StatefulDataLoader` sets `drop_last=True`, so a dataset smaller than `num_prompts_per_step` yields no batches at all. Both are config errors, so they are rejected in `setup()` alongside the other `_validate_*` helpers, before any Ray placement group exists — rather than in `async_grpo_train`, which runs after the clusters are up and would leave the synchronous trainers exiting 0 as they do today. `setup()` already computes `train_sample_count` for both the single- and multiple-dataloader branches, and this also floors the Megatron `train_iters` derived from the same operands, which has no `max(..., 1)` of its own here as `ppo.py` and the SingleController setup both do. The `<= 0` rejection follows `_validate_algo_settings` in `single_controller_utils/config.py`, which makes the same call for the same field: v1 async PPO's `-1` means "no epoch bound", but GRPO has never had that sentinel, and `None` is the shape reached for elsewhere when one is wanted. Third, the bound was applied by mutating `master_config.grpo.max_num_steps`, which `init_tmp_checkpoint` serializes into every checkpoint, so a run configured for 1000000 steps recorded the derived value. It is a local now, as `setup()` already does with `total_train_iters`. The existing test asserted the mutation; it asserts the config is untouched instead, the bound itself staying covered by the train call count and save state. Signed-off-by: ryan.u(류민호)/kakao <ryan.u@kakaocorp.com>
bzantium
force-pushed
the
fix/async-epoch-bound-zero-steps
branch
from
September 4, 2026 00:14
066213b to
6b15872
Compare
Three setup() tests patch StatefulDataLoader with a bare MagicMock, whose len() is 0, so the new empty-dataloader check fired before the AssertionError each of them asserts on. They stub a length now; the DummyLoader the other setup() tests use already had one. Also `ruff format`. Signed-off-by: ryan.u(류민호)/kakao <ryan.u@kakaocorp.com>
bzantium
force-pushed
the
fix/async-epoch-bound-zero-steps
branch
from
September 4, 2026 04:06
698d53f to
b54647b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do ?
Rejects, at setup time, the two
max_num_epochsbounds that make a run train zero steps — and stops the async bound from being written into the config it derives from.Three follow-ups to #3248.
max_num_epochs <= 0was skipped by that commit's> 0guard, so an async run went tomax_num_steps(default 1,000,000) unbounded. Both synchronous trainers gate onwhile current_epoch < max_num_epochs(grpo.py,grpo_sync.py) and fall straight through, returning 0 having trained nothing.An empty training dataloader made the bound 0.
StatefulDataLoaderis built withdrop_last=True, so a dataset smaller thannum_prompts_per_stepyields no batches; the clamp becomes 0, a fresh run satisfiesstep >= max_num_stepsbefore the collector starts, and it prints "Async GRPO training is already complete … limit of 0 steps" and returns 0. Before #3248 the same misconfiguration failed loudly at the exhausted collector.Both are configuration errors, so they are rejected in
setup()beside the other_validate_*helpers rather than at the top ofasync_grpo_train. That placement matters three ways:setup();setup()derives the Megatrontrain_itersfrom the same two operands with nomax(..., 1)floor (ppo.pyandsingle_controller_utils/setup.pyboth have one), so a non-positive bound handed Megatron-Bridge a scheduler horizon of 0.The
<= 0rejection follows_validate_algo_settingsinsingle_controller_utils/config.py, which makes the same call for the same field, with the same reasoning in its comment.Third, the bound was applied by mutating
master_config.grpo.max_num_steps.init_tmp_checkpoint(step + 1, vars(grpo_save_state), master_config)serializes that object into every checkpoint, so a run configured formax_num_steps: 1000000recorded the derived value instead of what the user wrote. It is a local now, assetup()already does withtotal_train_iters.Issues
Follows up #3248. Our earlier PR for the same underlying bug, #3948, is closed as superseded by it.
Usage
Unchanged for valid configs. The two rejected cases now fail at setup:
On
-1: v1 async PPO usesmax_num_epochs: -1to mean "no epoch bound", andexamples/run_ppo.pyraises unless it is exactly that. GRPO has never had that sentinel —GRPOConfig.max_num_epochsisint = 1, no shipped GRPO recipe or doc uses-1or0, and_validate_algo_settingsalready rejects<= 0forGRPOConfigon the SingleController path. Raising here cannot break a PPO recipe. If GRPO ever wants "unbounded", the shape used elsewhere in the codebase isNone, which this leaves available.Before your PR is "Ready for review"
Pre checks:
Additional Information
_validate_*tests. The existingtest_async_grpo_exit_on_max_epochskeeps covering the clamp; its assertion changed from "the config was mutated" to "the config is untouched".tests/unit/algorithms/test_grpo.pyneeds thenemomodule, which is not importable in the environment this was written in.