feat: add MOPD to single-controller text path - #3768
Conversation
Route teacher logprob inference through TransferQueue with deduplicated per-teacher serialization and wire OPD advantages into the single controller. Add configuration validation, failure cleanup, unit coverage, and a same-model nightly recipe. Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
Build teacher dynamic-batching metadata globally, warm the TransferQueue schema, and make teacher enrichment cleanup and cancellation safe. Pool OPD metrics from exact sufficient statistics and expand topology, routing, recipe, and lifecycle coverage. Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
|
/ok to test 5804832 |
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
|
/ok to test 7e7d721 |
yfw
left a comment
There was a problem hiding this comment.
Team review by 5 coordinated agents (rl-expert, bug-finder, test-agent, design-reviewer, devil's advocate) at head 7e7d721.
Overall: high-quality port — recommend addressing the two bugs before merge; nothing here is release-blocking. All 173 affected unit tests pass locally on this head, lint is green (thanks for the quick import-sort fix), recipe/nightly registration follows the conventions, and docstrings are complete.
Worth calling out as good design, explicitly: the typed PostWriteEnrichmentError enrichment seam (the buffer stays OPD-agnostic, failures classify by exception type, and the rollout retry budget is correctly not spent on post-write failures); TQTeacherLogprobCoordinator being fully unit-testable without Ray/GPUs; reuse of the legacy teacher reserve/create path with the init-ordering discipline pinned by a test; and the fail-fast MOPD validation before any resource allocation.
Findings summary: 2 bugs on config paths adjacent to (not hit by) the shipped recipe — a teacher dynamic-batching crash (executed repro, verified fix attached) and a router-replay gate that breaks MoE-student MOPD — plus 9 low-severity suggestions (1 perf-evidence ask, 2 dead-code notes, 1 stall note, 1 error-type nit, 2 design/tracking notes, 2 verified test additions). Devil's-advocate pass: 10 findings confirmed, 1 dropped as unreachable, 1 folded.
Generated by Claude Code
Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
|
/ok to test 359c895 |
terrykong
left a comment
There was a problem hiding this comment.
Important
This PR now has merge conflicts with main and needs a rebase. It was MERGEABLE when this review started and went CONFLICTING while the review was being written — four commits landed on main in between, including #3771 (fix(sc): skip reference logprobs when KL is disabled), which is the most likely overlap. None of them touched opd.py or tests/unit/algorithms/test_opd.py, so the findings below still apply as written, but the line numbers will shift after you rebase.
Re-review at 359c8958 by a team of 6 coordinated agents (rl-expert, bug-finder, test-agent, design-reviewer, an architecture tracer, and a devil's advocate).
All 9 findings from the previous review are verified genuinely fixed in b0b91a3 — three lanes checked each fix independently, including sibling branches, and found no partial fixes. Nothing below re-raises them. The 5-step legacy-vs-SC convergence comparison is accepted; nothing below re-requests performance or convergence evidence.
Overall this is a well-shaped port, and the hard parts are handled carefully: post-write TQ enrichment ordering, per-physical-teacher serialization with cross-teacher concurrency, DP padding with rollback under cancellation, driver-side global packing plans that keep DP collectives in sync, and exact pooled metrics across unequal streaming chunks. 4 low-severity items follow.
Design choices we examined and think are right — please don't churn these:
set_post_write_enricheris the right seam and the right size. A single optional callback invoked insidecommit()'s existingtry, so the enricher inherits theput_samplesrollback path for free. We looked for a second post-write consumer and could not name one (multimodal MOPD is this same enricher widened, not a second one; reference logprobs are a train-pump stage). Turning it into a stage list would force ordering and partial-failure semantics that nothing today can pin down.- The metric accumulators should stay inline. They score 2 of 3 on extraction — closed state and low reach-back, but no independent lifecycle. A
TeacherMetricsclass here would be an anemic helper. The coordinator itself earns being a class: it owns the per-teacher locks and a real stateful protocol. - The setup ordering is pinned executably, not just in comments:
assert events == ["reserve_teacher", "build_generation", "create_teacher"]turns "claim teacher GPUs before NeMo-Gym can take them, but load checkpoints after the student to avoid the shared HF-to-Megatron cache race" into a regression guard.
Two findings were raised and then killed by the adversarial pass, noted so they don't resurface: a proposed consistency guard in SingleControllerActor.__init__ (unreachable — validation enforces OPD-implies-teachers three times over) and a concern about the prev_logprobs/logprobs_policy kwarg branch (safe — validation is bidirectional and no estimator consumes both).
Caveat on verification: this review ran on macOS with no GPU, and the repo's uv.lock is linux-only, so no test and no linter was executed. Every suggestion below is marked unverified and should be run before merging.
Generated by Claude Code
Strengthen the SingleController MOPD nightly and batching/concurrency coverage. Reuse routing metric computation and omit cardinality values for idle enrichment intervals. Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com>
…roller-text Signed-off-by: Yi-Fu Wu <yifu.wu@gmail.com> # Conflicts: # nemo_rl/algorithms/single_controller.py # nemo_rl/algorithms/single_controller_utils/config.py # nemo_rl/algorithms/single_controller_utils/setup.py # nemo_rl/data_plane/schema.py # nemo_rl/data_plane/worker_mixin.py # tests/unit/single_controller/test_setup.py # tests/unit/single_controller/test_single_controller_actor.py # tests/unit/test_recipes_and_test_suites.py
|
/ok to test 37f9fed |
NVIDIA-NeMo#3768 added on_policy_distillation validation that reads algo_cfg.adv_estimator. DistillationConfig has no such field -- its teacher signal is the loss, not an advantage -- so a distillation config now dies with an AttributeError inside the validator. Reads it defensively and rejects the on_policy_distillation + distillation pairing explicitly, the same way the PPO pairing is already rejected. The two are different algorithms that both happen to use a teacher: MOPD routes per-agent teachers on their own nodes and turns their logprobs into an advantage, this path colocates one teacher with the trainer and feeds DistillationLossFn. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
NVIDIA-NeMo#3768 added on_policy_distillation validation that reads algo_cfg.adv_estimator. DistillationConfig has no such field -- its teacher signal is the loss, not an advantage -- so a distillation config now dies with an AttributeError inside the validator. Reads it defensively and rejects the on_policy_distillation + distillation pairing explicitly, the same way the PPO pairing is already rejected. The two are different algorithms that both happen to use a teacher: MOPD routes per-agent teachers on their own nodes and turns their logprobs into an advantage, this path colocates one teacher with the trainer and feeds DistillationLossFn. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
NVIDIA-NeMo#3768 added on_policy_distillation validation that reads algo_cfg.adv_estimator. DistillationConfig has no such field -- its teacher signal is the loss, not an advantage -- so a distillation config now dies with an AttributeError inside the validator. Reads it defensively and rejects the on_policy_distillation + distillation pairing explicitly, the same way the PPO pairing is already rejected. The two are different algorithms that both happen to use a teacher: MOPD routes per-agent teachers on their own nodes and turns their logprobs into an advantage, this path colocates one teacher with the trainer and feeds DistillationLossFn. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
Not every algorithm block defines it. The OPD test doubles main added in NVIDIA-NeMo#3768 build a bare namespace, and DistillationConfig has no reward to scale at all, so a hard attribute read turns this into an AttributeError on paths that never asked for scaling. A missing block is a disabled one, which is the shipped default anyway. Found by merging all twenty of my open PRs together: this passed alone and failed against main's MOPD tests. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…kens guard The rebase onto main left the new AdvantageResult call sitting *after* main's guarded block instead of replacing it, so the estimator ran twice and ran even when the sequence mask had removed the whole chunk -- which is exactly what test_advantage_stage_skips_estimator_when_seq_mask_removes_whole_chunk asserts must not happen. Also migrates the four estimator doubles main added since this PR opened (NVIDIA-NeMo#3768's OPD ones plus the GAE-like tuple) onto AdvantageResult, which is the point of the contract: a double that still returns a bare tensor or a tuple is modelling an interface that no longer exists. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…t-PPO Same class as the reward_scaling read one commit up. Only GRPOConfig carries advantage_clip_low/high; DistillationConfig does not, and the OPD test doubles main added in NVIDIA-NeMo#3768 build a bare namespace. "Not PPO" is not the same as "has the knob", so the not-is_ppo guard alone turns this into an AttributeError on paths that never configured clipping. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…ge reads Nine controller stubs in this file predate that attribute, so they pass here and fail once merged with current main -- NVIDIA-NeMo#3768 made _advantage_stage read it unconditionally. Same shape as the _rollout_manager stub gap in NVIDIA-NeMo#3783. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Two gaps that only show once this branch is merged with anything else:
- nine controller stubs predate _teacher_logprobs_required, which main's
NVIDIA-NeMo#3768 made _advantage_stage read unconditionally;
- the estimator double returns a bare tensor, which NVIDIA-NeMo#3512 replaces with
AdvantageResult.
The double now resolves AdvantageResult through the module rather than
importing it, so it returns whichever shape the checkout actually has and this
file does not depend on a name that exists only on the other branch.
Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
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>
NVIDIA-NeMo#3768 added on_policy_distillation validation that reads algo_cfg.adv_estimator. DistillationConfig has no such field -- its teacher signal is the loss, not an advantage -- so a distillation config now dies with an AttributeError inside the validator. Reads it defensively and rejects the on_policy_distillation + distillation pairing explicitly, the same way the PPO pairing is already rejected. The two are different algorithms that both happen to use a teacher: MOPD routes per-agent teachers on their own nodes and turns their logprobs into an advantage, this path colocates one teacher with the trainer and feeds DistillationLossFn. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The three nightly entries this branch adds bring the suite from 4138 to 4266 GPU hours: +32 vlm_grpo-nemotron-omni-30ba3b-clevr-2n8g-megatron-tp8ep8.v1 +32 vlm_grpo-nemotron-omni-30ba3b-clevr-2n8g-...v1-tq_mooncake +64 vlm_grpo-qwen3.5-35ba3b-geo3k-2n8g-automodel-ep16-tq_simple Two of those are the VLM data-plane coverage itself; the third is the 2-node base the mooncake wrapper delegates to, which exists because the recipe does not fit on one node. Follows the convention for adding nightly coverage -- the cap has moved 4048 -> 4139 -> 4149 -> 4157 -> 4181 -> 4197 as recipes landed (#2744, #3773, #3768, #3727, #3678). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@NVIDIA.com>
What does this PR do ?
Adds text-only Multi-Teacher On-Policy Distillation (MOPD) support to the new SingleController GRPO path.
Previously, MOPD was supported only through the legacy controller. This ports the feature to the TransferQueue-based SingleController flow while keeping teacher inference non-colocated.
Implementation
on_policy_distillation.enabled=truegrpo.adv_estimator.name=opdagent_refmetadata.teacher_reference_logprobsback to the same rows;logprob_mb_tokens.Limitations
Issues
List issues that this PR closes (syntax):
Usage
# Add a code snippet demonstrating how to use thisBefore your PR is "Ready for review"
Pre checks:
Additional Information