refactor(rollout): engine-level sync/async contracts for async rollout - #287
Merged
Conversation
2 tasks
celve
force-pushed
the
LIN-631/main
branch
2 times, most recently
from
July 31, 2026 09:13
762b04e to
22b2712
Compare
celve
marked this pull request as ready for review
July 31, 2026 09:43
celve
requested review from
CjhHa1,
Ideny42,
Jayce-Ping,
Zcchill,
haonan3,
leviking98z-rgb and
zzhuoxin1508
as code owners
July 31, 2026 09:43
There was a problem hiding this comment.
Pull request overview
Refactors async rollout to use engine-level sync/async contracts under unirl/rollout/engine/, replacing the previous unirl/rollout/async_runtime.py scheduler/dispatcher layer and moving async buffering/dispatch mechanisms into a shared driver-side engine module.
Changes:
- Introduces driver-side async engines/mechanisms (
AsyncBatchRolloutEngine,AsyncAgenticRolloutEngine,VersionedBuffer,InflightPool) inunirl/rollout/engine/asynchronous.pyand updates async trainers to use them. - Adds a non-blocking
Handle.launch_nowait()+PendingHandleCallseam to decouple async dispatch fromHandleinternals previously accessed byRayGenerationDispatcher. - Renames/reshapes worker-side sync engine contracts (
BaseSingleTurnRolloutEngine→SyncRolloutEngine), moveschunked_engine_generateintosynchronous.py, and updates engine implementations/docs/imports accordingly.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| unirl/trainer/README.md | Documents the new driver-side AsyncRolloutEngine protocol and concrete engines used by async trainers. |
| unirl/trainer/async_diffusion.py | Migrates diffusion async loop from AsyncRolloutScheduler to AsyncBatchRolloutEngine with explicit poll/launch policy in _next_step. |
| unirl/trainer/async_ar.py | Migrates AR async loop to AsyncBatchRolloutEngine and fixes missing _rollout_anchor_device init for inherited evaluate(). |
| unirl/trainer/agentic_partial.py | Switches colocate partial agentic trainer to AsyncAgenticRolloutEngine for group assembly/buffering and folds quiesce logic into engine. |
| unirl/trainer/agentic_async.py | Removes trainer-local group assembler/buffer, delegating to AsyncAgenticRolloutEngine for async agentic rollout bookkeeping. |
| unirl/rollout/README.md | Updates rollout architecture docs for synchronous.py and new driver-side async engines. |
| unirl/rollout/loop/README.md | Updates agent loop contract docs to reference SyncRolloutEngine. |
| unirl/rollout/loop/engine_port.py | Updates docstring to reference SyncRolloutEngine as the nominal runtime counterpart. |
| unirl/rollout/engine/vllm_omni/weight_sync.py | Updates references from base.py to synchronous.py in docs/comments. |
| unirl/rollout/engine/vllm_omni/engine.py | Updates base class to SyncRolloutEngine and sync-surface documentation references. |
| unirl/rollout/engine/vllm_omni/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/vllm_omni/init.py | Updates lazy-import commentary to match synchronous.py naming. |
| unirl/rollout/engine/trainside/engine.py | Updates base class to SyncRolloutEngine. |
| unirl/rollout/engine/trainside/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/trainside/init.py | Updates doc reference to unirl.rollout.engine.synchronous.BaseRolloutEngine. |
| unirl/rollout/engine/synchronous.py | Renames/reshapes sync engine base classes and moves chunked_engine_generate here. |
| unirl/rollout/engine/sglang/weight_sync.py | Updates references from base.py to synchronous.py in docs/comments. |
| unirl/rollout/engine/sglang/engine.py | Updates base class to SyncRolloutEngine and sync-surface documentation references. |
| unirl/rollout/engine/sglang/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/sglang_diffusion/weight_sync.py | Updates references from base.py to synchronous.py in docs/comments. |
| unirl/rollout/engine/sglang_diffusion/engine.py | Updates base class to SyncRolloutEngine and sync-surface documentation references. |
| unirl/rollout/engine/sglang_diffusion/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/fastvideo/engine.py | Updates base class to SyncRolloutEngine. |
| unirl/rollout/engine/fastvideo/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/composed/engine.py | Updates child engine type checks/annotations to SyncRolloutEngine. |
| unirl/rollout/engine/composed/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/asynchronous.py | Adds new driver-side async engine protocol, batch/agentic engines, and shared mechanisms. |
| unirl/rollout/engine/agentic/engine.py | Updates inner-engine contract/type checks to SyncRolloutEngine and removes the deprecated drained() probe. |
| unirl/rollout/engine/agentic/config.py | Switches BaseEngineConfig import to unirl.rollout.engine.synchronous. |
| unirl/rollout/engine/init.py | Replaces eager exports with lazy re-export shim to keep driver-side imports ray/torch-free. |
| unirl/rollout/async_runtime.py | Deletes the old async scheduler/dispatcher runtime layer. |
| unirl/distributed/group/handle.py | Adds PendingHandleCall and Handle.launch_nowait() non-blocking dispatch seam. |
| examples/diffusion/bagel/bagel_vllmomni_async.yaml | Updates comments to reflect trainer-side poll-before-launch policy (no scheduler flag). |
| .github/CODEOWNERS | Updates ownership mapping from removed async_runtime.py to new engine/asynchronous.py. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+188
to
+203
| jobs, self._jobs = self._jobs, [] | ||
| first_error: Optional[Exception] = None | ||
| completed = 0 | ||
| for job in jobs: | ||
| try: | ||
| complete(job.gen_id, job.weight_version, job.pending.result()) | ||
| completed += 1 | ||
| except Exception as exc: | ||
| self._jobs.append(job) | ||
| if first_error is None: | ||
| first_error = exc | ||
| else: | ||
| logger.error("drain_all: additional failure for gen_id=%s", job.gen_id, exc_info=exc) | ||
| if first_error is not None: | ||
| raise first_error | ||
| return completed |
Comment on lines
+487
to
+501
| try: | ||
| dispatch_mode, dispatch_fn, _, execute_fn = self._method_configs[method_name] | ||
| except KeyError: | ||
| raise AttributeError( | ||
| f"{method_name!r} is not a @distributed method of {_owning_class(self.role_cls).__name__}" | ||
| ) from None | ||
|
|
||
| batch_size = infer_batch_size(args, kwargs) | ||
| if ( | ||
| dispatch_mode in (Dispatch.DP_SCATTER, Dispatch.DP_SCATTER_HEAD) | ||
| and batch_size is not None | ||
| and batch_size % self.dp_size != 0 | ||
| ): | ||
| raise ValueError(f"batch_size={batch_size} not divisible by dp_size={self.dp_size}") | ||
|
|
This was referenced Jul 31, 2026
Handle.launch_nowait runs the dispatch -> localize -> execute half of a @distributed method call and returns a PendingHandleCall future (ready/wait/result) instead of blocking in ray.get; result() runs the rebind + collect half at most once (cached on success, retryable on failure). _bind_methods records each method's dispatch tuple so the seam works for any @distributed method. handle_fn itself is untouched; the async rollout path builds on this instead of reaching into the private _execute_all/_rebind_tree seams.
… contracts Replace the layered async_runtime (AsyncRolloutScheduler + GenerationDispatcher protocol + RayGenerationDispatcher + VersionedGroupBuffer) and the agentic trainers' duplicated producer plumbing (_GroupAssembler/_GroupBuffer/ _ingest_completed/_pump/_drain_buffer) with two driver-side engines built on two mechanism classes, all under unirl/rollout/engine/: - synchronous.py (was base.py): the worker-side sync contracts, unchanged in substance; BaseSingleTurnRolloutEngine is renamed SyncRolloutEngine and chunked_engine_generate moves in from the package __init__. - asynchronous.py (new): VersionedBuffer + InflightPool (ray-free mechanisms over Handle.launch_nowait), the AsyncRolloutEngine protocol (poll / drain_freshest / pop_evicted / quiesce + engine-owned weight_version), and its two concretes: AsyncBatchRolloutEngine (launch-time version stamps; used by AsyncARTrainer/AsyncDiffusionTrainer) and AsyncAgenticRolloutEngine (facade over the agentic rank-0 coordinator: [0] unwraps, PendingGroups sibling assembly, completion-time stamps, quiesce = abort + final poll; used by the partial/async agentic trainers). - engine/__init__.py becomes a lazy re-export shim so the driver-side module imports ray/torch-free. Trainers keep the policy (launch ceiling, reap-vs-launch order as statement order, quiesce points, tail carry/drop) as explicit loops; the reap_before_launch flag, dead drained()/pending_roots()/_pending_carried, and the trainer-side _weight_version counters are removed. Import paths change with no aliases: unirl.rollout.async_runtime and the names it exported (AsyncRolloutScheduler, RayGenerationDispatcher, VersionedGroupBuffer, GenerationDispatcher, InflightGeneration) are gone, and unirl.rollout.engine.base is now unirl.rollout.engine.synchronous. All in-repo consumers are migrated; out-of-tree code importing these must update. Verified on GPU (1x8 H20): the async AR recipe trains end-to-end with the PPO ratio pinned at 0.99-1.00 across every weight-sync cycle, and the BAGEL vllm-omni async recipe replays at ratio=1.0000+/-0.0000. CPU-only harnesses pinning the buffer/pool invariants were run but not committed, per the tests-tree policy (Tencent-Hunyuan#99/Tencent-Hunyuan#267); commands and results are in the PR Test Plan.
AsyncARTrainer.__init__ calls BaseTrainer.__init__ directly to avoid the colocate placement(fraction=1.0) block that ARTrainer.__init__ opens, and mirrors ARTrainer's scalar fields by hand. That hand-mirror covers ar.py:62-88 but stops short of ar.py:101, where _rollout_anchor_device is set — while the inherited ARTrainer.evaluate reads it at ar.py:394. Every eval on the async AR path therefore died with: AttributeError: 'AsyncARTrainer' object has no attribute '_rollout_anchor_device' reached from the rollout_id=-1 baseline eval (async_ar.py:309), i.e. before the first training step whenever eval_interval > 0. None is the correct value: the anchored branch is the colocate TP-anchor path, and the disaggregated layout is always SPMD. It keeps evaluate() on its non-anchored route (nullcontext instead of _anchored_rollout_session), where rollout.wake_up() is a no-op on the resident SGLang engine (early-returns while _is_offloaded is False). The other three anchored-path fields ARTrainer.__init__ sets (_enable_fsdp_offload, _anchored_backend_offloaded, _anchored_rollout_awake) are deliberately NOT mirrored: they are read only by the _ensure_anchored_* helpers and the anchored branch of evaluate, none of which async_ar reaches once _rollout_anchor_device is None. Sibling AsyncAgenticTrainer uses the same BaseTrainer.__init__-direct pattern and also omits the field, but pins eval_interval = 0 (AgenticTrainer.evaluate raises), so it never reaches the read and needs no change. Pre-existing on main, not a LIN-631 regression: ar.py is byte-identical between main and this branch, and the refactor does not touch the call site or the __init__ chain. Found by GPU e2e validation of the async AR recipe.
Five deletions, all surfaces LIN-631 itself introduced or relocated, each verified to have zero in-tree consumers: - engine/__init__.py: replace the lazy re-export shim with a docstring-only init. No package-level importer exists (py/md and yaml _target_ paths all verified); the ray/torch-free property comes from importing nothing, not from lazy machinery, and the branch already breaks out-of-tree imports with no aliases elsewhere. - chunked_engine_generate: zero callers on main since the initial release and zero after the move into synchronous.py; recoverable from git history if a consumer ever materializes. - AsyncRolloutEngine Protocol: zero annotations, zero isinstance checks, and neither trainer family can be typed against it (both use off-protocol verbs). The contract description moves into the module docstring. - __all__ trimmed to the consumed surface (the two engines + root_of); VersionedBuffer/InflightPool/PendingGroups/Complete stay module-internal. - InflightPool(method=...): the only construction site never overrides the default; hardcode "generate". ruff format --check + ruff check (v0.15.1) clean over the three .py files; python3 -c "import unirl.rollout.engine.asynchronous" on a torch-less interpreter confirms the driver-side chain still imports stdlib-only.
haonan3
approved these changes
Aug 2, 2026
4 tasks
2 tasks
haonan3
added a commit
that referenced
this pull request
Aug 2, 2026
…dger Closes the weight_version accounting gap documented as a follow-up in #287: ARTrainer.evaluate() pushed weights via _prepare_rollout without advancing the driver-side counter, leaving the engine on unnumbered weights until the next interval boundary. The async trainer families also disagreed on eval policy: AR synced-without-bump, diffusion explicitly passed sync_weights=False, agentic has no eval. Structure: both driver-side engines replace bump_weight_version() with sync_weights(weight_sync) - one call that pushes and advances the ledger, so the pairing stops being call-site discipline. The batch engine also enforces the quiesce invariant (raises before pushing if any generation is in flight). All async sync sites (boundary, resume, agentic drive) route through it; bump_weight_version drops to zero callers and is removed. Policy: async eval becomes read-only. ARTrainer.evaluate() gains sync_weights: bool = True (mirroring DiffusionTrainer.evaluate); the async trainers override evaluate() with async-correct defaults (no push; diffusion also defaults sleep_after=False), so bare evaluate() calls are safe outside train() too. A pre-train explicit push raises a lifecycle RuntimeError instead of an incidental AttributeError. Deliberate behavior changes: (1) async-AR eval no longer pushes weights, so BOTH the eval series and the training rollouts launched between an eval and the next interval boundary change - they previously ran under eval-synced fresher weights; deployment cadence is now governed solely by weight_sync_interval. At interval=1, or when eval_interval is a multiple of the sync interval, training is point-identical to main. (2) The scored eval policy is 1..interval optimizer steps old (exactly 1 at interval=1). (3) The resume-time push advances the ledger 0->1 (metric offset only - eviction math is relative; the launch ceiling is computed from rollout_id).
haonan3
added a commit
that referenced
this pull request
Aug 2, 2026
…ontrollers Naming and placement follow the control-plane role; loop ownership follows whose contract dictates the sequence: - engine/asynchronous.py -> controller.py at the rollout package root. AsyncBatchRolloutEngine -> BatchRolloutController; VersionedBuffer / InflightPool unchanged. The controller stays POLICY-FREE (#287's boundary): batch step loops remain in the trainers as visible statement order (AR launches before reaping; diffusion polls first), built on the verbs plus a new launch_until(gen_id_limit, max_inflight, build) window top-up whose parameters are all controller-native. The one shared batch-policy artifact is launch_ceiling(), a module-level formula the controller never calls — the on-policy invariant both trainers previously carried as duplicated arithmetic now exists exactly once. - The agentic driver side moves family-local: engine/agentic/controller.py with AgenticRolloutController (was AsyncAgenticRolloutEngine), root_of, PendingGroups. Its collect() pump DOES own its loop: that sequence is dictated by the coordinator's engine contract (finalize_if_drained atomicity vs submit's worker-buffer reset — misordering double-pulls), not by training policy, so it lives engine-side, parameterized by poll_interval_s / refill / on_evicted / context; the per-trainer _next_batch/_collect_until/_drain_buffer copies are deleted. - engine/agentic/__init__.py becomes docstring-only (its two re-exports had zero package-level importers; recipes name full dotpaths) so importing the agentic controller stays ray/torch-free like controller.py. Rule recorded in the docstrings: sequence dictated by an engine contract lives engine-side; sequence dictated by training policy lives trainer-side; shared invariants become named functions, not shared loops. Import paths change with no aliases (#287 precedent); in-repo consumers migrated.
haonan3
added a commit
that referenced
this pull request
Aug 2, 2026
Three duplications collapse, with zero renames and loops kept visible in the trainers (#287's mechanism/policy boundary unchanged): - launch_ceiling(): the on-policy launch-clamp arithmetic both batch trainers carried as duplicated inline math becomes one module-level function the engine classes never call. Glue may be copied; the load-bearing invariant may not. - AsyncBatchRolloutEngine.launch_until(gen_id_limit, max_inflight, build): the two-line window top-up both trainers repeated; both parameters are engine-native. The trainers' _next_step loops stay in place as visible statement order (AR launches then polls; diffusion polls first). - AsyncAgenticRolloutEngine.collect(): the producer pump both agentic trainers carried verbatim (poll -> drain -> finalize_if_drained -> backoff -> refill, plus the _drain_buffer eviction sweep). Unlike the batch loops this sequence is dictated by the coordinator's engine contract (finalize atomicity vs submit's worker-buffer reset; misordering double-pulls), not by training policy, so it lives with the engine, parameterized by poll_interval_s / refill / on_evicted / context. Trainer copies deleted. No import paths, class names, files, recipes, or knobs change.
haonan3
added a commit
that referenced
this pull request
Aug 2, 2026
…dger Closes the weight_version accounting gap documented as a follow-up in #287: ARTrainer.evaluate() pushed weights via _prepare_rollout without advancing the driver-side counter, leaving the engine on unnumbered weights until the next interval boundary. The async trainer families also disagreed on eval policy: AR synced-without-bump, diffusion explicitly passed sync_weights=False, agentic has no eval. Structure: both driver-side engines replace bump_weight_version() with sync_weights(weight_sync) - one call that pushes and advances the ledger, so the pairing stops being call-site discipline. The batch engine also enforces the quiesce invariant (raises before pushing if any generation is in flight). All async sync sites (boundary, resume, agentic drive) route through it; bump_weight_version drops to zero callers and is removed. Policy: async eval becomes read-only. ARTrainer.evaluate() gains sync_weights: bool = True (mirroring DiffusionTrainer.evaluate); the async trainers override evaluate() with async-correct defaults (no push; diffusion also defaults sleep_after=False), so bare evaluate() calls are safe outside train() too. A pre-train explicit push raises a lifecycle RuntimeError instead of an incidental AttributeError. Deliberate behavior changes: (1) async-AR eval no longer pushes weights, so BOTH the eval series and the training rollouts launched between an eval and the next interval boundary change - they previously ran under eval-synced fresher weights; deployment cadence is now governed solely by weight_sync_interval. At interval=1, or when eval_interval is a multiple of the sync interval, training is point-identical to main. (2) The scored eval policy is 1..interval optimizer steps old (exactly 1 at interval=1). (3) The resume-time push advances the ledger 0->1 (metric offset only - eviction math is relative; the launch ceiling is computed from rollout_id).
2 tasks
CjhHa1
added a commit
that referenced
this pull request
Aug 4, 2026
* fix(rollout): route async weight pushes through the engine version ledger Closes the weight_version accounting gap documented as a follow-up in #287: ARTrainer.evaluate() pushed weights via _prepare_rollout without advancing the driver-side counter, leaving the engine on unnumbered weights until the next interval boundary. The async trainer families also disagreed on eval policy: AR synced-without-bump, diffusion explicitly passed sync_weights=False, agentic has no eval. Structure: both driver-side engines replace bump_weight_version() with sync_weights(weight_sync) - one call that pushes and advances the ledger, so the pairing stops being call-site discipline. The batch engine also enforces the quiesce invariant (raises before pushing if any generation is in flight). All async sync sites (boundary, resume, agentic drive) route through it; bump_weight_version drops to zero callers and is removed. Policy: async eval becomes read-only. ARTrainer.evaluate() gains sync_weights: bool = True (mirroring DiffusionTrainer.evaluate); the async trainers override evaluate() with async-correct defaults (no push; diffusion also defaults sleep_after=False), so bare evaluate() calls are safe outside train() too. A pre-train explicit push raises a lifecycle RuntimeError instead of an incidental AttributeError. Deliberate behavior changes: (1) async-AR eval no longer pushes weights, so BOTH the eval series and the training rollouts launched between an eval and the next interval boundary change - they previously ran under eval-synced fresher weights; deployment cadence is now governed solely by weight_sync_interval. At interval=1, or when eval_interval is a multiple of the sync interval, training is point-identical to main. (2) The scored eval policy is 1..interval optimizer steps old (exactly 1 at interval=1). (3) The resume-time push advances the ledger 0->1 (metric offset only - eviction math is relative; the launch ceiling is computed from rollout_id). * fix(rollout): enforce decode-idle on agentic sync_weights; log eval/weight_version * fix(trainer): deterministic diffusion eval sends a pure-ODE request eval_eta=0 previously rode along with the training-resolved sde_indices — a contradictory request the central kernel silently degrades to ODE but BAGEL's worker-resident scheduler refuses (RuntimeError at the first gated step). Clearing the gate at eval_eta<=0 makes the request say what eval means; SD3-family trajectories are unchanged (they already ran ODE). * fix(rollout): log each ledger push at the engine The actor-side [LoRA-SYNC] lines never reach the driver log, leaving weight deployments invisible in stdout. sync_weights is now the single push path, so one driver-side INFO line covers every async push. --------- Co-authored-by: Celve <celve03@gmail.com> Co-authored-by: CjhHa1 <cjh18671720497@outlook.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Unifies the two async rollout paths behind engine-level contracts under
unirl/rollout/engine/, replacing the over-layeredasync_runtime.py(scheduler + one-implementation dispatcher Protocol + callback types) and the agentic trainers' duplicated producer plumbing.engine/synchronous.py(wasbase.py): worker-side sync contracts.BaseSingleTurnRolloutEngineis renamedSyncRolloutEngine;chunked_engine_generatemoves in from the package__init__.engine/asynchronous.py(new): the driver side —VersionedBuffer+InflightPoolmechanisms, theAsyncRolloutEngineprotocol (poll/drain_freshest/pop_evicted/quiesce, engine-ownedweight_version), and two concretes:AsyncBatchRolloutEngine(AR/diffusion; launch-time version stamps) andAsyncAgenticRolloutEngine(facade over the agentic rank-0 coordinator;[0]unwraps,PendingGroupssibling assembly, completion-time stamps,quiescefolds abort + the mandatory follow-up poll).Handlegains a public non-blocking seam (launch_nowait→PendingHandleCall.ready()/wait()/result()), removingRayGenerationDispatcher's documented "update in lockstep" coupling to the private_execute_all/_rebind_tree;handle_fnis untouched.150s→8s BAGEL constraint; quiesce before sync; tail carry/drop). Thereap_before_launchflag, deaddrained()/pending_roots()/_pending_carried, and trainer-side_weight_versioncounters are removed.engine/__init__.pybecomes a lazy re-export shim so the driver-side module imports ray/torch-free.Net −38 lines; the runtime layer covers both async paths in one place instead of one path plus per-trainer copies.
Also included: a one-line fix to
AsyncARTrainer.__init__(_rollout_anchor_device = None). It is a pre-existingmainbug, not part of the refactor —AsyncARTrainercallsBaseTrainer.__init__directly to skip the parent's colocate placement, but inheritsARTrainer.evaluate(), which reads that attribute. Every eval on the async AR path died withAttributeErrorbefore the first training step. It is bundled here because the refactor's own GPU validation could not run eval without it.Related Issue
N/A (tracked in Linear as LIN-631).
Test Plan
Static checks (branch @
22b27122, based onmain@b1499015):ruff format --check+ruff check(v0.15.1, the pinned pre-commit rev) over all 28 changed Python files —28 files already formatted,All checks passed!.python -m compileallover the same set — clean.CPU-only harnesses pinning the new buffer/pool invariants (complete-or-nothing reap retry without double-insert, first-error deferral, KeyboardInterrupt passthrough, freshest-first staleness eviction, launch-time vs completion-time version stamping, quiesce-poll-before-bump ordering, n-sibling group assembly):
python -m pytest tests/ -q→ 24 passed in 0.04s.CLAUDE.md§5), these harnesses are run but not committed.GPU end-to-end, 1x8 H20 (4 train + 4 rollout), models and datasets on pod-local disk:
examples/ar/qwen3_grpo_4b_base_dapo_sglang_async.yaml, Qwen3-4B-Base on DAPO-Math,num_devices=8 batch_size=16 sampling.samples_per_prompt=8:ratioheld at 0.991–1.000 and|Δlogp|at 1.1–3.4e-02 on every step. Withweight_sync_interval=1(on-policy) a correct implementation must pin the PPO ratio at ~1.0, so this exercisesAsyncBatchRolloutEngine's launch-time stamping and the quiesce-before-bump ordering across 22 consecutive sync cycles.eval_interval=10 +eval_num_prompts=8: therollout_id=-1baseline eval runsanchored=Falseand completes cleanly (verifies the bundled_rollout_anchor_devicefix).examples/diffusion/bagel/bagel_vllmomni_async.yaml, BAGEL-7B-MoT x PickScore x LoRA, vLLM-Omni rollout on a disjoint slab:DiffusionExecutor is closed, 0 tracebacks.ratio=1.0000±0.0000. Because BAGEL's opaque KV contexts cannot cross the IPC boundary, the adapter ships prompts and the stage rebuilds contexts trainer-side; an exact ratio is direct evidence those rebuilt contexts match the ones the rollout worker used, and that the cross-slab fp32 trajectory transfer and worker-vs-trainside SDE math agree.100-rollout block climbs 0.7730 -> 0.8886 and plateaus (first-50 vs last-50:
1.207x). The curve is monotonic until it flattens at ~0.889 — a complete
learning curve, not a truncated segment.
main@b1499015(separate pod, identical config)independently converges to the same place: 0.7796 -> 0.8874, lift 1.193x,
tracking the branch within ~0.002 at every block. The refactor reproduces
main's learning dynamics, not merely its crash-free behaviour. Early
rollouts additionally match rollout-for-rollout (mean |delta| 0.0011 over the
first 10), pinning identical group ordering and version stamping.
AsyncAgenticRolloutEngineremains covered only by the CPU harnesses above.Compatibility / Risk
_target_paths and knobs are unchanged (reap_before_launchwas internal, never a recipe key). The repo'scheck-recipe-targetshook guards this.unirl.rollout.async_runtimeand the names it exported (AsyncRolloutScheduler,RayGenerationDispatcher,VersionedGroupBuffer,GenerationDispatcher,InflightGeneration) are gone;unirl.rollout.engine.base→unirl.rollout.engine.synchronous. All in-repo consumers are migrated; out-of-tree code importing these must update._next_steploops (reviewed line-by-line against the deletednext_stepfor both orderings) and the agenticquiesce()fold (harness-pinned: groups completing during the quiesce carry the pre-sync version)._pending_carriedremoval is a verified dead-state deletion (only ever assigned[]).Reviewer Notes
handle.pyseam (additive) →engine/asynchronous.pymechanisms → protocol + engines →async_ar/async_diffusionloops (check reap ordering) →agentic_partial/agentic_async(check quiesce-before-bump and tail-policy counter divergence).main@b1499015mid-review, which already carried#276(PDEATHSIG for thread-launched workers) and#277(rebuild replay KV contexts in eval()). Both had been independently re-fixed on this branch during GPU debugging; those two commits were dropped in favour of upstream's. No other overlapping open PRs._next_step/_build_tasksloop pairs, makinghandle_fndelegate to the seam halves, and the_connect_separate/two-slab-build duplication.Checklist
Update 2026-08-02 (maintainer)
Rebased in place onto
main@00cf5df0(crossing #226 TP/EP full-weight sync, #259 AR PPO/GAE, #174 qwen3-5) and extended with a surface-trim commit; the branch keeps the original four commits plus one maintainer commit.Rebase conflict resolution (
unirl/trainer/async_ar.py, the only conflicted file):validate_qwen3_5_training_contractalongside the newAsyncBatchRolloutEngineimport; the deletedasync_runtimeimport is gone.rollout_parseddedicated-engine check and this PR's_rollout_anchor_device = Nonefix (the stalear.py:NNNline refs in that comment are rewritten as symbol refs).evaluate()'s weight sync without a driver-side version bump predates both branches (b1499015already synced inline; feat(weight-sync): support SGLang TP/EP full-weight sync #226 only extracted the_prepare_rollouthook), so no bump was added. Follow-up note: withweight_sync_interval>1, an eval landing mid-interval skews staleness accounting by one sync — pre-existing on both sides.Surface trim (
refactor(rollout): drop the async engine layer's zero-consumer surface) — five deletions, each verified zero-consumer in-tree:engine/__init__.pylazy re-export shim → docstring-only init (no package-level importer existed; emptiness preserves the ray/torch-free import property with zero machinery).chunked_engine_generatedeleted (zero callers since the initial release, both before and after the move intosynchronous.py).AsyncRolloutEngineProtocol deleted (zero annotations, zeroisinstance; neither trainer family can be typed against it — both use engine-specific verbs). The contract description folded into the module docstring.__all__trimmed to the consumed surface (AsyncBatchRolloutEngine,AsyncAgenticRolloutEngine,root_of).InflightPool(method=...)parameter removed (single construction site, never overridden).Net vs
mainis now −157 lines (34 files, +751/−908); the Summary's original −38 predates the trim.Post-rebase verification: ruff v0.15.1
format --check+checkclean over the 28 changed Python files;compileallclean;import unirl.rollout.engine.asynchronouson a torch-less interpreter stays stdlib-only; repo-wide grep finds zero references to any deleted name;handle_fnandlaunch_nowaitdispatch gates remain in parity (#226 did not touch the dispatch half). GPU revalidation of the async-AR ratio-pinning recipe on this base is queued before merge (the original GPU runs predate #226).