[Misc] Support WorkerWrapperBase and CustomPipeline for Diffusion Worker (#764)
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
This commit is contained in:
1 parent
e427da022b
commit
9994594782
12 files changed
+1359
-23
No files matched your search
@@ -0,0 +1,151 @@
|
||||
# Custom Pipeline Extension Guide
|
||||
|
||||
Transformer already support Custom Pipeline via
|
||||
https://github.com/huggingface/diffusers/blob/main/docs/source/en/using-diffusers/custom_pipeline_overview.md
|
||||
|
||||
This guide demonstrates how to use the newly added features for extending vLLM-Omni's diffusion pipeline with custom functionality.
|
||||
|
||||
## Overview
|
||||
|
||||
Three main features enable custom pipeline extension:
|
||||
|
||||
1. **`WorkerWrapperBase`**: A wrapper class that enables dynamic worker extension with custom functionality
|
||||
2. **`load_format`**: A parameter that controls how diffusion models are loaded, including support for custom pipelines
|
||||
3. **`CustomPipelineWorkerExtension`**: An extension class that enables pipeline re-initialization with custom implementations
|
||||
|
||||
## Features
|
||||
|
||||
### WorkerWrapperBase
|
||||
|
||||
`WorkerWrapperBase` is a wrapper class that creates `DiffusionWorker` instances with optional extension support. It enables dynamic inheritance, allowing you to add custom methods and functionality to workers without modifying the base worker class.
|
||||
|
||||
**Key capabilities:**
|
||||
- Dynamic worker class extension via `worker_extension_cls`
|
||||
- Support for custom pipeline initialization via `custom_pipeline_args`
|
||||
- Method delegation to underlying worker
|
||||
- Attribute access forwarding
|
||||
|
||||
**Location:** `vllm_omni/diffusion/worker/diffusion_worker.py`
|
||||
|
||||
### load_format Parameter
|
||||
|
||||
The `load_format` parameter controls how diffusion models are loaded. It supports the following values:
|
||||
|
||||
- **`"default"`**: Standard model loading using the model registry (default behavior)
|
||||
- **`"custom_pipeline"`**: Load a custom pipeline class specified by `custom_pipeline_name`
|
||||
- **`"dummy"`**: Skip model loading (useful for testing or when pipeline will be initialized separately)
|
||||
|
||||
**Location:** `vllm_omni/diffusion/model_loader/diffusers_loader.py`
|
||||
|
||||
### CustomPipelineWorkerExtension
|
||||
|
||||
`CustomPipelineWorkerExtension` is a mixin class that extends `DiffusionWorker` with the ability to re-initialize the pipeline with a custom implementation.
|
||||
|
||||
**Key method:**
|
||||
- `re_init_pipeline(custom_pipeline_args)`: Re-initializes the pipeline with custom arguments, properly cleaning up the old pipeline first
|
||||
|
||||
**Location:** `vllm_omni/diffusion/worker/diffusion_worker.py`
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Step 1: Create a Custom Pipeline
|
||||
|
||||
Create a custom pipeline class that extends an existing pipeline. In this example, we extend `QwenImageEditPipeline` to add trajectory tracking:
|
||||
|
||||
```python
|
||||
# custom_pipeline.py
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
|
||||
import torch
|
||||
|
||||
class CustomPipeline(QwenImageEditPipeline):
|
||||
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
|
||||
super().__init__(od_config=od_config, prefix=prefix)
|
||||
|
||||
def forward(self, req, prompt=None, negative_prompt=None, **kwargs):
|
||||
# Call parent's forward to get normal output
|
||||
output = super().forward(req=req, prompt=prompt, negative_prompt=negative_prompt, **kwargs)
|
||||
|
||||
# Add custom trajectory data
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or kwargs.get('num_inference_steps', 50)
|
||||
output.trajectory_timesteps = torch.linspace(1000, 0, actual_num_steps, dtype=torch.float32)
|
||||
output.trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
|
||||
|
||||
return output
|
||||
```
|
||||
|
||||
### Step 2: Use the Custom Pipeline with Omni
|
||||
|
||||
Initialize the `Omni` engine with custom pipeline configuration:
|
||||
|
||||
```python
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
# Initialize with custom pipeline
|
||||
omni = Omni(
|
||||
model="Qwen/Qwen-Image-Edit",
|
||||
diffusion_load_format="dummy", # Skip initial loading
|
||||
custom_pipeline_args={
|
||||
"pipeline_class": "custom_pipeline.CustomPipeline"
|
||||
},
|
||||
)
|
||||
|
||||
# Generate with the custom pipeline
|
||||
outputs = omni.generate(
|
||||
...
|
||||
)
|
||||
|
||||
# Access custom trajectory data
|
||||
output = outputs[0].request_output[0]
|
||||
print(f"Trajectory timesteps shape: {output.metrics['trajectory_timesteps'].shape}")
|
||||
print(f"Trajectory latents shape: {output.latents.shape}")
|
||||
```
|
||||
|
||||
### Step 3: Run the Example
|
||||
|
||||
The example provided in this directory demonstrates the complete workflow:
|
||||
|
||||
```bash
|
||||
cd examples/offline_inference/custom_pipeline/image_to_image
|
||||
|
||||
# Run with custom pipeline
|
||||
python image_edit.py \
|
||||
--model Qwen/Qwen-Image-Edit-2511 \
|
||||
--image cherry_blossom.jpg \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
|
||||
--output output_image_edit.png \
|
||||
--num_inference_steps 10
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Worker Extension
|
||||
|
||||
You can create custom worker extensions to add new methods beyond pipeline re-initialization:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
|
||||
|
||||
class MyCustomExtension(CustomPipelineWorkerExtension):
|
||||
def custom_method(self):
|
||||
"""Your custom worker method."""
|
||||
return "custom_result"
|
||||
|
||||
def another_method(self, data: Any):
|
||||
"""Another custom method."""
|
||||
# Access worker internals via self
|
||||
return self.model_runner.some_operation(data)
|
||||
|
||||
omni = Omni(
|
||||
model="Qwen/Qwen-Image-Edit",
|
||||
diffusion_load_format="dummy",
|
||||
custom_pipeline_args={
|
||||
"pipeline_class": "custom_pipeline.CustomPipeline"
|
||||
},
|
||||
worker_extension_cls=MyCustomExtension,
|
||||
# Note: worker_extension_cls is an internal parameter
|
||||
# CustomPipelineWorkerExtension will automatically init pipeline when custom_pipeline_args is provided
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import PIL.Image
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomPipeline(QwenImageEditPipeline):
|
||||
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
|
||||
super().__init__(od_config=od_config, prefix=prefix)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
req: OmniDiffusionRequest,
|
||||
prompt: str | list[str] | None = None,
|
||||
negative_prompt: str | list[str] | None = None,
|
||||
image: PIL.Image.Image | 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: list[str] = ["latents"],
|
||||
max_sequence_length: int = 512,
|
||||
) -> DiffusionOutput:
|
||||
"""Forward pass for image editing with dummy trajectory data."""
|
||||
# Call parent's forward to get the normal output
|
||||
output = super().forward(
|
||||
req=req,
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
image=image,
|
||||
true_cfg_scale=true_cfg_scale,
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=num_inference_steps,
|
||||
sigmas=sigmas,
|
||||
guidance_scale=guidance_scale,
|
||||
num_images_per_prompt=num_images_per_prompt,
|
||||
generator=generator,
|
||||
latents=latents,
|
||||
prompt_embeds=prompt_embeds,
|
||||
prompt_embeds_mask=prompt_embeds_mask,
|
||||
negative_prompt_embeds=negative_prompt_embeds,
|
||||
negative_prompt_embeds_mask=negative_prompt_embeds_mask,
|
||||
output_type=output_type,
|
||||
attention_kwargs=attention_kwargs,
|
||||
callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs,
|
||||
max_sequence_length=max_sequence_length,
|
||||
)
|
||||
|
||||
# Get actual num_inference_steps used
|
||||
actual_num_steps = req.sampling_params.num_inference_steps or num_inference_steps
|
||||
|
||||
# Create dummy trajectory data
|
||||
dummy_trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32)
|
||||
|
||||
# Inject dummy trajectory data into output
|
||||
output.trajectory_latents = dummy_trajectory_latents
|
||||
|
||||
return output
|
||||
@@ -0,0 +1,262 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""
|
||||
======================================================
|
||||
Qwen Image Editor (via vLLM-Omni)
|
||||
======================================================
|
||||
|
||||
Example CLI for Qwen-Image-Edit and compatible models.
|
||||
|
||||
This script edits, combines, or layers images according to a text prompt
|
||||
using the vLLM Omni diffusion backend.
|
||||
|
||||
Examples
|
||||
---------
|
||||
Single image edit:
|
||||
python image_edit.py \
|
||||
--image input.png \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles saying 'Be Kind'" \
|
||||
--output output.png
|
||||
|
||||
Multiple image composition:
|
||||
python image_edit.py \
|
||||
--image input1.png input2.png \
|
||||
--prompt "Combine these into a single magical composition" \
|
||||
--output combined.png
|
||||
|
||||
Accelerated with cache_dit:
|
||||
python image_edit.py \
|
||||
--image input.png \
|
||||
--prompt "Edit description" \
|
||||
--cache_backend cache_dit \
|
||||
--cache_dit_enable_taylorseer
|
||||
|
||||
Layered RGBA output:
|
||||
python image_edit.py \
|
||||
--model "Qwen/Qwen-Image-Layered" \
|
||||
--image input.png \
|
||||
--prompt "" \
|
||||
--output layered \
|
||||
--layers 4 \
|
||||
--color-format RGBA
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
# ===========================
|
||||
# Argument Parser
|
||||
# ===========================
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Edit or generate images using Qwen-Image-Edit.")
|
||||
parser.add_argument("--model", default="Qwen/Qwen-Image-Edit", help="Model name or local path.")
|
||||
parser.add_argument("--image", type=str, nargs="+", required=True, help="Input image file(s).")
|
||||
parser.add_argument("--prompt", type=str, required=True, help="Edit description prompt.")
|
||||
parser.add_argument("--negative_prompt", type=str, default=None)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--cfg_scale", type=float, default=4.0)
|
||||
parser.add_argument("--guidance_scale", type=float, default=1.0)
|
||||
parser.add_argument("--output", type=str, default="output.png")
|
||||
parser.add_argument("--num_outputs_per_prompt", type=int, default=1)
|
||||
parser.add_argument("--num_inference_steps", type=int, default=50)
|
||||
parser.add_argument("--cache_backend", type=str, default=None, choices=["cache_dit", "tea_cache"])
|
||||
parser.add_argument("--ulysses_degree", type=int, default=1)
|
||||
parser.add_argument("--ring_degree", type=int, default=1)
|
||||
parser.add_argument("--tensor_parallel_size", type=int, default=1)
|
||||
parser.add_argument("--layers", type=int, default=4)
|
||||
parser.add_argument("--resolution", type=int, default=640)
|
||||
parser.add_argument("--color-format", type=str, default="RGB")
|
||||
|
||||
# Acceleration + Optimization Options
|
||||
parser.add_argument("--cache_dit_fn_compute_blocks", type=int, default=1)
|
||||
parser.add_argument("--cache_dit_bn_compute_blocks", type=int, default=0)
|
||||
parser.add_argument("--cache_dit_max_warmup_steps", type=int, default=4)
|
||||
parser.add_argument("--cache_dit_residual_diff_threshold", type=float, default=0.24)
|
||||
parser.add_argument("--cache_dit_max_continuous_cached_steps", type=int, default=3)
|
||||
parser.add_argument("--cache_dit_enable_taylorseer", action="store_true", default=False)
|
||||
parser.add_argument("--cache_dit_taylorseer_order", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--cache_dit_scm_steps_mask_policy", type=str, default=None, choices=[None, "slow", "medium", "fast", "ultra"]
|
||||
)
|
||||
parser.add_argument("--cache_dit_scm_steps_policy", type=str, default="dynamic", choices=["dynamic", "static"])
|
||||
parser.add_argument("--tea_cache_rel_l1_thresh", type=float, default=0.2)
|
||||
parser.add_argument("--cfg_parallel_size", type=int, default=1, choices=[1, 2])
|
||||
parser.add_argument("--enforce_eager", action="store_true")
|
||||
parser.add_argument("--vae_use_slicing", action="store_true")
|
||||
parser.add_argument("--vae_use_tiling", action="store_true")
|
||||
parser.add_argument("--enable-cpu-offload", action="store_true")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
# ===========================
|
||||
# Core Logic
|
||||
# ===========================
|
||||
async def main():
|
||||
args = parse_args()
|
||||
|
||||
# ---- Load input images ----
|
||||
input_images: list[Image.Image] = []
|
||||
for image_path in args.image:
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(f"Input image not found: {image_path}")
|
||||
input_images.append(Image.open(image_path).convert(args.color_format))
|
||||
|
||||
# Single or multi-image input
|
||||
input_image: Image.Image | list[Image.Image] = input_images[0] if len(input_images) == 1 else input_images
|
||||
|
||||
# ---- Torch setup ----
|
||||
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
ulysses_degree=args.ulysses_degree,
|
||||
ring_degree=args.ring_degree,
|
||||
cfg_parallel_size=args.cfg_parallel_size,
|
||||
tensor_parallel_size=args.tensor_parallel_size,
|
||||
)
|
||||
|
||||
# ---- Cache Config ----
|
||||
if args.cache_backend == "cache_dit":
|
||||
cache_config = {
|
||||
"Fn_compute_blocks": args.cache_dit_fn_compute_blocks,
|
||||
"Bn_compute_blocks": args.cache_dit_bn_compute_blocks,
|
||||
"max_warmup_steps": args.cache_dit_max_warmup_steps,
|
||||
"residual_diff_threshold": args.cache_dit_residual_diff_threshold,
|
||||
"max_continuous_cached_steps": args.cache_dit_max_continuous_cached_steps,
|
||||
"enable_taylorseer": args.cache_dit_enable_taylorseer,
|
||||
"taylorseer_order": args.cache_dit_taylorseer_order,
|
||||
"scm_steps_mask_policy": args.cache_dit_scm_steps_mask_policy,
|
||||
"scm_steps_policy": args.cache_dit_scm_steps_policy,
|
||||
}
|
||||
elif args.cache_backend == "tea_cache":
|
||||
cache_config = {"rel_l1_thresh": args.tea_cache_rel_l1_thresh}
|
||||
else:
|
||||
cache_config = None
|
||||
|
||||
# ---- Initialize Omni ----
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
vae_use_slicing=args.vae_use_slicing,
|
||||
vae_use_tiling=args.vae_use_tiling,
|
||||
cache_backend=args.cache_backend,
|
||||
cache_config=cache_config,
|
||||
parallel_config=parallel_config,
|
||||
enforce_eager=args.enforce_eager,
|
||||
enable_cpu_offload=args.enable_cpu_offload,
|
||||
diffusion_load_format="dummy",
|
||||
custom_pipeline_args={"pipeline_class": "custom_pipeline.CustomPipeline"},
|
||||
)
|
||||
|
||||
print(">>> Pipeline loaded successfully")
|
||||
|
||||
# ---- Profiling + Info ----
|
||||
profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR"))
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Generation Configuration")
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Inference steps: {args.num_inference_steps}")
|
||||
print(f"Cache: {args.cache_backend or 'None'}")
|
||||
print(f"Input(s): {len(input_images)} image(s)")
|
||||
for idx, img in enumerate(input_images):
|
||||
print(f" • Image {idx + 1}: size={img.size}")
|
||||
print(
|
||||
f"Parallel config: Ulysses={args.ulysses_degree}, Ring={args.ring_degree}, "
|
||||
f"CFG Parallel={args.cfg_parallel_size}, Tensor Parallel={args.tensor_parallel_size}"
|
||||
)
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
if profiler_enabled:
|
||||
omni.start_profile()
|
||||
|
||||
# ---- Generation ----
|
||||
t0 = time.perf_counter()
|
||||
outputs = omni.generate(
|
||||
{
|
||||
"prompt": args.prompt,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
"multi_modal_data": {"image": input_image},
|
||||
},
|
||||
OmniDiffusionSamplingParams(
|
||||
generator=generator,
|
||||
true_cfg_scale=args.cfg_scale,
|
||||
guidance_scale=args.guidance_scale,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
num_outputs_per_prompt=args.num_outputs_per_prompt,
|
||||
layers=args.layers,
|
||||
resolution=args.resolution,
|
||||
),
|
||||
)
|
||||
t1 = time.perf_counter()
|
||||
print(f"Generation completed in {t1 - t0:.2f}s")
|
||||
|
||||
if profiler_enabled:
|
||||
omni.stop_profile()
|
||||
|
||||
# ---- Output Validation ----
|
||||
if not outputs:
|
||||
raise ValueError("No output produced from omni.generate()")
|
||||
|
||||
first_out = outputs[0].request_output[0]
|
||||
req_out: OmniRequestOutput = first_out
|
||||
|
||||
# Verify trajectory data (from custom pipeline)
|
||||
print(f"\n{'=' * 60}")
|
||||
print("TRAJECTORY VERIFICATION:")
|
||||
assert hasattr(req_out, "latents") and req_out.latents is not None
|
||||
print(f" ✓ trajectory_latents shape: {req_out.latents.shape}")
|
||||
print(f" ✓ trajectory_latents dtype: {req_out.latents.dtype}")
|
||||
print(f" ✓ trajectory_latents mean: {req_out.latents.mean().item():.6f}")
|
||||
print(f" ✓ trajectory_latents std: {req_out.latents.std().item():.6f}")
|
||||
assert req_out.latents.shape[0] == args.num_inference_steps, (
|
||||
f"Expected {args.num_inference_steps} latent snapshots, got {req_out.latents.shape[0]}"
|
||||
)
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
# ---- Save images ----
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
suffix = output_path.suffix or ".png"
|
||||
stem = output_path.stem or "output"
|
||||
|
||||
images = req_out.images
|
||||
if args.num_outputs_per_prompt <= 1:
|
||||
img = images[0]
|
||||
if isinstance(img, list): # Layered
|
||||
for i, sub_img in enumerate(img):
|
||||
save_path = output_path.parent / f"{stem}_{i}{suffix}"
|
||||
sub_img.save(save_path)
|
||||
print(f"Saved layer {i}: {save_path.resolve()}")
|
||||
else:
|
||||
img.save(output_path)
|
||||
print(f"Saved: {output_path.resolve()}")
|
||||
else:
|
||||
for idx, img in enumerate(images):
|
||||
if isinstance(img, list):
|
||||
for sub_idx, sub_img in enumerate(img):
|
||||
save_path = output_path.parent / f"{stem}_{idx}_{sub_idx}{suffix}"
|
||||
sub_img.save(save_path)
|
||||
print(f"Saved composite: {save_path.resolve()}")
|
||||
else:
|
||||
save_path = output_path.parent / f"{stem}_{idx}{suffix}"
|
||||
img.save(save_path)
|
||||
print(f"Saved: {save_path.resolve()}")
|
||||
|
||||
|
||||
# ===========================
|
||||
# Entrypoint
|
||||
# ===========================
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
python image_edit.py \
|
||||
--model Qwen/Qwen-Image-Edit-2511 \
|
||||
--image cherry_blossom.jpg \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \
|
||||
--output output_image_edit.png \
|
||||
--num_inference_steps 10
|
||||
@@ -36,7 +36,7 @@ def mock_od_config():
|
||||
@pytest.fixture
|
||||
def mock_gpu_worker(mock_od_config):
|
||||
"""Create a DiffusionWorker with mocked initialization."""
|
||||
with patch.object(DiffusionWorker, "init_device"):
|
||||
with patch.object(DiffusionWorker, "init_device"), patch.object(DiffusionWorker, "load_model"):
|
||||
worker = DiffusionWorker(local_rank=0, rank=0, od_config=mock_od_config)
|
||||
# Mock the model_runner with pipeline
|
||||
worker.model_runner = Mock()
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""
|
||||
Unit tests for WorkerWrapperBase class.
|
||||
|
||||
This module tests the WorkerWrapperBase implementation:
|
||||
- Initialization with and without worker extensions
|
||||
- Custom pipeline initialization
|
||||
- Method delegation via execute_method
|
||||
- Attribute delegation via __getattr__
|
||||
- Dynamic worker class extension
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import (
|
||||
CustomPipelineWorkerExtension,
|
||||
DiffusionWorker,
|
||||
WorkerWrapperBase,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_od_config():
|
||||
"""Create a mock OmniDiffusionConfig for use in tests."""
|
||||
config = Mock()
|
||||
config.num_gpus = 1
|
||||
config.master_port = 12345
|
||||
config.enable_sleep_mode = False
|
||||
config.cache_backend = None
|
||||
config.cache_config = None
|
||||
config.model = "test-model"
|
||||
config.diffusion_load_format = None
|
||||
config.dtype = "float32"
|
||||
config.max_cpu_loras = 0
|
||||
config.lora_path = None
|
||||
config.lora_scale = 1.0
|
||||
return config
|
||||
|
||||
|
||||
class TestExtension:
|
||||
"""Simple test extension adding one custom method."""
|
||||
|
||||
def custom_method(self):
|
||||
return "extension_method"
|
||||
|
||||
|
||||
class MockCustomPipeline:
|
||||
"""Mock custom pipeline for testing."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return "pipeline_output"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: Initialization
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseInitialization:
|
||||
"""Test WorkerWrapperBase initialization behavior."""
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_basic_initialization(self, mock_worker_init, mock_od_config):
|
||||
"""Test basic initialization without extensions."""
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
)
|
||||
|
||||
assert wrapper.gpu_id == 0
|
||||
assert wrapper.od_config == mock_od_config
|
||||
assert wrapper.base_worker_class == DiffusionWorker
|
||||
assert wrapper.worker_extension_cls is None
|
||||
assert wrapper.custom_pipeline_args is None
|
||||
assert wrapper.worker is not None
|
||||
|
||||
mock_worker_init.assert_called_once_with(
|
||||
local_rank=0,
|
||||
rank=0,
|
||||
od_config=mock_od_config,
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: Worker Extension Functionality
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseExtension:
|
||||
"""Test WorkerWrapperBase worker extension functionality."""
|
||||
|
||||
def test_prepare_worker_class_without_extension(self, mock_od_config):
|
||||
"""Test _prepare_worker_class without a worker extension."""
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
)
|
||||
worker_class = wrapper._prepare_worker_class()
|
||||
assert worker_class == DiffusionWorker
|
||||
|
||||
def test_prepare_worker_class_with_extension_class(self, mock_od_config):
|
||||
"""Test _prepare_worker_class with an explicit extension class."""
|
||||
|
||||
class TestExtension:
|
||||
def custom_method(self):
|
||||
return "extension_method"
|
||||
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=TestExtension,
|
||||
)
|
||||
|
||||
assert hasattr(wrapper.worker.__class__, "custom_method")
|
||||
assert TestExtension in wrapper.worker.__class__.__bases__
|
||||
|
||||
@patch("vllm.utils.import_utils.resolve_obj_by_qualname")
|
||||
def test_prepare_worker_class_with_extension_string(self, mock_resolve, mock_od_config):
|
||||
"""Test _prepare_worker_class with worker extension as string."""
|
||||
mock_resolve.return_value = TestExtension
|
||||
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls="tests.diffusion.test_worker_wrapper_base.TestExtension",
|
||||
)
|
||||
|
||||
assert hasattr(wrapper.worker.__class__, "custom_method")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: Method Delegation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseDelegation:
|
||||
"""Test WorkerWrapperBase delegation to wrapped worker."""
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_generate_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that generate() delegates to worker.generate()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
mock_output = Mock()
|
||||
wrapper.worker.generate = Mock(return_value=mock_output)
|
||||
|
||||
mock_requests = [Mock()]
|
||||
result = wrapper.generate(mock_requests)
|
||||
|
||||
wrapper.worker.generate.assert_called_once_with(mock_requests)
|
||||
assert result == mock_output
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_execute_model_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that execute_model() delegates to worker.execute_model()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
mock_output = Mock()
|
||||
wrapper.worker.execute_model = Mock(return_value=mock_output)
|
||||
|
||||
mock_reqs = [Mock()]
|
||||
result = wrapper.execute_model(mock_reqs, mock_od_config)
|
||||
|
||||
wrapper.worker.execute_model.assert_called_once_with(mock_reqs, mock_od_config)
|
||||
assert result == mock_output
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_load_weights_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that load_weights() delegates to worker.load_weights()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
expected_result = {"weight1", "weight2"}
|
||||
wrapper.worker.load_weights = Mock(return_value=expected_result)
|
||||
|
||||
mock_weights = [("weight1", Mock()), ("weight2", Mock())]
|
||||
result = wrapper.load_weights(mock_weights)
|
||||
|
||||
wrapper.worker.load_weights.assert_called_once_with(mock_weights)
|
||||
assert result == expected_result
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_sleep_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that sleep() delegates to worker.sleep()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.sleep = Mock(return_value=True)
|
||||
result = wrapper.sleep(level=1)
|
||||
|
||||
wrapper.worker.sleep.assert_called_once_with(1)
|
||||
assert result is True
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_wake_up_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that wake_up() delegates to worker.wake_up()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.wake_up = Mock(return_value=True)
|
||||
|
||||
result = wrapper.wake_up(tags=["weights"])
|
||||
wrapper.worker.wake_up.assert_called_once_with(["weights"])
|
||||
assert result is True
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_shutdown_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test that shutdown() delegates to worker.shutdown()."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.shutdown = Mock(return_value=None)
|
||||
|
||||
result = wrapper.shutdown()
|
||||
wrapper.worker.shutdown.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: execute_method
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseExecuteMethod:
|
||||
"""Test WorkerWrapperBase.execute_method functionality."""
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_execute_method_success(self, mock_worker_init, mock_od_config):
|
||||
"""Test execute_method successfully calls worker method."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.test_method = Mock(return_value="method_result")
|
||||
|
||||
result = wrapper.execute_method("test_method", "arg1", kwarg1="value1")
|
||||
|
||||
wrapper.worker.test_method.assert_called_once_with("arg1", kwarg1="value1")
|
||||
assert result == "method_result"
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_execute_method_with_no_args(self, mock_worker_init, mock_od_config):
|
||||
"""Test execute_method with no arguments."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.no_args_method = Mock(return_value="no_args_result")
|
||||
|
||||
result = wrapper.execute_method("no_args_method")
|
||||
wrapper.worker.no_args_method.assert_called_once_with()
|
||||
assert result == "no_args_result"
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_execute_method_error(self, mock_worker_init, mock_od_config):
|
||||
"""Test execute_method raises exception on error."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.error_method = Mock(side_effect=RuntimeError("Test error"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Test error"):
|
||||
wrapper.execute_method("error_method")
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_execute_method_invalid_type(self, mock_worker_init, mock_od_config):
|
||||
"""Test execute_method with invalid method type."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
|
||||
with pytest.raises(AssertionError, match="Method must be str"):
|
||||
wrapper.execute_method(b"bytes_method")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: __getattr__ delegation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseGetAttr:
|
||||
"""Test WorkerWrapperBase.__getattr__ delegation."""
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_getattr_delegation(self, mock_worker_init, mock_od_config):
|
||||
"""Test __getattr__ delegates to worker attributes."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.custom_attribute = "test_value"
|
||||
assert wrapper.custom_attribute == "test_value"
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_getattr_method_access(self, mock_worker_init, mock_od_config):
|
||||
"""Test __getattr__ delegates to worker methods."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
wrapper.worker.custom_method = Mock(return_value="method_result")
|
||||
|
||||
result = wrapper.custom_method()
|
||||
wrapper.worker.custom_method.assert_called_once()
|
||||
assert result == "method_result"
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_getattr_missing_attribute(self, mock_worker_init, mock_od_config):
|
||||
"""Test __getattr__ raises AttributeError for missing attributes."""
|
||||
wrapper = WorkerWrapperBase(gpu_id=0, od_config=mock_od_config, base_worker_class=DiffusionWorker)
|
||||
with pytest.raises(AttributeError):
|
||||
_ = wrapper.nonexistent_attribute
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: Edge Cases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkerWrapperBaseEdgeCases:
|
||||
"""Test WorkerWrapperBase edge cases and special scenarios."""
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_extension_conflict_warning(self, mock_worker_init, mock_od_config, caplog):
|
||||
"""Test a warning is logged when an extension conflicts with worker."""
|
||||
|
||||
class ConflictExtension:
|
||||
def load_model(self):
|
||||
return "extension_load_model"
|
||||
|
||||
with patch.object(DiffusionWorker, "load_model"):
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=ConflictExtension,
|
||||
)
|
||||
assert wrapper.worker is not None
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_multiple_extensions_same_class(self, mock_worker_init, mock_od_config):
|
||||
"""Test that applying same extension twice doesn't duplicate it."""
|
||||
|
||||
class TestExtension:
|
||||
def custom_method(self):
|
||||
return "extension"
|
||||
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
wrapper1 = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=TestExtension,
|
||||
)
|
||||
wrapper2 = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=TestExtension,
|
||||
)
|
||||
|
||||
assert hasattr(wrapper1.worker, "custom_method")
|
||||
assert hasattr(wrapper2.worker, "custom_method")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests: CustomPipelineWorkerExtension
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCustomPipelineWorkerExtension:
|
||||
"""Test CustomPipelineWorkerExtension functionality."""
|
||||
|
||||
@patch("torch.cuda.empty_cache")
|
||||
@patch("gc.collect")
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_re_init_pipeline_basic(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
|
||||
"""Test basic re_init_pipeline functionality."""
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=CustomPipelineWorkerExtension,
|
||||
)
|
||||
|
||||
# Setup mock model_runner and pipeline
|
||||
mock_model_runner = Mock()
|
||||
mock_pipeline = Mock()
|
||||
mock_model_runner.pipeline = mock_pipeline
|
||||
wrapper.worker.model_runner = mock_model_runner
|
||||
wrapper.worker.init_lora_manager = Mock()
|
||||
wrapper.worker.load_model = Mock()
|
||||
|
||||
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
|
||||
# Call re_init_pipeline
|
||||
wrapper.worker.re_init_pipeline(custom_args)
|
||||
|
||||
# Verify load_model was called with correct arguments
|
||||
wrapper.worker.load_model.assert_called_once_with(
|
||||
load_format="custom_pipeline",
|
||||
custom_pipeline_name="tests.diffusion.test_worker_wrapper_base.MockCustomPipeline",
|
||||
)
|
||||
wrapper.worker.init_lora_manager.assert_called_once()
|
||||
|
||||
@patch("torch.cuda.empty_cache")
|
||||
@patch("gc.collect")
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_re_init_pipeline_cleanup(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
|
||||
"""Test that re_init_pipeline properly cleans up old pipeline."""
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=CustomPipelineWorkerExtension,
|
||||
)
|
||||
|
||||
# Setup mock model_runner with pipeline
|
||||
mock_model_runner = Mock()
|
||||
mock_pipeline = Mock()
|
||||
mock_model_runner.pipeline = mock_pipeline
|
||||
wrapper.worker.model_runner = mock_model_runner
|
||||
wrapper.worker.init_lora_manager = Mock()
|
||||
wrapper.worker.load_model = Mock()
|
||||
|
||||
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
|
||||
# Call re_init_pipeline
|
||||
wrapper.worker.re_init_pipeline(custom_args)
|
||||
|
||||
# Verify cleanup was performed
|
||||
mock_gc_collect.assert_called_once()
|
||||
mock_empty_cache.assert_called_once()
|
||||
|
||||
@patch("torch.cuda.empty_cache")
|
||||
@patch("gc.collect")
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_re_init_pipeline_none_pipeline(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
|
||||
"""Test re_init_pipeline when pipeline is None."""
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=CustomPipelineWorkerExtension,
|
||||
)
|
||||
|
||||
# Setup mock model_runner with None pipeline
|
||||
mock_model_runner = Mock()
|
||||
mock_model_runner.pipeline = None
|
||||
wrapper.worker.model_runner = mock_model_runner
|
||||
wrapper.worker.init_lora_manager = Mock()
|
||||
wrapper.worker.load_model = Mock()
|
||||
|
||||
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
|
||||
# Should not raise an error
|
||||
wrapper.worker.re_init_pipeline(custom_args)
|
||||
|
||||
# Verify load_model was still called
|
||||
wrapper.worker.load_model.assert_called_once()
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_custom_pipeline_args_initialization(self, mock_worker_init, mock_od_config):
|
||||
"""Test initialization with custom_pipeline_args calls re_init_pipeline."""
|
||||
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
with patch.object(WorkerWrapperBase, "_prepare_worker_class") as mock_prepare:
|
||||
# Create a mock worker class with re_init_pipeline
|
||||
mock_worker_class = Mock()
|
||||
mock_worker_instance = Mock()
|
||||
mock_worker_instance.re_init_pipeline = Mock()
|
||||
mock_worker_class.return_value = mock_worker_instance
|
||||
mock_prepare.return_value = mock_worker_class
|
||||
|
||||
_ = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
custom_pipeline_args=custom_args,
|
||||
)
|
||||
|
||||
# Verify re_init_pipeline was called with custom_pipeline_args
|
||||
mock_worker_instance.re_init_pipeline.assert_called_once_with(custom_args)
|
||||
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_custom_pipeline_with_explicit_extension(self, mock_worker_init, mock_od_config):
|
||||
"""Test that explicit worker_extension_cls is preserved when custom_pipeline_args is provided."""
|
||||
|
||||
class CustomExtension:
|
||||
def re_init_pipeline(self, custom_pipeline_args: dict[str, Any]):
|
||||
return "custom_re_init_pipeline"
|
||||
|
||||
def custom_extension_method(self):
|
||||
return "custom_extension_method"
|
||||
|
||||
custom_args = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
|
||||
with patch.object(DiffusionWorker, "__init__", return_value=None):
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=CustomExtension,
|
||||
custom_pipeline_args=custom_args,
|
||||
)
|
||||
|
||||
# Should still have the explicitly provided extension
|
||||
assert CustomExtension in wrapper.worker.__class__.__bases__
|
||||
assert hasattr(wrapper.worker, "custom_extension_method")
|
||||
|
||||
@patch("torch.cuda.empty_cache")
|
||||
@patch("gc.collect")
|
||||
@patch.object(DiffusionWorker, "__init__", return_value=None)
|
||||
def test_re_init_pipeline_multiple_calls(self, mock_worker_init, mock_gc_collect, mock_empty_cache, mock_od_config):
|
||||
"""Test calling re_init_pipeline multiple times."""
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=0,
|
||||
od_config=mock_od_config,
|
||||
base_worker_class=DiffusionWorker,
|
||||
worker_extension_cls=CustomPipelineWorkerExtension,
|
||||
)
|
||||
|
||||
# Setup mock model_runner
|
||||
mock_model_runner = Mock()
|
||||
mock_pipeline1 = Mock()
|
||||
mock_pipeline2 = Mock()
|
||||
mock_model_runner.pipeline = mock_pipeline1
|
||||
wrapper.worker.model_runner = mock_model_runner
|
||||
wrapper.worker.init_lora_manager = Mock()
|
||||
wrapper.worker.load_model = Mock()
|
||||
|
||||
# First call
|
||||
custom_args1 = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
wrapper.worker.re_init_pipeline(custom_args1)
|
||||
|
||||
# Update pipeline for second call
|
||||
mock_model_runner.pipeline = mock_pipeline2
|
||||
|
||||
# Second call
|
||||
custom_args2 = {"pipeline_class": "tests.diffusion.test_worker_wrapper_base.MockCustomPipeline"}
|
||||
wrapper.worker.re_init_pipeline(custom_args2)
|
||||
|
||||
# Verify load_model was called twice with different pipelines
|
||||
assert wrapper.worker.load_model.call_count == 2
|
||||
assert wrapper.worker.init_lora_manager.call_count == 2
|
||||
@@ -332,6 +332,15 @@ class OmniDiffusionConfig:
|
||||
# TODO: do not hard code
|
||||
master_port: int | None = None
|
||||
|
||||
# Worker extension class for custom functionality
|
||||
worker_extension_cls: str | None = None
|
||||
|
||||
# Custom pipeline arguments for custom pipelines
|
||||
custom_pipeline_args: dict[str, Any] | None = None
|
||||
|
||||
# Diffusion model loading format
|
||||
diffusion_load_format: str = "default" # "default", "custom_pipeline", "dummy"
|
||||
|
||||
# http server endpoint config, would be ignored in local mode
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
|
||||
@@ -77,6 +77,10 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
mp.set_start_method("spawn", force=True)
|
||||
processes = []
|
||||
|
||||
# Extract worker_extension_cls and custom_pipeline_args from od_config
|
||||
worker_extension_cls = od_config.worker_extension_cls
|
||||
custom_pipeline_args = getattr(od_config, "custom_pipeline_args", None)
|
||||
|
||||
# Launch all worker processes
|
||||
scheduler_pipe_readers = []
|
||||
scheduler_pipe_writers = []
|
||||
@@ -91,6 +95,8 @@ class MultiprocDiffusionExecutor(DiffusionExecutor):
|
||||
od_config,
|
||||
writer,
|
||||
broadcast_handle,
|
||||
worker_extension_cls,
|
||||
custom_pipeline_args,
|
||||
),
|
||||
name=f"DiffusionWorker-{i}",
|
||||
daemon=True,
|
||||
|
||||
@@ -22,6 +22,7 @@ from vllm.model_executor.model_loader.weight_utils import (
|
||||
maybe_download_from_modelscope,
|
||||
safetensors_weights_iterator,
|
||||
)
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
from vllm.utils.torch_utils import set_default_torch_dtype
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
@@ -208,13 +209,22 @@ class DiffusersPipelineLoader:
|
||||
allow_patterns_overrides=None,
|
||||
)
|
||||
|
||||
def load_model(self, od_config: OmniDiffusionConfig, load_device: str) -> nn.Module:
|
||||
def load_model(
|
||||
self,
|
||||
od_config: OmniDiffusionConfig,
|
||||
load_device: str,
|
||||
load_format: str = "default",
|
||||
custom_pipeline_name: str | None = None,
|
||||
) -> nn.Module:
|
||||
"""Load a model with the given configurations."""
|
||||
target_device = torch.device(load_device)
|
||||
with set_default_torch_dtype(od_config.dtype):
|
||||
with target_device:
|
||||
model = initialize_model(od_config)
|
||||
|
||||
if load_format == "default":
|
||||
model = initialize_model(od_config)
|
||||
elif load_format == "custom_pipeline":
|
||||
model_cls = resolve_obj_by_qualname(custom_pipeline_name)
|
||||
model = model_cls(od_config=od_config)
|
||||
logger.debug("Loading weights on %s ...", load_device)
|
||||
# Quantization does not happen in `load_weights` but after it
|
||||
self.load_weights(model)
|
||||
|
||||
@@ -70,6 +70,8 @@ class DiffusionModelRunner:
|
||||
def load_model(
|
||||
self,
|
||||
memory_pool_context_fn: callable | None = None,
|
||||
load_format: str | None = None,
|
||||
custom_pipeline_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Load the diffusion model, apply compilation and offloading.
|
||||
@@ -77,7 +79,17 @@ class DiffusionModelRunner:
|
||||
Args:
|
||||
memory_pool_context_fn: Optional function that returns a context manager
|
||||
for memory pool allocation (used for sleep mode).
|
||||
load_format: Format for loading model weights. Supported formats:
|
||||
- "default" (default): Automatically detect and use the default format based on configuration
|
||||
- "custom_pipeline": Init model from a custom pipeline class specified by `custom_pipeline_name`
|
||||
- "dummy": Skip actual weight loading, useful for testing and custom pipelines that
|
||||
don't require default weights.
|
||||
custom_pipeline_name: Optional custom pipeline class name to use.
|
||||
"""
|
||||
|
||||
if load_format == "dummy":
|
||||
return
|
||||
|
||||
load_device = (
|
||||
"cpu" if self.od_config.enable_cpu_offload or self.od_config.enable_layerwise_offload else str(self.device)
|
||||
)
|
||||
@@ -97,6 +109,8 @@ class DiffusionModelRunner:
|
||||
self.pipeline = model_loader.load_model(
|
||||
od_config=self.od_config,
|
||||
load_device=load_device,
|
||||
load_format=load_format,
|
||||
custom_pipeline_name=custom_pipeline_name,
|
||||
)
|
||||
time_after_load = time.perf_counter()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""
|
||||
Diffusion Worker for vLLM-Omni.
|
||||
|
||||
@@ -7,15 +8,19 @@ Handles GPU infrastructure initialization and delegates model operations
|
||||
to DiffusionModelRunner.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import zmq
|
||||
from vllm.config import CompilationConfig, VllmConfig, set_current_vllm_config
|
||||
from vllm.distributed.device_communicators.shm_broadcast import MessageQueue
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
from vllm.utils.mem_utils import GiB_bytes
|
||||
from vllm.v1.worker.workspace import init_workspace_manager
|
||||
|
||||
@@ -67,6 +72,15 @@ class DiffusionWorker:
|
||||
self._sleep_saved_buffers: dict[str, torch.Tensor] = {}
|
||||
self.lora_manager: DiffusionLoRAManager | None = None
|
||||
self.init_device()
|
||||
# Create model runner
|
||||
self.model_runner = DiffusionModelRunner(
|
||||
vllm_config=self.vllm_config,
|
||||
od_config=self.od_config,
|
||||
device=self.device,
|
||||
)
|
||||
self.load_model(load_format=self.od_config.diffusion_load_format)
|
||||
self.init_lora_manager()
|
||||
logger.info(f"Worker {self.rank}: Initialization complete.")
|
||||
|
||||
def init_device(self) -> None:
|
||||
"""Initialize the device and distributed environment."""
|
||||
@@ -110,16 +124,22 @@ class DiffusionWorker:
|
||||
)
|
||||
init_workspace_manager(self.device)
|
||||
|
||||
# Create model runner and load model
|
||||
self.model_runner = DiffusionModelRunner(
|
||||
vllm_config=self.vllm_config,
|
||||
od_config=self.od_config,
|
||||
device=self.device,
|
||||
)
|
||||
def load_model(self, load_format: str = "default", custom_pipeline_name: str | None = None) -> None:
|
||||
"""Load the diffusion model using DiffusionModelRunner."""
|
||||
with (
|
||||
set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config),
|
||||
set_current_vllm_config(self.vllm_config),
|
||||
):
|
||||
self.model_runner.load_model(
|
||||
memory_pool_context_fn=self._maybe_get_memory_pool_context,
|
||||
load_format=load_format,
|
||||
custom_pipeline_name=custom_pipeline_name,
|
||||
)
|
||||
assert self.model_runner.pipeline is not None
|
||||
|
||||
def init_lora_manager(self) -> None:
|
||||
"""Initialize the LoRA manager for this worker."""
|
||||
if self.model_runner.pipeline is None:
|
||||
return
|
||||
self.lora_manager = DiffusionLoRAManager(
|
||||
pipeline=self.model_runner.pipeline,
|
||||
device=self.device,
|
||||
@@ -128,7 +148,6 @@ class DiffusionWorker:
|
||||
lora_path=self.od_config.lora_path,
|
||||
lora_scale=self.od_config.lora_scale,
|
||||
)
|
||||
logger.info(f"Worker {self.rank}: Initialization complete.")
|
||||
|
||||
def generate(self, request: OmniDiffusionRequest) -> DiffusionOutput:
|
||||
"""Generate output for the given requests."""
|
||||
@@ -247,6 +266,32 @@ class DiffusionWorker:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
class CustomPipelineWorkerExtension:
|
||||
def re_init_pipeline(self, custom_pipeline_args: dict[str, Any]) -> None:
|
||||
"""
|
||||
Re-initialize the pipeline with custom arguments.
|
||||
|
||||
Args:
|
||||
custom_pipeline_args: Dictionary of arguments for custom pipeline initialization
|
||||
"""
|
||||
|
||||
# Clean up old pipeline
|
||||
if self.model_runner.pipeline is not None:
|
||||
del self.model_runner.pipeline
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Get custom pipeline class name
|
||||
custom_pipeline_name = custom_pipeline_args["pipeline_class"]
|
||||
|
||||
# Use the DiffusionWorker's load_model method which handles the forward context
|
||||
self.load_model(
|
||||
load_format="custom_pipeline",
|
||||
custom_pipeline_name=custom_pipeline_name,
|
||||
)
|
||||
self.init_lora_manager()
|
||||
|
||||
|
||||
class WorkerProc:
|
||||
"""Wrapper that runs one Worker in a separate process."""
|
||||
|
||||
@@ -255,8 +300,11 @@ class WorkerProc:
|
||||
od_config: OmniDiffusionConfig,
|
||||
gpu_id: int,
|
||||
broadcast_handle,
|
||||
worker_extension_cls: str | None = None,
|
||||
custom_pipeline_args: dict[str, Any] | None = None,
|
||||
):
|
||||
self.od_config = od_config
|
||||
self.gpu_id = gpu_id
|
||||
|
||||
# Inter-process Communication
|
||||
self.context = zmq.Context(io_threads=2)
|
||||
@@ -274,17 +322,26 @@ class WorkerProc:
|
||||
logger.info(f"Worker {gpu_id} created result MessageQueue")
|
||||
|
||||
assert od_config.master_port is not None
|
||||
self.worker = self._create_worker(gpu_id, od_config)
|
||||
self.gpu_id = gpu_id
|
||||
|
||||
# Create worker using WorkerWrapperBase for extension support
|
||||
self.worker = self._create_worker(gpu_id, od_config, worker_extension_cls, custom_pipeline_args)
|
||||
self._running = True
|
||||
|
||||
def _create_worker(self, gpu_id: int, od_config: OmniDiffusionConfig) -> DiffusionWorker:
|
||||
def _create_worker(
|
||||
self,
|
||||
gpu_id: int,
|
||||
od_config: OmniDiffusionConfig,
|
||||
worker_extension_cls: str | None,
|
||||
custom_pipeline_args: dict[str, Any] | None = None,
|
||||
) -> DiffusionWorker:
|
||||
"""Create a worker instance. Override in subclasses for different worker types."""
|
||||
return DiffusionWorker(
|
||||
local_rank=gpu_id,
|
||||
rank=gpu_id,
|
||||
wrapper = WorkerWrapperBase(
|
||||
gpu_id=gpu_id,
|
||||
od_config=od_config,
|
||||
worker_extension_cls=worker_extension_cls,
|
||||
custom_pipeline_args=custom_pipeline_args,
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def return_result(self, output: DiffusionOutput):
|
||||
"""Reply to client, only on rank 0."""
|
||||
@@ -310,11 +367,8 @@ class WorkerProc:
|
||||
return None, False
|
||||
|
||||
try:
|
||||
if isinstance(method, str):
|
||||
func = getattr(self.worker, method)
|
||||
result = func(*args, **kwargs)
|
||||
else:
|
||||
result = method(self.worker, *args, **kwargs)
|
||||
# Use execute_method from WorkerWrapperBase for consistent method resolution
|
||||
result = self.worker.execute_method(method, *args, **kwargs)
|
||||
return result, should_reply
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing RPC: {e}", exc_info=True)
|
||||
@@ -385,6 +439,8 @@ class WorkerProc:
|
||||
od_config: OmniDiffusionConfig,
|
||||
pipe_writer: mp.connection.Connection,
|
||||
broadcast_handle,
|
||||
worker_extension_cls: str | None = None,
|
||||
custom_pipeline_args: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Worker initialization and execution loops."""
|
||||
from vllm_omni.plugins import load_omni_general_plugins
|
||||
@@ -394,6 +450,8 @@ class WorkerProc:
|
||||
od_config,
|
||||
gpu_id=rank,
|
||||
broadcast_handle=broadcast_handle,
|
||||
worker_extension_cls=worker_extension_cls,
|
||||
custom_pipeline_args=custom_pipeline_args,
|
||||
)
|
||||
logger.info(f"Worker {rank}: Scheduler loop started.")
|
||||
pipe_writer.send(
|
||||
@@ -404,3 +462,201 @@ class WorkerProc:
|
||||
)
|
||||
worker_proc.worker_busy_loop()
|
||||
logger.info(f"Worker {rank}: Shutdown complete.")
|
||||
|
||||
|
||||
class WorkerWrapperBase:
|
||||
"""
|
||||
Wrapper base class that creates DiffusionWorker with optional worker_extension_cls support.
|
||||
This enables dynamic inheritance for DiffusionWorker to extend with custom functionality.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gpu_id: int,
|
||||
od_config: OmniDiffusionConfig,
|
||||
base_worker_class: type = DiffusionWorker,
|
||||
worker_extension_cls: str | None = None,
|
||||
custom_pipeline_args: dict[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize WorkerWrapperBase with support for worker extensions.
|
||||
|
||||
Args:
|
||||
gpu_id: GPU device ID
|
||||
od_config: OmniDiffusionConfig configuration
|
||||
worker_extension_cls: Optional qualified name of worker extension class
|
||||
custom_pipeline_args: Optional arguments for custom pipeline initialization
|
||||
"""
|
||||
self.gpu_id = gpu_id
|
||||
self.od_config = od_config
|
||||
self.base_worker_class = base_worker_class
|
||||
self.worker_extension_cls = worker_extension_cls
|
||||
self.custom_pipeline_args = custom_pipeline_args
|
||||
|
||||
# Prepare worker class with extension support
|
||||
worker_class = self._prepare_worker_class()
|
||||
|
||||
# Create the actual worker instance
|
||||
self.worker = worker_class(
|
||||
local_rank=gpu_id,
|
||||
rank=gpu_id,
|
||||
od_config=od_config,
|
||||
)
|
||||
|
||||
# Re-initialize pipeline with custom pipeline if provided
|
||||
if self.custom_pipeline_args is not None:
|
||||
self.worker.re_init_pipeline(self.custom_pipeline_args)
|
||||
|
||||
def _prepare_worker_class(self) -> type:
|
||||
"""
|
||||
Prepare the worker class with optional extension.
|
||||
Dynamically extends GPUWorker with worker_extension_cls if provided.
|
||||
|
||||
Returns:
|
||||
The worker class (potentially extended)
|
||||
"""
|
||||
worker_class = self.base_worker_class
|
||||
|
||||
# If custom_pipeline_args is provided, use CustomPipelineWorkerExtension
|
||||
if self.custom_pipeline_args is not None:
|
||||
# Set worker_extension_cls to CustomPipelineWorkerExtension if not already set
|
||||
if self.worker_extension_cls is None:
|
||||
self.worker_extension_cls = CustomPipelineWorkerExtension
|
||||
|
||||
if self.worker_extension_cls:
|
||||
if isinstance(self.worker_extension_cls, str):
|
||||
worker_extension_cls = resolve_obj_by_qualname(self.worker_extension_cls)
|
||||
else:
|
||||
worker_extension_cls = self.worker_extension_cls
|
||||
extended_calls = []
|
||||
|
||||
if worker_extension_cls not in worker_class.__bases__:
|
||||
# Check for conflicts between worker and extension
|
||||
for attr in dir(worker_extension_cls):
|
||||
if attr.startswith("__"):
|
||||
continue
|
||||
if hasattr(worker_class, attr):
|
||||
logger.warning(
|
||||
f"Worker class {worker_class} already has attribute "
|
||||
f"{attr}, which may conflict with worker extension "
|
||||
f"class {worker_extension_cls}."
|
||||
)
|
||||
if callable(getattr(worker_extension_cls, attr)):
|
||||
extended_calls.append(attr)
|
||||
|
||||
# Dynamically inherit the worker extension class
|
||||
class_name = f"{worker_class.__name__}With{worker_extension_cls.__name__}"
|
||||
worker_class = type(class_name, (worker_extension_cls, worker_class), {})
|
||||
logger.info(
|
||||
"Created extended worker class %s from %s for extended calls %s",
|
||||
class_name,
|
||||
worker_extension_cls,
|
||||
extended_calls,
|
||||
)
|
||||
|
||||
return worker_class
|
||||
|
||||
def generate(self, requests: list[OmniDiffusionRequest]) -> DiffusionOutput:
|
||||
"""
|
||||
Generate output for the given requests.
|
||||
|
||||
Args:
|
||||
requests: List of diffusion requests
|
||||
|
||||
Returns:
|
||||
DiffusionOutput with generated results
|
||||
"""
|
||||
return self.worker.generate(requests)
|
||||
|
||||
def execute_model(self, reqs: list[OmniDiffusionRequest], od_config: OmniDiffusionConfig) -> DiffusionOutput:
|
||||
"""
|
||||
Execute a forward pass.
|
||||
|
||||
Args:
|
||||
reqs: List of diffusion requests
|
||||
od_config: OmniDiffusionConfig configuration
|
||||
|
||||
Returns:
|
||||
DiffusionOutput with generated results
|
||||
"""
|
||||
return self.worker.execute_model(reqs, od_config)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""
|
||||
Load model weights.
|
||||
|
||||
Args:
|
||||
weights: Iterable of (name, tensor) tuples
|
||||
|
||||
Returns:
|
||||
Set of loaded weight names
|
||||
"""
|
||||
return self.worker.load_weights(weights)
|
||||
|
||||
def sleep(self, level: int = 1) -> bool:
|
||||
"""
|
||||
Put the worker to sleep. The worker should not process any requests.
|
||||
The caller should guarantee that no requests are being processed
|
||||
during the sleep period, before `wake_up` is called.
|
||||
|
||||
Args:
|
||||
level: The sleep level. Level 1 sleep will offload the model
|
||||
weights and discard the kv cache.
|
||||
Currently only support level 1.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
return self.worker.sleep(level)
|
||||
|
||||
def wake_up(self, tags: list[str] | None = None) -> bool:
|
||||
"""
|
||||
Wake up the worker from sleep mode. See the sleep function
|
||||
method for more details.
|
||||
|
||||
Args:
|
||||
tags: An optional list of tags to reallocate the worker memory
|
||||
for specific memory allocations. Values must be in
|
||||
`("weights")`. If None, all memory is reallocated.
|
||||
wake_up should be called with all tags (or None) before the
|
||||
worker is used again.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
return self.worker.wake_up(tags)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the worker and cleanup resources."""
|
||||
return self.worker.shutdown()
|
||||
|
||||
def execute_method(self, method: str | bytes, *args, **kwargs) -> Any:
|
||||
"""
|
||||
Execute a method on the worker.
|
||||
|
||||
Args:
|
||||
method: Method name (str) or serialized callable (bytes)
|
||||
|
||||
Returns:
|
||||
Result of the method execution (type depends on the method)
|
||||
|
||||
Raises:
|
||||
Exception: If method execution fails
|
||||
"""
|
||||
try:
|
||||
# Method resolution order:
|
||||
# 1. If method is defined in this class, it will be called directly
|
||||
# 2. Otherwise, since we define `__getattr__` and redirect attribute
|
||||
# query to `self.worker`, the method will be called on the worker
|
||||
assert isinstance(method, str), "Method must be str"
|
||||
func = getattr(self.worker, method)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
msg = f"Error executing method {method!r}. This might cause issues in distributed execution."
|
||||
logger.exception(msg)
|
||||
raise e
|
||||
|
||||
def __getattr__(self, attr: str):
|
||||
"""Delegate attribute access to the wrapped worker."""
|
||||
return getattr(self.worker, attr)
|
||||
@@ -163,6 +163,8 @@ class AsyncOmni(OmniBase):
|
||||
"enable_cpu_offload": kwargs.get("enable_cpu_offload", False),
|
||||
"enable_layerwise_offload": kwargs.get("enable_layerwise_offload", False),
|
||||
"enforce_eager": kwargs.get("enforce_eager", False),
|
||||
"diffusion_load_format": kwargs.get("diffusion_load_format", "default"),
|
||||
"custom_pipeline_args": kwargs.get("custom_pipeline_args", None),
|
||||
},
|
||||
"final_output": True,
|
||||
"final_output_type": "image",
|
||||
|
||||
Reference in new issue
Block a user