Train with Single-Controller (Async GRPO and PPO)#
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 and PPO 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; the PPO one at examples/configs/ppo_math_1B_megatron_single_controller.yaml.
Mandatory settings#
Enable the TransferQueue data plane (required — the entrypoint refuses to start otherwise):
data_plane: enabled: true
Pick a generation backend. With vLLM, disable colocated inference (setup rejects
colocated.enabled: truefor every backend except Megatron) and enable the async engine (SC drives rollout viaRolloutManager.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
Megatron generation is also supported, colocated or non-colocated. It requires the Megatron trainer (
policy.megatron_cfg.enabled: true) and NeMo-Gym rollouts additionally requirepolicy.generation.mcore_generation_config.expose_http_server: true. Colocated (colocated.enabled: true) additionally requiresasync_rl.min_groups_for_streaming_train == num_prompts_per_step: the engine stands down for the whole train step, so each step must be assembled as one full batch before training takes the GPUs. Per-request deadlines (generation_timeout_s,rollout_timeout_s) exclude the time the engine is stood down: in-flight requests freeze with their clocks suspended, and the clocks resume on the post-step wake. The non-colocated exemplar — a NeMo-Gym run with the OpenAI server exposed — lives at examples/nemo_gym/grpo_qwen3_0_6b_megatron_generation_single_controller.yaml; the colocated exemplar at examples/configs/grpo_math_1B_megatron_generation_colocated_single_controller.yaml:policy: megatron_cfg: enabled: true generation: backend: "megatron" mcore_generation_config: expose_http_server: true # required for NeMo-Gym rollouts colocated: enabled: false # set true for colocated (no resources split needed) resources: num_nodes: 1 gpus_per_node: 1 # inference GPUs; remainder go to training
One RL step = one training batch. The batch a step trains on is the whole step (see
validate_single_controller_configin nemo_rl/algorithms/single_controller_utils/config.py). A GRPO step is also one optimizer step. A PPO step appliesppo.ppo_epochsactor updates andppo.critic_ppo_epochscritic updates over that same batch. Both counts must be at least 1 and can be configured independently; the exemplar defaults the critic count to${ppo.ppo_epochs}.num_prompts_per_step * num_generations_per_prompt == policy.train_global_batch_size num_prompts_per_step * num_generations_per_prompt == value.train_global_batch_size # PPO
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
Save the data plane for replay recovery. When Single-Controller checkpointing is enabled, all built-in samplers require
checkpointing.save_data_plane: trueso completed, unconsumed rollout groups survive a restart. Native TQ checkpointing currently supports only thesimplestorage backend. For multi-node runs,checkpoint_dirmust be on a durable filesystem visible at the same path from every node.checkpointing: enabled: true checkpoint_dir: /shared/checkpoints/my-run save_data_plane: true data_plane: enabled: true backend: "simple"
(PPO) Set
ppo:instead ofgrpo:— the two algorithm blocks are mutually exclusive, and SC reads every step setting from whichever one is present. A PPO run also needsvalue:,value_loss_fn:andppo.adv_estimator.name: gae(same schemas as legacy PPO), a Megatron critic, andpolicy.offload_optimizer_for_logprob: true, which is what keeps the policy optimizer off the GPU while the critic runs.ppo.policy_training_start_step: Ngives the usual critic warmup: for the first N steps the policy is neither trained nor refit, while the critic trains every step.ppo.warm_start_value_checkpointseeds that critic from another run’s checkpoint instead, so a fresh run can skip the online warmup entirely — see Warm-Starting the Critic.
Checkpointing and Replay Recovery#
With checkpointing.save_data_plane: true, each Single-Controller checkpoint contains:
The normal model, dataloader, and controller state, plus optimizer state when configured.
A native TQ snapshot containing rollout tensor payloads and TQ state.
A metadata-only replay index describing the completed rollout groups stored in TQ.
A
rollout_recovery.ptownership ledger describing unfinished prompt groups that must be redispatched after a restart.A
replacement_reserve.ptsidecar containing prompts held for dropped-rollout replacement, when applicable.The sampler dispatch position needed to continue scheduling from the correct point.
The TQ snapshot and replay index are captured under the same checkpoint barrier. Generation may continue while the snapshot is written, but completed-group commits and destructive TQ clears wait at the barrier. This ensures that the TQ snapshot and replay index describe the same set of groups.
On resume, Single-Controller validates the TQ snapshot against the trainer checkpoint, restores the replay index, and makes completed, committed, unconsumed groups available to the sampler before training resumes.
Replay recovery is supported by all built-in samplers: in_order, weight_fifo, ready_first, and windowed. Custom samplers must explicitly declare supports_buffer_checkpoint = True. Otherwise, setup emits a warning and completed buffered groups are not restored.
Periodic rollout snapshots#
Normal trainer checkpoints are written at step boundaries. Periodic rollout snapshots preserve newer rollout progress between those trainer checkpoints, including while the train pump is accumulating a streamed step:
checkpointing:
enabled: true
checkpoint_dir: /shared/checkpoints/my-run
save_data_plane: true
save_period: 1
rollout_checkpointing:
snapshot_attempt_interval_s: 120
keep_latest_k: 2
restore_mode: latest
extra_fingerprint_excluded_paths: []
token_capture:
enabled: true
snapshot_attempt_interval_s is the cadence at which Single-Controller attempts
a rollout snapshot. It is not a guarantee that a snapshot is written at every
interval. An attempt after step N succeeds only when the immutable trainer
checkpoint step_N is already durable. Consequently, save_period: 1 is
recommended for continuous post-step coverage; with a larger value, attempts
are skipped until the matching trainer checkpoint exists. Before the first
training step, snapshots are anchored to the initial model and a fingerprint of
the rollout-semantic configuration.
The bootstrap fingerprint is fail-closed: every configuration value affects compatibility unless NeMo-RL’s built-in denylist identifies it as operational, such as logging, cluster placement, checkpoint location, runtime ports, or credentials. This means configuration added by an external algorithm is safe by default—a change prevents bootstrap recovery instead of silently mixing incompatible rollout state. The bootstrap manifest also stores this credential-redacted compatibility identity so a rejected restart can report the exact changed dotpaths instead of showing only two opaque digests.
An integration may use extra_fingerprint_excluded_paths for additional
runtime-only values that are not part of NeMo-RL’s built-in configuration:
rollout_checkpointing:
extra_fingerprint_excluded_paths:
- custom_algo.observability
- env.private_agent.runtime_endpoint
- env.private_agent.workers.*.log_dir
Each dotpath removes that value and its children from the compatibility
identity. * matches one mapping or list level and ** matches any number of
levels.
Only exclude values that cannot affect prompts, generation, rewards, lineage,
or the interpretation of persisted rollout data. These exclusions must be set
on the original run as well as its restart.
Periodic snapshots currently require all of the following:
checkpointing.enabled: trueandcheckpointing.save_data_plane: true.data_plane.backend: simple, because native TQ save/load is required.token_capture.enabled: true.A replay-recoverable sampler with training-claim ownership. All built-in samplers qualify. A custom sampler must explicitly declare both
supports_buffer_checkpoint = Trueandsupports_training_claims = True.
Each trainer or bootstrap anchor has a rollout_snapshots/ directory. A
published snapshot_NNNNNN/ contains the native TQ snapshot and matching
replay, dataloader, controller, replacement-reserve, and unfinished-rollout
metadata. keep_latest_k retains recent committed snapshots as fallbacks;
temporary or interrupted directories are never selected for recovery.
With restore_mode: latest, startup selects the newest compatible committed
snapshot under the latest trainer anchor. With trainer_checkpoint, it ignores
newer periodic rollout progress and resumes from the trainer checkpoint bundle.
Checkpoint selection is read-only: neither mode removes snapshots. If no trainer
checkpoint exists, trainer_checkpoint cannot safely reuse an existing bootstrap
namespace, so startup fails without modifying it. Recover that state with latest
or choose a new checkpoint_dir to start a fresh bootstrap lineage. Obsolete
bootstrap snapshots are removed only by retention after a durable trainer
checkpoint exists.
Bootstrap-only
trainer_checkpointbehavior: A bootstrap rollout snapshot has no corresponding model or optimizer checkpoint. Thereforerestore_mode: trainer_checkpointdeliberately fails when bootstrap state exists but no trainer checkpoint does. It does not ignore or delete that state. Uselatestto recover it, or select a newcheckpoint_dirto start from scratch.
Note
Completed groups are restored directly from the TQ snapshot. For unfinished
token-capture groups, rollout_recovery.default_granularity controls both live
failure and restart behavior:
siblingpreserves each sealed sibling and redispatches only unfinished ones.prompt_groupretries every sibling in the group when any sibling is unfinished.
sibling is the default and avoids regenerating completed work. Use
prompt_group when every generation in a recovered group must come from the
policy weights live at redispatch.
task_source_granularity_overrides can select the policy using the Gym
task_source embedded in the raw rollout row. Unlike agent_ref, this identity
is available before Gym resolves the concrete agent and SC reserves the recovery
group. When a row already carries an agent_ref, a matching
agent_granularity_overrides entry wins over a matching task-source entry,
mirroring Gym’s concrete-route precedence. Otherwise the task-source override,
then the global default, applies. The agent map also keeps datasets collated
before Gym recorded task_source working, although re-collating them is
recommended. Non-default policies require token_capture.enabled: true. The
task source and resolved policy are persisted in rollout_recovery.pt, so
recovery does not reinterpret an existing group using changed configuration. A
generation that already finished keeps its tokens in the token-capture staging
area, so sibling reuses them unchanged; a redispatched sibling produces a new
sample from the same prompt. Neither becomes a training row until every
generation in the group has finished.
When a sampler does not support replay recovery, a requested data-plane checkpoint is written in shadow mode. The TQ snapshot is retained, but no authoritative replay index is written and its rows are not restored into the training replay buffer.
Native TQ save/load currently requires data_plane.backend: "simple". Mooncake-backed storage is not recoverable through this mechanism. A failure while saving or validating the TQ snapshot prevents the incomplete checkpoint bundle from becoming the latest resumable checkpoint.
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 five 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. |
|
Same gate as |
Take any ready group generated by a policy version no newer than the trainer, including late stragglers. |
Completion-order streaming without stale-group eviction. |
|
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 five modes:
Mode |
|
Sampler knob |
|
|
Exemplar |
|---|---|---|---|---|---|
Sync / on-policy |
|
|
|
|
|
Async, exact batch→step matching |
|
|
|
|
|
Streaming, gated dispatch |
|
|
|
|
— (none shipped) |
Streaming, ready-first |
|
|
|
|
— (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. Sized from the widest window the run ever uses, sowarmup_lookahead_versionsrather thanmax_lookahead_versionswhen it is set.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. (PPO) Must equalnum_prompts_per_step— the critic has no split train API, so each critic epoch calls the full-steptrain_from_metaonce per chunk. Splitting an RL step across chunks would multiply both models’ configured optimizer updates by the number of chunks.sampler.warmup_lookahead_versions(PPO) — lookahead used whileppo.policy_training_start_stepcritic warmup is in progress, shrinking back tomax_lookahead_versionsafterwards. The SC equivalent ofppo.async_ppo.warmup_generation_lead_steps.
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,ReadyFirstSampler,WeightFifoSampler, andInOrderSamplerare 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 → _value_stage (PPO only) → _advantage_stage → _value_train_epochs (PPO only) → 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 andsampler.selectpicks the next batch. On PPO,_value_stageruns the critic forward,_advantage_stagecomputes advantages, and_value_train_epochsrunsppo.critic_ppo_epochscritic updates. The TQPolicy split API then runs one optimizer step per RL step on GRPO, orppo.ppo_epochspolicy updates on PPO.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 (or ppo.async_ppo: null on a PPO run) — 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.
Do not carry max_num_epochs: -1 across either. ppo.md requires that value for legacy async PPO, but SC has no -1 convention: the rollout pump gates on _current_epoch < max_num_epochs, so any non-positive value trains zero steps and exits successfully. Setup rejects it — set a positive max_num_epochs and bound the run with max_num_steps.
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:
Multimodal/VLM GRPO is supported with Megatron generation. Set
policy.is_vlm: true; see the CLEVR Single-Controller recipe.Multi-Teacher On-Policy Distillation (MOPD) is supported for text-only NeMo Gym rollouts; multimodal/VLM MOPD is not yet supported. See Multi-Teacher On-Policy Distillation.
Train backend: only Megatron is supported and validated; the AutoModel training path has not been tested on SC.
Generation backend: vLLM and Megatron generation are supported (Megatron in both non-colocated and colocated modes); SGLang and TRT-LLM have not been tested on SC.
Validation is not yet supported (setup raises on
val_period > 0,val_at_start, orval_at_end); checkpointing is.(PPO) Rollout drop budgets —
async_rl.rollout_failure.max_skipped_promptsandmax_consecutive_dropped_promptsmust both be0. A drop shortens the step, and the critic shards it against the configuredvalue.train_global_batch_sizerather than its actual size, so setup rejects a non-zero budget. The resiliency layer stays available on GRPO.Reward shaping and sample filtering —
reward_shaping,reward_scaling, anduse_dynamic_samplingare implemented on neither algorithm block, so setup rejects them rather than silently skipping the shaping. Environment-flagged sample masking andoverlong_filteringare supported; truncated completions are excluded from the loss throughsample_mask, and a step in which every completion is filtered is rejected rather than skipped.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.