Rewrite fully-async rollout as FullyAsyncRolloutFn on the class-based rollout API - #1717
Conversation
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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
- 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).
| # don't count as processed for training | ||
| async def _drain(self, rollout_id: int) -> RolloutFnTrainOutput: | ||
| args = self.args | ||
| assert args.rollout_global_dataset |
There was a problem hiding this comment.
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.
| assert args.rollout_global_dataset | |
| if not args.rollout_global_dataset: | |
| raise ValueError("FullyAsyncRolloutFn requires rollout_global_dataset to be True") |
References
- Use
ValueErrorinstead ofassertfor validating function or constructor arguments (such as checking for positive or non-negative values).
86f7b4e to
b24c9dd
Compare
c0bb09f to
a6d7248
Compare
b24c9dd to
fa6d503
Compare
67c5ca8 to
21a18b9
Compare
… 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.
7cf1112 to
3068087
Compare
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.
… 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>
|
Pushed two small fixes from review:
|
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>
|
CC said eval has some conflict with current fully async code path. But I think we can leave this PR as-is. |
Motivation
#1716 moved
fully_async_rollout.pyfromexamples/intomiles/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, theGenerateStatesingleton fromsglang_rollout,printeverywhere, and broadtry/exceptthat silently drops data. The singleton's semaphore binds to the worker's private loop, which is why fully-async + eval was structurally impossible (cross-loopRuntimeError).This PR rewrites it on the class-based rollout API and gives it a real flag.
What this PR does
FullyAsyncRolloutFn, selected bytrain_async.py --fully-async(requiresMILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1):atexit;asyncio.Queue(maxsize=1000)keeps the same backpressure (a full queue pauses submission).inference_rollout_common.generate_and_rm_groupwith an instance-ownedGenerateState, so custom generate functions receive the state typeGenerateFnInputdeclares._iter_samplesalso fixes a latent bug where multi-sample groups could never be abort-recycled — the old broadexcepthid anAttributeError.)rollout_batch_sizegroups; ABORTED-group recycle on weight-update pauses;--max-weight-stalenessfilter; final sort by index. New:assert len(group) == n_samples_per_promptand per-step metrics (rollout/fully_async/queue_size, recycle counts, staleness stats) viaRolloutFnTrainOutput.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-pathand--rollout-sample-filter-pathare 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-prefilland--rollout-all-samples-process-pathnow 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-pathloses its string default in favor ofNone, soresolve_rollout_function_paths()is the single place that answers "which rollout and eval function did these arguments select".--fully-asyncasserts 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 standardInferenceRolloutFnunless--eval-function-pathis set.train.pyrejects--fully-asyncoutright (it needs the async driver).Weight-version cache.
_CachedWeightVersiononly stamped_last_queryon success, so an unreachable router made every group in the drain pay the full 2s timeout instead of one per TTL — silently, atlogger.debug. Stamped in afinallyon completion now, which also fixes a router slower than the TTL defeating the cache entirely.Scripts and docs move to the
--fully-asyncpath; the qwen scripts gain the env flag._submit_one_groupis shaped to receive #1673's sample-completion backfill with minimal conflict.Testing
New e2e case —
tests/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 tests —
tests/fast/rollout/test_fully_async_rollout.py,stage-a-cpu, 12 cases on aFakeGenerateState/FakeDataSourceharness:drain_collects_batch_sorted_with_metricseval_raisesaborted_group_recycled/stale_group_recycledworker_error_propagates/worker_failure_beats_queued_groupsworker_bounds_in_flight_groups/async_max_concurrent_samples_caps_in_flight_groupsnested_group_recycles_the_flat_prompt_groupdynamic_filter_drops_group_without_recyclingsample_filter_marks_samples_without_shrinking_the_batchremove_sampleset, batch size unchangedweight_version_throttles_failed_queriesresolve_rollout_function_pathswas checked to be behavior-identical to the two functions it replaces across all 10 valid argument combinations.pre-commit run --all-filespasses.