Skip to content

[diffusion, fsdp, cfg] feat: stage Qwen-Image training inputs by timestep - #549

Merged
WenzheWang merged 8 commits into
verl-project:mainfrom
WenzheWang:wenzhe/diffusion-output-lifetime
Sep 10, 2026
Merged

[diffusion, fsdp, cfg] feat: stage Qwen-Image training inputs by timestep#549
WenzheWang merged 8 commits into
verl-project:mainfrom
WenzheWang:wenzhe/diffusion-output-lifetime

Conversation

@WenzheWang

@WenzheWang WenzheWang commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Reduce retained diffusion-training tensors in two bounded steps:

  • Release unused PPO/NFT model_output after backward by default; explicit return_model_output=True and inference keep their outputs.
  • Add default-off synchronous timestep input staging for QwenImagePipeline, FlowGRPO or DiffusionNFT, FSDP/FSDP2, GPU, SP=1. Keep the caller's trajectory on CPU, copy prompt conditions once per micro-batch, and copy only current-step fields for each forward/backward.

PPO stages the current/next latent pair and matching loss inputs. NFT shares the clean latent/static noise and stages per-step noise when supplied. Dtypes, ordering, gradient accumulation and loss implementations are unchanged. Central trainer config validation rejects staging with sequence parallelism; the validated model/backend scope is documented in the Qwen-Image README rather than enforced through a constructor allowlist. DPO, other models/backends, sequence parallelism, async prefetch/pinned buffers and rollout transport are out of scope.

Checklist Before Starting

  • Checked staging / trajectory / offload PRs, exact head and the author's open PRs. No equivalent Qwen training-side timestep-staging PR was found. #354 owns rollout SHM/GenRM overlap, #479 owns an SD3 train-side sampler, and #545 owns distillation control-plane scheduling.
  • Title follows [{modules}] feat: ....

API and Usage Example

actor_rollout_ref.actor.enable_timestep_staging=true

Consumed tensor inputs must already be CPU tensors without input gradients; there is no implicit full-trajectory GPU-to-CPU round trip. Inference bypasses staging. Explicitly retaining training outputs still incurs timestep-dependent output memory. Transfers are synchronous, so this is a memory/transfer-cost tradeoff, not a throughput guarantee.

Test

Documentation CI fix (2026-09-10)

Fixed in 7de0250099d87d7023a993bc6d22d4f57abb5e3e, after the main refresh b134e233 (main 6dc0fc03). This is documentation-only: register the Qwen-Image README as a Git symlink under docs/examples, add its Examples toctree entry, and keep its cross-page links valid from both source and documentation paths.

  • Reproduced the two example_docs_links failures on b134e233.
  • python3 tests/special_sanity/check_example_docs_symlinks.py: all 19 README pages passed on final tree 641bea6ddfb6adb58a710cf2497cfaf88e5822df.
  • All three README links passed from both source locations (6 checks); all 12 pre-commit run --all-files hooks passed.
  • CPU-only job 349568 completed 0:0 in 45s; independent review found no actionable issues. No training code or historical performance evidence changed.

The previous GitHub CPU, docs, pre-commit and other successful checks were on b134e233; new-head CI is triggered separately. Its GPU smoke was cancelled, not a test failure.

Reviewer follow-up (2026-09-10)

Updated in 870a4034f018f1228f7d7f747042741a768514d1, on the maintainer's main merge c4eb4117 (main 4e89d4c9). Exact committed and tested tree: 5766bf971efa70e40380f1ff40649f370e148089.

  • Removed the single-purpose engine config subclass. The default-off option belongs to the existing diffusion actor config; actor/reference engines use upstream FSDPEngineConfig. The worker forwards the option through per-request batch metadata.
  • Moved the SP=1 constraint into central validate_config, exercised through both diffusion entrypoints. Removed the model/algorithm/backend/device allowlist from the shared constructor and documented the validated scope in the Qwen-Image README.
  • 174 CPU tests passed (28 warnings), including nondefault Hydra/worker propagation, reference isolation, stale metadata reset and both entrypoint guards, plus the existing staging/output-lifetime and condition-row regression suite.
  • All 12 pre-commit run --all-files hooks passed; bash scripts/generate_trainer_config.sh verified all 4 configs with no residual diff. Independent review found no actionable issues.
  • CPU-only job 349191 completed 0:0 in 2m16s, using 3 CPUs / 4 GiB in the reused A800 CPU-partition runtime. This remains separate from official CI and does not claim a new GPU benchmark.

The historical sections and GPU harness below are preserved unchanged. Their old config subclass/API reproduces the original 678d855d revision; use the actor-level override above for the current head. Original measurements have not been replaced or relabeled.

Main refresh (2026-09-10)

