Skip to content

Rewrite fully-async rollout as FullyAsyncRolloutFn on the class-based rollout API - #1717

Merged
yueming-yuan merged 14 commits into
mainfrom
yueming/fully-async-class-api
Aug 1, 2026
Merged

Rewrite fully-async rollout as FullyAsyncRolloutFn on the class-based rollout API#1717
yueming-yuan merged 14 commits into
mainfrom
yueming/fully-async-class-api

Conversation

@yueming-yuan

@yueming-yuan yueming-yuan commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Motivation

#1716 moved fully_async_rollout.py from examples/ into miles/rollout/ unchanged. What landed was still example-quality code on the legacy stack: a module-global worker (_global_worker + threading.Lock + atexit), a private thread event loop, the GenerateState singleton from sglang_rollout, print everywhere, and broad try/except that silently drops data. The singleton's semaphore binds to the worker's private loop, which is why fully-async + eval was structurally impossible (cross-loop RuntimeError).

This PR rewrites it on the class-based rollout API and gives it a real flag.

What this PR does

FullyAsyncRolloutFn, selected by train_async.py --fully-async (requires MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1):

  • Worker = long-lived task on the shared rollout event loop, lazy-started on the first train call. No thread, no private loop, no globals, no atexit; asyncio.Queue(maxsize=1000) keeps the same backpressure (a full queue pauses submission).
  • New-stack primitives: inference_rollout_common.generate_and_rm_group with an instance-owned GenerateState, so custom generate functions receive the state type GenerateFnInput declares.
  • Errors are loud: a failed generation task kills the worker, and the next drain raises the original exception instead of hanging. The recycle paths no longer swallow. (_iter_samples also fixes a latent bug where multi-sample groups could never be abort-recycled — the old broad except hid an AttributeError.)
  • Behavior preserved: in-flight bound = rollout_batch_size groups; ABORTED-group recycle on weight-update pauses; --max-weight-staleness filter; final sort by index. New: assert len(group) == n_samples_per_prompt and per-step metrics (rollout/fully_async/queue_size, recycle counts, staleness stats) via RolloutFnTrainOutput.metrics.

Rollout features the drain used to silently ignore. The drain reimplements the collection loop, so everything the standard path does around it was lost:

  • --dynamic-sampling-filter-path and --rollout-sample-filter-path are now applied. A dynamic-filtered group is dropped rather than recycled, matching the standard path — continuous submission already replaces it, so no over-sampling machinery is needed. This mattered in practice: examples/swe-agent/run-glm47-flash-agentic-async.py, one of the scripts this PR migrates, passes --dynamic-sampling-filter-path.
  • --partial-rollout, --recompute-logprobs-via-prefill and --rollout-all-samples-process-path now fail fast instead of being ignored. Partial rollout is actively defeated by the recycle path (reset_for_retry() drops the partial response), and a group can cross a weight update before it is drained, so a drain-time logprob recompute would score it under newer weights than generated it — the correct fix there is a per-group recompute at completion time, which is out of scope here.

Argument resolution. --rollout-function-path loses its string default in favor of None, so resolve_rollout_function_paths() is the single place that answers "which rollout and eval function did these arguments select". --fully-async asserts against the flags it conflicts with (colocate, multi-LoRA, an explicit --rollout-function-path, the missing refactor env var), and eval is resolved before the fully-async override, so "fully async does not serve eval" holds by construction — eval keeps the standard InferenceRolloutFn unless --eval-function-path is set. train.py rejects --fully-async outright (it needs the async driver).

Weight-version cache. _CachedWeightVersion only stamped _last_query on success, so an unreachable router made every group in the drain pay the full 2s timeout instead of one per TTL — silently, at logger.debug. Stamped in a finally on completion now, which also fixes a router slower than the TTL defeating the cache entirely.

Scripts and docs move to the --fully-async path; the qwen scripts gain the env flag. _submit_one_group is shaped to receive #1673's sample-completion backfill with minimal conflict.

Testing

New e2e casetests/e2e/megatron/test_qwen3_30B_A3B/test_fully_async.py, stage-c-8-gpu-h100. Fully-async on the disaggregated topology (it cannot colocate); three rollouts exercise the states that only exist with a persistent worker: a cold start, a drain from an already-warm queue, and a drain across a weight update (which pauses generation and makes the worker recycle aborted groups).

New CPU teststests/fast/rollout/test_fully_async_rollout.py, stage-a-cpu, 12 cases on a FakeGenerateState / FakeDataSource harness:

drain_collects_batch_sorted_with_metrics sorted batch + metrics, worker persists across calls
eval_raises eval is rejected without starting a worker
aborted_group_recycled / stale_group_recycled both recycle paths return the prompt group to the data source
worker_error_propagates / worker_failure_beats_queued_groups a dead worker fails the step, even with a backlog queued
worker_bounds_in_flight_groups / async_max_concurrent_samples_caps_in_flight_groups both in-flight bounds
nested_group_recycles_the_flat_prompt_group multi-sample generate functions resubmit the flat prompt group
dynamic_filter_drops_group_without_recycling dropped, not recycled; drop reason reaches metrics
sample_filter_marks_samples_without_shrinking_the_batch remove_sample set, batch size unchanged
weight_version_throttles_failed_queries one HTTP call per TTL when the router is down, not one per group

