Skip to main content
Fully async rollout decouples generation from training so that rollout never waits on a training step. The engines keep generating continuously while the trainer consumes finished groups, instead of the two taking turns.

When to use it

Use fully async when rollout is slow, or when its wall time is set by a long tail of straggler trajectories rather than by the average one, which is most often the case in long-context, tool-use, and agentic workloads. The trade-off is that training goes off-policy, because the trainer consumes groups generated under older weights. Staying synchronous is recommended while you are debugging a recipe or validating loss and reward code, where an exact on-policy cadence is worth more than throughput.

Usage & Examples

Basic usage

Switch the entrypoint from train.py to train_async.py, enable the class-based rollout API, and pass --fully-async:

Examples

Four launch scripts show the mode end to end, from a single-node smoke test to a 16-node agentic run:

Customizations

Starting from a working run, the rest of this page covers what you can change:

The fully async schedule

How generation is scheduled

The implementation lives in miles/rollout/fully_async_rollout.py (the worker) and miles/rollout/submission_scheduler.py (the submission scheduler). Fully async rollout splits Miles into two concurrent loops:
  1. A background rollout worker keeps SGLang generation in flight and puts completed groups into a data buffer.
  2. The trainer drains the buffer, runs optimizer steps, and syncs updated weights back to the rollout engines.
The worker is a persistent background task, started on the first training step and generating from then on. It never stops between steps, so a training step no longer triggers generation; it only takes finished groups out of the buffer. The worker keeps a bounded amount of generation in flight. It always submits whole prompt groups, and a submission scheduler decides when a finished unit frees the slot for the next one. Under fully async the default granularity is sample: every finished trajectory frees its own slot, and a new group goes out as soon as n_samples_per_prompt trajectories have completed, whichever groups they came from. This keeps the engines full when trajectory lengths vary widely, as in agentic workloads, where holding each slot until the group’s slowest trajectory returns would leave the engines idle. Generation is the only thing that runs continuously. Everything else still happens on the driver’s step schedule:
  1. The trainer drains --rollout-batch-size groups from the buffer, waiting if not enough have finished yet.
  2. It runs the optimizer step while the worker keeps generating.
  3. Every --update-weights-interval steps it pauses generation, synchronizes the new weights through the configured update mode, and resumes. The --pause-generation-mode flag decides how in-flight requests survive that pause: the default retract returns them to the waiting queue and recomputes their KV cache, while in_place freezes them and resumes on the existing cache. Passing abort would kill them outright, which is why fully async rejects it.
Because generation spans those weight updates, the samples in one group can carry different weight versions. The gap between a group’s oldest weight version and the engines’ current one is its staleness, and it is the reason a group that finished long ago may no longer be worth training on.

Arguments: Scheduling options

Three flags control how much generation stays in flight:

Data path

The data buffer

The implementation lives in miles/rollout/fully_async_data_buffer.py. The data buffer is the store of finished groups between the two loops, and every group-level decision lives in it. The producer puts each group in as it completes, the trainer takes groups back out one at a time, and everything in between — what to keep, what to discard, what to send back for regeneration — is the buffer’s call. It is one replaceable component with three methods: Those three methods are the whole interface: the worker and the trainer see nothing else, and everything inside the box below is the built-in DefaultDataBuffer. Groups are filtered at two points, because the two decisions become available at different times. Whether a group was aborted, and whether --dynamic-sampling-filter-path keeps it, is fixed the moment generation finishes, so both are decided on put(). Staleness depends on how long the group then sits in the buffer, so it is decided on get(). Once the trainer has a full batch it sorts the groups by index and applies --rollout-sample-filter-path to the assembled batch. The buffer decouples the two loops. As long as it holds finished groups, the trainer never waits for generation. If it sits empty, rollout is still the bottleneck and async cannot hide it.

Arguments: Buffer options

Buffer capacity bounds how far generation can run ahead of training: Staleness control decides which of those groups training is allowed to see: When those knobs are not enough, --custom-async-data-buffer-path replaces the buffer itself. This is a larger step than setting any flag above: your DataBuffer subclass takes over all three methods and therefore every group-level decision, and the flags in this section apply only if your class reads them. The one decision that stays outside is --rollout-sample-filter-path, which runs on the assembled batch rather than on individual groups.

Evaluation

Fully async rollout changes one thing about eval: generation is always in flight, so an eval that runs on the rollout engines costs production time. Two independent choices follow, which backend runs the eval and where that backend gets its weights. The fleet and an external backend each select a backend, so passing both is an error.

Mode 1: Shared engines

The implementation lives in miles/rollout/fully_async_rollout.py. Eval runs on the rollout engines. The producer stops submitting for the duration of the blocking eval and resumes after; in-flight requests finish and buffer, and nothing is aborted. No extra weight movement is needed, because the engines already carry the weights the step’s update_weights broadcast pushed. Production stalls for roughly the eval duration, which is acceptable for a small debug set and for points that must land strictly on time.

Mode 2: Dedicated fleet