Merged main at bcf81ab26e8da631aa5b06fcdcc29c209e9422a7 in c709bc7572a10af7a12a0c8d53a9a85781663b56. The committed tree (16003ef71e091e40a37875ce0c5311c7b9dd7ea4) is exactly the validated tree. The performance-reference date conflict is resolved; upstream PPO/NFT condition-row restoration and all existing performance evidence are preserved.

  • 167 CPU tests passed, including four PPO/NFT nested-condition-row regressions added to the previous 163-test gate.
  • All 12 hooks passed with literal pre-commit run --all-files.
  • bash scripts/generate_trainer_config.sh regenerated and verified all four configurations without a diff.
  • Preservation checks confirmed unchanged timestep/output-lifetime methods and existing benchmark sections; independent merge review found no issues.
  • CPU-only job 349096 completed 0:0 in 2m22s, using 3 CPUs and 4 GiB on the A800 cluster's CPU partition. This reused validation runtime is not official CI parity.

The original GPU measurements below remain tied to 678d855d / tree 8b4000b3; they were not rerun or replaced by this main refresh.

Main refresh (2026-09-08)

Merged main 23a5fc4f26a525dc559c968cf9807be00ae9eb27 into this branch as ba66c044cbca5eb9314bc184fb3de34c6d4b49c9; exact tested tree 8b32b8117690c830829faef112478d61f3e54118. Both the upstream merged-LoRA synchronization (#553) and the DAPO performance reference (#456) are retained alongside this PR's original timestep-staging/output-lifetime changes.

On the refreshed tree, 163 CPU tests passed, all 12 literal pre-commit run --all-files hooks passed, and all 4 trainer configs regenerated with no residual diff. CPU-only job completed 0:0 in 3m04s. The focused pytest command below was rerun with tests/workers/test_diffusers_fsdp_merged_lora_on_cpu.py additionally included. AST comparisons verify unchanged timestep/output-loss methods against the original PR and unchanged merged-LoRA methods against main; both documentation/evidence sections are preserved verbatim. Independent review: SAFE.

These refreshed CPU checks reused the runtime documented below. The original GPU matrix and measurements remain attached to 678d855d / tree 8b4000b3; they were not rerun or relabeled as measurements of this merge. No new algorithm, runtime, or feature scope is introduced. Official CI remains separate from these standalone checks.

Original implementation and GPU measurements (unchanged)

Base: 03655dc422b0437371dd856de00f3fd9fce91af9; exact declared verl pin: fefb080262e1c015a0ea05f958822a6a512dc795.
Validated candidate tree: 8b4000b353db491c7ed40351b33b9414ecb689bb (local tree independently matched).
Commit 678d855d8af837393a3cb836ccc1529e98027709 was created after validation and has exactly this tree.

bash scripts/generate_trainer_config.sh
pre-commit run --all-files
TORCH_COMPILE_DISABLE=1 TORCHINDUCTOR_DISABLE=1 python3 -m pytest -q -x \
  tests/workers/test_diffusers_timestep_staging_on_cpu.py \
  tests/workers/test_diffusers_output_lifetime_on_cpu.py \
  tests/workers/test_diffusers_dtype_on_cpu.py \
  tests/workers/config/test_diffusion_config_on_cpu.py \
  tests/trainer/diffusion/test_worker_batch_projection_on_cpu.py \
  tests/special_sanity/test_config_docs.py

156 passed, all 12 literal all-file pre-commit hooks passed, all 4 flattened configs generated with no skip or residual diff. Coverage includes weakref ownership through backward, repeated updates, failure recovery, default/retention/inference behavior, row-distinct NFT timesteps and all three noise layouts, optional PPO loss inputs, invalid scope/input rejection, and documented top-level Hydra override propagation. A focused CPU test exercises real engine forward_step, registered Qwen adapters and PPO scheduler with a tiny linear projection; it is not transformer/FSDP hardware evidence.

GPU: all four profiles passed on 2 x NVIDIA A800 80 GB. Real Qwen transformer, adapters, scheduler, diffusion losses, FSDP/FSDP2 backward and AdamW updates. The immutable randomly initialized fixture has two layers, two heads, head dimension 32; native attention, BF16 model, SP=1, per-rank batch 4 / micro-batch 2, 1024x64 latent tokens (512x512 equivalent), positive/negative prompts and True-CFG 2.0. FP32 LoRA except explicitly BF16 for FSDP2/NFT.

Three arms (actual main, A-only output release, A+B input staging), four backend/algorithm profiles, and 1/8/32 timesteps: 36 arm-cases; 108 measured distributed optimizer updates plus 36 warmups. Both ranks passed all comparisons: 216 measured rank observations and 18 completion markers, not 216 independent replicates. Each arm consumes the same main inference outputs/old_log_probs; all inference tensors, training losses, local gradients and updated parameters compare at rtol=1e-5, atol=1e-6. Updates are finite/nonzero. State/optimizer/RNG are reset between paired observations. The exact-tree job completed 0:0 in 28m13s.

At 32 timesteps, values are descriptive medians of three repetitions, taking the maximum rank for each repetition. Starting allocated bytes match across arms within every profile/length.

Backend / algorithm Main peak MiB A peak MiB A+B peak MiB A seconds A+B seconds B vs A time
fsdp/flow_grpo 179.27 118.12 102.62 20.11 18.69 -7.1%
fsdp/diffusion_nft 308.93 121.61 106.11 22.82 23.08 1.2%
fsdp2/flow_grpo 179.28 118.06 102.56 8.43 8.60 2.1%
fsdp2/diffusion_nft 307.67 120.79 105.29 12.57 14.03 11.7%

The observed step-input CUDA storage is 16.50 MiB -> 1.00 MiB from A to A+B (about 93.9% lower) and stays near 1.00 MiB at 1/8/32 timesteps in this fixed-shape grid. B's incremental peak-allocated reduction is 15.50 MiB here. NFT uses supplied per-step noise in this GPU grid; that input-saving number does not apply to NFT runs that already generate noise on demand.

Tradeoff: FSDP2/NFT update time increases about 11.7% relative to A. Other timings also vary; arms ran in fixed order, so apparent speedups are not a controlled production-throughput claim. This small random model establishes the input-residency mechanism and numerical integration, not large-model peak-memory savings. The default-off flag lets users make a workload-specific memory/transfer-cost choice.

All measured-case aggregates (36 rows; three repeated max-rank observations per row)
variant,strategy,algorithm,steps,peak_allocated_MiB,start_allocated_MiB,peak_reserved_MiB,max_input_storage_MiB,median_max_rank_seconds,min_max_rank_seconds,max_max_rank_seconds
main,fsdp,flow_grpo,1,103.120605,66.664551,130.000000,1.004173,0.576789,0.572853,0.580672
a,fsdp,flow_grpo,1,102.619141,66.664551,130.000000,1.004173,0.633456,0.632962,0.634061
ab,fsdp,flow_grpo,1,102.619141,66.664551,130.000000,1.004173,0.630626,0.630528,0.635539
main,fsdp,flow_grpo,8,113.641113,66.664551,158.000000,4.504333,4.641108,4.636991,4.650221
a,fsdp,flow_grpo,8,106.119141,66.664551,128.000000,4.504333,4.645812,4.636187,4.649722
ab,fsdp,flow_grpo,8,102.619141,66.664551,130.000000,1.004173,5.080342,5.075288,5.102016
main,fsdp,flow_grpo,32,179.267578,66.664551,226.000000,16.504883,18.764022,18.597090,18.785161
a,fsdp,flow_grpo,32,118.119141,66.664551,126.000000,16.504883,20.112818,20.110825,20.169019
ab,fsdp,flow_grpo,32,102.619141,66.664551,130.000000,1.004173,18.687138,18.668437,18.701372
main,fsdp,diffusion_nft,1,107.863281,67.895508,136.000000,1.004166,0.717446,0.715278,0.717489
a,fsdp,diffusion_nft,1,106.112793,67.895508,134.000000,1.004166,0.803190,0.802838,0.804875
ab,fsdp,diffusion_nft,1,106.112793,67.895508,134.000000,1.004166,0.718866,0.717897,0.720760
main,fsdp,diffusion_nft,8,128.910645,67.895508,194.000000,4.504272,5.776797,5.774918,5.778564
a,fsdp,diffusion_nft,8,109.612793,67.895508,134.000000,4.504272,5.705620,5.702425,5.711195
ab,fsdp,diffusion_nft,8,106.112793,67.895508,134.000000,1.004166,6.468721,6.463199,6.473139
main,fsdp,diffusion_nft,32,308.934082,67.895508,430.000000,16.504639,25.628432,25.626415,25.659985
a,fsdp,diffusion_nft,32,121.612793,67.895508,150.000000,16.504639,22.815814,22.798162,22.829415
ab,fsdp,diffusion_nft,32,106.112793,67.895508,134.000000,1.004166,23.084161,23.032093,23.085334
main,fsdp2,flow_grpo,1,103.061523,66.579590,128.000000,1.004173,0.255264,0.249817,0.260705
a,fsdp2,flow_grpo,1,102.560059,66.579590,128.000000,1.004173,0.260484,0.255807,0.261830
ab,fsdp2,flow_grpo,1,102.560059,66.579590,128.000000,1.004173,0.276065,0.275778,0.276744
main,fsdp2,flow_grpo,8,113.582031,66.579590,156.000000,4.504333,2.137726,2.082971,2.169007
a,fsdp2,flow_grpo,8,106.060059,66.579590,128.000000,4.504333,2.119337,2.041742,2.129787
ab,fsdp2,flow_grpo,8,102.560059,66.579590,128.000000,1.004173,2.239548,2.112779,2.276339
main,fsdp2,flow_grpo,32,179.283691,66.579590,224.000000,16.504883,8.543005,8.445626,8.688514
a,fsdp2,flow_grpo,32,118.060059,66.579590,126.000000,16.504883,8.426803,8.425714,8.441801
ab,fsdp2,flow_grpo,32,102.560059,66.579590,128.000000,1.004173,8.602956,8.547832,8.604706
main,fsdp2,diffusion_nft,1,107.041016,66.580078,134.000000,1.004166,0.405020,0.404238,0.406048
a,fsdp2,diffusion_nft,1,105.290527,66.580078,132.000000,1.004166,0.453305,0.448536,0.454265
ab,fsdp2,diffusion_nft,1,105.290527,66.580078,132.000000,1.004166,0.401611,0.400806,0.402562
main,fsdp2,diffusion_nft,8,128.047852,66.580078,190.000000,4.504272,3.160905,3.158372,3.168793
a,fsdp2,diffusion_nft,8,108.790527,66.580078,132.000000,4.504272,3.153288,3.145452,3.155891
ab,fsdp2,diffusion_nft,8,105.290527,66.580078,132.000000,1.004166,3.159030,3.157298,3.162322
main,fsdp2,diffusion_nft,32,307.669434,66.580078,428.000000,16.504639,12.635724,12.631546,12.642104
a,fsdp2,diffusion_nft,32,120.790527,66.580078,148.000000,16.504639,12.565981,12.558570,12.567242
ab,fsdp2,diffusion_nft,32,105.290527,66.580078,132.000000,1.004166,14.030557,14.029209,14.036057

The reused Linux validation runtime has PyTorch 2.11.0+cu130, diffusers 0.38.0 and older vLLM-Omni compatibility dependencies; it is not full current official-CI dependency parity (current diffusers requirement is >=0.40). These checks do not validate rollout transport, reward-training convergence, pretrained numerical alignment, SP>1, NPU or other architectures. The existing FSDP2/NFT FP32-LoRA backward dtype failure was reproduced on unmodified main in this runtime; the declared FSDP2/NFT profile uses BF16 LoRA. This contribution does not fix or claim the FP32 profile.

Design & Code Changes

The diffusion-owned FSDP config subclass exposes the flag through the existing actor engine config. The shared PPO/NFT loop validates an explicit field map before training, selects CPU slices before device transfer, rebases all temporal fields together to local step zero, and releases step-owned inputs after backward. Shared inputs live only for the current micro-batch; no persistent cache or new adapter abstraction is added.

Checklist Before Submitting

  • Read the contributing guide and checked overlap.
  • Ran complete pre-commit hooks and updated generated configuration.
  • Added usage/boundary documentation.
  • Added *_on_cpu.py regression tests discovered by the existing CPU workflow. GPU integration is manual; no CI workflow or runner policy changed.
  • Independent code/evidence review and exact-tree CPU/GPU validation passed.
  • The human submitter confirmed review of every changed line, including the newly added B scope, before publication.

AI assistance was used. The human submitter has reviewed every changed line and authorized publication.

Complete standalone GPU reproducer and A-only control patch

Save the Python script as gpu_validate.py and the diff as a-control.patch outside the repository. Use a compatible Linux CUDA environment with the exact verl pin and versions above; this does not install dependencies or download model weights. From this PR checkout:

repro_script=$(realpath gpu_validate.py)
control_patch=$(realpath a-control.patch)
candidate_checkout=$PWD
experiment_root=$(mktemp -d)
git worktree add --detach "$experiment_root/main" 03655dc422b0437371dd856de00f3fd9fce91af9
git worktree add --detach "$experiment_root/a" 03655dc422b0437371dd856de00f3fd9fce91af9
(cd "$experiment_root/a" && git apply "$control_patch")
mkdir -p "$experiment_root/reference"
export TORCH_COMPILE_DISABLE=1 TORCHINDUCTOR_DISABLE=1
export CUBLAS_WORKSPACE_CONFIG=:4096:8
export OMP_NUM_THREADS=8 MKL_NUM_THREADS=8
python3 "$repro_script" --build-only --variant main \
  --reference "$experiment_root/reference" --model "$experiment_root/model" \
  --output "$experiment_root/reference"
for arm in main a ab; do
  source_dir="$experiment_root/$arm"
  if [ "$arm" = ab ]; then source_dir="$candidate_checkout"; fi
  for profile in fsdp fsdp2-ppo fsdp2-nft; do
    case "$profile" in
      fsdp) strategy=fsdp; algorithms=(flow_grpo diffusion_nft); lora=float32 ;;
      fsdp2-ppo) strategy=fsdp2; algorithms=(flow_grpo); lora=float32 ;;
      fsdp2-nft) strategy=fsdp2; algorithms=(diffusion_nft); lora=bfloat16 ;;
    esac
    result_dir="$experiment_root/results/$arm/$profile"
    mkdir -p "$result_dir"
    (cd "$source_dir" && PYTHONPATH="$source_dir:${PYTHONPATH:-}" \
      torchrun --standalone --nnodes=1 --nproc-per-node=2 "$repro_script" \
      --variant "$arm" --model "$experiment_root/model" \
      --reference "$experiment_root/reference" --output "$result_dir" \
      --strategies "$strategy" --algorithms "${algorithms[@]}" --lora-dtype "$lora")
  done