resolve_rollout_function_paths was checked to be behavior-identical to the two functions it replaces across all 10 valid argument combinations. pre-commit run --all-files passes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the fully asynchronous rollout implementation by replacing the thread-based global worker with a class-based FullyAsyncRolloutFn that runs a persistent worker task on the shared rollout event loop. The feedback highlights several important issues to address: reusing the rollout function instance for both training and evaluation in RolloutManager introduces state-sharing and concurrency bugs; parsing the engine weight version outside the try-except block could crash the training process on malformed responses; a pending task leak may occur if _next_group is cancelled; and assert should be replaced with ValueError for configuration validation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread miles/ray/rollout/rollout_manager.py Outdated
Comment on lines 67 to 75
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=2)) as resp:
if resp.status == 200:
data = await resp.json()
self._value = int(data["weight_version"])
self._last_query = now
except Exception as e:
data = await asyncio.wait_for(get(url), timeout=WEIGHT_VERSION_QUERY_TIMEOUT_SECS)
except (httpx.HTTPError, asyncio.TimeoutError) as e:
# Transient router unavailability; the staleness filter is best-effort.
logger.debug(f"Failed to query engine weight version: {e}")
return self._value
self._value = int(data["weight_version"])
self._last_query = now
return self._value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation catches httpx.HTTPError and asyncio.TimeoutError but performs the integer parsing of data["weight_version"] outside the try-except block. If the router returns a malformed response (e.g., missing the "weight_version" key, or containing a non-integer value), this will raise a KeyError or ValueError and crash the entire training process.

Since the staleness filter is best-effort, we should defensively handle any parsing or key errors and fall back to the cached/previous value instead of raising a hard error.

Suggested change
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=2)) as resp:
if resp.status == 200:
data = await resp.json()
self._value = int(data["weight_version"])
self._last_query = now
except Exception as e:
data = await asyncio.wait_for(get(url), timeout=WEIGHT_VERSION_QUERY_TIMEOUT_SECS)
except (httpx.HTTPError, asyncio.TimeoutError) as e:
# Transient router unavailability; the staleness filter is best-effort.
logger.debug(f"Failed to query engine weight version: {e}")
return self._value
self._value = int(data["weight_version"])
self._last_query = now
return self._value
try:
data = await asyncio.wait_for(get(url), timeout=WEIGHT_VERSION_QUERY_TIMEOUT_SECS)
self._value = int(data["weight_version"])
self._last_query = now
except (httpx.HTTPError, asyncio.TimeoutError, KeyError, ValueError, TypeError) as e:
# Transient router unavailability or malformed response; the staleness filter is best-effort.
logger.debug(f"Failed to query engine weight version: {e}")
return self._value
References
  1. Avoid raising hard errors (such as ValueError) for non-critical metrics or data produced solely by internal components. Raising errors in these cases can turn a minor, non-critical issue (like a missing or malformed metric) into a hard application failure. Instead, use defensive fallback behavior (e.g., silent no-ops).

Comment thread miles/rollout/fully_async_rollout.py Outdated
# don't count as processed for training
async def _drain(self, rollout_id: int) -> RolloutFnTrainOutput:
args = self.args
assert args.rollout_global_dataset

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use ValueError instead of assert for validating configuration or function arguments, as assertions can be globally disabled in Python when run with optimization flags (-O), leading to silent bypasses of critical configuration guards.

Suggested change
assert args.rollout_global_dataset
if not args.rollout_global_dataset:
raise ValueError("FullyAsyncRolloutFn requires rollout_global_dataset to be True")
References
  1. Use ValueError instead of assert for validating function or constructor arguments (such as checking for positive or non-negative values).

yueming-yuan and others added 8 commits July 31, 2026 14:29
… rollout API

- worker becomes a long-lived task on the shared rollout event loop
  (lazy-started on the first train call): no thread, no private loop, no
  module globals, no atexit; asyncio.Queue with the same backpressure
- switch to inference_rollout primitives (instance GenerateState); the
  cross-loop hazard of the legacy singleton disappears
- errors are loud: a failed generation task kills the worker and the next
  drain raises instead of hanging; recycle paths no longer swallow
- report queue depth / staleness / recycle counts via
  RolloutFnTrainOutput.metrics; assert group size matches
  n_samples_per_prompt
- RolloutManager reuses the rollout fn instance when eval_function_path
  equals rollout_function_path
- requires MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1; scripts and docs updated
  to the FullyAsyncRolloutFn path; eval raises with guidance
Reuses the 30B harness: fully_async selects train_async.py plus
FullyAsyncRolloutFn, on the disaggregated topology it requires (train_async
rejects colocation), with the standard CI metric gates.

