Train with Single-Controller (Async GRPO)#
Warning
The Single-Controller path is a beta feature and still under active development. The API and configuration surface are not yet stable and may change without notice. Issues and feedback are welcome — please file them at github.com/NVIDIA-NeMo/RL/issues.
The Single-Controller (SC) path is an alternative async GRPO runtime that runs rollout generation and policy training as two independent pumps coordinated by a single Ray actor (SingleControllerActor) sitting over a shared TransferQueue (TQ) data plane. Compared to the legacy async GRPO in async-grpo.md, SC decouples per-prompt rollouts from the per-step batch boundary: producers push finished rollouts into TQReplayBuffer at group granularity, and a pluggable StalenessSampler decides which groups the trainer consumes on each step.
Configure the Single-Controller Path#
The SC path is launched via a dedicated entrypoint:
uv run examples/run_grpo_single_controller.py --config <your-sc.yaml>
run_grpo_single_controller.py mirrors run_grpo.py for config loading — the same YAML files apply — but requires a few settings the legacy path does not. The default exemplar lives at examples/configs/grpo_math_1B_megatron_single_controller.yaml.
Mandatory settings#
Enable the TransferQueue data plane (required — the entrypoint refuses to start otherwise):
data_plane: enabled: true
Enable vLLM async engine and disable colocated inference (SC drives rollout via
RolloutManager.generate_and_push, which is only supported on the disaggregated async engine):policy: generation: backend: "vllm" vllm_cfg: async_engine: true colocated: enabled: false resources: num_nodes: 1 gpus_per_node: 4 # inference GPUs; remainder go to training
One RL step = one optimizer step. The SC train pump does not support multi-mini-step inside a single RL step (see
validate_single_controller_configin nemo_rl/algorithms/single_controller_utils/config.py):num_prompts_per_step * num_generations_per_prompt == policy.train_global_batch_size
Enable importance sampling correction whenever the sampler admits off-policy data (any
max_staleness_versions > 0on thewindowed/weight_fifosamplers, ormax_lookahead_versions > 0onin_order). The correction and its derivation are the same as for legacy async GRPO — see Why Importance Sampling Correction Is Required for Async:loss_fn: use_importance_sampling_correction: true
Async-RL Knobs and Sampler Modes#
All SC async-RL runtime knobs live under async_rl: in the master config. The most important choice is the sampler, which sets the staleness policy shared by the rollout pump (how far it may run ahead) and the train pump (which groups it may consume).
Sampler modes#
Pick one of four modes with sampler.name. Each mode takes its own knobs, listed below — a knob from one mode has no effect under another:

Same buffer under trainer weight 2 (num_prompts_per_step=2, staleness window [0, 2]). windowed and weight_fifo select on start_weight (stamped at dispatch); in_order selects on target_step (stamped at admit). The three usually pick the same groups and diverge only when rollouts finish out of order, as drawn here.
|
Rollout gating |
Train selection |
Typical use |
|---|---|---|---|
|
Dispatch may lead the trainer by up to |
Consume the group whose |
Sync mode ( |
|
Same gate as |
Drain the oldest in-window |
Strict weight-version FIFO under a bounded lookahead. |
|
Ungated — rollout keeps producing until the buffer fills. |
Take any ready group with |
Over-sampled streaming; aged groups outside the window are evicted (wasted compute). |
|
Determined by the imported class. |
Determined by the imported class. |
|
Config → behavior map#
The shipped exemplars cover three of the four modes:
Mode |
|
Sampler knob |
|
|
Exemplar |
|---|---|---|---|---|---|
Sync / on-policy |
|
|
|
|
|
Async, exact batch→step matching |
|
|
|
|
|
Streaming, gated dispatch |
|
|
|
|
— (none shipped) |
Streaming, over-sampled |
|
|
|
Larger than the gated capacity (dispatch is ungated) |
|
Field definitions:
max_buffered_rollouts— hard cap on unconsumed rollout groups buffered in the data plane. Validated at setup against the gated sampler’s required capacity; a value too small deadlocks the rollout pump, so setup raises instead of silently blocking.min_groups_for_streaming_train— minimum ready groups the trainer waits for before dispatching a batch. Set tonum_prompts_per_stepfor sync/legacy semantics; lower for streaming.
Implementation Structure#
The SC path splits the async-GRPO loop across a rollout pump and a train pump that share a TQReplayBuffer and are orchestrated by the SingleControllerActor.

The driver builds every heavy object and cloudpickles it into SingleControllerActor (a CPU-only Ray actor). TQReplayBuffer, RolloutManager, and the sampler live inside that actor — reaching them is a direct call, not RPC. Only the generation worker group, TQPolicy, and the TransferQueue data plane are separate processes; the dashed arrows are the only hops that cross a process boundary.
Core components#
1. SingleControllerActor (nemo_rl/algorithms/single_controller.py)#
Single Ray actor that runs
_rollout_pumpand_train_pumpconcurrently as asyncio tasks.Receives a fully-constructed
SingleControllerActorArgs(cloudpickled by the driver) — the actor does no construction work of its own, because running setup inside a nested Ray actor breaksruntime_envresolution. Exception:Loggeris built inside the actor, because wandb/TB backends hold a_thread.lockthat cloudpickle can’t serialize.On startup, rebinds
self._rollout_manager._tq_buffer = self._buffer.rollout_managerandtq_bufferare separate fields on the args dataclass, so Ray deserializes them as two independent buffers; without the rebind, the writer and the sampler would see different copies and the sampler would never observe committed groups.Pump crashes propagate to the driver; in-flight rollouts drain on exit.
2. TQReplayBuffer (nemo_rl/algorithms/async_utils/replay_buffer.py)#
Group-granular replay buffer with reserve/commit slot accounting.
start_weightis stamped on the slot atreserve(dispatch);committensorizes the group into N training-shaped rows, recordsend_weight, and flips the slot ready. Because slots are appended at reserve, buffer index order equals dispatch order andstart_weightonly ever increases down the buffer — which is whywindowedandweight_fifousually pick the same groups and diverge only when rollouts finish out of order.Tracks
target_stepper group when the sampler assigns one at admit time (used byin_order).
3. RolloutManager.generate_and_push (nemo_rl/experience/rollout_manager.py)#
One entry point per prompt group: reserve a buffer slot, drive the rollout via
AsyncRolloutImplorAsyncNemoGymRolloutImpl, and commit with the observed weight versions.env_handlesprovide per-task environments to the rollout implementations.
4. Samplers (nemo_rl/algorithms/async_utils/staleness_sampler.py)#
Filter-only prompt-group selector over
TQReplayBuffer. The basePromptGroupSamplerprotocol definesadmit,select, andevict.WindowedSampler,WeightFifoSampler,InOrderSamplerare the built-in policies (one per row in the Sampler modes table). Thecustommode (CustomSamplerConfig.target) makescreate_samplerimport a user-supplied class by FQN and type-check it againstPromptGroupSampler.
5. _rollout_pump and _train_pump#
_rollout_pump: pulls prompts from the dataloader, callssampler.admit, dispatchesRolloutManager.generate_and_push, and honoursmax_inflight_promptsas a backpressure cap._train_pump:sampler.evict → sampler.select → _advantage_stage → TQPolicy split API (begin_train_step / train_microbatches_from_meta / finish_train_step) → dp_client.clear_samples.
Coordination Flow#
Driver setup:
setup_single_controllerbuilds the worker groups, virtual cluster, dp client, dataloader,TQReplayBuffer,RolloutManager, and weight synchronizer, and packs them into aSingleControllerActorArgsthat the entrypoint cloudpickles into the actor.Actor startup:
SingleControllerActorlaunches_rollout_pumpand_train_pumpconcurrently as asyncio tasks; both share the sameTQReplayBufferandStalenessSampler.Rollout pump loop:
sampler.admitgates dispatch against the current trainer version (returning atarget_stepforin_order); the pump then reserves a buffer slot, drivesRolloutManager.generate_and_push, and commits with the observedstart_weight/end_weight.Train pump loop:
sampler.evictdrops out-of-window groups,sampler.selectpicks the next batch,_advantage_stagecomputes advantages, and the TQPolicy split API runs one optimizer step per RL step.Weight sync: after each optimizer step the pump bumps the trainer version, clears rollout permission, calls the weight synchronizer, and re-opens the rollout pump for the next version.
Relation to Legacy Async GRPO#
The legacy async GRPO (grpo.async_grpo.enabled: true under run_grpo.py) and the SC path both target the same async training problem but partition responsibilities differently:
Legacy async GRPO |
Single-Controller |
|
|---|---|---|
Entrypoint |
|
|
Data-plane |
Direct actor RPC |
TransferQueue ( |
Rollout batching |
Full-batch |
Per-prompt |
Staleness policy |
Single knob ( |
Pluggable |
Batch boundary |
Sampled by target weight |
Sampler-defined; can decouple rollout dispatch from train batch (streaming) |
Migrating a legacy async config#
SC reads its async knobs from async_rl: and requires grpo.async_grpo: null — run_grpo_single_controller.py raises if a legacy block is still present, so null it out when porting rather than leaving it in place.
Legacy |
SC equivalent |
|---|---|
|
Implicit — SC is always async; use |
|
|
|
|
|
Always effectively true; |
(no legacy equivalent — matches legacy full-batch train semantics) |
|
(no legacy equivalent — matches legacy |
|
(no legacy equivalent — legacy sizes its buffer to |
|
Known Missing Features#
The SC path is still under active development. Feature gaps are tracked in issue #2625. Notable items:
Train backend: only Megatron is supported and validated; the AutoModel training path has not been tested on SC.
Generation backend: only vLLM is supported and validated; Megatron generation, SGLang, and TRT-LLM have not been tested on SC.
Checkpointing and validation are not yet supported (setup raises if enabled).
The
windowedsampler has noover_sampling_ratiocap — over-produced groups aged past the window are evicted, wasting rollout compute.The drain gate in refit is not yet supported.