done

A-only production control (documentation/tests do not affect this GPU harness):

diff --git a/verl_omni/workers/engine/fsdp/diffusers_impl.py b/verl_omni/workers/engine/fsdp/diffusers_impl.py
index 513af77..bc72f2c 100644
--- a/verl_omni/workers/engine/fsdp/diffusers_impl.py
+++ b/verl_omni/workers/engine/fsdp/diffusers_impl.py
@@ -825,6 +825,7 @@ class DiffusersFSDPEngine(LoRAAdapterMixin, BaseEngine, ABC):
         timesteps_key: str,
     ) -> dict:
         num_timesteps = int(data[timesteps_key].shape[1])
+        return_model_output = tu.get_non_tensor_data(data, "return_model_output", default=False)
         tu.assign_non_tensor(data, sp_size=self.ulysses_sequence_parallel_size)
         tu.assign_non_tensor(data, use_dynamic_bsz=False)
 
@@ -848,6 +849,9 @@ class DiffusersFSDPEngine(LoRAAdapterMixin, BaseEngine, ABC):
                     )
                     if not forward_only:
                         loss.backward()
+                        if not return_model_output:
+                            # Training consumers only need metrics; do not retain every timestep's latents.
+                            meta_info.pop("model_output", None)
                     for key, val in meta_info.items():
                         meta_info_lst[key].append(val)
             output_lst.append(meta_info_lst)

