[CI] [RL]: Add e2e test for custom pipeline (#2005)

Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
Signed-off-by: Samit <285365963@qq.com>
Co-authored-by: Samit <285365963@qq.com>
Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-03-24 15:49:11 +08:00
1 parent fb9f20a921
commit 5c14757ef0
8 files changed
+1090

No files matched your search

+16
View File
@@ -67,6 +67,22 @@ steps:
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Custom Pipeline Test"
depends_on: upload-ready-pipeline
commands:
- timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model"
agents:
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
- label: "Diffusion Model CPU offloading Test"
depends_on: upload-ready-pipeline
commands:
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob import (
QwenImagePipelineWithLogProbForTest,
)
from tests.e2e.offline_inference.custom_pipeline.worker_extension import (
vLLMOmniColocateWorkerExtensionForTest,
)
__all__ = [
"QwenImagePipelineWithLogProbForTest",
"vLLMOmniColocateWorkerExtensionForTest",
]
@@ -0,0 +1,220 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Literal
import torch
from diffusers import FlowMatchEulerDiscreteScheduler
from diffusers.utils import BaseOutput
from diffusers.utils.torch_utils import randn_tensor
@dataclass
class FlowMatchSDEDiscreteSchedulerOutputForTest(BaseOutput):
"""
Output class for the scheduler's `step` function output.
Args:
prev_sample (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images):
Computed sample `(x_{t-1})` of previous timestep. `prev_sample` should be used as next model input in the
denoising loop.
log_prob (`torch.FloatTensor` of shape `(batch_size,)`, *optional*):
The log probability of the previous sample.
prev_sample_mean (`torch.FloatTensor` of shape `(batch_size, sequence_length, num_channels)` for images):
The mean of the computed sample of previous timestep.
std_dev_t (`torch.FloatTensor` of shape `(batch_size, 1, 1)`):
The standard deviation used to compute `prev_sample`.
"""
prev_sample: torch.FloatTensor
log_prob: torch.FloatTensor | None
prev_sample_mean: torch.FloatTensor
std_dev_t: torch.FloatTensor
class FlowMatchSDEDiscreteSchedulerForTest(FlowMatchEulerDiscreteScheduler):
"""SDE version of the FlowMatchEulerDiscreteScheduler.
The implementation is based on FlowGRPO paper (https://arxiv.org/abs/2505.05470)
and diffusers v0.37 branch.
"""
def step(
self,
model_output: torch.FloatTensor,
timestep: float | torch.FloatTensor,
sample: torch.FloatTensor,
s_churn: float = 0.0,
s_tmin: float = 0.0,
s_tmax: float = float("inf"),
s_noise: float = 1.0,
generator: torch.Generator | None = None,
per_token_timesteps: torch.Tensor | None = None,
return_dict: bool = True,
noise_level: float = 0.7,
prev_sample: torch.FloatTensor | None = None,
sde_type: Literal["sde", "cps"] = "sde",
return_logprobs: bool = True,
) -> FlowMatchSDEDiscreteSchedulerOutputForTest | tuple:
"""
Predict the sample from the previous timestep by reversing the SDE. This function propagates the diffusion
process from the learned model outputs (most often the predicted noise).
Modified from https://github.com/yifan123/flow_grpo/blob/main/flow_grpo/diffusers_patch/sd3_sde_with_logprob.py
Args:
model_output (`torch.FloatTensor`):
The direct output from learned diffusion model.
timestep (`float`):
The current discrete timestep in the diffusion chain.
sample (`torch.FloatTensor`):
A current instance of a sample created by the diffusion process.
s_churn (`float`):
s_tmin (`float`):
s_tmax (`float`):
s_noise (`float`, defaults to 1.0):
Scaling factor for noise added to the sample.
generator (`torch.Generator`, *optional*):
A random number generator.
per_token_timesteps (`torch.Tensor`, *optional*):
The timesteps for each token in the sample.
return_dict (`bool`):
Whether or not to return a
[`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchSDEDiscreteSchedulerOutput`] or tuple.
noise_level (`float`, *optional*, defaults to 0.7):
The noise level used in the SDE.
prev_sample (`torch.FloatTensor`, *optional*):
The sample from the previous timestep. If not provided, it will be sampled inside the function.
sde_type (`str`, *optional*, defaults to "sde"):
The type of SDE to use. Choose between "sde" and "cps".
return_logprobs (`bool`, *optional*, defaults to True):
Whether to return log probabilities of the previous sample.
"""
if isinstance(timestep, (int, torch.IntTensor, torch.LongTensor)):
raise ValueError(
(
"Passing integer indices (e.g. from `enumerate(timesteps)`) as timesteps to"
" `FlowMatchEulerDiscreteScheduler.step()` is not supported. Make sure to pass"
" one of the `scheduler.timesteps` as a timestep."
),
)
if self.step_index is None:
self._init_step_index(timestep)
# Upcast to avoid precision issues when computing prev_sample
sample = sample.to(torch.float32)
if prev_sample is not None:
prev_sample = prev_sample.to(torch.float32)
prev_sample, log_prob, prev_sample_mean, std_dev_t = self.sample_previous_step(
sample=sample,
model_output=model_output,
generator=generator,
per_token_timesteps=per_token_timesteps,
noise_level=noise_level,
prev_sample=prev_sample,
sde_type=sde_type,
return_logprobs=return_logprobs,
)
# upon completion increase step index by one
self._step_index += 1
if per_token_timesteps is None:
# Cast sample back to model compatible dtype
prev_sample = prev_sample.to(model_output.dtype)
if not return_dict:
return (prev_sample, log_prob, prev_sample_mean, std_dev_t)
return FlowMatchSDEDiscreteSchedulerOutputForTest(
prev_sample=prev_sample, log_prob=log_prob, prev_sample_mean=prev_sample_mean, std_dev_t=std_dev_t
)
def sample_previous_step(
self,
sample: torch.Tensor,
model_output: torch.Tensor,
timestep: torch.FloatTensor | None = None,
generator: torch.Generator | None = None,
per_token_timesteps: torch.Tensor | None = None,
noise_level: float = 0.7,
prev_sample: torch.Tensor | None = None,
sde_type: Literal["cps", "sde"] = "sde",
return_logprobs: bool = True,
):
assert sde_type in ["sde", "cps"]
assert sample.dtype == torch.float32
if prev_sample is not None:
assert prev_sample.dtype == torch.float32
if per_token_timesteps is not None:
raise NotImplementedError("per_token_timesteps is not supported yet for FlowMatchSDEDiscreteScheduler.")
else:
if timestep is None:
sigma_idx = self.step_index
sigma = self.sigmas[sigma_idx]
sigma_prev = self.sigmas[sigma_idx + 1]
else:
sigma_idx = torch.tensor([self.index_for_timestep(t) for t in timestep])
sigma = self.sigmas[sigma_idx].view(-1, *([1] * (len(sample.shape) - 1)))
sigma_prev = self.sigmas[sigma_idx + 1].view(-1, *([1] * (len(sample.shape) - 1)))
sigma_max = self.sigmas[1]
dt = sigma_prev - sigma
if sde_type == "sde":
std_dev_t = torch.sqrt(sigma / (1 - torch.where(sigma == 1, sigma_max, sigma))) * noise_level
prev_sample_mean = (
sample * (1 + std_dev_t**2 / (2 * sigma) * dt)
+ model_output * (1 + std_dev_t**2 * (1 - sigma) / (2 * sigma)) * dt
)
if prev_sample is None:
variance_noise = randn_tensor(
model_output.shape,
generator=generator,
device=model_output.device,
dtype=model_output.dtype,
)
prev_sample = prev_sample_mean + std_dev_t * torch.sqrt(-1 * dt) * variance_noise
if return_logprobs:
log_prob = (
-((prev_sample.detach() - prev_sample_mean) ** 2) / (2 * ((std_dev_t * torch.sqrt(-1 * dt)) ** 2))
- torch.log(std_dev_t * torch.sqrt(-1 * dt))
- torch.log(torch.sqrt(2 * torch.as_tensor(math.pi)))
)
else:
log_prob = None
elif sde_type == "cps":
std_dev_t = sigma_prev * math.sin(noise_level * math.pi / 2)
pred_original_sample = sample - sigma * model_output
noise_estimate = sample + model_output * (1 - sigma)
prev_sample_mean = pred_original_sample * (1 - sigma_prev) + noise_estimate * torch.sqrt(
sigma_prev**2 - std_dev_t**2
)
if prev_sample is None:
variance_noise = randn_tensor(
model_output.shape,
generator=generator,
device=model_output.device,
dtype=model_output.dtype,
)
prev_sample = prev_sample_mean + std_dev_t * variance_noise
if return_logprobs:
log_prob = -((prev_sample.detach() - prev_sample_mean) ** 2)
else:
log_prob = None
# mean along all but batch dimension
log_prob = log_prob.mean(dim=tuple(range(1, log_prob.ndim))) if log_prob is not None else None
return prev_sample, log_prob, prev_sample_mean, std_dev_t
@@ -0,0 +1,405 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Detailed custom Qwen-Image pipeline for E2E tests.
This pipeline follows the structure of the user's reference implementation:
- supports pre-tokenized prompt IDs via OmniCustomPrompt-style dict input
- uses an SDE scheduler that can return step logprobs
- returns rich custom_output fields for testing
"""
from __future__ import annotations
import os
from typing import Any, Literal
import torch
from tests.e2e.offline_inference.custom_pipeline.flow_match_sde_scheduler import FlowMatchSDEDiscreteSchedulerForTest
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.models.qwen_image import QwenImagePipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
def _maybe_to_cpu(v):
if isinstance(v, torch.Tensor):
return v.detach().cpu()
return v
# Custom pipeline class for QwenImage that returns log probabilities during the diffusion process.
# This is for test
class QwenImagePipelineWithLogProbForTest(QwenImagePipeline):
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
self.device = get_local_device()
model = od_config.model
# Check if model is a local path
local_files_only = os.path.exists(model)
self.scheduler = FlowMatchSDEDiscreteSchedulerForTest.from_pretrained(
model, subfolder="scheduler", local_files_only=local_files_only
)
def _get_qwen_prompt_embeds(
self,
prompt_ids: torch.Tensor,
attention_mask: torch.Tensor | None = None,
dtype: torch.dtype | None = None,
):
dtype = dtype or self.text_encoder.dtype
if attention_mask is None:
attention_mask = torch.ones_like(prompt_ids, dtype=torch.long)
prompt_ids = prompt_ids.unsqueeze(0) if prompt_ids.ndim == 1 else prompt_ids
attention_mask = attention_mask.unsqueeze(0) if attention_mask.ndim == 1 else attention_mask
drop_idx = self.prompt_template_encode_start_idx
encoder_hidden_states = self.text_encoder(
input_ids=prompt_ids.to(self.device),
attention_mask=attention_mask.to(self.device),
output_hidden_states=True,
)
hidden_states = encoder_hidden_states.hidden_states[-1]
split_hidden_states = self._extract_masked_hidden(hidden_states, attention_mask)
split_hidden_states = [e[drop_idx:] for e in split_hidden_states]
attn_mask_list = [torch.ones(e.size(0), dtype=torch.long, device=e.device) for e in split_hidden_states]
max_seq_len = max([e.size(0) for e in split_hidden_states])
prompt_embeds = torch.stack(
[torch.cat([u, u.new_zeros(max_seq_len - u.size(0), u.size(1))]) for u in split_hidden_states]
)
encoder_attention_mask = torch.stack(
[torch.cat([u, u.new_zeros(max_seq_len - u.size(0))]) for u in attn_mask_list]
)
prompt_embeds = prompt_embeds.to(dtype=dtype)
return prompt_embeds, encoder_attention_mask
def encode_prompt(
self,
prompt_ids: torch.Tensor,
attention_mask: torch.Tensor | None = None,
num_images_per_prompt: int = 1,
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
max_sequence_length: int = 1024,
):
prompt_ids = prompt_ids.unsqueeze(0) if prompt_ids.ndim == 1 else prompt_ids
attention_mask = (
attention_mask.unsqueeze(0) if attention_mask is not None and attention_mask.ndim == 1 else attention_mask
)
batch_size = prompt_ids.shape[0] if prompt_embeds is None else prompt_embeds.shape[0]
if prompt_embeds is None:
prompt_embeds, prompt_embeds_mask = self._get_qwen_prompt_embeds(prompt_ids, attention_mask=attention_mask)
prompt_embeds = prompt_embeds[:, :max_sequence_length]
prompt_embeds_mask = prompt_embeds_mask[:, :max_sequence_length]
_, seq_len, _ = prompt_embeds.shape
prompt_embeds = prompt_embeds.repeat(1, num_images_per_prompt, 1)
prompt_embeds = prompt_embeds.view(batch_size * num_images_per_prompt, seq_len, -1)
prompt_embeds_mask = prompt_embeds_mask.repeat(1, num_images_per_prompt, 1)
prompt_embeds_mask = prompt_embeds_mask.view(batch_size * num_images_per_prompt, seq_len)
return prompt_embeds, prompt_embeds_mask
def diffuse(
self,
prompt_embeds,
prompt_embeds_mask,
negative_prompt_embeds,
negative_prompt_embeds_mask,
latents,
img_shapes,
txt_seq_lens,
negative_txt_seq_lens,
timesteps,
do_true_cfg,
guidance,
true_cfg_scale,
noise_level,
sde_window,
sde_type,
generator,
logprobs,
):
all_latents = []
all_log_probs = []
all_timesteps = []
self.scheduler.set_begin_index(0)
for i, t in enumerate(timesteps):
if self.interrupt:
continue
if i < sde_window[0]:
cur_noise_level = 0.0
elif i == sde_window[0]:
cur_noise_level = noise_level
all_latents.append(latents)
elif i > sde_window[0] and i < sde_window[1]:
cur_noise_level = noise_level
else:
cur_noise_level = 0.0
self._current_timestep = t
# Broadcast timestep to match batch size
timestep = t.expand(latents.shape[0]).to(device=latents.device, dtype=latents.dtype)
# Forward pass for positive prompt (or unconditional if no CFG)
self.transformer.do_true_cfg = do_true_cfg
noise_pred = self.transformer(
hidden_states=latents,
timestep=timestep / 1000,
guidance=guidance,
encoder_hidden_states_mask=prompt_embeds_mask,
encoder_hidden_states=prompt_embeds,
img_shapes=img_shapes,
txt_seq_lens=txt_seq_lens,
attention_kwargs=self.attention_kwargs,
return_dict=False,
)[0]
# Forward pass for negative prompt (CFG)
if do_true_cfg:
neg_noise_pred = self.transformer(
hidden_states=latents,
timestep=timestep / 1000,
guidance=guidance,
encoder_hidden_states_mask=negative_prompt_embeds_mask,
encoder_hidden_states=negative_prompt_embeds,
img_shapes=img_shapes,
txt_seq_lens=negative_txt_seq_lens,
attention_kwargs=self.attention_kwargs,
return_dict=False,
)[0]
comb_pred = neg_noise_pred + true_cfg_scale * (noise_pred - neg_noise_pred)
cond_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
noise_norm = torch.norm(comb_pred, dim=-1, keepdim=True)
noise_pred = comb_pred * (cond_norm / noise_norm)
# compute the previous noisy sample x_t -> x_t-1
latents, log_prob, _, _ = self.scheduler.step(
noise_pred,
t,
latents,
generator=generator,
noise_level=cur_noise_level,
sde_type=sde_type,
return_logprobs=logprobs,
return_dict=False,
)
if i >= sde_window[0] and i < sde_window[1]:
all_latents.append(latents)
all_log_probs.append(log_prob)
all_timesteps.append(t)
all_latents = torch.stack(all_latents, dim=1)
if all_log_probs[0] is not None:
all_log_probs = torch.stack(all_log_probs, dim=1)
else:
all_log_probs = None
all_timesteps = torch.stack(all_timesteps).unsqueeze(0).expand(latents.shape[0], -1)
return latents, all_latents, all_log_probs, all_timesteps
def forward(
self,
req: OmniDiffusionRequest,
prompt_ids: torch.Tensor | list[int] | None = None,
prompt_mask: torch.Tensor | None = None,
negative_prompt_ids: torch.Tensor | list[int] | None = None,
negative_prompt_mask: torch.Tensor | None = None,
true_cfg_scale: float = 4.0,
height: int | None = None,
width: int | None = None,
num_inference_steps: int = 50,
sigmas: list[float] | None = None,
guidance_scale: float = 1.0,
num_images_per_prompt: int = 1,
generator: torch.Generator | list[torch.Generator] | None = None,
latents: torch.Tensor | None = None,
prompt_embeds: torch.Tensor | None = None,
prompt_embeds_mask: torch.Tensor | None = None,
negative_prompt_embeds: torch.Tensor | None = None,
negative_prompt_embeds_mask: torch.Tensor | None = None,
output_type: str | None = "pil",
attention_kwargs: dict[str, Any] | None = None,
callback_on_step_end_tensor_inputs: tuple[str, ...] = ("latents",),
max_sequence_length: int = 512,
noise_level: float = 0.7,
sde_window_size: int | None = None,
sde_window_range: tuple[int, int] = (0, 5),
sde_type: Literal["sde", "cps"] = "sde",
logprobs: bool = True,
) -> DiffusionOutput:
# Extract prompt data from OmniCustomPrompt in req.prompts[0]
custom_prompt = req.prompts[0] if req.prompts else {}
if isinstance(custom_prompt, dict):
prompt_ids = custom_prompt.get("prompt_ids", prompt_ids)
prompt_mask = custom_prompt.get("prompt_mask", prompt_mask)
negative_prompt_ids = custom_prompt.get("negative_prompt_ids", negative_prompt_ids)
negative_prompt_mask = custom_prompt.get("negative_prompt_mask", negative_prompt_mask)
# Read sampling params from req.sampling_params
sp = req.sampling_params
height = sp.height or self.default_sample_size * self.vae_scale_factor
width = sp.width or self.default_sample_size * self.vae_scale_factor
num_inference_steps = sp.num_inference_steps or num_inference_steps
max_sequence_length = sp.max_sequence_length or max_sequence_length
noise_level = sp.extra_args.get("noise_level", None) or noise_level
sde_window_size = sp.extra_args.get("sde_window_size", None) or sde_window_size
sde_window_range = sp.extra_args.get("sde_window_range", None) or sde_window_range
sde_type = sp.extra_args.get("sde_type", None) or sde_type
logprobs = sp.extra_args.get("logprobs", None) or logprobs
generator = sp.generator or generator
if generator is None and sp.seed is not None:
generator = torch.Generator(device=self.device).manual_seed(sp.seed)
true_cfg_scale = sp.true_cfg_scale or true_cfg_scale
req_num_outputs = getattr(sp, "num_outputs_per_prompt", None)
if req_num_outputs and req_num_outputs > 0:
num_images_per_prompt = req_num_outputs
self._guidance_scale = guidance_scale
self._attention_kwargs = attention_kwargs
self._current_timestep = None
self._interrupt = False
if prompt_ids is not None:
if isinstance(prompt_ids, list):
prompt_ids = torch.tensor(prompt_ids, device=self.device)
batch_size = prompt_ids.shape[0] if prompt_ids.ndim == 2 else 1
elif prompt_embeds is not None:
batch_size = prompt_embeds.shape[0]
else:
# Both prompt_ids and prompt_embeds are None (e.g. during warmup/dummy run).
# Return a minimal dummy output to avoid crashing.
return DiffusionOutput(output=None, custom_output={})
if isinstance(negative_prompt_ids, list):
negative_prompt_ids = torch.tensor(negative_prompt_ids, device=self.device)
has_neg_prompt = negative_prompt_ids is not None or (
negative_prompt_embeds is not None and negative_prompt_embeds_mask is not None
)
do_true_cfg = true_cfg_scale > 1 and has_neg_prompt
prompt_embeds, prompt_embeds_mask = self.encode_prompt(
prompt_ids=prompt_ids,
attention_mask=prompt_mask,
prompt_embeds=prompt_embeds,
prompt_embeds_mask=prompt_embeds_mask,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
)
if do_true_cfg:
negative_prompt_embeds, negative_prompt_embeds_mask = self.encode_prompt(
prompt_ids=negative_prompt_ids,
attention_mask=negative_prompt_mask,
prompt_embeds=negative_prompt_embeds,
prompt_embeds_mask=negative_prompt_embeds_mask,
num_images_per_prompt=num_images_per_prompt,
max_sequence_length=max_sequence_length,
)
num_channels_latents = self.transformer.in_channels // 4
latents = self.prepare_latents(
batch_size * num_images_per_prompt,
num_channels_latents,
height,
width,
prompt_embeds.dtype,
self.device,
generator,
latents,
)
img_shapes = [[(1, height // self.vae_scale_factor // 2, width // self.vae_scale_factor // 2)]] * batch_size
timesteps, num_inference_steps = self.prepare_timesteps(num_inference_steps, sigmas, latents.shape[1])
# num_warmup_steps = max(len(timesteps) - num_inference_steps * self.scheduler.order, 0)
self._num_timesteps = len(timesteps)
# handle guidance
if self.transformer.guidance_embeds:
guidance = torch.full([1], guidance_scale, dtype=torch.float32)
guidance = guidance.expand(latents.shape[0])
else:
guidance = None
if self.attention_kwargs is None:
self._attention_kwargs = {}
txt_seq_lens = prompt_embeds_mask.sum(dim=1).tolist() if prompt_embeds_mask is not None else None
negative_txt_seq_lens = (
negative_prompt_embeds_mask.sum(dim=1).tolist() if negative_prompt_embeds_mask is not None else None
)
if sde_window_size is not None:
start = torch.randint(
sde_window_range[0],
sde_window_range[1] - sde_window_size + 1,
(1,),
generator=generator,
device=self.device,
).item()
end = start + sde_window_size
sde_window = (start, end)
else:
sde_window = (0, len(timesteps) - 1)
latents, all_latents, all_log_probs, all_timesteps = self.diffuse(
prompt_embeds,
prompt_embeds_mask,
negative_prompt_embeds,
negative_prompt_embeds_mask,
latents,
img_shapes,
txt_seq_lens,
negative_txt_seq_lens,
timesteps,
do_true_cfg,
guidance,
true_cfg_scale,
noise_level,
sde_window,
sde_type,
generator,
logprobs,
)
self._current_timestep = None
if output_type == "latent":
image = latents
else:
latents = self._unpack_latents(latents, height, width, self.vae_scale_factor)
latents = latents.to(self.vae.dtype)
latents_mean = (
torch.tensor(self.vae.config.latents_mean)
.view(1, self.vae.config.z_dim, 1, 1, 1)
.to(latents.device, latents.dtype)
)
latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to(
latents.device, latents.dtype
)
latents = latents / latents_std + latents_mean
image = self.vae.decode(latents, return_dict=False)[0][:, :, 0]
return DiffusionOutput(
output=_maybe_to_cpu(image),
custom_output={
"all_latents": _maybe_to_cpu(all_latents),
"all_log_probs": _maybe_to_cpu(all_log_probs),
"all_timesteps": _maybe_to_cpu(all_timesteps),
"prompt_embeds": _maybe_to_cpu(prompt_embeds),
"prompt_embeds_mask": _maybe_to_cpu(prompt_embeds_mask),
"negative_prompt_embeds": _maybe_to_cpu(negative_prompt_embeds),
"negative_prompt_embeds_mask": _maybe_to_cpu(negative_prompt_embeds_mask),
},
)
@@ -0,0 +1,152 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Regression tests for AsyncOmni collective_rpc in inline diffusion mode.
When AsyncOmni runs a single diffusion stage it activates "inline diffusion
mode", which skips stage worker subprocess creation and therefore never
attaches IPC queues (_in_q / _out_q) to the OmniStage. Methods like
list_loras(), add_lora(), sleep(), wake_up() all delegate to
collective_rpc(), which must handle this mode correctly instead of
trying to use the non-existent queues.
This is the same code path that verl's vLLMOmniHttpServer.generate()
exercises when it calls ``await self.engine.list_loras()`` before
dispatching a generation request.
Usage:
pytest tests/e2e/offline_inference/custom_pipeline/test_async_omni_collective_rpc.py -v -s
"""
from __future__ import annotations
import uuid
from contextlib import ExitStack
import pytest
from tests.utils import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
MODEL = "tiny-random/Qwen-Image"
CUSTOM_PIPELINE_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest"
)
WORKER_EXTENSION_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest"
)
def _create_inline_engine() -> AsyncOmni:
"""Create an AsyncOmni instance that uses inline diffusion mode.
A single diffusion stage triggers inline mode automatically.
"""
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
)
return engine
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_list_loras_inline_mode():
"""list_loras() must not crash in inline diffusion mode.
This is the exact call that vLLMOmniHttpServer.generate() makes
before every generation request.
"""
with ExitStack() as after:
engine = _create_inline_engine()
after.callback(engine.shutdown)
result = await engine.list_loras()
assert isinstance(result, list), f"Expected list, got {type(result)}"
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_collective_rpc_inline_mode():
"""collective_rpc() must delegate to the inline engine, not stage queues."""
with ExitStack() as after:
engine = _create_inline_engine()
after.callback(engine.shutdown)
result = await engine.collective_rpc(method="list_loras")
assert isinstance(result, list), f"Expected list, got {type(result)}"
assert len(result) == 1, "Inline mode has exactly one stage"
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_sleep_wake_up_inline_mode():
"""sleep() and wake_up() must work in inline diffusion mode."""
with ExitStack() as after:
engine = _create_inline_engine()
after.callback(engine.shutdown)
await engine.sleep(level=1)
assert await engine.is_sleeping()
await engine.wake_up()
assert not await engine.is_sleeping()
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_generate_after_list_loras_inline_mode():
"""Full flow: list_loras() then generate(), matching vLLMOmniHttpServer.
This reproduces the exact sequence that caused the original crash:
1. list_loras() (was crashing with AssertionError on _out_q)
2. generate() (should succeed)
"""
with ExitStack() as after:
engine = _create_inline_engine()
after.callback(engine.shutdown)
# Step 1: list_loras (the call that was crashing)
loras = await engine.list_loras()
assert isinstance(loras, list)
# Step 2: generate (should still work after list_loras)
sampling_params = OmniDiffusionSamplingParams(
num_inference_steps=2,
guidance_scale=0.0,
height=256,
width=256,
seed=42,
)
last_output = None
async for output in engine.generate(
prompt={"prompt_ids": list(range(50))},
request_id=f"test_after_lora_{uuid.uuid4().hex[:8]}",
sampling_params_list=[sampling_params],
output_modalities=["image"],
):
last_output = output
assert last_output is not None
assert isinstance(last_output, OmniRequestOutput)
assert last_output.images, "Expected at least one generated image"
@@ -0,0 +1,239 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E tests for AsyncOmni Qwen-Image generation flow (no Ray, no HTTP server)."""
from __future__ import annotations
import asyncio
import uuid
from contextlib import ExitStack
from functools import lru_cache
import numpy as np
import pytest
from transformers import AutoTokenizer
from tests.utils import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
CUSTOM_PIPELINE_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.qwen_image_pipeline_with_logprob.QwenImagePipelineWithLogProbForTest"
)
WORKER_EXTENSION_CLASS = (
"tests.e2e.offline_inference.custom_pipeline.worker_extension.vLLMOmniColocateWorkerExtensionForTest"
)
# Use your specified HF repo name/path directly here
MODEL = "tiny-random/Qwen-Image"
TOKENIZER_MODEL = "Qwen/Qwen2-1.5B-Instruct"
# ---------------------------------------------------------------------
# 👇 Test Helper Functions & Fixtures 👇
# ---------------------------------------------------------------------
_MIN_PROMPT_TOKENS = 35
def normalize_token_ids(tokenized_output) -> list[int]:
"""Normalize tokenizer outputs into a flat ``list[int]``.
This handles Transformers 4/5 differences where ``apply_chat_template(tokenize=True)``
may return either ``list[int]`` or a ``BatchEncoding``/mapping with ``input_ids``.
"""
token_ids = tokenized_output
if isinstance(tokenized_output, dict):
if "input_ids" in tokenized_output:
token_ids = tokenized_output["input_ids"]
elif hasattr(tokenized_output, "input_ids"):
token_ids = tokenized_output.input_ids
if hasattr(token_ids, "tolist"):
token_ids = token_ids.tolist()
if isinstance(token_ids, tuple):
token_ids = list(token_ids)
if isinstance(token_ids, list) and len(token_ids) == 1 and isinstance(token_ids[0], list | tuple):
token_ids = list(token_ids[0])
if not isinstance(token_ids, list):
raise TypeError(f"token_ids must be list-like token ids, got {type(token_ids).__name__}: {token_ids!r}")
normalized_ids = []
for idx, token_id in enumerate(token_ids):
if hasattr(token_id, "item"):
token_id = token_id.item()
try:
normalized_ids.append(int(token_id))
except (TypeError, ValueError) as e:
raise TypeError(f"token_id must be int-convertible, got {type(token_id).__name__}: {token_id!r}") from e
return normalized_ids
@lru_cache(maxsize=1)
def _tokenize_prompt(text: str) -> list[int]:
"""Tokenize a text prompt into valid token IDs for the model."""
tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_MODEL, trust_remote_code=True)
messages = [{"role": "user", "content": text}]
token_ids = normalize_token_ids(tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=False))
assert len(token_ids) > _MIN_PROMPT_TOKENS, (
f"Prompt too short ({len(token_ids)} tokens, need >{_MIN_PROMPT_TOKENS}). "
f"The pipeline drops the first 34 chat‑template prefix tokens; "
f"use a longer prompt so content tokens remain after the drop."
)
return token_ids
def _sampling_params(*, logprobs: bool = False, seed: int = 42) -> OmniDiffusionSamplingParams:
return OmniDiffusionSamplingParams(
num_inference_steps=2,
guidance_scale=0.0,
height=256,
width=256,
seed=seed,
extra_args={"logprobs": logprobs},
)
async def _generate_once(
engine: AsyncOmni,
prompt: str | list[int] | dict,
*,
request_id: str,
sampling_params: OmniDiffusionSamplingParams,
) -> OmniRequestOutput:
# Convert text prompt to dict with tokenized prompt_ids
if isinstance(prompt, str):
prompt_ids = _tokenize_prompt(prompt)
prompt = {"prompt_ids": prompt_ids}
elif isinstance(prompt, list):
prompt = {"prompt_ids": prompt}
# else: assume it's already a dict with prompt_ids
last_output = None
async for output in engine.generate(
prompt=prompt,
request_id=request_id,
sampling_params_list=[sampling_params],
output_modalities=["image"],
):
last_output = output
assert last_output is not None
assert isinstance(last_output, OmniRequestOutput)
return last_output
def _assert_valid_image_output(output: OmniRequestOutput) -> None:
assert output.final_output_type == "image"
assert output.images, "Expected at least one generated image"
image = output.images[0]
arr = np.asarray(image, dtype=np.float32) / 255.0
assert arr.ndim == 3 and arr.shape[2] == 3, f"Expected HWC RGB image, got shape={arr.shape}"
assert arr.shape[0] > 0 and arr.shape[1] > 0
assert 0.0 <= float(arr[0, 0, 0]) <= 1.0
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_async_omni_generate():
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
)
after.callback(engine.shutdown)
output = await _generate_once(
engine,
"a beautiful sunset over the ocean with vibrant orange and purple clouds "
"reflecting on the calm water surface near a rocky coastline",
request_id=f"test_{uuid.uuid4().hex[:8]}",
sampling_params=_sampling_params(logprobs=False, seed=42),
)
_assert_valid_image_output(output)
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_async_omni_generate_with_logprobs():
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
)
after.callback(engine.shutdown)
output = await _generate_once(
engine,
"a futuristic city at night with neon lights glowing on tall glass "
"skyscrapers and flying vehicles soaring between the buildings",
request_id=f"test_lp_{uuid.uuid4().hex[:8]}",
sampling_params=_sampling_params(logprobs=True, seed=123),
)
_assert_valid_image_output(output)
all_log_probs = output.custom_output.get("all_log_probs")
assert all_log_probs is not None, "all_log_probs should be present when logprobs=True"
assert hasattr(all_log_probs, "shape")
assert all_log_probs.numel() > 0
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.asyncio
async def test_async_omni_generate_concurrent():
with ExitStack() as after:
engine = AsyncOmni(
model=MODEL,
custom_pipeline_args={"pipeline_class": CUSTOM_PIPELINE_CLASS},
worker_extension_cls=WORKER_EXTENSION_CLASS,
enforce_eager=True,
)
after.callback(engine.shutdown)
prompts = [
"a beautiful sunset over the ocean with vibrant orange and purple clouds "
"reflecting on the calm water surface near a rocky coastline",
"a fluffy orange cat sitting on a wooden windowsill looking outside at "
"a garden full of colorful flowers on a bright sunny afternoon",
"a majestic mountain landscape covered with fresh white snow under a "
"clear blue sky with pine trees in the foreground and a frozen lake",
"a futuristic city at night with neon lights glowing on tall glass "
"skyscrapers and flying vehicles soaring between the buildings",
]
tasks = [
_generate_once(
engine,
prompt,
request_id=f"concurrent_{i}_{uuid.uuid4().hex[:8]}",
sampling_params=_sampling_params(logprobs=False, seed=100 + i),
)
for i, prompt in enumerate(prompts)
]
outputs = await asyncio.gather(*tasks)
assert len(outputs) == len(prompts)
for output in outputs:
_assert_valid_image_output(output)
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import pytest
from tests.e2e.offline_inference.custom_pipeline.worker_extension import (
vLLMOmniColocateWorkerExtensionForTest,
)
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_worker_extension_inheritance():
assert issubclass(vLLMOmniColocateWorkerExtensionForTest, CustomPipelineWorkerExtension)
def test_worker_extension_test_function():
assert (
vLLMOmniColocateWorkerExtensionForTest.test_extension_name() == "vllm-omni-colocate-worker-extension-for-test"
)
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Test-only worker extension for custom diffusion pipeline E2E tests."""
from __future__ import annotations
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
class vLLMOmniColocateWorkerExtensionForTest(CustomPipelineWorkerExtension):
"""Minimal worker extension used by tests.
This intentionally stays lightweight: we only inherit the base custom
pipeline extension and add one test function.
"""
@staticmethod
def test_extension_name() -> str:
"""Return a stable identifier for assertions in unit tests."""
return "vllm-omni-colocate-worker-extension-for-test"