Optimize Qwen-Image 2.1 attention and decode graph reuse (#7790)
Signed-off-by: Tianyu Guo <guoty9@mail2.sysu.edu.cn>
This commit is contained in:
1 parent
f930348e79
commit
ab4cf24b3e
24 files changed
+1147
-414
No files matched your search
@@ -221,6 +221,18 @@ steps:
|
||||
- pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Diffusion · Qwen Image 2.1 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen_image_21.py
|
||||
- tests/helpers/
|
||||
- vllm_omni/diffusion/models/qwen_image_21/
|
||||
- vllm_omni/diffusion/distributed/autoencoders/
|
||||
- vllm_omni/diffusion/sched/
|
||||
- vllm_omni/entrypoints/openai/
|
||||
commands:
|
||||
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen_image_21.py -m 'advanced_model and cuda' --run-level 'advanced_model'
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Diffusion · Qwen Image Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen_image.py
|
||||
|
||||
@@ -331,6 +331,18 @@ steps:
|
||||
"
|
||||
mirror_hardwares: h100_2
|
||||
|
||||
- label: "Diffusion · Qwen Image 2.1 Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen_image_21.py
|
||||
- tests/helpers/
|
||||
- vllm_omni/diffusion/models/qwen_image_21/
|
||||
- vllm_omni/diffusion/distributed/autoencoders/
|
||||
- vllm_omni/diffusion/sched/
|
||||
- vllm_omni/entrypoints/openai/
|
||||
commands:
|
||||
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen_image_21.py -m 'core_model and cuda' --run-level 'core_model'
|
||||
mirror_hardwares: h100_1
|
||||
|
||||
- label: "Diffusion · Qwen Image Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_qwen_image.py
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Example script for image editing with OmniGen2.
|
||||
@@ -121,14 +121,14 @@ def parse_profiler_config(value: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Edit an image with Qwen-Image-Edit.")
|
||||
parser = argparse.ArgumentParser(description="Edit images with vLLM-Omni.")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="Qwen/Qwen-Image-Edit",
|
||||
help=(
|
||||
"Diffusion model name or local path. "
|
||||
"For multiple image inputs, use Qwen/Qwen-Image-Edit-2509 or Qwen/Qwen-Image-Edit-2511"
|
||||
"which supports QwenImageEditPlusPipeline."
|
||||
"For multiple image inputs, use Qwen/Qwen-Image-Edit-2509, "
|
||||
"Qwen/Qwen-Image-Edit-2511, or Qwen/Qwen-Image-2.1."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -280,7 +280,7 @@ def parse_args() -> argparse.Namespace:
|
||||
"--color-format",
|
||||
type=str,
|
||||
default="RGB",
|
||||
help="For Qwen-Image-Layered, set to RGBA.",
|
||||
help="For Qwen-Image-Layered or Qwen-Image-2.1, set to RGBA to preserve transparency.",
|
||||
)
|
||||
|
||||
# Cache-DiT specific parameters
|
||||
@@ -541,6 +541,7 @@ def main():
|
||||
|
||||
diffusion_params = OmniDiffusionSamplingParams(
|
||||
generator=generator,
|
||||
seed=args.seed,
|
||||
true_cfg_scale=args.cfg_scale,
|
||||
guidance_scale=args.guidance_scale,
|
||||
guidance_scale_2=args.guidance_scale_2,
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Example script for image-conditioned generation with Qwen-Image 2.1.
|
||||
|
||||
Qwen-Image 2.1 uses a single pipeline (QwenImage21Pipeline) for both
|
||||
text-to-image and image-conditioned generation. Pass one or more condition
|
||||
images (at most 4) via --image to run the image-conditioned path.
|
||||
|
||||
Usage (single image):
|
||||
python qwen_image_21_edit.py \
|
||||
--image input.png \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
|
||||
--negative-prompt "blurry, low quality, text, watermark" \
|
||||
--output qwen_image_21_edit.png \
|
||||
--num-inference-steps 50 \
|
||||
--cfg-scale 4.0
|
||||
|
||||
Usage (multiple images, up to 4):
|
||||
python qwen_image_21_edit.py \
|
||||
--image input1.png input2.png \
|
||||
--prompt "Combine these images into a single scene" \
|
||||
--negative-prompt "blurry, low quality" \
|
||||
--output qwen_image_21_edit.png \
|
||||
--num-inference-steps 50 \
|
||||
--cfg-scale 4.0
|
||||
|
||||
Notes:
|
||||
- Qwen-Image 2.1 only supports true classifier-free guidance
|
||||
(--cfg-scale); there is no guidance-distilled guidance_scale.
|
||||
--cfg-scale > 1 only takes effect when --negative-prompt is provided.
|
||||
- --height/--width must be multiples of 32. When omitted, the output size
|
||||
is derived from the last condition image's aspect ratio at ~1024x1024.
|
||||
- Cache acceleration backends (cache_dit / tea_cache) are not supported.
|
||||
|
||||
For more options, run:
|
||||
python qwen_image_21_edit.py --help
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.utils.image_output import extract_images_from_outputs
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_extras import (
|
||||
build_image_to_image_prompt,
|
||||
get_model_class_name,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
MAX_INPUT_IMAGES = 4
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Image-conditioned generation with Qwen-Image 2.1.")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="Qwen/Qwen-Image-2.1",
|
||||
help="Diffusion model name or local path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--image",
|
||||
type=str,
|
||||
nargs="+",
|
||||
required=True,
|
||||
help="Path(s) to input condition image file(s) (PNG, JPG, etc.). At most 4 images.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Text prompt describing the desired generation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--negative-prompt",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Negative prompt. Required for true classifier-free guidance (--cfg-scale > 1) to take effect.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="W",
|
||||
help="Output image width in pixels (multiple of 32). Default: None (derived from the condition image).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="H",
|
||||
help="Output image height in pixels (multiple of 32). Default: None (derived from the condition image).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="Random seed for deterministic results.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-scale",
|
||||
type=float,
|
||||
default=4.0,
|
||||
help=(
|
||||
"True classifier-free guidance scale (default: 4.0). Enabled by setting cfg_scale > 1 and providing "
|
||||
"a negative prompt. Qwen-Image 2.1 supports true CFG only; there is no guidance_scale."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default="qwen_image_21_edit.png",
|
||||
help="Path to save the generated image (PNG).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of images to generate for the given prompt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of denoising steps for the diffusion sampler.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ulysses-degree",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of GPUs used for ulysses sequence parallelism (ring attention is not supported).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-parallel-size",
|
||||
type=int,
|
||||
default=1,
|
||||
choices=[1, 2],
|
||||
help="Number of GPUs used for classifier free guidance parallel size.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vae-use-tiling",
|
||||
action="store_true",
|
||||
help="Enable VAE tiling for memory optimization (also tiles condition-image encoding).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enforce-eager",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Disable torch.compile and force eager execution.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if len(args.image) > MAX_INPUT_IMAGES:
|
||||
raise ValueError(f"Qwen-Image 2.1 supports at most {MAX_INPUT_IMAGES} input images, got {len(args.image)}")
|
||||
|
||||
input_images = []
|
||||
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("RGBA"))
|
||||
|
||||
# Use single image or list based on number of inputs
|
||||
input_image = input_images[0] if len(input_images) == 1 else input_images
|
||||
|
||||
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
|
||||
|
||||
omni_kwargs = {
|
||||
"model": args.model,
|
||||
"ulysses_degree": args.ulysses_degree,
|
||||
"cfg_parallel_size": args.cfg_parallel_size,
|
||||
"vae_use_tiling": args.vae_use_tiling,
|
||||
}
|
||||
if args.enforce_eager is not None:
|
||||
omni_kwargs["enforce_eager"] = args.enforce_eager
|
||||
omni = Omni(**omni_kwargs)
|
||||
model_class_name = get_model_class_name(omni)
|
||||
print("Pipeline loaded")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Generation Configuration:")
|
||||
print(f" Model: {args.model}")
|
||||
print(f" Inference steps: {args.num_inference_steps}")
|
||||
print(f" CFG scale (true CFG): {args.cfg_scale}")
|
||||
if args.height is not None or args.width is not None:
|
||||
print(f" Output size: {args.width or 'auto'}x{args.height or 'auto'}")
|
||||
if isinstance(input_image, list):
|
||||
print(f" Number of input images: {len(input_image)}")
|
||||
else:
|
||||
print(f" Input image size: {input_image.size}")
|
||||
print(f" Parallel configuration: ulysses_degree={args.ulysses_degree}, cfg_parallel_size={args.cfg_parallel_size}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
generation_start = time.perf_counter()
|
||||
|
||||
prompt_dict = build_image_to_image_prompt(
|
||||
model_class_name=model_class_name,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
input_image=input_image,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
)
|
||||
|
||||
diffusion_params = OmniDiffusionSamplingParams(
|
||||
generator=generator,
|
||||
seed=args.seed,
|
||||
true_cfg_scale=args.cfg_scale,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
num_outputs_per_prompt=args.num_outputs_per_prompt,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
)
|
||||
|
||||
outputs = omni.generate(prompt_dict, sampling_params_list=[diffusion_params])
|
||||
generation_end = time.perf_counter()
|
||||
generation_time = generation_end - generation_start
|
||||
|
||||
print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)")
|
||||
|
||||
if not outputs:
|
||||
raise ValueError("No output generated from omni.generate()")
|
||||
|
||||
images = None
|
||||
for output in outputs:
|
||||
images = getattr(output, "images", None)
|
||||
if images:
|
||||
break
|
||||
|
||||
if not images:
|
||||
images = extract_images_from_outputs(outputs)
|
||||
|
||||
if not images:
|
||||
raise ValueError("No images found in request_output")
|
||||
|
||||
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 "qwen_image_21_edit"
|
||||
|
||||
if len(images) <= 1:
|
||||
images[0].save(output_path)
|
||||
print(f"Saved generated image to {os.path.abspath(output_path)}")
|
||||
else:
|
||||
for idx, img in enumerate(images):
|
||||
save_path = output_path.parent / f"{stem}_{idx}{suffix}"
|
||||
img.save(save_path)
|
||||
print(f"Saved generated image to {os.path.abspath(save_path)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +1,17 @@
|
||||
python qwen_image_21_edit.py \
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
python "$(dirname "${BASH_SOURCE[0]}")/image_edit.py" \
|
||||
--model Qwen/Qwen-Image-2.1 \
|
||||
--color-format RGBA \
|
||||
--seed 42 \
|
||||
--image qwen_bear.png \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \
|
||||
--negative-prompt "blurry, low quality, text, watermark" \
|
||||
--output qwen_image_21_edit.png \
|
||||
--num-inference-steps 50 \
|
||||
--cfg-scale 4.0
|
||||
--cfg-scale 4.0 \
|
||||
"$@"
|
||||
@@ -27,7 +27,7 @@ text-to-image.
|
||||
|
||||
- Related offline examples:
|
||||
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py),
|
||||
[`examples/offline_inference/image_to_image/qwen_image_21_edit.py`](../../examples/offline_inference/image_to_image/qwen_image_21_edit.py)
|
||||
[`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py)
|
||||
- Supported models table:
|
||||
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)
|
||||
- Feature compatibility matrix:
|
||||
@@ -52,7 +52,10 @@ python examples/offline_inference/text_to_image/text_to_image.py \
|
||||
### Image-conditioned generation (editing)
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/image_to_image/qwen_image_21_edit.py \
|
||||
python examples/offline_inference/image_to_image/image_edit.py \
|
||||
--model Qwen/Qwen-Image-2.1 \
|
||||
--color-format RGBA \
|
||||
--seed 42 \
|
||||
--image qwen_bear.png \
|
||||
--prompt "Let this mascot dance under the moon, surrounded by floating stars" \
|
||||
--negative-prompt "blurry, low quality, text, watermark" \
|
||||
@@ -70,7 +73,10 @@ bash examples/offline_inference/image_to_image/run_qwen_image_21_edit.sh
|
||||
Up to 4 condition images can be passed via `--image`:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/image_to_image/qwen_image_21_edit.py \
|
||||
python examples/offline_inference/image_to_image/image_edit.py \
|
||||
--model Qwen/Qwen-Image-2.1 \
|
||||
--color-format RGBA \
|
||||
--seed 42 \
|
||||
--image input1.png input2.png \
|
||||
--prompt "Combine these images into a single scene" \
|
||||
--negative-prompt "blurry, low quality" \
|
||||
@@ -79,6 +85,13 @@ python examples/offline_inference/image_to_image/qwen_image_21_edit.py \
|
||||
--cfg-scale 4.0
|
||||
```
|
||||
|
||||
Use `--color-format RGBA` to preserve transparency in condition images. The
|
||||
shared example otherwise loads inputs as RGB. Add `--width` and `--height`
|
||||
(multiples of 32) to set the output size; when omitted, Qwen-Image 2.1 derives
|
||||
it from the last condition image's aspect ratio at approximately 1024×1024.
|
||||
Use `--num-outputs-per-prompt` to generate multiple images and `--vae-use-tiling`
|
||||
to reduce VAE memory usage. The launcher accepts these options as well.
|
||||
|
||||
## Online Serving
|
||||
|
||||
Start the server:
|
||||
@@ -102,6 +115,32 @@ requests wait for that batch to finish before starting their own prefill.
|
||||
`--max-num-seqs` controls batch capacity; mixed prefill/decode admission is not
|
||||
supported yet.
|
||||
|
||||
### CUDA Graph decode
|
||||
|
||||
Qwen-Image-2.1 automatically uses CUDA Graph for supported fixed-shape denoising
|
||||
decode steps when `enforce_eager=False` (the default). Prefill remains eager.
|
||||
This works with both request execution and `--step-execution`.
|
||||
|
||||
To disable graph capture:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen-Image-2.1 --omni --enforce-eager --vae-use-tiling
|
||||
```
|
||||
|
||||
The same control is available as `Omni(..., enforce_eager=True)` and as
|
||||
`enforce_eager: true` on a stage in the deployment YAML. It disables both graph
|
||||
capture and automatic `torch.compile`. Otherwise, this transformer uses its
|
||||
model-specific decode graphs instead of automatic `torch.compile`.
|
||||
|
||||
The autoregressive engine's `compilation_config.cudagraph_mode` does not control
|
||||
this diffusion path; use `enforce_eager` to force eager execution.
|
||||
|
||||
TP/SP/ring parallelism, HSDP, offload/cache hooks, compiled blocks, quantized
|
||||
KV caches, dynamic LoRA, and padded text masks fall back to eager decode.
|
||||
Graphs keep separate entries for different image layouts and copy request-owned
|
||||
prefix K/V into static buffers before replay. Those buffers require additional
|
||||
memory; graph entries are bounded by the transformer's cache limit.
|
||||
|
||||
### Verification
|
||||
|
||||
For a direct API smoke test:
|
||||
|
||||
@@ -12,9 +12,10 @@ import vllm_omni.diffusion.attention.backends.cudnn_attn as cudnn_backend
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionBackend, CuDNNAttentionImpl
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_backend_uses_math_for_kv_seq_len_one(monkeypatch):
|
||||
"""Automatic CUDNN_ATTN (platform default) may use MATH for singleton K/V."""
|
||||
selected_backends = []
|
||||
@@ -40,6 +41,7 @@ def test_cudnn_backend_uses_math_for_kv_seq_len_one(monkeypatch):
|
||||
assert selected_backends == [(SDPBackend.MATH,)]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_explicit_cudnn_rejects_kv_seq_len_one():
|
||||
impl = CuDNNAttentionImpl(
|
||||
num_heads=2,
|
||||
@@ -54,6 +56,7 @@ def test_explicit_cudnn_rejects_kv_seq_len_one():
|
||||
impl.forward_cuda(query, singleton_kv, singleton_kv)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_backend_pins_cudnn_only_when_kv_seq_len_gt_one(monkeypatch):
|
||||
selected_backends = []
|
||||
|
||||
@@ -77,6 +80,7 @@ def test_cudnn_backend_pins_cudnn_only_when_kv_seq_len_gt_one(monkeypatch):
|
||||
assert selected_backends == [(SDPBackend.CUDNN_ATTENTION,)]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
|
||||
observed: dict[str, Any] = {}
|
||||
|
||||
@@ -112,6 +116,7 @@ def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
|
||||
assert observed["kwargs"]["attn_mask"] is None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_rejects_invalid_valid_kv_length():
|
||||
impl = CuDNNAttentionImpl(
|
||||
num_heads=2,
|
||||
@@ -130,12 +135,97 @@ def test_cudnn_rejects_invalid_valid_kv_length():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head_size", [8, 64, 128, 256])
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_backend_accepts_blackwell_fmha_head_sizes(head_size):
|
||||
assert CuDNNAttentionBackend.supports_head_size(head_size)
|
||||
assert head_size in CuDNNAttentionBackend.get_supported_head_sizes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head_size", [0, 7, 12, 320])
|
||||
@pytest.mark.cpu
|
||||
def test_cudnn_backend_rejects_incompatible_head_sizes(head_size):
|
||||
assert not CuDNNAttentionBackend.supports_head_size(head_size)
|
||||
assert head_size not in CuDNNAttentionBackend.get_supported_head_sizes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2])
|
||||
@pytest.mark.parametrize("kv_heads", [1, 2])
|
||||
@pytest.mark.parametrize("query_offset", [0, 4])
|
||||
@pytest.mark.cpu
|
||||
def test_piecewise_matches_dense_block_causal_mask(monkeypatch, batch_size, kv_heads, query_offset):
|
||||
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
|
||||
torch.manual_seed(42)
|
||||
query = torch.randn(batch_size, 12, 2, 8)
|
||||
key = torch.randn(batch_size, 12, kv_heads, 8)
|
||||
value = torch.randn_like(key)
|
||||
spans = [(2, 5), (7, 10)]
|
||||
positions = torch.arange(12)
|
||||
mask = positions[:, None] >= positions[None, :]
|
||||
for start, end in spans:
|
||||
mask[start:end, start:end] = True
|
||||
expected = torch.nn.functional.scaled_dot_product_attention(
|
||||
query[:, query_offset:].transpose(1, 2),
|
||||
key.transpose(1, 2),
|
||||
value.transpose(1, 2),
|
||||
attn_mask=mask[None, None, query_offset:],
|
||||
scale=0.5,
|
||||
enable_gqa=kv_heads == 1,
|
||||
).transpose(1, 2)
|
||||
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
|
||||
actual = impl.forward_cuda(
|
||||
query[:, query_offset:], key, value, AttentionMetadata(full_attn_spans=[spans] * batch_size)
|
||||
)
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_piecewise_preserves_discontinuous_query_ranges(monkeypatch):
|
||||
from vllm_omni.diffusion.attention.backends.abstract import QueryRange
|
||||
|
||||
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
|
||||
query, key, value = [torch.randn(2, 12, 2, 8) for _ in range(3)]
|
||||
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
|
||||
metadata = AttentionMetadata(full_attn_spans=[[(2, 5), (7, 12)]] * 2)
|
||||
expected = impl.forward_cuda(query, key, value, metadata)
|
||||
metadata.query_ranges = (QueryRange(0, 3, 0), QueryRange(3, 6, 8))
|
||||
actual = impl.forward_cuda(torch.cat([query[:, :3], query[:, 8:11]], dim=1), key, value, metadata)
|
||||
torch.testing.assert_close(actual, torch.cat([expected[:, :3], expected[:, 8:11]], dim=1))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_piecewise_rejects_padding_mask(monkeypatch):
|
||||
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
|
||||
query = torch.randn(1, 8, 2, 8)
|
||||
metadata = AttentionMetadata(full_attn_spans=[[(2, 8)]], attn_mask=torch.ones(1, 8, dtype=torch.bool))
|
||||
with pytest.raises(ValueError, match="padding mask"):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_dense_mask_takes_precedence_over_spans(monkeypatch):
|
||||
monkeypatch.setattr(cudnn_backend, "sdpa_kernel", lambda _: nullcontext())
|
||||
query, key, value = [torch.randn(2, 8, 2, 8) for _ in range(3)]
|
||||
mask = torch.ones(2, 1, 8, 8, dtype=torch.bool).tril()
|
||||
mask[1, :, :, 3] = False
|
||||
impl = CuDNNAttentionImpl(num_heads=2, head_size=8, softmax_scale=0.5)
|
||||
expected = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask))
|
||||
actual = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask, full_attn_spans=[[(2, 8)]] * 2))
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.parametrize("batch_size", [1, 2])
|
||||
def test_cudnn_piecewise_matches_dense_on_device(batch_size):
|
||||
generator = torch.Generator(device="cuda").manual_seed(42)
|
||||
query, key, value = [
|
||||
torch.randn(batch_size, 96, 2, 128, generator=generator, device="cuda", dtype=torch.bfloat16) for _ in range(3)
|
||||
]
|
||||
spans = [(8, 40), (48, 96)]
|
||||
mask = torch.ones(96, 96, device="cuda", dtype=torch.bool).tril()
|
||||
for start, end in spans:
|
||||
mask[start:end, start:end] = True
|
||||
impl = CuDNNAttentionImpl(num_heads=2, head_size=128, softmax_scale=128**-0.5)
|
||||
actual = impl.forward_cuda(query, key, value, AttentionMetadata(full_attn_spans=[spans] * batch_size))
|
||||
expected = impl.forward_cuda(query, key, value, AttentionMetadata(attn_mask=mask[None, None]))
|
||||
torch.testing.assert_close(actual, expected, rtol=0.02, atol=0.004)
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -62,6 +65,7 @@ class E2EOperator:
|
||||
@dataclass
|
||||
class FakeWorldGroup:
|
||||
device_group: object
|
||||
cpu_group: object = None
|
||||
|
||||
|
||||
class DummyMixin(DistributedVaeMixin):
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders import distributed_vae_executor as vae_executor
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class TileOperator:
|
||||
def split(self, tensor):
|
||||
return [vae_executor.TileTask(i, (i,), row[None]) for i, row in enumerate(tensor)], vae_executor.GridSpec(
|
||||
split_dims=(0,), grid_shape=(tensor.shape[0],)
|
||||
)
|
||||
|
||||
def exec(self, task):
|
||||
return task.tensor * 2
|
||||
|
||||
def merge(self, tiles, grid):
|
||||
return torch.cat([tiles[(i,)] for i in range(grid.grid_shape[0])])
|
||||
|
||||
|
||||
def run_recovery_worker(rank, init_file):
|
||||
torch.set_num_threads(1)
|
||||
dist.init_process_group(
|
||||
"gloo", init_method=f"file://{init_file}", rank=rank, world_size=2, timeout=timedelta(seconds=30)
|
||||
)
|
||||
try:
|
||||
group = SimpleNamespace(device_group=dist.group.WORLD, cpu_group=dist.group.WORLD)
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setattr(vae_executor, "get_world_group", lambda: group)
|
||||
executor = vae_executor.DistributedVaeExecutor()
|
||||
executor.set_parallel_size(2)
|
||||
tensor = torch.arange(12, dtype=torch.float32).reshape(4, 3)
|
||||
operator = TileOperator()
|
||||
for stage, failing_rank in [("decode", 1), ("packing", 1), ("merge", 0), ("gather", 1)]:
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
if rank == failing_rank:
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise torch.OutOfMemoryError("injected OOM")
|
||||
|
||||
target, attribute = {
|
||||
"decode": (operator, "exec"),
|
||||
"packing": (executor, "_pack_local_tiles"),
|
||||
"merge": (operator, "merge"),
|
||||
"gather": (torch, "empty_like"),
|
||||
}[stage]
|
||||
patch.setattr(target, attribute, fail)
|
||||
with pytest.raises(torch.OutOfMemoryError, match="Distributed VAE"):
|
||||
executor.execute(tensor, operator)
|
||||
torch.testing.assert_close(executor.execute(tensor, operator), tensor * 2)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Distributed VAE mixed failures failed"):
|
||||
with executor._sync_errors("mixed failures"):
|
||||
if rank == 0:
|
||||
raise torch.OutOfMemoryError("retryable")
|
||||
raise ValueError("not retryable")
|
||||
torch.testing.assert_close(executor.execute(tensor, operator), tensor * 2)
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def test_all_ranks_observe_failures_and_can_retry(tmp_path):
|
||||
torch.multiprocessing.spawn(run_recovery_worker, args=(str(tmp_path / "rendezvous"),), nprocs=2, join=True)
|
||||
@@ -82,3 +82,40 @@ def test_text_input_forms_share_compatibility_key(preprocess, prompt):
|
||||
empty_list_request = preprocess(make_request("empty-list", []))
|
||||
assert processed.batch_compatibility_key is not None
|
||||
assert processed.batch_compatibility_key == empty_list_request.batch_compatibility_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_type", ["numpy", "tensor"])
|
||||
def test_preprocess_accepts_array_images_without_truth_testing(preprocess, image_type):
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
pixels = np.full((32, 32, 4), 255, dtype=np.uint8)
|
||||
image = torch.from_numpy(pixels).permute(2, 0, 1) if image_type == "tensor" else pixels
|
||||
request = make_request("array", [])
|
||||
request.prompt["multi_modal_data"]["image"] = image
|
||||
processed = preprocess(request)
|
||||
assert processed.batch_compatibility_key is not None
|
||||
assert processed.allow_mixed_step_phases is False
|
||||
|
||||
|
||||
def test_late_prefill_waits_until_all_decode_requests_finish(preprocess):
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
scheduler = StepScheduler()
|
||||
scheduler.initialize(SimpleNamespace(max_num_seqs=3))
|
||||
for request_id in ("a", "b"):
|
||||
scheduler.add_request(preprocess(make_request(request_id, [])))
|
||||
wave = scheduler.schedule()
|
||||
assert wave.scheduled_request_ids == ["a", "b"]
|
||||
scheduler.update_from_output(
|
||||
wave,
|
||||
BatchRunnerOutput.from_list(
|
||||
[RunnerOutput(request_id=request_id, step_index=1, finished=False) for request_id in ("a", "b")]
|
||||
),
|
||||
)
|
||||
scheduler.add_request(preprocess(make_request("late", [])))
|
||||
assert scheduler.schedule().scheduled_request_ids == ["a", "b"]
|
||||
scheduler.finish_requests("a", DiffusionRequestStatus.FINISHED_COMPLETED)
|
||||
assert scheduler.schedule().scheduled_request_ids == ["b"]
|
||||
scheduler.finish_requests("b", DiffusionRequestStatus.FINISHED_COMPLETED)
|
||||
assert scheduler.schedule().scheduled_request_ids == ["late"]
|
||||
@@ -0,0 +1,264 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image_21 import decode_graph
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class DecodeModel(torch.nn.Module):
|
||||
in_channels = 4
|
||||
|
||||
def _decode_graph_forward(self, entry):
|
||||
return (
|
||||
entry.hidden.sin()
|
||||
+ entry.k[0].mean(dim=(1, 2, 3))[:, None, None]
|
||||
+ entry.v[0].mean(dim=(1, 2, 3))[:, None, None]
|
||||
+ entry.freqs[None, :, :]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_case(request, monkeypatch):
|
||||
device = "cuda" if request.node.get_closest_marker("cuda") else "cpu"
|
||||
manager = decode_graph.QwenImage21DecodeGraphManager(DecodeModel(), max_entries=2)
|
||||
monkeypatch.setattr(manager, "eligible", lambda: True)
|
||||
monkeypatch.setattr(manager, "_backend_name", lambda: "test")
|
||||
cleanup = []
|
||||
monkeypatch.setattr(decode_graph.current_omni_platform, "empty_cache", lambda: cleanup.append(True))
|
||||
return manager, device, cleanup
|
||||
|
||||
|
||||
def make_cache(device, value):
|
||||
return [
|
||||
{
|
||||
"cond": {
|
||||
"key": torch.full((1, 2, 1, 4), value, device=device),
|
||||
"value": torch.full((1, 2, 1, 4), value + 1, device=device),
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def register(manager, cache, layout=(1, 2, 2)):
|
||||
device = cache[0]["cond"]["key"].device
|
||||
manager.register_prefill(
|
||||
kv_cache=cache,
|
||||
cache_branch="cond",
|
||||
prefix_len=2,
|
||||
img_shapes=[layout],
|
||||
target_freqs=torch.full((4, 4), float(layout[1]), device=device),
|
||||
joint_key_valid=None,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def decode(manager, cache, layout=(1, 2, 2)):
|
||||
device = cache[0]["cond"]["key"].device
|
||||
return manager.try_decode(
|
||||
hidden_states=torch.ones(1, 4, 4, device=device),
|
||||
timestep=torch.ones(1, device=device),
|
||||
kv_cache=cache,
|
||||
cache_branch="cond",
|
||||
img_shapes=[layout],
|
||||
img_mask=torch.tensor([[False, False, True]], device=device),
|
||||
encoder_hidden_states_mask=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_allocation_oom_releases_partial_buffers_and_remembers_failure(graph_case, monkeypatch):
|
||||
manager, device, cleanup = graph_case
|
||||
cache = make_cache(device, 1.0)
|
||||
constructor = decode_graph.QwenImage21DecodeGraphEntry
|
||||
allocated = []
|
||||
|
||||
def fail_allocation(**kwargs):
|
||||
entry = constructor(**kwargs)
|
||||
allocated.append(weakref.ref(entry.k[0]))
|
||||
raise torch.OutOfMemoryError("injected allocation OOM")
|
||||
|
||||
monkeypatch.setattr(decode_graph, "QwenImage21DecodeGraphEntry", fail_allocation)
|
||||
register(manager, cache)
|
||||
register(manager, cache)
|
||||
assert len(allocated) == 1
|
||||
assert allocated[0]() is None
|
||||
assert list(manager.entries.values()) == [None]
|
||||
assert len(cleanup) == 1
|
||||
assert torch.equal(cache[0]["cond"]["key"], torch.ones(1, 2, 1, 4))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_evict_before_allocating_replacement(graph_case, monkeypatch):
|
||||
manager, device, _ = graph_case
|
||||
manager.max_entries = 1
|
||||
cache = make_cache(device, 1.0)
|
||||
register(manager, cache)
|
||||
old_buffer = weakref.ref(next(iter(manager.entries.values())).k[0])
|
||||
constructor = decode_graph.QwenImage21DecodeGraphEntry
|
||||
|
||||
def allocate_after_release(**kwargs):
|
||||
assert old_buffer() is None
|
||||
return constructor(**kwargs)
|
||||
|
||||
monkeypatch.setattr(decode_graph, "QwenImage21DecodeGraphEntry", allocate_after_release)
|
||||
register(manager, cache, (1, 1, 4))
|
||||
assert len(manager.entries) == 1
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_registration_preserves_request_cache_and_separates_layouts(graph_case):
|
||||
manager, device, _ = graph_case
|
||||
first, second = make_cache(device, 1.0), make_cache(device, 5.0)
|
||||
original = first[0]["cond"]["key"]
|
||||
register(manager, first)
|
||||
register(manager, second)
|
||||
assert first[0]["cond"]["key"] is original
|
||||
assert torch.equal(original, torch.ones_like(original))
|
||||
register(manager, second, (1, 1, 4))
|
||||
assert len(manager.entries) == 2
|
||||
entries = list(manager.entries.values())
|
||||
assert not torch.equal(entries[0].freqs, entries[1].freqs)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.parametrize("failure_phase", ["warmup", "capture"])
|
||||
def test_capture_failure_releases_buffers_and_keeps_request_cache(graph_case, monkeypatch, failure_phase):
|
||||
manager, device, cleanup = graph_case
|
||||
cache = make_cache(device, 1.0)
|
||||
register(manager, cache)
|
||||
entry = next(iter(manager.entries.values()))
|
||||
entry_ref, buffer_ref = weakref.ref(entry), weakref.ref(entry.k[0])
|
||||
del entry
|
||||
|
||||
original = manager.model._decode_graph_forward
|
||||
|
||||
def fail_capture(entry):
|
||||
output = original(entry)
|
||||
if failure_phase == "warmup" or torch.cuda.is_current_stream_capturing():
|
||||
raise torch.OutOfMemoryError("injected capture OOM")
|
||||
return output
|
||||
|
||||
monkeypatch.setattr(manager.model, "_decode_graph_forward", fail_capture)
|
||||
assert decode(manager, cache) is None
|
||||
assert entry_ref() is None and buffer_ref() is None
|
||||
assert list(manager.entries.values()) == [None]
|
||||
register(manager, cache)
|
||||
assert decode(manager, cache) is None
|
||||
assert len(cleanup) == 1
|
||||
assert bool((cache[0]["cond"]["key"] == 1).all())
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
def test_replay_uses_active_request_and_layout(graph_case):
|
||||
manager, device, _ = graph_case
|
||||
first, second = make_cache(device, 1.0), make_cache(device, 5.0)
|
||||
with torch.inference_mode():
|
||||
register(manager, first)
|
||||
register(manager, second)
|
||||
for cache in (first, second, first):
|
||||
output = decode(manager, cache)
|
||||
expected = (
|
||||
torch.ones_like(output).sin() + cache[0]["cond"]["key"].mean() + cache[0]["cond"]["value"].mean() + 2
|
||||
)
|
||||
torch.testing.assert_close(output, expected)
|
||||
original = decode(manager, first).clone()
|
||||
register(manager, second, (1, 1, 4))
|
||||
other = decode(manager, second, (1, 1, 4))
|
||||
torch.testing.assert_close(other, torch.ones_like(other).sin() + 5 + 6 + 1)
|
||||
torch.testing.assert_close(decode(manager, first), original)
|
||||
assert all(entry.captures == 1 for entry in manager.entries.values())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("inference", [False, True])
|
||||
def test_prefix_reuse_refreshes_every_layer_without_retaining_requests(graph_case, inference):
|
||||
manager, device, _ = graph_case
|
||||
with torch.inference_mode(inference):
|
||||
cache = make_cache(device, 1.0) + make_cache(device, 2.0)
|
||||
register(manager, cache)
|
||||
entry = next(iter(manager.entries.values()))
|
||||
entry.refresh_prefix(cache)
|
||||
versions = [tensor._version for tensor in entry.k + entry.v]
|
||||
entry.refresh_prefix(cache)
|
||||
assert versions == [tensor._version for tensor in entry.k + entry.v]
|
||||
cache[1]["cond"]["value"] = torch.full_like(cache[1]["cond"]["value"], 7.0)
|
||||
entry.refresh_prefix(cache)
|
||||
assert bool((entry.v[1] == 7).all())
|
||||
source = weakref.ref(cache[0]["cond"]["key"])
|
||||
del cache
|
||||
assert source() is None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_prefix_reuse_detects_inplace_changes_and_prefill_registration(graph_case):
|
||||
manager, device, _ = graph_case
|
||||
cache = make_cache(device, 1.0)
|
||||
register(manager, cache)
|
||||
entry = next(iter(manager.entries.values()))
|
||||
entry.refresh_prefix(cache)
|
||||
cache[0]["cond"]["value"].add_(3)
|
||||
entry.refresh_prefix(cache)
|
||||
assert bool((entry.v[0] == 5).all())
|
||||
register(manager, cache)
|
||||
assert not entry.prefix_sources
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_all_valid_mask_shares_graph_entry_and_padding_falls_back(graph_case):
|
||||
manager, device, _ = graph_case
|
||||
cache = make_cache(device, 1.0)
|
||||
register(manager, cache)
|
||||
entry = next(iter(manager.entries.values()))
|
||||
for valid in (True, False):
|
||||
manager.register_prefill(
|
||||
kv_cache=cache,
|
||||
cache_branch="cond",
|
||||
prefix_len=2,
|
||||
img_shapes=[(1, 2, 2)],
|
||||
target_freqs=torch.ones(4, 4),
|
||||
joint_key_valid=torch.full((1, 6), valid),
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert list(manager.entries.values()) == [entry]
|
||||
assert entry.attn_metadata is None
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
def test_batch_changes_and_padding_do_not_reuse_stale_prefix(graph_case):
|
||||
manager, device, _ = graph_case
|
||||
first = make_cache(device, 1.0)
|
||||
second = make_cache(device, 4.0)
|
||||
merged = [
|
||||
{"cond": {part: torch.cat([first[0]["cond"][part], second[0]["cond"][part]]) for part in ("key", "value")}}
|
||||
]
|
||||
with torch.inference_mode():
|
||||
register(manager, first)
|
||||
single = decode(manager, first).clone()
|
||||
register(manager, merged)
|
||||
kwargs = dict(
|
||||
hidden_states=torch.ones(2, 4, 4, device=device),
|
||||
timestep=torch.ones(2, device=device),
|
||||
kv_cache=merged,
|
||||
cache_branch="cond",
|
||||
img_shapes=[(1, 2, 2)],
|
||||
img_mask=torch.tensor([[False, False, True]] * 2, device=device),
|
||||
encoder_hidden_states_mask=torch.ones(2, 3, dtype=torch.bool, device=device),
|
||||
)
|
||||
result = manager.try_decode(**kwargs)
|
||||
assert result is not None and result.shape == (2, 4, 4)
|
||||
expected = torch.ones_like(result).sin() + torch.tensor([5.0, 11.0], device=device)[:, None, None]
|
||||
torch.testing.assert_close(result, expected)
|
||||
kwargs["kv_cache"] = [{"cond": {part: tensor.flip(0) for part, tensor in merged[0]["cond"].items()}}]
|
||||
torch.testing.assert_close(manager.try_decode(**kwargs), expected.flip(0))
|
||||
kwargs["encoder_hidden_states_mask"][1, 0] = False
|
||||
assert manager.try_decode(**kwargs) is None
|
||||
torch.testing.assert_close(decode(manager, first), single, rtol=0, atol=0)
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image_21 import qwen_image_21_transformer as transformer
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device",
|
||||
[pytest.param("cpu", marks=pytest.mark.cpu), pytest.param("cuda", marks=[pytest.mark.cuda, pytest.mark.gpu])],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_qk_norm_matches_diffusers_rounding(monkeypatch, device, dtype):
|
||||
monkeypatch.setattr(
|
||||
"vllm.distributed.parallel_state.get_tp_group", lambda: SimpleNamespace(rank_in_group=0, world_size=1)
|
||||
)
|
||||
monkeypatch.setattr(transformer, "Attention", lambda **kwargs: torch.nn.Identity())
|
||||
attention = transformer.QwenImage21Attention(dim=256, heads=2, dim_head=128).to(device=device, dtype=dtype)
|
||||
values = torch.randn(2, 17, 2, 128, generator=torch.Generator().manual_seed(42)).to(device=device, dtype=dtype)
|
||||
for norm in (attention.norm_q, attention.norm_k):
|
||||
with torch.no_grad():
|
||||
norm.weight.copy_(torch.linspace(0.5, 1.5, 128, device=device, dtype=dtype))
|
||||
variance = values.float().square().mean(-1, keepdim=True)
|
||||
expected = (values * torch.rsqrt(variance + norm.eps)).to(dtype) * norm.weight
|
||||
torch.testing.assert_close(norm(values), expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device",
|
||||
[pytest.param("meta", marks=pytest.mark.cpu), pytest.param("cuda", marks=[pytest.mark.cuda, pytest.mark.gpu])],
|
||||
)
|
||||
def test_rope_frequencies_do_not_depend_on_loader_device(device):
|
||||
reference = transformer.QwenImage21Rope(theta=10000, axes_dim=[16, 56, 56])
|
||||
with torch.device(device):
|
||||
loaded = transformer.QwenImage21Rope(theta=10000, axes_dim=[16, 56, 56])
|
||||
for expected, actual in zip(reference.freqs, loaded.freqs):
|
||||
assert actual.device.type == "cpu"
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
def test_scheduler_matches_diffusers_on_latent_device():
|
||||
import numpy as np
|
||||
from diffusers import FlowMatchEulerDiscreteScheduler
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image_21.pipeline_qwen_image_21 import QwenImage21Pipeline
|
||||
|
||||
pipeline = object.__new__(QwenImage21Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cuda", 0)
|
||||
pipeline.scheduler = FlowMatchEulerDiscreteScheduler(use_dynamic_shifting=True)
|
||||
timesteps, _ = pipeline.prepare_timesteps(50, None, 4096)
|
||||
reference = FlowMatchEulerDiscreteScheduler.from_config(pipeline.scheduler.config)
|
||||
reference.set_timesteps(sigmas=np.linspace(1.0, 1 / 50, 50), device=pipeline.device, mu=1.15)
|
||||
sample = torch.randn(1, 16, 64, generator=torch.Generator().manual_seed(42)).to(
|
||||
device=pipeline.device, dtype=torch.bfloat16
|
||||
)
|
||||
noise = sample.cos()
|
||||
actual = pipeline.scheduler.step(noise, timesteps[0], sample, return_dict=False)[0]
|
||||
expected = reference.step(noise, reference.timesteps[0], sample, return_dict=False)[0]
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
assert pipeline.scheduler.sigmas.device == sample.device
|
||||
assert timesteps.device == sample.device
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.gpu
|
||||
def test_timestep_frequencies_match_cpu_initialization():
|
||||
reference = transformer.QwenImage21TemporalTimesteps(timestep_dim=256)
|
||||
with torch.device("cuda"):
|
||||
loaded = transformer.QwenImage21TemporalTimesteps(timestep_dim=256)
|
||||
assert loaded.freqs.device.type == "cuda"
|
||||
torch.testing.assert_close(loaded.freqs.cpu(), reference.freqs, rtol=0, atol=0)
|
||||
timesteps = torch.tensor([0.0, 0.984375, 1.0], device="cuda")
|
||||
torch.testing.assert_close(loaded(timesteps), reference.to("cuda")(timesteps), rtol=0, atol=0)
|
||||
@@ -0,0 +1,206 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_qwenimage21 import (
|
||||
DistributedAutoencoderKLQwenImage21,
|
||||
)
|
||||
from vllm_omni.diffusion.models.qwen_image_21.autoencoder_kl_qwenimage21 import (
|
||||
AutoencoderKLQwenImage21,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def single_thread():
|
||||
previous = torch.get_num_threads()
|
||||
torch.set_num_threads(1)
|
||||
yield
|
||||
torch.set_num_threads(previous)
|
||||
|
||||
|
||||
def _make_vae(cls=AutoencoderKLQwenImage21, **overrides):
|
||||
"""Toy VAE with the 2.1 residual architecture; 4x spatial compression."""
|
||||
kwargs = dict(
|
||||
base_dim=16,
|
||||
decoder_base_dim=16,
|
||||
z_dim=4,
|
||||
dim_mult=[1, 2, 2],
|
||||
num_res_blocks=1,
|
||||
temperal_downsample=[False, False],
|
||||
is_residual=True,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
latents_mean=[0.0] * 4,
|
||||
latents_std=[1.0] * 4,
|
||||
scale_factor_spatial=None,
|
||||
scale_factor_temporal=None,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
def test_spatial_compression_ratio_derived_from_architecture():
|
||||
# One 2x downsampling stage per `temperal_downsample` entry.
|
||||
vae = _make_vae()
|
||||
assert vae.spatial_compression_ratio == 4
|
||||
|
||||
# The real Qwen-Image-2.1 checkpoint ships scale_factor_spatial=8, but its
|
||||
# four downsampling stages compress 16x; the architecture wins.
|
||||
with torch.device("meta"):
|
||||
vae = AutoencoderKLQwenImage21(
|
||||
base_dim=96,
|
||||
decoder_base_dim=144,
|
||||
z_dim=64,
|
||||
dim_mult=[1, 2, 4, 8, 8],
|
||||
num_res_blocks=2,
|
||||
temperal_downsample=[False, True, True, True],
|
||||
is_residual=True,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
scale_factor_spatial=8,
|
||||
)
|
||||
assert vae.spatial_compression_ratio == 16
|
||||
|
||||
|
||||
def test_tiled_decode_output_shape_and_closeness():
|
||||
torch.manual_seed(0)
|
||||
vae = _make_vae()
|
||||
z = torch.randn(1, 4, 1, 16, 16)
|
||||
ratio = vae.spatial_compression_ratio
|
||||
|
||||
with torch.no_grad():
|
||||
ref = vae._decode(z, return_dict=False)[0]
|
||||
vae.enable_tiling(
|
||||
tile_sample_min_height=32,
|
||||
tile_sample_min_width=32,
|
||||
tile_sample_stride_height=24,
|
||||
tile_sample_stride_width=24,
|
||||
)
|
||||
tiled = vae.decode(z, return_dict=False)[0]
|
||||
|
||||
# Regression: with the wrong (config-derived) ratio the tiled output was
|
||||
# cropped to a fraction of the sample size.
|
||||
assert tiled.shape == (1, 4, 1, 16 * ratio, 16 * ratio)
|
||||
assert tiled.shape == ref.shape
|
||||
|
||||
# Tiled decode blends overlapping tiles, so it is not bitwise-identical to
|
||||
# untiled decode; on this toy model the seam error stays small.
|
||||
mse = ((ref - tiled) ** 2).mean().item()
|
||||
psnr = 10 * math.log10(4.0 / mse)
|
||||
assert psnr > 20.0
|
||||
|
||||
# Tiled output is clamped to [-1, 1] like the untiled path.
|
||||
assert tiled.min() >= -1.0 and tiled.max() <= 1.0
|
||||
|
||||
|
||||
def test_distributed_tile_hooks_match_tiled_decode():
|
||||
"""tile_split -> tile_exec -> tile_merge must reproduce tiled_decode exactly."""
|
||||
torch.manual_seed(0)
|
||||
vae = _make_vae()
|
||||
vae.enable_tiling(
|
||||
tile_sample_min_height=32,
|
||||
tile_sample_min_width=32,
|
||||
tile_sample_stride_height=24,
|
||||
tile_sample_stride_width=24,
|
||||
)
|
||||
distributed = _make_vae(DistributedAutoencoderKLQwenImage21)
|
||||
distributed.enable_tiling(
|
||||
tile_sample_min_height=32,
|
||||
tile_sample_min_width=32,
|
||||
tile_sample_stride_height=24,
|
||||
tile_sample_stride_width=24,
|
||||
)
|
||||
distributed.load_state_dict(vae.state_dict())
|
||||
|
||||
z = torch.randn(1, 4, 1, 16, 16)
|
||||
with torch.no_grad():
|
||||
tiled = vae.tiled_decode(z, return_dict=False)[0]
|
||||
|
||||
tasks, grid_spec = DistributedAutoencoderKLQwenImage21.tile_split(distributed, z)
|
||||
# 16x16 latent, 8x8 latent tiles, stride 6 -> range(0, 16, 6) = 3 stops/axis.
|
||||
assert len(tasks) == 9
|
||||
assert grid_spec.grid_shape == (3, 3)
|
||||
assert grid_spec.split_dims == (3, 4)
|
||||
# Every tile carries all frames (T=1 for images).
|
||||
assert all(len(task.tensor) == z.shape[2] for task in tasks)
|
||||
|
||||
coord_tensor_map = {task.grid_coord: distributed.tile_exec(task) for task in tasks}
|
||||
merged = distributed.tile_merge(coord_tensor_map, grid_spec)
|
||||
|
||||
assert merged.shape == tiled.shape
|
||||
torch.testing.assert_close(merged, tiled)
|
||||
|
||||
|
||||
def test_tiled_encode_output_shape_and_closeness():
|
||||
"""tiled_encode is engaged for image-conditioned generation when tiling is on."""
|
||||
torch.manual_seed(0)
|
||||
vae = _make_vae()
|
||||
x = torch.randn(1, 4, 1, 64, 64)
|
||||
|
||||
with torch.no_grad():
|
||||
ref = vae._encode(x)
|
||||
vae.enable_tiling(
|
||||
tile_sample_min_height=32,
|
||||
tile_sample_min_width=32,
|
||||
tile_sample_stride_height=24,
|
||||
tile_sample_stride_width=24,
|
||||
)
|
||||
tiled = vae._encode(x)
|
||||
|
||||
ratio = vae.spatial_compression_ratio
|
||||
assert tiled.shape == (1, 8, 1, 64 // ratio, 64 // ratio)
|
||||
assert tiled.shape == ref.shape
|
||||
# Blended tile borders deviate from untiled encode but stay small.
|
||||
assert (ref - tiled).abs().max().item() < 1.0
|
||||
|
||||
|
||||
def test_encode_decode_round_trip_shape():
|
||||
torch.manual_seed(0)
|
||||
vae = _make_vae()
|
||||
x = torch.randn(1, 4, 1, 32, 32)
|
||||
with torch.no_grad():
|
||||
latents = vae.encode(x, return_dict=False)[0].mode()
|
||||
decoded = vae.decode(latents, return_dict=False)[0]
|
||||
ratio = vae.spatial_compression_ratio
|
||||
assert latents.shape == (1, 4, 1, 32 // ratio, 32 // ratio)
|
||||
assert decoded.shape == x.shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize("persistent", [False, True])
|
||||
def test_adaptive_oom_restores_tile_configuration(monkeypatch, persistent):
|
||||
vae = _make_vae()
|
||||
vae.enable_tiling(
|
||||
tile_sample_min_height=512,
|
||||
tile_sample_min_width=512,
|
||||
tile_sample_stride_height=384,
|
||||
tile_sample_stride_width=384,
|
||||
)
|
||||
attempts = []
|
||||
z = torch.zeros(1, 4, 1, 4, 4)
|
||||
|
||||
def decode_tile(z, return_dict):
|
||||
attempts.append(vae.tile_sample_min_height)
|
||||
if persistent or vae.tile_sample_min_height > 128:
|
||||
raise torch.OutOfMemoryError("injected tile OOM")
|
||||
return (z,)
|
||||
|
||||
monkeypatch.setattr(vae, "tiled_decode", decode_tile)
|
||||
monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
|
||||
if persistent:
|
||||
with pytest.raises(torch.OutOfMemoryError):
|
||||
vae._tiled_decode_adaptive(z, return_dict=False)
|
||||
else:
|
||||
assert vae._tiled_decode_adaptive(z, return_dict=False)[0] is z
|
||||
assert attempts == [512, 256, 128]
|
||||
assert (
|
||||
vae.tile_sample_min_height,
|
||||
vae.tile_sample_min_width,
|
||||
vae.tile_sample_stride_height,
|
||||
vae.tile_sample_stride_width,
|
||||
) == (512, 512, 384, 384)
|
||||
@@ -0,0 +1,66 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from tests.helpers.media import generate_synthetic_image
|
||||
from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient, dummy_messages_from_mix_data
|
||||
|
||||
MODEL = os.environ.get("QWEN_IMAGE_21_TEST_MODEL", "Qwen/Qwen-Image-2.1")
|
||||
SERVER_CASES = [
|
||||
pytest.param(
|
||||
OmniServerParams(model=MODEL, server_args=["--vae-use-tiling", "--max-num-seqs", "2"] + extra),
|
||||
id=name,
|
||||
marks=hardware_marks(res={"cuda": "H100"}),
|
||||
)
|
||||
for name, extra in [("request", []), ("step", ["--step-execution"])]
|
||||
]
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.advanced_model,
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.skipif(
|
||||
not os.environ.get("QWEN_IMAGE_21_TEST_MODEL"),
|
||||
reason="Set QWEN_IMAGE_21_TEST_MODEL to a provisioned checkpoint until public weights are available.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
|
||||
def test_text_to_image(omni_server: OmniServer, online_client: OnlineOmniClient):
|
||||
online_client.send_diffusion_request(
|
||||
{
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(content_text="A red ceramic teapot on a wooden table."),
|
||||
"extra_body": {
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 4,
|
||||
"seed": 42,
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"true_cfg_scale": 4.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omni_server", SERVER_CASES, indirect=True)
|
||||
def test_image_edit(omni_server: OmniServer, online_client: OnlineOmniClient):
|
||||
image = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
|
||||
online_client.send_diffusion_request(
|
||||
{
|
||||
"model": omni_server.model,
|
||||
"messages": dummy_messages_from_mix_data(content_text="Make the image blue.", image_data_url=image),
|
||||
"extra_body": {
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_inference_steps": 4,
|
||||
"seed": 42,
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"true_cfg_scale": 4.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -849,7 +849,7 @@ def test_image_edits_streaming_returns_ar_delta_then_image(streaming_image_edit_
|
||||
assert payloads[0]["index"] == 0
|
||||
assert payloads[1]["delta"] == " done"
|
||||
assert payloads[2]["output_format"] == "png"
|
||||
assert payloads[2]["size"] == "16x16"
|
||||
assert payloads[2]["size"] == "32x24"
|
||||
|
||||
image_payload = payloads[2]["data"][0]
|
||||
img = Image.open(io.BytesIO(base64.b64decode(image_payload["b64_json"])))
|
||||
@@ -1719,7 +1719,7 @@ def test_image_edit_parameter_pass(async_omni_test_client):
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
assert img.format.lower() == "jpeg"
|
||||
assert data["output_format"] == "jpeg"
|
||||
assert data["size"] == "16x24"
|
||||
assert data["size"] == f"{img.width}x{img.height}"
|
||||
|
||||
|
||||
def test_image_edit_layers_and_resolution(async_omni_test_client):
|
||||
|
||||
@@ -10,11 +10,13 @@ from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import _maybe_reshape_attn_mask
|
||||
from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import piecewise_attn
|
||||
|
||||
|
||||
class CuDNNAttentionBackend(AttentionBackend):
|
||||
accept_output_buffer: bool = True
|
||||
supports_prefix_kv_slicing: bool = True
|
||||
supports_piecewise_spans: bool = True
|
||||
|
||||
# cuDNN 9.5+ FMHA on Blackwell: head_dim divisible by 8 and at most 256
|
||||
# for BF16/FP16. Used by automatic platform selection; explicit CUDNN_ATTN
|
||||
@@ -74,6 +76,23 @@ class CuDNNAttentionImpl(AttentionImpl):
|
||||
value: torch.Tensor,
|
||||
attn_metadata: AttentionMetadata | None = None,
|
||||
) -> torch.Tensor:
|
||||
if (
|
||||
attn_metadata is not None
|
||||
and attn_metadata.full_attn_spans is not None
|
||||
and (attn_metadata.attn_mask is None or attn_metadata.attn_mask.ndim != 4)
|
||||
):
|
||||
if attn_metadata.attn_mask is not None or "valid_kv_length" in attn_metadata.extra:
|
||||
raise ValueError("Piecewise cuDNN attention cannot combine spans with a padding mask.")
|
||||
return piecewise_attn(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_metadata.full_attn_spans,
|
||||
self.softmax_scale,
|
||||
self._piecewise_attention,
|
||||
query_ranges=attn_metadata.query_ranges,
|
||||
)
|
||||
|
||||
attention_mask = None
|
||||
if attn_metadata:
|
||||
valid_kv_length = attn_metadata.extra.get("valid_kv_length")
|
||||
@@ -97,6 +116,19 @@ class CuDNNAttentionImpl(AttentionImpl):
|
||||
mask_mode="broadcast_k",
|
||||
)
|
||||
|
||||
return self._attention(query, key, value, attention_mask, self.causal, self.softmax_scale)
|
||||
|
||||
def _piecewise_attention(self, query, key, value, *, causal, softmax_scale):
|
||||
mask = None
|
||||
if causal:
|
||||
# SDPA's causal flag aligns at the top left. Text segments need
|
||||
# bottom-right alignment so every query can also see its prefix.
|
||||
q_len, kv_len = query.shape[1], key.shape[1]
|
||||
mask = torch.ones(q_len, kv_len, dtype=torch.bool, device=query.device).tril(kv_len - q_len)
|
||||
mask = mask[None, None]
|
||||
return self._attention(query, key, value, mask, False, softmax_scale)
|
||||
|
||||
def _attention(self, query, key, value, attention_mask, causal, softmax_scale):
|
||||
enable_gqa = query.shape[2] != key.shape[2]
|
||||
kv_seq_len = key.shape[1]
|
||||
query, key, value = (x.permute(0, 2, 1, 3) for x in (query, key, value))
|
||||
@@ -130,8 +162,8 @@ class CuDNNAttentionImpl(AttentionImpl):
|
||||
value,
|
||||
attn_mask=attention_mask,
|
||||
dropout_p=0.0,
|
||||
is_causal=self.causal,
|
||||
scale=self.softmax_scale,
|
||||
is_causal=causal,
|
||||
scale=softmax_scale,
|
||||
enable_gqa=enable_gqa,
|
||||
)
|
||||
return output.permute(0, 2, 1, 3)
|
||||
@@ -902,11 +902,6 @@ class OmniDiffusionConfig:
|
||||
|
||||
# Compilation
|
||||
enforce_eager: bool = False
|
||||
# Opt-in: capture fixed-shape KV-cache decode (denoising) steps into CUDA
|
||||
# graphs. Currently implemented by Qwen-Image-2.1's transformer; unsupported
|
||||
# configurations (SP/TP, ring, HSDP, dynamic LoRA, padded masks) log and
|
||||
# fall back to eager decode. Other models ignore this flag.
|
||||
enable_cuda_graph_decode: bool = False
|
||||
# Controls the generic compilation path used when a pipeline does not
|
||||
# provide its own setup_compile() implementation.
|
||||
diffusion_compile_granularity: str = "regional"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
@@ -46,7 +47,11 @@ class DistributedVaeExecutor:
|
||||
|
||||
def __init__(self):
|
||||
# Use a dedicated process group spanning the complete worker WORLD.
|
||||
self.group = get_world_group().device_group
|
||||
world_group = get_world_group()
|
||||
self.group = world_group.device_group
|
||||
self._error_group = world_group.cpu_group
|
||||
# Reporting an OOM must not require another device allocation.
|
||||
self._error_status = torch.zeros((), dtype=torch.int32, device="cpu")
|
||||
self.world_size = dist.get_world_size(self.group)
|
||||
self.rank = dist.get_rank(self.group)
|
||||
self.parallel_size = 1
|
||||
@@ -56,8 +61,34 @@ class DistributedVaeExecutor:
|
||||
self.parallel_size = parallel_size
|
||||
self.parallel_mode = mode
|
||||
|
||||
@contextmanager
|
||||
def _sync_errors(self, stage: str):
|
||||
"""Agree on local-stage failures before entering the next collective."""
|
||||
status = 0
|
||||
message = ""
|
||||
try:
|
||||
yield
|
||||
except torch.OutOfMemoryError as error:
|
||||
status = 1
|
||||
message = str(error)
|
||||
except Exception as error:
|
||||
# A non-retryable error takes precedence over OOM on another rank.
|
||||
status = 2
|
||||
message = f"{type(error).__name__}: {error}"
|
||||
|
||||
self._error_status.fill_(status)
|
||||
dist.all_reduce(self._error_status, op=dist.ReduceOp.MAX, group=self._error_group)
|
||||
global_status = self._error_status.item()
|
||||
if global_status == 2:
|
||||
detail = message if status == 2 else "another rank failed"
|
||||
raise RuntimeError(f"Distributed VAE {stage} failed: {detail}")
|
||||
if global_status == 1:
|
||||
detail = message if status == 1 else "another rank ran out of memory"
|
||||
raise torch.OutOfMemoryError(f"Distributed VAE {stage} failed: {detail}")
|
||||
|
||||
def gather_tensors(self, tensor: torch.Tensor):
|
||||
gather_list = [torch.empty_like(tensor) for _ in range(self.world_size)]
|
||||
with self._sync_errors("gather allocation"):
|
||||
gather_list = [torch.empty_like(tensor) for _ in range(self.world_size)]
|
||||
dist.all_gather(gather_list, tensor, group=self.group)
|
||||
return gather_list if self.rank == 0 else None
|
||||
|
||||
@@ -66,11 +97,12 @@ class DistributedVaeExecutor:
|
||||
return tensor
|
||||
|
||||
def _compute_global_padding_shape(self, local_results, output_ndim: int, device):
|
||||
local_tile_max_dims = [0] * output_ndim
|
||||
for _, tile_tensor in local_results:
|
||||
for dim_idx, dim_size in enumerate(tile_tensor.shape):
|
||||
local_tile_max_dims[dim_idx] = max(local_tile_max_dims[dim_idx], dim_size)
|
||||
local_shape_stat = torch.tensor([len(local_results), *local_tile_max_dims], device=device)
|
||||
with self._sync_errors("shape preparation"):
|
||||
local_tile_max_dims = [0] * output_ndim
|
||||
for _, tile_tensor in local_results:
|
||||
for dim_idx, dim_size in enumerate(tile_tensor.shape):
|
||||
local_tile_max_dims[dim_idx] = max(local_tile_max_dims[dim_idx], dim_size)
|
||||
local_shape_stat = torch.tensor([len(local_results), *local_tile_max_dims], device=device)
|
||||
dist.all_reduce(
|
||||
local_shape_stat,
|
||||
op=dist.ReduceOp.MAX,
|
||||
@@ -122,49 +154,51 @@ class DistributedVaeExecutor:
|
||||
def execute(self, z: torch.Tensor, operator: DistributedOperator, broadcast_result: bool = True):
|
||||
pp_size = min(self.parallel_size, self.world_size)
|
||||
|
||||
# 1. Split into tiles
|
||||
tiletask_list, grid_spec = operator.split(z)
|
||||
tid_coord_map = {task.tile_id: task.grid_coord for task in tiletask_list}
|
||||
|
||||
# 2. local decode
|
||||
assigned = self._balance_tasks(tiletask_list, pp_size)
|
||||
local_tasks = assigned[self.rank] if self.rank < pp_size else []
|
||||
local_results = [(t.tile_id, operator.exec(t)) for t in local_tasks]
|
||||
with self._sync_errors("tile decode"):
|
||||
tiletask_list, grid_spec = operator.split(z)
|
||||
tid_coord_map = {task.tile_id: task.grid_coord for task in tiletask_list}
|
||||
assigned = self._balance_tasks(tiletask_list, pp_size)
|
||||
local_tasks = assigned[self.rank] if self.rank < pp_size else []
|
||||
local_results = [(t.tile_id, operator.exec(t)) for t in local_tasks]
|
||||
|
||||
# 3. compute shape per rank
|
||||
global_padding_shape = self._compute_global_padding_shape(local_results, z.ndim, z.device)
|
||||
|
||||
# 5. prepare tile tensors
|
||||
output_dtype = grid_spec.output_dtype if grid_spec.output_dtype is not None else z.dtype
|
||||
local_tile_tensor, local_meta_tensor = self._pack_local_tiles(
|
||||
local_results, global_padding_shape, grid_spec, z.device, output_dtype
|
||||
)
|
||||
with self._sync_errors("tile packing"):
|
||||
local_tile_tensor, local_meta_tensor = self._pack_local_tiles(
|
||||
local_results, global_padding_shape, grid_spec, z.device, output_dtype
|
||||
)
|
||||
|
||||
# 6. gather tiles & meta
|
||||
meta_gather = self.gather_tensors(local_meta_tensor)
|
||||
tile_gather = self.gather_tensors(local_tile_tensor)
|
||||
|
||||
if self.rank != 0:
|
||||
result = torch.empty(0, device=z.device) # Dummy return for non-zero ranks
|
||||
else:
|
||||
# 7. reconstruct full tensor (rank 0)
|
||||
coord_tensor_map = self._unpack_tiles(meta_gather, tile_gather, grid_spec, tid_coord_map)
|
||||
result = operator.merge(coord_tensor_map, grid_spec)
|
||||
with self._sync_errors("tile merge"):
|
||||
if self.rank != 0:
|
||||
result = torch.empty(0, device=z.device) # Dummy return for non-zero ranks
|
||||
else:
|
||||
# 7. reconstruct full tensor (rank 0)
|
||||
coord_tensor_map = self._unpack_tiles(meta_gather, tile_gather, grid_spec, tid_coord_map)
|
||||
result = operator.merge(coord_tensor_map, grid_spec)
|
||||
|
||||
if broadcast_result:
|
||||
result = self._sync_final_result(result, z.ndim, z.device, output_dtype)
|
||||
return result
|
||||
|
||||
def _sync_final_result(self, rank0_result, output_ndim, output_device, output_dtype):
|
||||
shape_tensor = torch.empty((output_ndim,), device=output_device, dtype=torch.int64)
|
||||
if self.rank == 0:
|
||||
shape_tensor.copy_(torch.tensor(tuple(rank0_result.shape), device=output_device, dtype=torch.int64))
|
||||
with self._sync_errors("output shape preparation"):
|
||||
shape_tensor = torch.empty((output_ndim,), device=output_device, dtype=torch.int64)
|
||||
if self.rank == 0:
|
||||
shape_tensor.copy_(torch.tensor(tuple(rank0_result.shape), device=output_device, dtype=torch.int64))
|
||||
dist.broadcast(shape_tensor, src=0, group=self.group)
|
||||
|
||||
if self.rank != 0:
|
||||
sync_result = torch.empty(tuple(shape_tensor.tolist()), device=output_device, dtype=output_dtype)
|
||||
else:
|
||||
sync_result = rank0_result
|
||||
with self._sync_errors("output allocation"):
|
||||
if self.rank != 0:
|
||||
sync_result = torch.empty(tuple(shape_tensor.tolist()), device=output_device, dtype=output_dtype)
|
||||
else:
|
||||
sync_result = rank0_result
|
||||
dist.broadcast(sync_result, src=0, group=self.group)
|
||||
return sync_result
|
||||
|
||||
|
||||
@@ -9,19 +9,14 @@ default cache stores prefix K/V in freshly allocated tensors, so a captured
|
||||
graph would bind addresses that the next request (or a step-mode cache merge)
|
||||
no longer owns.
|
||||
|
||||
This module keeps the prefix K/V in persistent, fixed-address buffers. Prefill
|
||||
copies its K/V into them and re-points the caller's cache dict at the buffers;
|
||||
decode then replays a captured graph that reads those buffers plus static input
|
||||
buffers (target latents, timestep, RoPE frequencies). One graph is captured per
|
||||
``(branch, batch, prefix_len, target_tokens, dtype, device, backend)`` key and
|
||||
reused across requests: a new prefill with the same key rewrites the buffers in
|
||||
place, which a captured graph reads correctly because it binds addresses, not
|
||||
contents.
|
||||
Requests retain ownership of their prefix K/V. Decode refreshes separate
|
||||
fixed-address graph buffers when the active prefix tensors change. Graphs
|
||||
are keyed by branch, batch, prefix length, complete image layout,
|
||||
dtype, device and backend, so equal token counts with different RoPE layouts stay separate.
|
||||
|
||||
Memory note: only the *prefix* K/V are persistent. The decode attention still
|
||||
concatenates prefix K/V with the current step's target K/V inside the captured
|
||||
region, and that transient tensor is served by the graph's memory pool, so the
|
||||
persistent footprint is the same as the existing dict cache.
|
||||
Memory note: graph entries own an additional copy of the prefix K/V. Decode
|
||||
attention concatenates that prefix with target K/V inside the captured region;
|
||||
transient tensors use the graph memory pool.
|
||||
|
||||
Fallbacks (all logged, all eager): CUDA unavailable, sequence/ring/tensor
|
||||
parallelism, HSDP/offload hooks, torch.compile'd blocks, KV-cache quantization,
|
||||
@@ -32,6 +27,7 @@ capture failure.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -39,6 +35,8 @@ import torch
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.diffusion.models.qwen_image_21.qwen_image_21_transformer import (
|
||||
QwenImage21Transformer2DModel,
|
||||
@@ -67,8 +65,8 @@ class QwenImage21DecodeGraphEntry:
|
||||
|
||||
Buffers are created outside inference mode so replay-side ``copy_`` is
|
||||
legal regardless of the caller's grad context. ``graph`` is None until the
|
||||
first decode step captures it; ``failed`` disables capture permanently for
|
||||
this key after an exception.
|
||||
first decode step captures it. Failed entries are replaced by a ``None``
|
||||
marker in the manager so their buffers can be released.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -99,15 +97,30 @@ class QwenImage21DecodeGraphEntry:
|
||||
# inference_mode, and the captured body takes views of this tensor
|
||||
# outside it, which is forbidden for inference tensors.
|
||||
self.freqs = freqs.clone()
|
||||
# Decode needs no mask once padding is excluded (see the manager's
|
||||
# prefill gate), so the captured body attends without metadata.
|
||||
self.attn_metadata = None
|
||||
self.attn_metadata = None
|
||||
# Per-block cache dicts with the same shape as the legacy protocol.
|
||||
self.block_caches = [{branch: {"key": self.k[i], "value": self.v[i]}} for i in range(len(kv_shapes))]
|
||||
self.graph: torch.cuda.CUDAGraph | None = None
|
||||
self.output: torch.Tensor | None = None
|
||||
self.failed = False
|
||||
self.captures = 0
|
||||
self.prefix_sources: list[tuple[weakref.ReferenceType[torch.Tensor], int | None]] = []
|
||||
|
||||
def refresh_prefix(self, kv_cache: list[dict[str, dict[str, torch.Tensor]]]) -> None:
|
||||
sources = [block[self.branch][name] for block in kv_cache for name in ("key", "value")]
|
||||
versions = [None if tensor.is_inference() else tensor._version for tensor in sources]
|
||||
if len(sources) == len(self.prefix_sources) and all(
|
||||
previous() is tensor and version == current_version
|
||||
for (previous, version), tensor, current_version in zip(self.prefix_sources, sources, versions)
|
||||
):
|
||||
return
|
||||
# Prefill replaces inference-mode tensors; decode treats them as immutable.
|
||||
# Weak references avoid retaining completed requests or mistaking a reused
|
||||
# allocation for the same prefix. Normal tensors also track in-place writes.
|
||||
self.prefix_sources = []
|
||||
for i, block in enumerate(kv_cache):
|
||||
self.k[i].copy_(block[self.branch]["key"])
|
||||
self.v[i].copy_(block[self.branch]["value"])
|
||||
self.prefix_sources = [(weakref.ref(tensor), version) for tensor, version in zip(sources, versions)]
|
||||
|
||||
def capture(self, body) -> None:
|
||||
"""Warm up on a side stream, then capture one decode step.
|
||||
@@ -119,10 +132,12 @@ class QwenImage21DecodeGraphEntry:
|
||||
"""
|
||||
side = torch.cuda.Stream()
|
||||
side.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(side), torch.inference_mode(False), torch.no_grad():
|
||||
for _ in range(2):
|
||||
body()
|
||||
torch.cuda.current_stream().wait_stream(side)
|
||||
try:
|
||||
with torch.cuda.stream(side), torch.inference_mode(False), torch.no_grad():
|
||||
for _ in range(2):
|
||||
body()
|
||||
finally:
|
||||
torch.cuda.current_stream().wait_stream(side)
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
# The platform-wide pool is shared with every other captured path in
|
||||
@@ -142,7 +157,7 @@ class QwenImage21DecodeGraphManager:
|
||||
def __init__(self, model: QwenImage21Transformer2DModel, max_entries: int = 8):
|
||||
self.model = model
|
||||
self.max_entries = max_entries
|
||||
self.entries: OrderedDict[tuple, QwenImage21DecodeGraphEntry] = OrderedDict()
|
||||
self.entries: OrderedDict[tuple, QwenImage21DecodeGraphEntry | None] = OrderedDict()
|
||||
self._static_eligible: bool | None = None
|
||||
|
||||
# ── eligibility ──
|
||||
@@ -202,17 +217,12 @@ class QwenImage21DecodeGraphManager:
|
||||
kv_cache: list[dict[str, dict[str, torch.Tensor]]],
|
||||
cache_branch: str,
|
||||
prefix_len: int,
|
||||
target_tokens: int,
|
||||
img_shapes: list[tuple[int, int, int]],
|
||||
target_freqs: torch.Tensor,
|
||||
joint_key_valid: torch.Tensor | None,
|
||||
dtype: torch.dtype,
|
||||
) -> None:
|
||||
"""Move a freshly prefilled prefix K/V into static buffers.
|
||||
|
||||
The caller's ``kv_cache`` dicts are re-pointed at the static buffers, so
|
||||
the existing decode path reads fixed addresses and a captured graph can
|
||||
be replayed for later steps. Prefill itself always runs eagerly.
|
||||
"""
|
||||
"""Prepare graph buffers without changing the request's prefix K/V."""
|
||||
if not self.eligible() or torch.compiler.is_compiling():
|
||||
return
|
||||
if _dynamic_lora_wrappers_present(self.model):
|
||||
@@ -239,44 +249,55 @@ class QwenImage21DecodeGraphManager:
|
||||
cache_branch,
|
||||
batch_size,
|
||||
prefix_len,
|
||||
target_tokens,
|
||||
tuple(tuple(shape) for shape in img_shapes),
|
||||
dtype,
|
||||
first["key"].device.index,
|
||||
self._backend_name(),
|
||||
)
|
||||
|
||||
entry = self.entries.get(key)
|
||||
if entry is None:
|
||||
if key not in self.entries:
|
||||
while len(self.entries) >= self.max_entries:
|
||||
evicted_key, _ = self.entries.popitem(last=False)
|
||||
evicted_key, evicted_entry = self.entries.popitem(last=False)
|
||||
del evicted_entry
|
||||
logger.debug("Evicting decode graph entry %s (max_entries=%d)", evicted_key, self.max_entries)
|
||||
entry = QwenImage21DecodeGraphEntry(
|
||||
branch=cache_branch,
|
||||
batch_size=batch_size,
|
||||
prefix_len=prefix_len,
|
||||
target_tokens=target_tokens,
|
||||
in_channels=self.model.in_channels,
|
||||
dtype=dtype,
|
||||
device=first["key"].device,
|
||||
kv_shapes=[block_cache[cache_branch]["key"].shape for block_cache in kv_cache],
|
||||
kv_dtype=first["key"].dtype,
|
||||
freqs=target_freqs,
|
||||
)
|
||||
self.entries[key] = entry
|
||||
allocation_error = None
|
||||
try:
|
||||
self.entries[key] = QwenImage21DecodeGraphEntry(
|
||||
branch=cache_branch,
|
||||
batch_size=batch_size,
|
||||
prefix_len=prefix_len,
|
||||
target_tokens=target_freqs.shape[0],
|
||||
in_channels=self.model.in_channels,
|
||||
dtype=dtype,
|
||||
device=first["key"].device,
|
||||
kv_shapes=[block_cache[cache_branch]["key"].shape for block_cache in kv_cache],
|
||||
kv_dtype=first["key"].dtype,
|
||||
freqs=target_freqs,
|
||||
)
|
||||
except torch.OutOfMemoryError as exc:
|
||||
allocation_error = str(exc)
|
||||
if allocation_error is not None:
|
||||
# Keep a bounded failure marker without retaining tensors or the exception traceback.
|
||||
self.entries[key] = None
|
||||
current_omni_platform.empty_cache()
|
||||
logger.warning(
|
||||
"Qwen-Image-2.1 CUDA graph allocation failed for key=%s: %s. Falling back to eager decode.",
|
||||
key,
|
||||
allocation_error,
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.entries.move_to_end(key)
|
||||
|
||||
for i, block_cache in enumerate(kv_cache):
|
||||
parts = block_cache[cache_branch]
|
||||
entry.k[i].copy_(parts["key"])
|
||||
entry.v[i].copy_(parts["value"])
|
||||
parts["key"] = entry.k[i]
|
||||
parts["value"] = entry.v[i]
|
||||
entry = self.entries[key]
|
||||
if entry is not None:
|
||||
entry.prefix_sources = []
|
||||
|
||||
logger.debug(
|
||||
"Registered decode graph entry key=%s (entries=%d, captured=%d)",
|
||||
key,
|
||||
len(self.entries),
|
||||
sum(1 for e in self.entries.values() if e.graph is not None),
|
||||
sum(1 for e in self.entries.values() if e is not None and e.graph is not None),
|
||||
)
|
||||
|
||||
# ── decode replay ──
|
||||
@@ -288,11 +309,20 @@ class QwenImage21DecodeGraphManager:
|
||||
timestep: torch.Tensor,
|
||||
kv_cache: list[dict[str, dict[str, torch.Tensor]]],
|
||||
cache_branch: str,
|
||||
target_tokens: int,
|
||||
img_shapes: list[tuple[int, int, int]],
|
||||
img_mask: torch.Tensor,
|
||||
encoder_hidden_states_mask: torch.Tensor | None,
|
||||
) -> torch.Tensor | None:
|
||||
"""Replay the captured decode step, or return None to fall back to eager."""
|
||||
if not self.eligible() or torch.compiler.is_compiling():
|
||||
return None
|
||||
if _dynamic_lora_wrappers_present(self.model):
|
||||
self.clear()
|
||||
return None
|
||||
if encoder_hidden_states_mask is not None:
|
||||
text_positions = ~img_mask[0, : encoder_hidden_states_mask.shape[1]]
|
||||
if not bool(encoder_hidden_states_mask[:, text_positions].all()):
|
||||
return None
|
||||
first = kv_cache[0][cache_branch]
|
||||
cached_key = first["key"]
|
||||
if cached_key.device.type != "cuda":
|
||||
@@ -304,40 +334,36 @@ class QwenImage21DecodeGraphManager:
|
||||
cache_branch,
|
||||
cached_key.shape[0],
|
||||
cached_key.shape[1],
|
||||
target_tokens,
|
||||
tuple(tuple(shape) for shape in img_shapes),
|
||||
hidden_states.dtype,
|
||||
cached_key.device.index,
|
||||
self._backend_name(),
|
||||
)
|
||||
entry = self.entries.get(key)
|
||||
if entry is None or entry.failed:
|
||||
if entry is None:
|
||||
return None
|
||||
self.entries.move_to_end(key)
|
||||
|
||||
entry.hidden.copy_(hidden_states[:, -target_tokens:])
|
||||
entry.hidden.copy_(hidden_states[:, -entry.target_tokens :])
|
||||
entry.timestep.copy_(timestep)
|
||||
# The caller's cache dicts normally already reference the entry's
|
||||
# buffers (prefill re-pointed them). A step-mode batched decode instead
|
||||
# hands over freshly merged tensors; copy those into the static buffers.
|
||||
for i, block_cache in enumerate(kv_cache):
|
||||
parts = block_cache[cache_branch]
|
||||
if parts["key"] is not entry.k[i]:
|
||||
entry.k[i].copy_(parts["key"])
|
||||
entry.v[i].copy_(parts["value"])
|
||||
parts["key"] = entry.k[i]
|
||||
parts["value"] = entry.v[i]
|
||||
# Graph buffers are scratch space; request caches must never alias them.
|
||||
entry.refresh_prefix(kv_cache)
|
||||
|
||||
if entry.graph is None:
|
||||
capture_error = None
|
||||
try:
|
||||
entry.capture(lambda: self.model._decode_graph_forward(entry))
|
||||
except Exception as exc:
|
||||
entry.failed = True
|
||||
entry.graph = None
|
||||
capture_error = str(exc)
|
||||
if capture_error is not None:
|
||||
self.entries[key] = None
|
||||
del entry
|
||||
current_omni_platform.empty_cache()
|
||||
logger.warning(
|
||||
"Qwen-Image-2.1 CUDA graph capture failed for key=%s: %s. "
|
||||
"Falling back to eager decode for this shape.",
|
||||
key,
|
||||
exc,
|
||||
capture_error,
|
||||
)
|
||||
return None
|
||||
logger.info("Captured Qwen-Image-2.1 decode graph for key=%s", key)
|
||||
|
||||
@@ -144,7 +144,7 @@ def get_qwen_image_21_pre_process_func(
|
||||
if "additional_information" not in prompt:
|
||||
prompt["additional_information"] = {}
|
||||
|
||||
if not raw_image: # None or empty list: pure text-to-image request
|
||||
if raw_image is None or (isinstance(raw_image, list) and len(raw_image) == 0):
|
||||
request.batch_compatibility_key = ("qwen_image_21", ())
|
||||
request.prompt = prompt
|
||||
return request
|
||||
@@ -744,6 +744,7 @@ class QwenImage21Pipeline(
|
||||
timesteps, num_inference_steps = retrieve_timesteps(
|
||||
self.scheduler,
|
||||
num_inference_steps,
|
||||
device=self.device,
|
||||
sigmas=sigmas,
|
||||
mu=mu,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from diffusers.models.modeling_outputs import Transformer2DModelOutput
|
||||
from diffusers.models.normalization import RMSNorm
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
@@ -107,7 +108,10 @@ class QwenImage21TemporalTimesteps(nn.Module):
|
||||
self.time_factor = time_factor
|
||||
|
||||
half = timestep_dim // 2
|
||||
freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half)
|
||||
# Compute the fixed table on CPU like Diffusers, then honor the loader's device.
|
||||
freqs = torch.exp(
|
||||
-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32, device="cpu") / half
|
||||
).to(torch.get_default_device())
|
||||
self.register_buffer("freqs", freqs, persistent=False)
|
||||
|
||||
def forward(self, timestep: torch.Tensor) -> torch.Tensor:
|
||||
@@ -294,12 +298,15 @@ class QwenImage21Rope(nn.Module):
|
||||
self.theta = theta
|
||||
self.axes_dim = axes_dim
|
||||
|
||||
pos_index = torch.arange(8192)
|
||||
neg_index = torch.arange(1024).flip(0) * -1 - 1
|
||||
self.freqs = [
|
||||
torch.cat([self.rope_params(pos_index, dim, theta), self.rope_params(neg_index, dim, theta)], dim=0)
|
||||
for dim in axes_dim
|
||||
]
|
||||
# Match Diffusers' CPU initialization even when the loader sets a CUDA
|
||||
# default device; CPU and CUDA trigonometric kernels round differently.
|
||||
with torch.device("cpu"):
|
||||
pos_index = torch.arange(8192)
|
||||
neg_index = torch.arange(1024).flip(0) * -1 - 1
|
||||
self.freqs = [
|
||||
torch.cat([self.rope_params(pos_index, dim, theta), self.rope_params(neg_index, dim, theta)], dim=0)
|
||||
for dim in axes_dim
|
||||
]
|
||||
|
||||
def rope_params(self, index: torch.Tensor, dim: int, theta: int = 10000) -> torch.Tensor:
|
||||
freqs = torch.outer(index, 1.0 / torch.pow(theta, torch.arange(0, dim, 2).to(torch.float32).div(dim)))
|
||||
@@ -374,9 +381,10 @@ class QwenImage21Attention(nn.Module):
|
||||
self.num_heads = self.to_qkv.num_heads
|
||||
self.num_kv_heads = self.to_qkv.num_kv_heads
|
||||
|
||||
# Per-head RMSNorm over the head dim; TP shards whole heads, so the norm is TP-safe.
|
||||
self.norm_q = nn.RMSNorm(dim_head, eps=eps)
|
||||
self.norm_k = nn.RMSNorm(dim_head, eps=eps)
|
||||
# Diffusers normalizes in FP32, then casts before the learned scale.
|
||||
# Keep that rounding order for BF16 Q/K; TP shards whole heads.
|
||||
self.norm_q = RMSNorm(dim_head, eps=eps)
|
||||
self.norm_k = RMSNorm(dim_head, eps=eps)
|
||||
|
||||
self.to_out = RowParallelLinear(
|
||||
heads * dim_head,
|
||||
@@ -718,7 +726,6 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
causal_block: bool = True,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "transformer",
|
||||
enable_cuda_graph_decode: bool | None = None,
|
||||
cuda_graph_max_decode_graphs: int = 8,
|
||||
):
|
||||
super().__init__()
|
||||
@@ -790,14 +797,11 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
# Module boundary where _sp_plan shards the target tokens + their RoPE freqs.
|
||||
self.sequence_prepare = QwenImage21SequencePrepare(self.img_in, self.pos_embed)
|
||||
|
||||
# Opt-in CUDA Graph capture of the fixed-shape KV-cache decode steps.
|
||||
# Default off: ``None`` defers to ``od_config.enable_cuda_graph_decode``.
|
||||
if enable_cuda_graph_decode is None:
|
||||
enable_cuda_graph_decode = bool(getattr(od_config, "enable_cuda_graph_decode", False))
|
||||
self.enable_cuda_graph_decode = enable_cuda_graph_decode
|
||||
# Use decode graphs unless the caller requests eager execution.
|
||||
self.enable_cuda_graph_decode = not od_config.enforce_eager
|
||||
self._decode_graph_manager = (
|
||||
QwenImage21DecodeGraphManager(self, max_entries=cuda_graph_max_decode_graphs)
|
||||
if enable_cuda_graph_decode
|
||||
if self.enable_cuda_graph_decode
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -972,7 +976,9 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
timestep=timestep,
|
||||
kv_cache=kv_cache,
|
||||
cache_branch=cache_branch,
|
||||
target_tokens=math.prod(layout[-1]),
|
||||
img_shapes=layout,
|
||||
img_mask=img_mask,
|
||||
encoder_hidden_states_mask=encoder_hidden_states_mask,
|
||||
)
|
||||
if graph_output is not None:
|
||||
if not return_dict:
|
||||
@@ -1039,6 +1045,9 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
joint_key_valid = F.pad(joint_key_valid, (0, sp_padding), value=False)
|
||||
|
||||
has_padding = joint_key_valid is not None and not bool(joint_key_valid.all())
|
||||
if not has_padding:
|
||||
# Match Diffusers and use the same mask-free dispatch in eager and graph decode.
|
||||
joint_key_valid = None
|
||||
attn_metadata: AttentionMetadata | None = None
|
||||
cache_write_len: int | None = None
|
||||
if is_decode:
|
||||
@@ -1082,13 +1091,12 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
and kv_cache is not None
|
||||
and self._decode_graph_manager is not None
|
||||
):
|
||||
# Move the freshly written prefix K/V into fixed-address buffers so
|
||||
# decode steps can replay a captured CUDA graph against them.
|
||||
# Allocate graph scratch buffers; the request keeps its own prefix K/V.
|
||||
self._decode_graph_manager.register_prefill(
|
||||
kv_cache=kv_cache,
|
||||
cache_branch=cache_branch,
|
||||
prefix_len=cache_write_len,
|
||||
target_tokens=target_freqs.shape[0],
|
||||
img_shapes=layout,
|
||||
target_freqs=target_freqs,
|
||||
joint_key_valid=joint_key_valid,
|
||||
dtype=hidden_states.dtype,
|
||||
@@ -1113,9 +1121,9 @@ class QwenImage21Transformer2DModel(CachedTransformer):
|
||||
"""The exact decode-step computation, captured into a CUDA graph.
|
||||
|
||||
Mirrors the eager decode path of ``forward``: target tokens only,
|
||||
prefix K/V read from the entry's static buffers, no attention metadata
|
||||
(the registration gate excludes padded masks), and an all-ones
|
||||
modulation mask. All inputs live in the entry's fixed-address buffers.
|
||||
prefix K/V read from the entry's static buffers, the same mask-free
|
||||
attention dispatch as eager decode, and an all-ones modulation mask.
|
||||
All inputs live in the entry's fixed-address buffers.
|
||||
"""
|
||||
hidden_states = self.img_in(entry.hidden)
|
||||
timestep = torch.cat([entry.timestep, entry.timestep.new_zeros(1)], dim=0)
|
||||
|
||||
@@ -231,6 +231,10 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
if model is None:
|
||||
return
|
||||
|
||||
if getattr(model, "enable_cuda_graph_decode", False):
|
||||
logger.info("Model runner: %s uses CUDA graph decode instead of torch.compile.", attr_name)
|
||||
return
|
||||
|
||||
compile_granularity = self.od_config.diffusion_compile_granularity
|
||||
compile_dynamic = self.od_config.diffusion_compile_dynamic
|
||||
try:
|
||||
|
||||
@@ -1605,8 +1605,8 @@ def _build_image_generation_response(
|
||||
peak_memory_mb=peak_memory_mb,
|
||||
),
|
||||
}
|
||||
if request.size is not None:
|
||||
response_kwargs["size"] = request.size
|
||||
if images:
|
||||
response_kwargs["size"] = f"{images[0].width}x{images[0].height}"
|
||||
response = ImageGenerationResponse(**response_kwargs)
|
||||
if request.response_format == ResponseFormat.FILE:
|
||||
return response.stream_response()
|
||||
@@ -1839,6 +1839,7 @@ async def generate_images(
|
||||
|
||||
@router.post(
|
||||
"/v1/images/edits",
|
||||
response_model=None,
|
||||
responses={
|
||||
HTTPStatus.OK.value: {"model": ImageGenerationResponse},
|
||||
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
|
||||
@@ -1846,6 +1847,7 @@ async def generate_images(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
@with_cancellation
|
||||
async def edit_images(
|
||||
raw_request: Request,
|
||||
image: list[UploadFile] | None = File(None),
|
||||
@@ -2181,7 +2183,7 @@ async def edit_images(
|
||||
created=int(time.time()),
|
||||
data=image_data,
|
||||
output_format=output_format,
|
||||
size=size_str,
|
||||
size=f"{images[0].width}x{images[0].height}" if images else size_str,
|
||||
cot_output=cot_output,
|
||||
metrics=_build_image_response_metrics(
|
||||
response_metrics=response_metrics,
|
||||
|
||||
@@ -3466,7 +3466,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin):
|
||||
chunk = ImageEditImageChunk(
|
||||
data=image_data,
|
||||
output_format=output_format,
|
||||
size=size,
|
||||
size=f"{images[0].width}x{images[0].height}",
|
||||
created=created,
|
||||
model=model,
|
||||
metrics=metrics,
|
||||
|
||||
Reference in new issue
Block a user