Harness SHA256: acc900870c96dd97b743cb5cbbf67a9e3196f3ceca31aa40f73674d0d388b063.

"""Frozen main/A/A+B input-residency integration; not a production benchmark."""

import argparse
import gc
import hashlib
import json
import os
import time
from dataclasses import asdict
from functools import partial
from pathlib import Path

import torch
import torch.distributed as dist
from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageTransformer2DModel
from tensordict import TensorDict
from torch.distributed.tensor import DTensor
from verl.trainer.config import CheckpointConfig
from verl.utils import tensordict_utils as tu
from verl.utils.fsdp_utils import fsdp_version
from verl.workers.config import FSDPEngineConfig, FSDPOptimizerConfig

from verl_omni.workers.config import DiffusionActorConfig, DiffusionLossConfig, DiffusionModelConfig
from verl_omni.workers.config.diffusion.rollout import DiffusionPipelineConfig, DiffusionRolloutAlgoConfig
from verl_omni.workers.engine.fsdp.diffusers_impl import NFTDiffusersFSDPEngine, PPODiffusersFSDPEngine
from verl_omni.workers.utils.losses import diffusion_loss

SEED = 20260906


def atomic_json(path, value):
    path = Path(path)
    pending = path.with_suffix(path.suffix + ".pending")
    pending.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n")
    pending.replace(path)