Three rollouts instead of two, to cover the states that only exist with a
persistent worker: cold start, drain from a warm queue, and a drain across a
weight update that pauses generation and recycles aborted groups.
Rollout selection belongs in the argument surface, not in an environment
variable that rewrites sys.argv before parsing.

--rollout-function-path now defaults to None, so "the user chose one" is a
plain is-None check instead of a comparison against a computed default, and
resolve_rollout_function_path() is the single place that maps arguments to a
rollout function. miles_validate_args rejects the configurations that cannot
work: no class-based rollout API, a competing --rollout-function-path, or
--colocate, which the async driver cannot honor. Evaluation keeps the standard
rollout function, since fully async does not serve eval.

train.py asserts the flag is off, so picking the wrong driver fails loudly.

Co-authored-by: yueming-yuan <yym022502@gmail.com>
--rollout-function-path now defaults to None, so multi-LoRA's "the user did
not pick one" test can be a plain is-None check; comparing against the
standard paths silently stopped matching and left multi-LoRA runs on the
default rollout function.

Also reject --fully-async together with multi-LoRA, which resolves its own
rollout function before the fully-async block runs.
Two issues from review:

- _next_group checked the output queue before the worker task, so a worker that
  died with groups still queued stayed hidden until the backlog drained: up to
  OUTPUT_QUEUE_MAX_GROUPS of increasingly stale data trained against while
  weight updates continued. The worker loop never returns normally, so its
  completion is checked first.
- _recycle put the generation result back into the data source. A generate
  function may expand one trajectory into several samples, and resubmitting
  that nested shape raises AttributeError inside generate_and_rm_group. The
  queue now carries the submitted prompt group next to its result, and retries
  resubmit the prompt group. Unchanged for flat groups, where the result is the
  same list object.
_last_query was only stamped on success, so an unreachable router made every
group in the drain pay the full 2s timeout instead of one per TTL. Stamp it in
a finally, on completion, and drop the `_value is not None` short-circuit guard
so a never-successful query is throttled too.
The drain reimplements the collection loop, so --dynamic-sampling-filter-path
and --rollout-sample-filter-path were silently ignored -- including in
examples/swe-agent/run-glm47-flash-agentic-async.py, which passes the former.
A rejected group is dropped rather than recycled, matching the standard path;
continuous submission already replaces it, so no over-sampling is needed.
The drain has no partial-sample collection, and a group can cross a weight
update before it is drained, so a drain-time logprob recompute would score it
under newer weights than generated it. Continuous generation also has no
per-step boundary for --rollout-all-samples-process-path.
@yueming-yuan
yueming-yuan force-pushed the yueming/fully-async-class-api branch from 7cf1112 to 3068087 Compare July 31, 2026 21:31
Both branches of the eval ternary were the same expression: --fully-async
asserts --rollout-function-path is unset, so the standard path it wanted is
just the unresolved rollout path. Computing eval before the fully-async
override makes "fully async does not serve eval" hold by construction.
yueming-yuan and others added 3 commits July 31, 2026 17:15
… snippet

--fully-async hard-asserts MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1 at argument
parsing, so the launch-script diff in the async rollout section failed at
startup as written. Align the snippet with docs/user-guide/fully-async.md and
count the env var as the third change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "group[0][0] if isinstance(group[0], list) else group[0]" pattern appeared
three times (first-sample log, finish log, sort key). Name it once next to
_iter_samples, which owns the same nested-Group knowledge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@guapisolo

Copy link
Copy Markdown
Collaborator

Pushed two small fixes from review:

  • a793f52 — docs: the async-rollout snippet in docs/user-guide/training-script-walkthrough.md still said "two changes" and was missing MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1. Since --fully-async hard-asserts that env var, the launch command failed at startup as written. Aligned the snippet with docs/user-guide/fully-async.md.
  • 4470cd9 — refactor: extracted a _first_sample(group) helper next to _iter_samples for the group[0][0] if isinstance(group[0], list) else group[0] unwrap that appeared three times in _drain (first-sample log, finish log, sort key).

tests/fast/rollout/test_fully_async_rollout.py (12 cases) passes locally; pre-commit hooks pass on both commits.

@guapisolo guapisolo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. clean design.

Registers "fully-async" in KNOWN_LABELS and applies it to the
test_qwen3_30B_A3B fully-async case, so it can be triggered on its own with
the run-ci-fully-async PR label (created in the GitHub repo) instead of only
through the broader run-ci-megatron / run-ci-weight-update scopes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@guapisolo

Copy link
Copy Markdown
Collaborator

CC said eval has some conflict with current fully async code path. But I think we can leave this PR as-is.

@yueming-yuan
yueming-yuan merged commit e5916b3 into main Aug 1, 2026
41 checks passed
@yueming-yuan
yueming-yuan deleted the yueming/fully-async-class-api branch August 1, 2026 04:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants