feat(sc): support PPO in single controller - #3773
Conversation
|
/ok to test a61654e |
|
/ok to test aa5bfb3 |
|
/ok to test 5026969 |
|
Four places this touches what I have open. Flagging them so the ordering is yours, not something we hit in a conflict. 1. 2. 3. 4. The test renames. This moves 1 and 2 are pre-existing and open separately; no action needed for this PR to be correct. Mentioning them because "SC never applied it" is the same shape as your #3770, and the PPO path picks both up :) |
|
hi @tianyi-zhang-02 , thanks for your interest. this is the init implementation of single controller + PPO and is aim to get into main in recent days. many things is still subject to change, feel free to follow this PR to watch. |
|
Went through the whole diff rather than just my overlaps. One finding, one note, and four things that hold up — recording those too, since "I looked and it is fine" is worth as much to you as a list. Finding, low severity: Five of six are converted — It matters a little more than an annotation. This PR narrows the name to Note: this closes a #2625 item. "Support multiple mini-steps inside a single RL step" is still unchecked there, and Checked and clean — listing these so you know where I did not find anything:
Three lenses I did not get to: the GAE recursion itself (mask handling at sequence ends, VAPO decoupled λ, |
|
Closing two of the three lenses. Both clean, no action needed. The six-file rename drops no test. Collected 67 net new, nothing lost.
That leaves the GAE recursion: mask handling at sequence ends, the decoupled |
|
/ok to test 9d61ad0 |
yuki-97
left a comment
There was a problem hiding this comment.
Self-review pass over the whole diff, done with a team of specialised agents plus an adversarial verification round. 14 inline comments below: 3 correctness bugs, 9 suggestions, 1 batched nit.
Rebase needed -- the PR is currently CONFLICTING against main. Worth doing before the comments below, since a few of them touch files that will move.
What held up well, so it does not get lost among the findings: test coverage is genuinely strong -- ppo_epochs > 1, the critic warmup path, the value stage, the pad-target isolation and save/resume across the warmup boundary all have real unit coverage, and three reviewers independently said so. _validate_algo_settings is the right pattern, with seven config-time rejections whose messages each name the fix. Keeping PPO_VALUE_FIELDS out of DP_TRAIN_FIELDS so a missing column errors rather than reading zeros is the correct call. driver_mixin.py is a real two-consumer extraction. The Megatron loss-averaging counteraction from 6e103d0 is fully preserved, VPP is asserted off, PP is handled correctly, and every Megatron API call checks out against the pinned SHA.
Checked and cleared, listed so nobody re-derives them: the GAE recursion, masking and normalize_advantages axis are unchanged at the merge base; the logprobs -> logprobs_policy rename is a fix rather than a break (SC already used the new names, so its KL kwargs were being swallowed by **kwargs); train_iters = max_num_steps * ppo_epochs matches ppo.py verbatim; old logprobs, values and advantages are computed once before the epoch loop, which is correct PPO; the six-file rename drops no test and leaves no stale reference anywhere in the tree; both new test files genuinely collect in their CI shards.
Two candidate findings died in verification and are recorded here rather than posted. The nightly GPU-hour cap going to 4190 when the measured total is 4155 looked like slack that should be tightened -- but git log -L on that assertion shows the cap has never been an exact fit, so 35 hours of headroom is normal practice. And adv_estimator.name: raw_reward reaching the critic with no returns column is real, but ppo.py:1580 does the identical thing, so it is ported behaviour, not something this PR introduces.
One scope note: roughly 60 lines of the diff are unrelated churn -- pyrefly.toml re-sorted end to end, the L1 driver reordered (which is what carried away the 42 comments flagged below), and two of the six renames are pure no-ops. Splitting the rename and re-sort commits out would make this easier to review.
Generated by Claude Code
|
/ok to test b5b1bb8 |
|
/ok to test 90c48c9 |
|
Looked at the five reds. They are two separate things, and I could only pin one down. The three All three fail the same way:
# Normally set from get_rank_safe() in __init__, which object.__new__ skips.So the fixture needs one more line next to it,
A threshold miss in 2.4% over. With no main baseline I cannot tell whether this PR moved it or it was already close. One thing that might help you decide:
|
|
/ok to test 8fda593 |
Signed-off-by: Yuki Huang <yukih@nvidia.com>
… modules back in pyrefly project-includes, add the value stages to the data-flow diagram Signed-off-by: Yuki Huang <yukih@nvidia.com>
… hoist the warmup-lookahead guard onto the GRPO path, unprefix shared validation messages, retune the nightly thresholds Signed-off-by: Yuki Huang <yukih@nvidia.com>
…r-identical groups Removed: - test_ppo_setup.py TestPPOValidation::test_the_ppo_schema_rejects_a_non_ppo_estimator asserted exactly what test_ppo.py::test_ppo_schema_rejects_unsupported_estimator_name already does, and it covers the PPOConfig schema rather than SC setup. - test_single_controller_actor.py::test_train_pump_loads_and_offloads_the_critic_around_each_stage asserted a critic call sequence that is a strict subsequence of the interleaved list in test_train_pump_parks_the_policy_on_cpu_across_the_critic_stages. - test_single_controller_actor.py::test_train_pump_steps_both_optimizers_once_per_ppo_epoch asserted per-epoch counts that follow from the exact call list in test_train_pump_offloads_the_policy_between_ppo_epochs; its two unique assertions, one refit and one version bump per RL step, moved there. - test_sampler_interface.py TestWarmupLookaheadWindow::test_set_gate_window_retunes_admission asserted the private _gate_window; the setter's observable effect is covered by test_capacity_does_not_shrink_when_the_gate_is_retuned and by TestLookaheadSchedule. Parametrized, with the same set of cases as before: - TestIsPPORun's three tests differed only in the config handed to a one-line predicate. - TestTrainClusterSizesForTheCritic's five tests were one body over (colocated, backend, algorithm); the _groups helper goes with them. - TestMegatronTrainIters' two injection tests differed only in ppo_epochs. - test_train_pump_runs_every_critic_epoch_during_warmup folds into test_train_pump_freezes_the_policy_during_critic_warmup as ppo_epochs=2, with the critic-train count asserted against ppo_epochs. - test_init_leaves_the_critic_handles_unset_on_a_grpo_run folds into test_init_picks_up_the_critic_handles as the handles-absent case. - TestPPOWarmupCheckpoint's two optimizer-path tests differed only in is_policy_training_step. The metric pair stays split: one expects a warning, the other an exception. - test_tq_value.py's two get_values_from_meta tests shared a patch stack; the packing-budget check is now one assertion in the surviving test. test_rejects_a_config_with_neither_block and test_rejects_a_config_with_both_blocks move to TestPPOValidation, since both exercise validate_single_controller_config rather than algo_config. Signed-off-by: Yuki Huang <yukih@nvidia.com>
…orker, cover the finish-path re-enable Signed-off-by: Yuki Huang <yukih@nvidia.com>
…PO-only drop-budget constraint Signed-off-by: Yuki Huang <yukih@nvidia.com>
…w guards, make the warmup top-k assertion observable, tighten the nightly GPU-hour cap to 4149 Signed-off-by: Yuki Huang <yukih@nvidia.com>
Signed-off-by: Yuki Huang <yukih@nvidia.com>
…, guard ppo.async_ppo=null in run_ppo.py, make the advantage-estimator logprob args keyword-only, document the colocated branch as unreachable Signed-off-by: Yuki Huang <yukih@nvidia.com>
…b is required Signed-off-by: Yuki Huang <yukih@nvidia.com>
|
/ok to test 6d303d3 |
Signed-off-by: Yuki Huang <yukih@nvidia.com>
|
/ok to test b60aee0 |
Rebased onto NVIDIA-NeMo#3773, which added overlong_filtering to the unsupported list -- "SC reads none of these on either path, so an enabled one describes shaping this run does not do". That reasoning is right while nobody implements it, and this PR implements it: build_sample_mask drops truncated rows from the loss, from the flag the Completion already carries. So the rejection comes off the list and its test becomes the opposite assertion. Also fixes a real break from the rebase: the TQReplayBuffer wiring read grpo_config.overlong_filtering, and NVIDIA-NeMo#3773 made master_config.grpo optional, so that name no longer exists. It now goes through algo_config(master_config) like everything else in that file. test_setup_forwards_latest_resume_paths catches it. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
14 upstream commits; four of them produced seven conflicts. Each resolution below. #3612 feat(sglang): megatron backend weight refit for sglang rollouts - weight_sync/factory.py: it rewrote the train_cluster/inference_cluster/ refit_buffer_size_gb docstrings (SGLang owns its own process group, so it needs neither cluster handle). Took its wording and kept our refit_timeout_s entry, which it never saw. refit_timeout_s still reaches NcclReshardWeightSynchronizer and CollectiveWeightSynchronizer; the new SGLang synchronizer does not take it, which is correct -- our watchdog bounds a JOINT communicator and SGLang does not build one. - base_policy_worker.py: it added _refit_transport_state and connect_sglang_rollout_engines at the same insertion point as our stand_down_refit_watchdog. Disjoint additions; kept both. - pyrefly.toml: it swapped http_weight_synchronizer for sglang_weight_synchronizer. Corroborated by the merge deleting http_weight_synchronizer.py outright. #3773 feat(sc): support PPO in single controller - single_controller_utils/setup.py: the SC path is no longer GRPO-only, so it renamed grpo_config to algo_cfg. Kept our nccl_reshard precondition guard and applied the rename to the val-period line inside it; grpo_config no longer appears anywhere. - L1_Functional_Tests_SingleController.sh: it added a ppo_async run_test and padded every non-fast entry to align with "run_test fast". Kept our annotation -- it says which of skip-vs-pass a green lane actually means, which its one-line version does not -- and adopted the alignment, including on our seven recovery entries, so the file does not end up half-converted. - pyrefly.toml: it re-sorted the list, moving vllm_remote_sparse_weight_synchronizer to its correct alphabetical slot. Our side had added membership.py AND held that entry in the old position, so taking our block verbatim would have duplicated it. Kept membership.py only; verified the result is sorted and has no duplicates. #3545 fix(vllm): support native BF16 FlashInfer TRTLLM refit - vllm_backend.py: its _nrl_layerwise_reload_* class attributes landed where our model_update_group declaration is. Disjoint; kept both. - tests/unit/models/generation/test_vllm_backend.py: its layerwise-reload suite against our init_collective release tests plus the _RecordingGroup fixture. Disjoint; kept both. 53 tests collect. #3768 feat: add MOPD to single-controller text path - Touched setup.py alongside #3773; no separate resolution needed. Submodule: the merge advances Megatron-Bridge to d352aced (#3824). Verified the STAGED pointer is upstream's and not our stale 8c46dc42 -- staging the local one is what breaks the fast-forward check and `uv lock --check` together. Gym is untouched by the merge. Verified after resolving: no conflict markers remain, all four lint hooks clean (the one pyrefly error is the pre-existing unrelated transfer_queue import), and 841 unit tests pass across single_controller, refit_watchdog, worker_refit_signatures and weight_sync -- up from 725, because #3773 brings a large new SC suite that passes alongside ours. Signed-off-by: asolergibert <asolergibert@nvidia.com>
…o PR4
Brings PR3's sync with upstream/main down the stack. Three conflicts, all between PR4's
own additions and changes that arrived from main:
pyrefly.toml
#3773 re-sorted the list and moved generation/fleet_health.py to its correct
alphabetical slot. PR4's block added engine_supervisor.py AND held fleet_health.py in
the old position, so taking it verbatim would have duplicated the entry -- the same
trap this file set on PR3 with vllm_remote_sparse_weight_synchronizer. Kept
engine_supervisor.py only, placed where it sorts (after dynamo/, before fleet_health).
Verified the whole nemo_rl list is sorted and duplicate-free.
nemo_rl/algorithms/single_controller.py
PR4's EngineSupervisor wiring against #3768's MOPD TQTeacherLogprobCoordinator, both
landing in the same __init__ region. Disjoint, so both kept, with the coordinator
first because it installs a post-write enricher on the buffer.
Also removed a duplicate this merge would otherwise have introduced: main relocated
the `_rollout_manager._tq_buffer = self._buffer` rebind next to the assignment it
guards, so PR4's copy at the old site became redundant. Confirmed main carries it
exactly once before deleting the second.
tests/functional/L1_Functional_Tests_SingleController.sh
PR4's RESTART_DEAD_SHARDS entry against the column alignment #3773 introduced. Kept
the entry and applied the alignment to it and to every other unpadded line, so the
file is not left half-converted.
Submodules: both pointers match upstream/main exactly (Megatron-Bridge d352aced from
#3824, Gym c3bac963), so the fast-forward check and `uv lock --check` both see a clean
state. The locally dirty submodule working trees were deliberately not staged.
Verified: no markers remain, all four lint hooks clean (the single pyrefly error is the
pre-existing unrelated transfer_queue import), 849 unit tests pass.
Signed-off-by: asolergibert <asolergibert@nvidia.com>
What does this PR do ?
Ports PPO — including the critic/value model — to the SingleController (SC) path, so an async PPO run can be driven by
examples/run_grpo_single_controller.pyinstead ofrun_ppo.py. Megatron critic only, async only.What lands here
Config.
MasterConfig.grpobecomes optional and appo:block is added beside it; the two are mutually exclusive and every read goes throughalgo_config(). The advantage estimator config moves fromdictto a BaseModel withnamepinned to aLiteral._validate_algo_settingsrejects the misconfigurations listed below at setup time, each message naming the fix.Value model on the data plane. New
TQValue(driver) andget_values_presharded(worker), withTQDriverMixinextracted fromtq_policy.pyso the policy and the critic share one copy of the packing / data-plane helpers.PPO_VALUE_FIELDSis deliberately kept out ofDP_TRAIN_FIELDSso a GRPO run never fetches a column nobody wrote.Train pump.
_value_stage(critic forward) and_value_train(critic optimizer step) around the existing advantage stage, plusppo.ppo_epochs(N optimizer steps per RL step, the policy offloaded between epochs — closes the "multiple mini-steps inside a single RL step" item in #2625),ppo.policy_training_start_step(critic warmup: weight sync skipped, policy optimizer not checkpointed, step excluded from best-checkpoint tracking), andasync_rl.sampler.warmup_lookahead_versions(the SC equivalent of legacywarmup_generation_lead_steps; buffer capacity is sized from the peak window). The critic forward runs after the policy and reference logprobs rather than before as inppo.py, the policy is parked on CPU across both critic stages, and the two checkpoint saves are serialized critic-first.Setup. The train cluster is sized for two worker groups on a PPO run, the critic is built and resumed alongside the policy, and the Megatron tick budget is scaled by
ppo_epochs.Constraints enforced at config time
ppo.async_ppo: null— SC drives the async loop itself (rejected in the entrypoint).ppoandgrpoare mutually exclusive, and exactly one must be set.valueandvalue_loss_fnare present iff theppoblock is, andvalue.megatron_cfg.enabled: true.ppo.ppo_epochs >= 1.async_rl.sampler.name: in_order.min_groups_for_streaming_train == ppo.num_prompts_per_step.value.train_global_batch_size == num_prompts_per_step * num_generations_per_prompt, so one RL step is one criticoptimizer.step.async_rl.rollout_failure.max_skipped_promptsandmax_consecutive_dropped_promptsare both 0.warmup_lookahead_versionsrequirespolicy_training_start_step > 0.ckpt_assume_constant_structureis rejected together with critic warmup + optimizer checkpointing.policy.offload_optimizer_for_logprob: trueis required but not validated — nothing else on the SC path takes the policy optimizer off the GPU.Not covered (intentional, tracked)
Rejected at setup rather than silently skipped, except where noted.
ppo.overlong_filtering,reward_shaping,reward_scaling,use_dynamic_sampling— implemented on neither algorithm block on the SC path ([Single Controller / Async RL] cleanup tracking issue #2625).in_order—windowed,weight_fifoandcustomare not supported under PPO.value.train_global_batch_sizerather than its actual size, so the first short step fails a divisibility assert inside the value workers. The resiliency layer (feat(sc): tolerate and replace dropped rollouts in the SingleController #3665) stays available on GRPO.adv_estimator.name: raw_rewardunder PPO — yields noreturnscolumn;ppo.pybehaves the same way. Not rejected.total_flopsis reported from the last epoch only, so it under-reports N-fold underppo_epochs > 1; throughput here is not comparable to the v1 path.run_grpo_single_controller.pytorun_single_controller.py— separate PR, to keep this diff reviewable.Issues
Advances #2625 (does not close it). The SC exemplar added here inherits the exemplar-sizing OOM tracked in #3793.
Usage
examples/configs/ppo_math_1B_megatron_single_controller.yamlis the exemplar (schema documentation / starting point). Running it as-is OOMs — see #3793, which covers all three PPO exemplars. The functional test drives it with the usual smoke-test overrides (Qwen/Qwen2.5-0.5B,num_prompts_per_step=2,train_global_batch_size=8, 2 GPUs) and passes, as does the 2-node nightly recipe.Before your PR is "Ready for review"
Pre checks:
Additional Information
Tests added. Unit:
test_ppo_setup.py,tests/unit/models/value/test_tq_value.pyandtest_sampler_interface.pyare new, plus PPO coverage intest_single_controller_actor.pyandtest_checkpointing.py— the epoch loop, the warmup path, the value stage, pad-target isolation, and save/resume across the warmup boundary. Functional:tests/functional/ppo_async_single_controller.sh, registered in the L1 list. Nightly: the 2n8g recipe above, added tonightly.txt; its thresholds come from this recipe's own 40-step run, not the legacy recipe's — at 256 prompts/step it has consumed a quarter of the legacy run's data by step 40.Docs.
docs/guides/single-controller.mdnow covers the PPO path: theppo:/value:/value_loss_fn:blocks, the constraints above,warmup_lookahead_versions, and the legacyppo.async_ppo.*→async_rl.*migration table.Test Results. yellow: legacy with same settings; red: v2, this PR; blue: v2, this PR. resume from ckpt 20.