def make_fixture(path):
    torch.manual_seed(SEED)
    path = Path(path)
    path.mkdir(exist_ok=False)
    model = QwenImageTransformer2DModel(
        num_attention_heads=2, attention_head_dim=32, num_layers=2,
        in_channels=64, out_channels=16, patch_size=2, joint_attention_dim=32,
        axes_dims_rope=(8, 12, 12), guidance_embeds=False,
    )
    model.save_pretrained(path / "transformer")
    FlowMatchEulerDiscreteScheduler(use_dynamic_shifting=True).save_pretrained(path / "scheduler")
    atomic_json(path / "model_index.json", {"_class_name": "QwenImagePipeline"})
    atomic_json(path / "fixture.json", {
        "seed": SEED, "weights": "random", "scope": "GPU integration only",
        "files": {str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest()
                  for p in sorted(path.rglob("*")) if p.is_file()},
    })


def local(tensor):
    return tensor.to_local() if isinstance(tensor, DTensor) else tensor


def snapshot(engine, gradients=False):
    return {name: local(p.grad if gradients else p).detach().float().cpu().clone()
            for name, p in engine.module.named_parameters()
            if p.requires_grad and (not gradients or p.grad is not None)}


def restore(engine, state):
    engine.optimizer_zero_grad()
    engine.optimizer.state.clear()
    with torch.no_grad():
        for name, p in engine.module.named_parameters():
            if name in state:
                local(p).copy_(state[name])
    torch.manual_seed(SEED)
    torch.cuda.manual_seed_all(SEED)


