feat(trainer): AsyncDiffusionTrainer for disaggregated async diffusion RL - #192
Conversation
…usion RL Diffusion sibling of AsyncARTrainer: subclasses DiffusionTrainer(layout=separate) to reuse the two-slab build + NCCLWeightSync handshake, and overlays the async rollout buffer loop (non-blocking generate, reap-time reward scoring off the train critical path, buffer of scored GRPO groups, train consumes the freshest batch). Knobs: max_inflight (overlap depth), buffer_max_staleness (0=on-policy). Adds unirl/trainer/async_diffusion.py, unirl/train_async_diffusion.py, and examples/diffusion/sd3/sd3_vllmomni_async.yaml. Purely additive.
…segment transfer, add BAGEL async recipe, drop SD3 async recipe
4f6c53a to
e35bd9c
Compare
CjhHa1
left a comment
There was a problem hiding this comment.
Need a long run before merge to see the reward gain
Default Hydra config still pointed at the dropped SD3 async yaml. Point train_async_diffusion at bagel_vllmomni_async and set buffer_max_staleness=2 (the throughput-optimal knob from the PR validation table).
Evaluate the resident rollout policy without syncing or offloading the async engine, while preserving synchronous defaults and forwarding configured eval suites.
State consistently that generation overlaps training while reap-time reward scoring remains synchronous.
Fail before worker construction unless max_inflight is exactly one, preserving the idle-worker window required by reap-time transfer.
Record the train slab fraction and describe the actual remote LoRA sync and bounded policy-lag ratio semantics without changing runtime behavior.
Brings in the sample-native rollout boundary (Tencent-Hunyuan#214), which removed unirl/types/rollout_req.py and unirl/types/rollout_resp.py along with the RolloutReq / RolloutResp / RolloutTrack triplet. Conflict resolution in unirl/trainer/diffusion.py takes main's version and re-applies this branch's evaluate() seam (sync_weights / sleep_after) plus the _train_fraction field on top of it. unirl/trainer/async_diffusion.py still imports the deleted types at this commit, so it does not import here; the next commit migrates it.
…sync runtime Two things broke this branch against current main, and both are fixed here. The trainer was written against the retired RolloutReq / RolloutResp / RolloutTrack triplet, deleted by the sample-native rollout boundary (Tencent-Hunyuan#214). It is now sample-native: the request is the Sample from _build_request_sample, scoring is reward.score_and_attach(sample) on the self-contained filled Sample instead of the old (req=, track=) pair, groups reassemble with Sample.concat, and the RolloutResp(tracks=...) rebuild and its _track_key bookkeeping are gone. The entry point's stage_config was likewise renamed to task_config. The async buffer / generate seam this branch duplicated from AsyncARTrainer has since been lifted into unirl/rollout/async_runtime.py, the follow-up refactor this PR's description anticipated. _RolloutBuffer, _generate_async, _collect_resp, _is_ready, _launch, _reap_ready and the _next_batch loop are all replaced by AsyncRolloutScheduler + RayGenerationDispatcher, leaving only the diffusion hooks: build a request Sample, score-and-split at reap time, and advantage + FlowGRPO step. Adopting that runtime needs one addition to it, because it launched before it reaped and this path requires the opposite. Reaping pulls the trajectory segment off the rollout slab as an NCCL send issued on the rollout workers, so a generation launched ahead of that send blocks it -- the ~150s/rollout instead of ~8s that reap-before-launch was introduced to fix. Reap-first at max_inflight=1 hands the send idle workers while still launching before the step returns, so the next generation overlaps the caller's train step. The new reap_before_launch flag selects the order and defaults to the existing launch-first behavior, so the AR path is unchanged. Verified: ruff check and format clean, the trainer and entry point import against current main, Hydra compose of the BAGEL async recipe passes, all recipe _target_ paths resolve, both constructor guards fire before any Ray construction, and a fake-dispatcher check confirms reap-first at max_inflight=1 both keeps one generation in flight across every train step and always reaps against idle rollout workers. Not re-run: the GPU reward-curve and localize-timing validation in the PR description.
Post-merge validation of the async path on current
|
Resolves one conflict in unirl/rollout/async_runtime.py, where upstream's AsyncDiffusionTrainer (Tencent-Hunyuan#192) added a reap_before_launch phase switch to AsyncRolloutScheduler while this branch renamed the same API (next_step -> next_batch, groups_per_step -> groups_per_batch, _launch_one -> launch_one) and rewrote the launch loop around launch_ceiling(). Kept both sides: the constructor now takes groups_per_batch plus reap_before_launch, and next_batch wraps its committed-cap top-up in upstream's reap/launch ordering switch. Upstream's inline min(num_rollouts, staleness_window) ceiling was dropped in favor of this branch's launch_ceiling(..., num_rollouts=target); the two were verified identical over 27300 (num_rollouts, sync_interval, max_staleness, rollout_id) combinations, so the on-policy launch clamp is unchanged. Upstream's _top_up helper is kept, retargeted onto the renamed public launch_one; the stale-eviction replenish loop stays inline because its extra buffer-size condition does not fit that helper. Also ports unirl/trainer/async_diffusion.py, which git auto-merged cleanly but still called the pre-rename API (groups_per_step=, next_step) and would have failed at scheduler construction. Its own _next_step is renamed _next_batch to match AsyncARTrainer. Verified reap_before_launch still behaves as documented: at max_inflight=1 the reap-first path issues its post-reap launch before returning, leaving one generation in flight to overlap the train step, while launch-first returns with nothing in flight.


Maintainer Update (2026-07-29) — migrated to sample-native
mainRebased onto current
main. Two APIs this branch was written against no longer exist, somergeable=MERGEABLEwas textual only — the previous head raisedImportErroron currentmain.RolloutReq/RolloutResp/RolloutTracktriplet, deleted by the sample-native rollout boundary (feat(agentic): add Sample-native multi-turn rollout and training #214). The trainer is now sample-native: the request is theSamplefrom_build_request_sample, scoring isreward.score_and_attach(sample)on the self-contained filledSampleinstead of the old(req=, track=)pair, groups reassemble withSample.concat, and theRolloutResp(tracks=...)rebuild plus its_track_keybookkeeping are gone. The Hydra entry'sstage_configbecametask_config.unirl/rollout/async_runtime.py— the follow-up refactor this description anticipated.AsyncRolloutScheduler+RayGenerationDispatchernow own the loop, so_RolloutBuffer,_generate_async,_collect_resp,_is_ready,_launch,_reap_readyand_next_batchare gone andasync_diffusion.pydrops from 373 to 283 lines.Adopting that runtime needed one addition to it: it launched before it reaped, which is exactly the ordering this path cannot use (see the key-mechanism section below).
reap_before_launchselects the order and defaults to the existing launch-first behavior, soAsyncARTraineris unchanged.Current-head validation:
ruff check/ruff format, full repository pre-commit, all recipe_target_paths resolve, the trainer and entry point import against currentmain, BAGEL async Hydra compose passes, both constructor guards fire before any Ray construction, and a fake-dispatcher check confirms reap-first atmax_inflight=1both keeps one generation in flight across every train step and always reaps against idle rollout workers. The GPU numbers below were measured on the pre-migration head and have not been re-run.Maintainer Update (2026-07-26)
Final review aligned the async-only path with the current synchronous and AsyncAR safety behavior:
Current-head validation: non-persisted component-hydration and fault-injection smoke passed; BAGEL async Hydra compose passed; full repository pre-commit and
git diff --checkpassed; synthetic merge with latestmainis clean. The prior Tencent external-scan failure contained a literalnullsecurity count and an empty task ID rather than a reported defect; the new head has retriggered the checks.Summary
AsyncDiffusionTrainer— the pure-DiT async RL trainer. Diffusion sibling ofAsyncARTrainer: training and rollout run on disjoint GPU slabs, generation is overlapped with training, and weights are pushed cross-slab. Completed generations are synchronously localized and reward-scored at reap time before the next launch / training consume; reward itself is not overlapped.The synchronous diffusion trainer's behavior is unchanged. The PR adds three async-specific files, one ordering option on the shared async rollout runtime, and a backward-compatible evaluation control seam in
DiffusionTrainer.Motivation: the synchronous diffusion path runs
generate → reward → trainin series each step. Async removes generation from the train critical path by running the next rollout concurrently with the current train step.The key mechanism — reap-before-launch (what makes the overlap actually fast)
The buffer loop reaps (and cross-slab-transfers the completed generation's trajectory segment) BEFORE launching the next generation. That transfer runs on the rollout worker as an NCCL send; if a fresh generation were already queued on that worker (launch-first), the send blocks behind it — measured ~150s/rollout on BAGEL, even though the send itself is only ~3–8s. Reaping first gives the transfer an idle-worker window (~7–8s), then the next generation overlaps the caller's train step.
This requires
max_inflight=1: a second in-flight generation co-tenants the same rollout workers and reintroduces the stall. On shared rollout workers generations serialize anyway, somax_inflight=1costs no throughput while enabling the contention-free transfer + overlap. (This is also why plainvllm sync-separatenever hit the stall — it never runs a generation concurrently with the transfer.)This ordering now lives in the shared runtime as
AsyncRolloutScheduler(reap_before_launch=True). It is load-bearing rather than cosmetic: launch-first atmax_inflight=1leaves nothing in flight when the step returns, so the trainer would still be correct and still pass every static check while silently running atvllm sync-separatespeed.What's changed
unirl/trainer/async_diffusion.py—AsyncDiffusionTrainer(DiffusionTrainer). Reuses thelayout="separate"two-slab build, cross-slab weight-sync wiring,_build_request_sample/_drop_decoded/ checkpoint, and FlowGRPOstack.train_track. Drives the sharedAsyncRolloutSchedulerand supplies only the diffusion hooks:_build_async_sample(one data batch → one requestSample),_score_completed(reap-time reward, thenSample.splitinto tree-complete groups),_advantage_and_train, and_drain_allquiescence before weight sync / eval / checkpoint.unirl/rollout/async_runtime.py—reap_before_launchonAsyncRolloutScheduler, selecting whether each step launches or reaps first. Defaults to the existing launch-first order, so the AR path is byte-equivalent; the launch top-up is factored into_top_upso both orders share it.unirl/train_async_diffusion.py— Hydra entry (sibling oftrain_diffusion.py).examples/diffusion/bagel/bagel_vllmomni_async.yaml— BAGEL-7B-MoT async recipe (max_inflight=1,weight_sync_interval=4,buffer_max_staleness=2).unirl/trainer/diffusion.py— backward-compatible eval flags. Async eval uses the policy already resident in the rollout engine, does not sync train weights, and leaves the dedicated engine resident; synchronous callers retain the existing sync + sleep defaults.Knobs:
max_inflight(must be 1, see above);weight_sync_interval(>1 enables overlap across train steps);buffer_max_staleness(0 = buffered groups do not cross a regular rollout-weight sync; >0 = bounded policy-lag buffer that can survive sync boundaries).Eval semantics
Periodic async eval measures the currently resident rollout policy. It deliberately skips
train → rolloutweight sync and does not sleep/offload the rollout engine, so evaluation neither changes_weight_versionnor perturbs the resident async pipeline.Validation (BAGEL-7B-MoT FlowGRPO, PickScore, 4 train + 4 rollout on 8×H20, batch=16 × 16 samples/prompt)
Measured on the pre-migration head; see the 2026-07-29 maintainer update.
ratio = 1.0000observed throughout the validation run; reward grows, no crash / OOM.inflight=1 stale=0 interval=4inflight=1 stale=2 interval=4staleness=2eliminates the per-window cold sync-boundary rollout (276s → 110s) → ~148s avg, matching vllm colocate at 1.85×.old_logp_source=rolloutpreserves the generating policy's emitted π_old for importance sampling. It does not mathematically force ratio=1; the near-1 ratio above is an empirical result of this run under bounded policy lag.ProcessGroupNCCL(TCPStore)mechanismlocalizeuses, solocalizestays ~8s cross-node — the fix holds multi-node.Notes / risk
vllm_omnienablement fixes required by this recipe are included in merged PR perf(vllm-omni): batch BAGEL grouped t2i rollout into one packed generate_image #203.unirl/rollout/async_runtime.pythatAsyncARTraineralso drives. The one behavioral difference between the two callers is thereap_before_launchorder.Test Plan
Post-migration, on current
main:ruff check/ruff formatclean; full repository pre-commit passes (includingcheck-recipe-targets)unirl.trainer.async_diffusionandunirl.train_async_diffusionimport against currentmainpython -m unirl.train_async_diffusion --config-name=diffusion/bagel/bagel_vllmomni_async --cfg job --resolvecomposeslayout != "separate"andmax_inflight != 1guards fire before any Ray constructionmax_inflight=1returns each step with one generation in flight and always reaps against idle rollout workers; launch-first atmax_inflight=1is serialized; launch-first atmax_inflight=2keeps AR's overlapPre-migration head (not re-run):
staleness=0vsstaleness=2comparison; cross-node IB transfer validatedstaleness=2AI-assisted: the sample-native / shared-runtime migration above was prepared with agent assistance and reviewed against the current
mainAPI surface; duplicate-work check — PR #272 covers the only other remainingRolloutReqconsumer (sglang_diffusion/adapters/video.py) and does not overlap these files.