Diffusion V1 training
Last updated: 11/03/2026
This guide runs the diffusion V1 trainer in synchronous or separate-asynchronous mode using the provided Stable Diffusion 3.5 Medium FlowGRPO OCR recipes. Qwen-Image FlowGRPO now has a matching V1 sync LoRA recipe as well. The V1 trainer uses TransferQueue and ReplayBuffer to move rollout trajectories into the training loop. Synchronous mode waits for a complete rollout batch before each training step. Wan2.2 DanceGRPO on CUDA also defaults to the V1 sync recipe; see DanceGRPO Trainer.
Since v0.3.0 the V1 trainer is the default for every diffusion model:
trainer.use_v1 defaults to true, and python -m verl_omni.trainer.main_diffusion_v1
launches it without extra flags. The legacy v0 trainer
(python -m verl_omni.trainer.main_diffusion or trainer.use_v1=false) is
deprecated — see Legacy v0 trainer (deprecated).
The examples support a single-node NVIDIA GPU setup. Sync mode uses two GPUs for the colocated actor and rollout plus one reward GPU. Separate-async mode also requires dedicated standalone rollout GPUs.
Architecture
main_diffusion_v1 starts the Ray task runner, enables TransferQueue, and selects
the sync or separate_async trainer. The diffusion agent loop samples media
trajectories through the configured rollout backend (commonly vLLM-Omni).
TransferQueue is the center of the online experience data path. A row can hold
prompt/group/session identity, generated media or latents, rollout
log-probabilities, reward scores, extra fields, and policy-step metadata. In
separate_async, prompt records and statuses can also be queued while generation
is pending.
Reward workers called by the agent loop receive the prompt and generated output; their score returns to the agent loop and is stored with the trajectory row. An optional colocated reward scorer runs trainer-side after the row is read. The trainer then writes selected score, advantage, and return fields back to that same queue row. ReplayBuffer samples and groups queue rows for actor training; the trainer computes configured advantages and diffusion loss, then updates the actor policy.
The main implementation points are main_diffusion_v1.py,
agent_loop/diffusion_agent_loop_tq.py::_write_trajectories_to_tq,
trainer/diffusion/v1/trainer_base.py::_train_sampled_batch, and
trainer/diffusion/v1/tq_utils.py::put_dataproto_fields_to_tq. The trainer’s
checkpoint manager handles actor/dataloader checkpoints and rollout weight
sync; separate-async checkpoints can also snapshot TransferQueue state. Training
rows are cleared after metric and rollout-dump handling.
In sync, rollout and actor workers are colocated, and each training step waits
for a complete rollout batch before updating the actor and refreshing rollout
weights. In separate_async, standalone rollout workers can generate while the
actor trains; parameter_sync_step controls how many local actor updates happen
before rollout weights are refreshed. Set sync_compatible=true to pause
standalone generation during actor updates. The replay buffer handles
asynchronous batch availability and rollout staleness.
The diagram shows the shared online diffusion RL path. Optional old/reference policy scoring, rollout correction, and reward components depend on the recipe.
Prerequisites
Install VeRL-Omni and its training dependencies by following the installation guide. Run all commands below from the repository root in the same Python environment.
The OCR reward also requires Levenshtein:
uv pip install -e ".[ocr]"
Verify that TransferQueue and the V1 entrypoint can be imported:
python -c "import transfer_queue; import verl_omni.trainer.main_diffusion_v1; print('Diffusion V1 dependencies are ready')"
The package is installed as TransferQueue and imported in Python as
transfer_queue.
Prepare the OCR dataset
Set WORKSPACE to a writable directory. It defaults to $HOME in the run
script:
export WORKSPACE=${WORKSPACE:-$HOME}
Download train.txt and test.txt from the original
Flow-GRPO OCR dataset
and place them in $WORKSPACE/data/ocr. Convert them to parquet files:
python3 examples/flowgrpo_trainer/data_process/sd3_ocr.py \
--input_dir "$WORKSPACE/data/ocr" \
--output_dir "$WORKSPACE/data/ocr/sd3"
This creates:
$WORKSPACE/data/ocr/sd3/train.parquet$WORKSPACE/data/ocr/sd3/test.parquet
See the FlowGRPO quickstart for the dataset format and custom-dataset instructions.
Run V1 sync mode
Launch the V1 SD3.5 Medium LoRA recipe:
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1.sh
The script selects the V1 synchronous path with:
python3 -m verl_omni.trainer.main_diffusion_v1
trainer.use_v1=true
trainer.v1.trainer_mode=sync
trainer.use_v1=true and trainer.v1.trainer_mode=sync are the defaults since
v0.3.0; the scripts keep them for explicitness.
Hydra settings can be appended to the command. For example, to run fewer steps
and disable W&B:
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1.sh \
trainer.total_training_steps=10 \
trainer.logger='["console"]'
Checkpoints are written by default to:
checkpoints/flow_grpo/sd35_medium_ocr_lora_v1
Qwen-Image FlowGRPO
Qwen-Image FlowGRPO uses the same V1 sync entrypoint and flags. Launch the 4-GPU LoRA OCR recipe with:
bash examples/flowgrpo_trainer/qwen_image/run_qwen_image_ocr_lora_v1.sh
Model, LoRA, reward, pipeline, and SDE knobs match the v0 script
run_qwen_image_ocr_lora.sh. Prepare the Qwen-Image OCR parquet files as in
the FlowGRPO quickstart (use qwenimage_ocr.py,
not the SD3 converter above). Checkpoints default to
checkpoints/flow_grpo/qwen_image_ocr_lora_v1.
Wan2.2 DanceGRPO (default CUDA recipe)
Wan2.2 DanceGRPO on CUDA now defaults to the same V1 sync trainer:
bash examples/dancegrpo_trainer/wan22/run_wan22_5b_t2v_hpsv3_v1.sh
See DanceGRPO Trainer for dataset and HPSv3 setup. The
legacy v0 auto-detect script (run_wan22_5b_t2v_hpsv3_auto.sh) is
deprecated for CUDA and remains for NPU.
Optional training watchdog
The reporting-only watchdog is disabled by default. Enable it with ordinary Hydra overrides, for example:
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1.sh \
trainer.v1.watchdog.enabled=true \
trainer.v1.watchdog.window_size=3 \
trainer.v1.watchdog.warmup_steps=5
It reads already reduced scalar metrics after each V1 training step. Warnings
include the rule, triggering step, consecutive evidence window and scalar values;
the existing logger also receives watchdog/alerts, per-rule
watchdog/coverage/<rule>, watchdog/warmup_remaining, watchdog/ignored_step
and watchdog/processing_time_s. Coverage zero means evidence is unavailable or
the loss does not support that rule, not a healthy result.
The rules distinguish numerical failure from sustained risk signals:
Nonfinite
actor/grad_normwarns immediately, including during warmup.For
flow_grpo,dance_grpo,flow_dppoandgrpo_guard, a complete window jointly requires zero reward variation in every prompt group, average group size greater than one, reward std at mostreward_std_maxand gradient norm at mostgrad_norm_max. Flat reward alone, singleton groups and a single zero-gradient step are not proof of collapse.Those ratio-producing losses also warn on a complete window of
actor/ratio_meanoutside[ratio_lower, ratio_upper]. Other losses, including DiffusionNFT and distillation, do not receive ratio or grouped-reward warnings.max_staleness=nulldisables the staleness rule. Set an explicit budget to warn on sustainedtraining/off_policy/trajectory_staleness_worst/maxabove it. The metric is(global_steps - 1) - min_global_stepsover each nonpadding trajectory, then the batch maximum. An on-policysyncbatch can report-1: this is valid healthy coverage and can end an alert episode. For example,max_staleness=2warns after a complete window above2, not at2or-1: a submission three outer steps behind the current step has lag2and is still within that budget. This model-version lag differs from the replay-buffer spanglobal_steps - prompt_global_steps + 1; the budget is not a span limit.
In separate_async, ratio and gradient warnings use metrics aggregated for the
outer training step, not each local actor update.
Defaults are configurable diagnostic heuristics, not CI gates or validated
collapse thresholds. Sustained rules skip five accepted observations after
start/resume, then need three consecutive complete observations. Missing or
invalid prerequisites and forward step gaps break consecutive evidence;
duplicate/older steps are ignored. An episode emits once and can rearm after a
known recovery, with cooldown_steps=20 between reports. Diagnostics reset on
each fit, including checkpoint resume, and are not checkpointed. Only bounded
scalar history is retained; the watchdog never changes training inputs, RNG,
optimizer state, checkpoints, scheduling or job lifetime, and needs no LLM service.
Run V1 separate-async mode
Launch the separate-async recipe:
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1_separate_async.sh
This mode runs standalone rollout workers on dedicated GPUs. For
parameter_sync_step=N, each outer step consumes N complete PPO mini-batches,
keeps π_old fixed at the cycle-start actor weights, and synchronizes rollout
weights once after all N actor updates. Configure batch sizes with:
data.train_batch_size =
trainer.v1.separate_async.parameter_sync_step *
actor_rollout_ref.actor.ppo_mini_batch_size
For example:
PARAMETER_SYNC_STEP=4 \
TRAIN_BATCH_SIZE=8 \
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1_separate_async.sh
sync_compatible=true pauses standalone generation during actor updates. It
requires num_warmup_batches=0; set it to false to retain rollout/training
overlap.
Weight-sync backends
Separate-async mode requires a non-naive
actor_rollout_ref.rollout.checkpoint_engine.backend for the trainer ->
standalone rollout weight sync. nccl (the default in the recipes above)
broadcasts the full model every sync. omni_delta_sharded instead broadcasts
only the weights that changed since the last sync: the first (seed) sync
streams the full export, steady syncs ship sparse (position, value) updates,
and the rollout verifies a per-flush checksum and applies them in place. RL
updates are highly sparse in BF16, so steady-state payloads shrink to the
per-step changed ratio while the rollout weights stay bit-identical to a full
broadcast.
The backend is a verl-omni subclass of verl’s DeltaShardedCheckpointEngine
(verl gates the raw delta_sharded name to sglang rollouts, so
omni_delta_sharded resolves through verl’s CheckpointEngineRegistry). The
subclass re-declares the wire as the stock named_tensors bucketed stream and
flattens each flush’s sentinel tensors into it. verl’s CheckpointEngineWorker
and the inherited vLLM ServerAdapter weight sender drive the sync; the omni
adapter requests checked replies for IPC updates. The vllm-omni worker
extension routes the stream by sniffing the first bucket and
applies flushes in place through verl’s delta loader.
omni_delta_sharded is gated: it requires separate_async mode and full-weight
training (LoRA configs raise at startup, since adapter sync is already small),
QAT exports are refused, and fused-MoE rollout models raise at the first sync
(the sparse in-place apply does not reproduce their checkpoint-layout reload).
The SD3.5-Medium full-weight recipe is:
bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_v1_separate_async_delta.sh
This backend is covered by CPU unit tests only so far; the sparsity preflight
and a bit-exact GPU smoke against the nccl backend are pending (RFC #38).
The same recipe doubles as the A/B harness: CKPT_BACKEND=nccl runs the
identical full-weight configuration over a full broadcast.
CKPT_BACKEND=nccl bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_v1_separate_async_delta.sh
Watch checkpoint_engine/changed_ratio and checkpoint_engine/payload_mbytes
in the trainer metrics to confirm the sparsity premise holds for a given model
and optimizer before relying on the speedup.
Model-agnostic LoRA policy snapshots
With FSDP2, a single default LoRA adapter, and sequence parallel size 1,
separate-async CPU snapshots retain only the trainable parameters regardless of
model architecture. This includes any trainable parameters outside the LoRA
layers. The cycle-start π_old and current-actor restoration semantics are
unchanged; no new configuration is required.
This reduces the snapshot payload, not the cost of materializing the actor when manual parameter offload is enabled. FSDP1 and other backends, multiple policy adapters, native FSDP2 CPU offload policy, and full-parameter training retain full snapshots. Changing the trainable parameter set or shard layout within a snapshot’s lifetime is unsupported and fails before restore. Qwen-Image is the validation fixture, not an eligibility restriction. FSDP snapshot copy and restore failures are reported across actor ranks before the RPC returns. A failed save preserves the previous snapshot. A restore can fail after some local shards were copied: the training step fails rather than continuing with mixed weights; this does not provide transactional rollback or recovery from a failed process or accelerator collective.
For steady-state, unmerged LoRA weight synchronization with manual actor parameter offload, the actor first gathers independent CPU adapter tensors and offloads parameters and runs synchronized cache cleanup before waking rollout weights. This avoids overlapping actor and colocated rollout weight residency. It trades gather/wakeup overlap for GPU memory headroom; it does not change snapshot contents or guarantee that every model/configuration fits. First base sync, merged LoRA and non-offloaded paths retain their existing synchronization order.
To measure snapshot overhead with a locally available pretrained Qwen-Image checkpoint, run the standalone benchmark outside fast CI:
torchrun --standalone --nproc-per-node=2 scripts/benchmark_trainable_snapshot.py \
--model /path/to/Qwen-Image --output /tmp/qwen-snapshot-60-offload \
--run-id qwen-image-depth60-offload-20260911-a \
--checkpoint-revision FULL_MODEL_COMMIT --source-revision FULL_CODE_COMMIT \
--checkpoint-manifest /path/to/transformer-shards.manifest \
--layers 60 --lora-rank 64 --sync-steps 3 --warmup 2 --repeats 10 --offload
Use a fresh output directory for each run. Omit --offload for GPU-resident
parameters. Depths 10 and 30 select prefixes of the original pretrained
transformer, preserving its width; these are not full-model quality benchmarks.
The script requires Linux/glibc and exactly two CUDA ranks. Budget host memory
for two retained snapshots, temporary cloning, and manual offload, in addition
to model loading; the tiny regression’s memory allocation is not sufficient.
Both source and model directories must be Git checkouts at the declared
revisions. The manifest has one path|bytes|sha256 row per transformer shard,
obtained from that revision’s Git LFS pointers. Every shard is hashed before
loading; the source/metrics bundle is also digested. The fixed matrix uses
depth 10/30/60, rank 64, seed 20260911, sync steps 3, two warmup pairs and ten
timed pairs. Change the experiment contract before adding another setting.
Before CUDA or NCCL initialization, rank zero creates the output directory,
verifies all pinned metadata and shard hashes, and atomically publishes a
self-identifying run manifest plus preflight status. Other ranks independently
verify their imported source checkouts and wait up to 900 seconds for that
same run ID. Every partial and final result references the manifest digest.
The benchmark loads once per depth/offload run and alternates full/trainable
snapshot pairs. summary.json reports the slowest-rank latency per pair;
result-rank*.json retains raw timings, exact tensor payloads, and a separate
5-ms sampled RSS/GPU-memory pass. RSS is allocator-sensitive and sampled, not
an exact peak. Bootstrap intervals describe repeats in that allocation, not
independent runs. Forward/backward, rollout and reward are excluded, so
training_cycle_fraction is unavailable and no end-to-end speedup is implied.
Hybrid rollout switching
The colocated rollout replicas share GPUs with the actor. By default they are lent to generation only during validation: they start in rollout mode after initialization and are reclaimed (removed from the load balancer, in-flight requests aborted, slept) before the warmup batches are fed. Validation lends them out again and the next step reclaims them.
Enable dynamic switching so the trainer also lends them to the next step’s generation whenever the replay buffer is short:
SYNC_COMPATIBLE=false bash examples/flowgrpo_trainer/sd35/run_sd35_medium_ocr_lora_v1_separate_async.sh \
trainer.v1.separate_async.hybrid_rollout.enable_switch=true
At the end of a step, the trainer syncs the standalone replicas as usual and
then estimates how many prompt groups the next step still misses. If the
expected wait exceeds the recent cost of a switch round trip, it installs the
new actor weights into the colocated replicas, resumes them, and registers them
with the standalone load balancer. The next step submits its prompts, waits
until switch_threshold_ratio * train_batch_size groups (at least one
mini-batch) are sampleable, and reclaims the replicas. Aborted diffusion
samples are retried as whole samples on the remaining replicas. Switching
happens at most once per step; adaptive_switch_threshold raises the
threshold after sustained sampling waits and lowers it after calm steps.
enable_switch=true requires sync_compatible=false and
actor_rollout_ref.rollout.disaggregation.enabled=false. The remaining
knobs follow the upstream HybridRolloutSwitchConfig defaults:
switch_threshold_ratio, adaptive_switch_threshold,
switch_threshold_step_up, switch_threshold_step_down,
switch_threshold_release_steps, and switch_cost_window_size.
Logged metrics: timing_s/switch_wait, timing_s/switch_to_rollout,
timing_s/switch_to_trainer, separate_async/switch/*, and
separate_async/decision/*.
This is the v1 analog of verl’s fully_async_policy
DynamicResourceController (verl#6556). That controller is not wired on
main_diffusion_v1; setting async_training.use_dynamic_resource_scheduling=true
raises at startup.
Checkpoint recovery
Separate-async checkpoints save TransferQueue next to the actor and
dataloader (global_step_N/transfer_queue/). Resume with
trainer.resume_mode=auto (or resume_path) restores queued prompt groups,
re-issues pending and running groups, and tops warmup up to
num_warmup_batches * train_batch_size instead of enqueueing that many new
batches. Requires TransferQueue 0.1.9. Sync mode does not write a queue
snapshot. Old checkpoints without transfer_queue/ warn and start the queue
empty.
Streaming refill already uses data.gen_batch_size=1 in separate_async
(and whenever exact incomplete-group refill is on). Do not set a larger
gen_batch_size expecting it to stick; the trainer overrides it.
Throughput metrics of every separate_async run, with switching on or off, now
also count the standalone rollout GPUs in the denominator, so they read lower
than earlier runs of the same recipe.
Reclaiming waits for the batch the colocated replicas are executing to
finish: the diffusion engine runs whole request batches and only then
processes the abort and the sleep, so timing_s/switch_to_trainer includes
up to one batch of generation. The tiny smoke covers this path:
ENABLE_SWITCH=1 NUM_WARMUP_BATCHES=1 \
bash tests/special_e2e/run_flowgrpo_qwen_image_v1_separate_async.sh
How TransferQueue supports the V1 trainer
TransferQueue (pip package TransferQueue, imported as transfer_queue) is the
streaming queue the V1 control plane uses to hand rollout data to the trainer.
Every V1 diffusion run depends on it:
Force-enabled lifecycle. The yaml default
transfer_queue.enableisfalse, but a V1 launch force-sets it totruebeforeray.init()— a V1 run cannot start without TransferQueue.ray.initthen exportsTRANSFER_QUEUE_ENABLE=1through the Ray runtime env so all workers join the same queue, and the task runner wraps training intq.init(config.transfer_queue)…tq.close(). If Ray workers reportModuleNotFoundError: No module named 'transfer_queue', stop the cluster (ray stop) and relaunch from the environment where TransferQueue is installed.Rollout-side producer. The diffusion agent loop ships a TransferQueue writer (
diffusion_agent_loop_tq.py) that serializes each finished rollout session — prompts, latents, rewards, and an explicit allowlist of extra fields (e.g.img_shapesfor Qwen-Image 2D RoPE). The allowlist is intentional: silently forwarding unknown fields has broken metadata before, so new fields must be added there explicitly.Trainer-side consumer. The trainer converts queued rows back to
DataProtobatches (tq_utils.diffusion_tq_batch_to_dataproto) and feeds them to the ReplayBuffer, which drivessyncbatching, staleness eviction, andseparate_asyncpartial rollout.Tuning.
transfer_queue.backend.SimpleStorage.total_storage_sizecaps how many experience samples the default backend holds;num_data_storage_unitssets the in-memory storage units; metrics can be exposed viatransfer_queue.metrics.*. See Important settings.
Omni models get the same support through verl’s TaskRunnerV1
(python -m verl_omni.trainer.main_omni); omni inherits verl’s TransferQueue
setup and requires TransferQueue >= 0.1.9 for async checkpoint resume.
Important settings
trainer.use_v1(defaulttruesince v0.3.0) selects the V1 trainer; setting it tofalseexplicitly selects the deprecated legacy v0 trainer.trainer.v1.trainer_modeselectssyncorseparate_async.trainer.v1.separate_async.parameter_sync_stepcontrols the number of local actor updates per rollout-weight synchronization cycle.trainer.v1.separate_async.hybrid_rollout.enable_switchlends the colocated rollout replicas to generation between steps when the replay buffer is short.actor_rollout_ref.rollout.agent.num_workerscontrols the rollout worker count.trainer.v1.sampler.drop_incomplete_groups=trueevicts a training prompt group when any of its rollout sessions fails and submits the same number of replacement prompts. This policy is supported only withtrainer.v1.trainer_mode=sync; validation sampling is unchanged.trainer.v1.sampler.max_incomplete_group_refill_roundsbounds consecutive replacement rounds within one training sample call. Exact refill uses a generation batch size of one while the policy is enabled.transfer_queue.backend.SimpleStorage.total_storage_sizecontrols the maximum number of experience samples held by the default backend.transfer_queue.backend.SimpleStorage.num_data_storage_unitscontrols the number of in-memory storage units.
The configurable incomplete-group refill policy above applies to sync mode.
In separate_async, the upstream async replay buffer automatically evicts and
replaces stale or failed prompt groups. colocate_async is not yet supported.
Legacy v0 trainer (deprecated)
The legacy v0 diffusion trainer — launched by
python -m verl_omni.trainer.main_diffusion or by
trainer.use_v1=false — is deprecated for every model since v0.3.0:
Every v0 launch of a path that has a V1 equivalent emits a
DeprecationWarning; the v0 trainer will be removed in a future release.Models without a landed V1 recipe (tracked in verl-project/verl-omni#389) keep working on v0 until their port lands; the warning is expected there. Their scripts pin
trainer.use_v1=falseexplicitly, so the launch stays on v0 even if the entrypoint is swapped tomain_diffusion_v1.Stays on v0 by design (no warning, no V1 planned): diffusion offline DPO (
examples/dpo_trainer/sd35/) and omni offline DPO (algorithm.sample_source=offline+algorithm.trainer_type=direct_preference).
Migrating a v0 recipe to V1 sync takes three lines — swap the entrypoint and the two (now-default) switches:
python3 -m verl_omni.trainer.main_diffusion -> python3 -m verl_omni.trainer.main_diffusion_v1
trainer.use_v1=false -> trainer.use_v1=true
trainer.v1.trainer_mode=sync
Batch math is unchanged for sync; separate_async adds the
train_batch_size = parameter_sync_step * ppo_mini_batch_size identity
described above.
Troubleshooting
Ray workers cannot import transfer_queue
: Stop the existing Ray cluster with ray stop, activate the environment where
TransferQueue is installed, and launch the recipe again.