def check_equal(actual, expected):
    assert actual.keys() == expected.keys()
    for name in actual:
        torch.testing.assert_close(actual[name], expected[name], rtol=1e-5, atol=1e-6)


def make_batch(engine, algorithm, steps):
    gen = torch.Generator().manual_seed(SEED + dist.get_rank())
    common = {
        "prompt_embeds": torch.randn(4, 16, 32, generator=gen, dtype=torch.bfloat16),
        "prompt_embeds_mask": torch.ones(4, 16, dtype=torch.int32),
        "negative_prompt_embeds": torch.randn(4, 16, 32, generator=gen, dtype=torch.bfloat16),
        "negative_prompt_embeds_mask": torch.ones(4, 16, dtype=torch.int32),
    }
    if algorithm == "flow_grpo":
        common.update(
            all_latents=torch.randn(4, steps + 1, 1024, 64, generator=gen),
            all_timesteps=engine.scheduler.timesteps[:steps].cpu().expand(4, -1).clone(),
            advantages=torch.tensor([0.25, 0.75, -0.25, -0.75])[:, None].expand(-1, steps).clone(),
            old_log_probs=torch.zeros(4, steps),
        )
    else:
        common.update(
            latents_clean=torch.randn(4, 1024, 64, generator=gen),
            forward_noise=torch.randn(4, steps, 1024, 64, generator=gen),
            train_timesteps=torch.stack([torch.linspace(900, 100, steps)[torch.randperm(steps, generator=gen)] for _ in range(4)]),
            reward_prob=torch.tensor([0.25, 0.75, 0.25, 0.75])[:, None].expand(-1, steps).clone(),
        )
    data = TensorDict(common, batch_size=[4])
    tu.assign_non_tensor(data, micro_batch_size_per_gpu=2, height=512, width=512, vae_scale_factor=8)
    return data