The implementation lives in miles/ray/rollout/eval_fleet.py. The fleet runs on its own GPUs behind its own router, synced by loading HF checkpoint snapshots rather than by joining training weight updates:
On each eval-due step the trainer hands the snapshot to the fleet, fires the eval without waiting for it, and keeps training. Your --eval-function-path function generates against the fleet exactly as it would against the training engines, so custom eval functions work unchanged. The point lands at the right step on the x-axis even when it completes a few steps later, and eval/lag_steps reports how late it was. The fleet’s engines inherit every --sglang-* setting from the rollout engines, so by default they are configured exactly like the engines you already tuned. You can override any single field with the matching --eval-sglang-* flag:
Tensor parallelism (TP) is the exception, because it comes from --eval-num-gpus-per-engine, which also places the engines. SGLang ties dp_size, pp_size, ep_size, and attn_cp_size to TP, so when the eval and rollout TP sizes differ those four default to 1 instead of being inherited; inheriting them across a different TP produces an engine that fails SGLang’s own validation at boot. You can set them explicitly with --eval-sglang-* if the fleet is large enough to want them.

Mode 3: External backend

The contract lives in miles/rollout/checkpoint_eval.py, with a reference implementation in examples/infra_features/fully_async/external_eval_fn.py. Subclass CheckpointEvalFn and implement evaluate_checkpoint(checkpoint_dir, input). The trainer hands over a snapshot path per eval point and owns dispatch, logging, and garbage collection; raise EvalSkip(reason) for an attributable skipped point. Because the function runs in-job it reads the real training args and logs through the trainer, with no GPU carve-out. The reference implementation launches its own SGLang server on spare GPUs or attaches to an existing one, configured through the env vars documented in the script. A non-SGLang black box implements the same contract by calling out to its API and mapping the response into RolloutFnEvalOutput.

The weight snapshot pipeline

The implementation lives in miles/ray/rollout/eval_dispatch.py. The fleet and an external backend both evaluate a checkpoint, so both need --eval-interval and one of these two snapshot sources: On a real run you are persisting checkpoints anyway, so reuse costs nothing. A test run that saves no checkpoints needs the export, and a tmpfs staging directory keeps it off disk. The export is the one part of a point that is not fire-and-forget: it is a collective across every train actor and the training loop waits for it. Under the default --eval-overflow-policy backpressure a due point can also wait out the oldest pending eval; --eval-overflow-policy skip drops it instead so a slow eval set can never stall training. Size the staging directory before pointing it at tmpfs. Snapshots are retired on every outcome, but the directory holds up to --eval-keep-snapshots retired plus --eval-max-in-flight still-evaluating model-sized directories at once, four by default, so around 32 GB of /dev/shm for a 4B model in bf16. Evals are serialized inside the trainer, so raising --eval-max-in-flight does not run more of them at once; it lets the trainer export further ahead at the cost of one more snapshot on disk.

Metrics

Async rollout metrics

The buffer reports these metrics to wandb on every training step, alongside the standard rollout metrics:
The avg_staleness and max_staleness pair covers the groups training actually consumed, while the buffer_ pair covers the groups still sitting in the buffer when the step drained it. A queue_size pinned at zero means rollout is the bottleneck, so scale rollout capacity or lower per-sample generation cost. A queue_size pinned at capacity means training is the bottleneck, and the buffer_ staleness metrics will climb with it. A rising stale_groups_filtered means groups are aging out faster than the trainer consumes them. In the logs, a No completed rollout groups for 30.0s warning means the drain is starved.

Async eval metrics

The metrics are logged in miles/ray/rollout/metrics.py. Every skipped point is logged at the step it would have landed on, with the reason: For a point that did run, eval/{dataset}/weight_version/mean == eval/step and eval/{dataset}/weight_version/mixed_version_ratio == 0 together confirm it measured exactly the intended weights. A checkpoint backend measures the actor’s exact weights at that step, whatever the broadcast schedule. Shared engines measure the engines’ last-broadcast version, which equals the actor’s current weights when --update-weights-interval is 1.

Performance metrics

For performance work, use the metrics below as a basic reference:
  1. Engine concurrency. Watch sglang_num_running_reqs across engines. If some engines sit far below the others, or concurrency collapses without a weight update to explain it, check the router configuration — requests are not being spread evenly.
  2. Prefix cache hit rate. Watch sglang_cache_hit_rate per engine, or the per-step prefix_cache_hit_rate in the rollout metrics. A coding-agent workload should stay above 90%, since every turn re-prefills its session prefix. If it is low, suspect the KV cache memory (--sglang-mem-fraction-static) and the router configuration.
  3. Where the time goes. Compare rollout time, train time, and the staleness metrics. If rollout is slower, consider more GPUs for rollout, a higher --async-max-concurrent-samples, and throughput-oriented SGLang settings. If training is slower, consider more GPUs for training, a lower concurrency, and latency-oriented SGLang settings.

Arguments: Logging options

Two flags replace the default metric logging, both defined in miles/ray/rollout/metrics.py: Returning True skips the default logging for that call, so a function that only forwards metrics elsewhere should return False and leave the built-in logging in place. To change which numbers the buffer reports in the first place, override get_metrics() on a custom buffer instead, as described in Arguments: Buffer options.