def run_case(args, strategy, algorithm):
    torch.manual_seed(SEED)
    model_config = DiffusionModelConfig(
        path=args.model, algorithm=algorithm, load_tokenizer=False,
        attn_backend="native", enable_gradient_checkpointing=False,
        lora_rank=8, lora_alpha=16, lora_dtype=args.lora_dtype,
        policy_state_adapters=("default", "old") if algorithm == "diffusion_nft" else ("default",),
        pipeline=DiffusionPipelineConfig(height=512, width=512, num_inference_steps=40, true_cfg_scale=2.0),
        algo=DiffusionRolloutAlgoConfig(noise_level=0.8, sde_type="sde"),
    )
    engine_config = FSDPEngineConfig(
        strategy=strategy, fsdp_size=dist.get_world_size(), ulysses_sequence_parallel_size=1,
        model_dtype="bfloat16", dtype="bfloat16", use_orig_params=True,
        mixed_precision={"param_dtype": "bfloat16", "reduce_dtype": "float32", "buffer_dtype": "float32"},
        param_offload=False, optimizer_offload=False, forward_only=False,
    )
    if args.variant == "ab":
        from verl_omni.workers.config import FSDPDiffusionEngineConfig
        engine_config = FSDPDiffusionEngineConfig(**asdict(engine_config), enable_timestep_staging=True)
    optim = FSDPOptimizerConfig(lr=1e-4, clip_grad=1.0, total_training_steps=100)
    actor = DiffusionActorConfig(
        strategy=strategy, ppo_micro_batch_size_per_gpu=2, rollout_n=1,
        diffusion_loss=DiffusionLossConfig(loss_mode=algorithm, clip_ratio=0.2),
    )
    cls = PPODiffusersFSDPEngine if algorithm == "flow_grpo" else NFTDiffusersFSDPEngine
    engine = cls(model_config, engine_config, optim, CheckpointConfig())
    engine.initialize()
    assert fsdp_version(engine.module) == (1 if strategy == "fsdp" else 2)
    if algorithm == "diffusion_nft":
        engine.copy_adapter("default", "old")
    initial = snapshot(engine)
    assert initial
    loss_fn = partial(diffusion_loss, config=actor)
    records = []
    for steps in (1, 8, 32):
        data = make_batch(engine, algorithm, steps)
        with engine.eval_mode():
            inference_a = engine.infer_batch(data.clone())
            flagged = data.clone()
            tu.assign_non_tensor(flagged, return_model_output=False)
            inference_b = engine.infer_batch(flagged)
        assert inference_a["model_output"]
        for name, tensor in inference_a["model_output"].items():
            assert tensor.shape[:2] == (4, steps), (name, tensor.shape)
            torch.testing.assert_close(tensor, inference_b["model_output"][name], rtol=0, atol=0)
        inference_path = Path(args.reference) / f"inference-{strategy}-{algorithm}-t{steps}-rank{dist.get_rank()}.pt"
        actual_inference = {name: tensor.detach().cpu() for name, tensor in inference_a["model_output"].items()}
        if args.variant == "main":
            torch.save(actual_inference, inference_path)
            reference_inference = actual_inference
        else:
            reference_inference = torch.load(inference_path, weights_only=True)
            check_equal(actual_inference, reference_inference)
        if algorithm == "flow_grpo":
            data["old_log_probs"] = reference_inference["log_probs"]
        del actual_inference, reference_inference
        del inference_a, inference_b, flagged

        reference_path = Path(args.reference) / f"{strategy}-{algorithm}-t{steps}-rank{dist.get_rank()}.pt"
        expected = None if args.variant == "main" else torch.load(reference_path, weights_only=True)
        original_forward_step = engine.forward_step
        input_bytes = []

        def observed_forward_step(micro_batch, *forward_args, **forward_kwargs):
            storages = {value.untyped_storage().data_ptr(): value.untyped_storage().nbytes()
                        for value in micro_batch.values()
                        if isinstance(value, torch.Tensor) and value.is_cuda}
            input_bytes.append(sum(storages.values()))
            if args.variant == "ab":
                assert forward_kwargs["step"] == 0
                if algorithm == "flow_grpo":
                    assert micro_batch["all_latents"].shape[1] == 2
                else:
                    assert micro_batch["forward_noise"].shape[1] == 1
                assert micro_batch["all_timesteps" if algorithm == "flow_grpo" else "train_timesteps"].shape[1] == 1
            return original_forward_step(micro_batch, *forward_args, **forward_kwargs)

        engine.forward_step = observed_forward_step
        # One discarded warm-up then three fixed observations for every arm.
        for repetition in range(-1, 3):
            restore(engine, initial)
            batch = data.clone()
            input_bytes.clear()
            gc.collect()
            torch.cuda.empty_cache()
            dist.barrier()
            with engine.train_mode():
                torch.cuda.synchronize()
                start_allocated = torch.cuda.memory_allocated()
                torch.cuda.reset_peak_memory_stats()
                started = time.perf_counter()
                output = engine.train_batch(batch, loss_fn)
                torch.cuda.synchronize()
                seconds = time.perf_counter() - started
                measurement = {
                    "variant": args.variant, "rank": dist.get_rank(), "strategy": strategy,
                    "algorithm": algorithm, "steps": steps, "repetition": repetition,
                    "loss": output["loss"], "grad_norm": output["metrics"]["grad_norm"],
                    "seconds": seconds, "start_allocated": start_allocated,
                    "end_allocated": torch.cuda.memory_allocated(),
                    "peak_allocated": torch.cuda.max_memory_allocated(),
                    "peak_reserved": torch.cuda.max_memory_reserved(),
                    "max_input_storage_bytes": max(input_bytes),
                }
                gradients = snapshot(engine, gradients=True)
                updated = snapshot(engine)
                assert torch.isfinite(torch.tensor(measurement["loss"])).all()
                assert 0 < measurement["grad_norm"] < float("inf")
                changed = sum(torch.count_nonzero(updated[n] - initial[n]).item() for n in initial)
                global_changed = torch.tensor(changed, device="cuda", dtype=torch.long)
                dist.all_reduce(global_changed)
                assert global_changed.item() > 0
                measurement["changed_local_parameters"] = changed
                assert bool(output["model_output"]) is (args.variant == "main")
                assert all(v.device.type == "cpu" for v in batch.values() if isinstance(v, torch.Tensor))
                reference_value = {"loss": torch.tensor(output["loss"]), "gradients": gradients, "updated": updated}
                if expected is None:
                    expected = reference_value
                    torch.save(expected, reference_path)
                torch.testing.assert_close(reference_value["loss"], expected["loss"], rtol=1e-5, atol=1e-6)
                check_equal(gradients, expected["gradients"])
                check_equal(updated, expected["updated"])
                del output, batch
            if repetition >= 0:
                records.append(measurement)
                atomic_json(Path(args.output) / f"{strategy}-{algorithm}-rank{dist.get_rank()}.json", records)
            if dist.get_rank() == 0:
                print("CASE_PASS", args.variant, strategy, algorithm, steps, repetition, flush=True)
        engine.forward_step = original_forward_step
        restore(engine, initial)
    del engine, initial
    gc.collect()
    torch.cuda.empty_cache()
    dist.barrier()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--variant", choices=("main", "a", "ab"), required=True)
    parser.add_argument("--reference", required=True)
    parser.add_argument("--build-only", action="store_true")
    parser.add_argument("--strategies", nargs="+", choices=("fsdp", "fsdp2"), default=("fsdp", "fsdp2"))
    parser.add_argument("--algorithms", nargs="+", choices=("flow_grpo", "diffusion_nft"), default=("flow_grpo", "diffusion_nft"))
    parser.add_argument("--lora-dtype", choices=("float32", "bfloat16"), default="float32")
    args = parser.parse_args()
    if args.build_only:
        make_fixture(args.model)
        return
    torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))
    dist.init_process_group("nccl")
    assert dist.get_world_size() == 2
    import diffusers, verl
    from verl_omni.workers.engine.fsdp import diffusers_impl
    print("SOURCE", args.variant, diffusers_impl.__file__, verl.__file__, diffusers.__version__, flush=True)
    print("GPU_PREFLIGHT", dist.get_rank(), torch.__version__, torch.cuda.get_device_name(), flush=True)
    for strategy in args.strategies:
        for algorithm in args.algorithms:
            run_case(args, strategy, algorithm)
    atomic_json(Path(args.output) / f"complete-rank{dist.get_rank()}.json", {
        "status": "passed", "rank": dist.get_rank(), "world_size": 2,
        "cases": 3 * len(args.strategies) * len(args.algorithms),
        "measured_updates": 9 * len(args.strategies) * len(args.algorithms),
        "warmup_updates": 3 * len(args.strategies) * len(args.algorithms),
        "lora_dtype": args.lora_dtype,
        "scope": "tiny random Qwen-Image GPU integration; not production performance",
    })
    dist.destroy_process_group()


if __name__ == "__main__":
    main()

…step

Release unused PPO/NFT training outputs after backward and add opt-in
synchronous CPU timestep staging for Qwen-Image FSDP training. Keep
shared conditions at micro-batch scope and copy only matching current
step inputs while preserving loss ordering and gradient accumulation.

Cover ownership, failure recovery, configuration wiring, and the real
Qwen adapter/scheduler contracts. Document the bounded supported scope
and memory versus transfer-cost tradeoff.

Signed-off-by: Wenzhe Wang <w_wenzhe@163.com>
Preserve the upstream merged-LoRA synchronization and DAPO performance reference alongside timestep staging. Resolve only the two overlapping insertion points; keep existing behavior and measured evidence unchanged.

Signed-off-by: Wenzhe Wang <w_wenzhe@163.com>
Preserve upstream condition-row restoration in PPO and NFT while keeping
timestep staging, output lifetime, and existing performance evidence intact.
Resolve the performance-reference date conflict without changing results.

Validated tree16003ef7 with 167 CPU tests, 12 pre-commit hooks, and all four
generated trainer configurations. Independent merge review passed.

Signed-off-by: Wenzhe Wang <w_wenzhe@163.com>
@WenzheWang

Copy link
Copy Markdown
Collaborator Author

@cr-gao @zhtmike @wtomin The merge conflict is resolved in c709bc75, preserving upstream PPO/NFT condition-row restoration and the existing timestep-staging/output-lifetime behavior.

The exact committed tree passed 167 CPU tests, all 12 pre-commit hooks, and regeneration checks for all four trainer configurations (CPU job 349096, 0:0, 2m22s). This includes four additional nested-condition-row regressions. The PR body now records the refresh; the original GPU measurements and performance evidence remain unchanged at their original tested commit.

Could you please review the updated PR? The existing review requests are retained.

@zhtmike

zhtmike commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Ping @knlnguyen1802 for this

Comment thread verl_omni/workers/config/diffusion/actor.py Outdated
Comment thread verl_omni/workers/engine/fsdp/diffusers_impl.py Outdated

@knlnguyen1802 knlnguyen1802 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.

Thank you for the optimization, left some minor comment

@knlnguyen1802 knlnguyen1802 added the ready-for-ci read for running CI label Sep 10, 2026
@knlnguyen1802

Copy link
Copy Markdown
Collaborator

@WenzheWang Please check CI

Register the example README through the required documentation symlink and toctree entry. Keep cross-page links valid from both source and documentation paths.

Signed-off-by: Wenzhe Wang <w_wenzhe@163.com>
@github-actions github-actions Bot removed the ready-for-ci read for running CI label Sep 10, 2026
@WenzheWang WenzheWang added the ready-for-ci read for running CI label Sep 10, 2026
@WenzheWang

Copy link
Copy Markdown
Collaborator Author

@knlnguyen1802 Fixed in 7de0250. The failing example_docs_links check required a Git symlink for the new Qwen-Image README and its Examples toctree entry. Both are added; I also corrected two relative links so the README works from both GitHub source and the documentation page.

The exact committed tree passed the full 19-README check, all six source/docs link checks and all 12 pre-commit hooks. This is documentation-only; the training code and existing performance evidence are unchanged. I restored ready-for-ci to run CI on the new head.

@WenzheWang
WenzheWang merged commit c25701c into verl-project:main Sep 10, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci read for running CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants