418 lines
18 KiB
Markdown
418 lines
18 KiB
Markdown
# Qwen-Image-2.1
|
||||
|
|
|
|||
|
|
> Text-to-image and image-conditioned generation with Qwen-Image 2.1
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
- Vendor: Qwen
|
|||
|
|
- Model: `Qwen/Qwen-Image-2.1`
|
|||
|
|
- Task: Text-to-image generation and image-conditioned generation (editing)
|
|||
|
|
- Mode: Offline inference and online serving with optional step-wise execution
|
|||
|
|
- Maintainer: Community
|
|||
|
|
|
|||
|
|
> **Model id:** `Qwen/Qwen-Image-2.1` follows the reference diffusers
|
|||
|
|
> documentation and may change before the official release — the final
|
|||
|
|
> checkpoint name is subject to the official release.
|
|||
|
|
|
|||
|
|
## When to use this recipe
|
|||
|
|
|
|||
|
|
Use this recipe as a starting point for running `Qwen/Qwen-Image-2.1`. A single
|
|||
|
|
pipeline class, `QwenImage21Pipeline`, handles both pure text-to-image requests
|
|||
|
|
(no condition image) and image-conditioned requests with up to 4 condition
|
|||
|
|
images: prompt and condition images are encoded together by a Qwen3-VL text
|
|||
|
|
encoder, so editing-style requests go through the same serving path as
|
|||
|
|
text-to-image.
|
|||
|
|
|
|||
|
|
## References
|
|||
|
|
|
|||
|
|
- 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/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:
|
|||
|
|
[`docs/user_guide/diffusion_features.md`](../../docs/user_guide/diffusion_features.md)
|
|||
|
|
- Sibling recipes: [`Qwen-Image.md`](./Qwen-Image.md),
|
|||
|
|
[`Qwen-Image-Edit.md`](./Qwen-Image-Edit.md)
|
|||
|
|
|
|||
|
|
## Offline Inference
|
|||
|
|
|
|||
|
|
### Text-to-image
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python examples/offline_inference/text_to_image/text_to_image.py \
|
|||
|
|
--model Qwen/Qwen-Image-2.1 \
|
|||
|
|
--prompt "A ceramic teapot on a wooden table" \
|
|||
|
|
--output qwen_image_21_t2i.png \
|
|||
|
|
--num-inference-steps 50 \
|
|||
|
|
--cfg-scale 1.0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Image-conditioned generation (editing)
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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" \
|
|||
|
|
--output qwen_image_21_edit.png \
|
|||
|
|
--num-inference-steps 50 \
|
|||
|
|
--cfg-scale 1.0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
or via the bundled launcher:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
bash examples/offline_inference/image_to_image/run_qwen_image_21.sh
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Up to 4 condition images can be passed via `--image`:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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" \
|
|||
|
|
--output qwen_image_21_multi.png \
|
|||
|
|
--num-inference-steps 50 \
|
|||
|
|
--cfg-scale 1.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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
vllm serve Qwen/Qwen-Image-2.1 --omni --port 8091
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
To enable the step-wise runtime:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
vllm serve Qwen/Qwen-Image-2.1 --omni \
|
|||
|
|
--port 8091 \
|
|||
|
|
--step-execution \
|
|||
|
|
--max-num-seqs 8
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Step execution batches compatible requests at the same KV-cache phase. The
|
|||
|
|
pipeline tags every request with a `batch_compatibility_key` (conditioning
|
|||
|
|
mode plus condition-image layout), so a newly arriving request that would
|
|||
|
|
otherwise join a batch already past its first denoising step is isolated by
|
|||
|
|
the scheduler and deferred until the in-flight batch drains — it no longer
|
|||
|
|
fails the whole batch. `--max-num-seqs` controls batch capacity.
|
|||
|
|
|
|||
|
|
### CUDA Graph decode
|
|||
|
|
|
|||
|
|
Qwen-Image-2.1 automatically uses CUDA Graph for supported fixed-shape denoising
|
|||
|
|
decode steps when `enable_cuda_graph_decode=True` (the default) and
|
|||
|
|
`enforce_eager=False`. Prefill remains eager. This works with both request
|
|||
|
|
execution and `--step-execution`.
|
|||
|
|
|
|||
|
|
To disable graph capture while keeping the rest of the compilation setup:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
vllm serve Qwen/Qwen-Image-2.1 --omni --no-enable-cuda-graph-decode
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The same control is available as `Omni(..., enable_cuda_graph_decode=False)`
|
|||
|
|
and as `enable_cuda_graph_decode: false` on a stage in the deployment YAML.
|
|||
|
|
`--enforce-eager` / `enforce_eager: true` remains the broader switch: it
|
|||
|
|
disables both graph capture and automatic `torch.compile`. The two
|
|||
|
|
optimizations stack: the DiT blocks are regionally `torch.compile`'d and
|
|||
|
|
graph capture records the compiled (fused) kernels, while inductor's own
|
|||
|
|
cudagraphs stay disabled so the two graph layers never nest. Measured on a
|
|||
|
|
single H200 (1024×1024, 50 steps, seed 42, BF16, `true_cfg_scale=1.0`,
|
|||
|
|
warmup 1 + median of 3): compile+graph **5.93 s**, compile-only **6.88 s**,
|
|||
|
|
graph-only **9.52 s**, eager **8.28 s** end-to-end per image; combo and
|
|||
|
|
compile-only vs eager are **38.10 dB** PSNR (compile fusion drift), and
|
|||
|
|
graph-only vs eager is bit-identical.
|
|||
|
|
|
|||
|
|
The autoregressive engine's `compilation_config.cudagraph_mode` does not
|
|||
|
|
control this diffusion path; use `enable_cuda_graph_decode` (or
|
|||
|
|
`enforce_eager` for everything) to force eager decode execution.
|
|||
|
|
|
|||
|
|
TP/SP/ring parallelism, HSDP, cache hooks, quantized KV caches,
|
|||
|
|
dynamic LoRA, padded text masks, and a second in-flight request whose cache
|
|||
|
|
aliases an already-owned graph key fall back to eager decode.
|
|||
|
|
Model-level CPU offload does not disable decode graphs while
|
|||
|
|
`enable_cuda_graph_decode` stays on (the default). DiT weights stay on fixed
|
|||
|
|
device storage so captured pointers remain valid, and that storage is not
|
|||
|
|
released while the text encoder runs. Pass `--no-enable-cuda-graph-decode`
|
|||
|
|
(or `enable_cuda_graph_decode: false`) for the eager offload path, which
|
|||
|
|
moves DiT weights off the device and frees that VRAM. Peak memory for the
|
|||
|
|
default graph-plus-offload combination was not re-measured in this PR.
|
|||
|
|
Graphs keep separate entries for different image layouts and copy
|
|||
|
|
request-owned prefix K/V into static buffers before replay; each graph key
|
|||
|
|
has a single live owner at a time, so concurrent same-shape requests never
|
|||
|
|
overwrite each other's prefix. Those buffers require additional memory; graph
|
|||
|
|
entries are bounded by the transformer's cache limit.
|
|||
|
|
|
|||
|
|
### Verification
|
|||
|
|
|
|||
|
|
For a direct API smoke test:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl http://localhost:8091/v1/images/generations \
|
|||
|
|
-H "Content-Type: application/json" \
|
|||
|
|
-d '{
|
|||
|
|
"model": "Qwen/Qwen-Image-2.1",
|
|||
|
|
"prompt": "A ceramic teapot on a wooden table",
|
|||
|
|
"size": "1024x1024",
|
|||
|
|
"num_inference_steps": 50,
|
|||
|
|
"true_cfg_scale": 1.0,
|
|||
|
|
"seed": 42
|
|||
|
|
}'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For image-conditioned generation, pass condition images through the
|
|||
|
|
OpenAI-compatible multimodal endpoint (`/v1/chat/completions` with
|
|||
|
|
`image_url` content parts, or `/v1/images/edits`), same as
|
|||
|
|
[`Qwen-Image-Edit.md`](./Qwen-Image-Edit.md).
|
|||
|
|
|
|||
|
|
## Parameters
|
|||
|
|
|
|||
|
|
| Parameter | Default | Notes |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `true_cfg_scale` | 1.0 | Disables the negative-prompt branch by default. Set above 1 with a `negative_prompt` to enable true CFG. Qwen-Image 2.1 supports true CFG only — there is no `guidance_scale` parameter. |
|
|||
|
|
| `num_inference_steps` | 50 | Number of denoising steps. |
|
|||
|
|
| `height` / `width` | 1024×1024 | Must be multiples of 32. For image-conditioned requests, leaving them unset derives the output size from the last condition image's aspect ratio at ~1024×1024. |
|
|||
|
|
| `negative_prompt` | None | Ignored at the default CFG scale of 1.0; used only when `true_cfg_scale > 1`. |
|
|||
|
|
| condition images | — | Up to 4 input images per request; more than 4 raises an error. |
|
|||
|
|
| `seed` | — | Fix for reproducible outputs. |
|
|||
|
|
|
|||
|
|
The default CFG scale of 1.0 runs only the positive-prompt branch at each
|
|||
|
|
denoising step. To enable negative-prompt guidance, explicitly set
|
|||
|
|
`--cfg-scale` (offline) or `true_cfg_scale` (API) above 1 and provide a
|
|||
|
|
negative prompt. This changes the generated output as well as compute cost.
|
|||
|
|
|
|||
|
|
### Prefix KV cache
|
|||
|
|
|
|||
|
|
The transformer caches the text/condition-image prefix across denoising steps
|
|||
|
|
(a prefill/decode split keyed by CFG branch). The cache is enabled
|
|||
|
|
automatically when the checkpoint declares `causal_condition: true` and is
|
|||
|
|
re-created per generation, so no state leaks between requests. It changes the
|
|||
|
|
compute path — cached prefix vs. full recompute at every step — so exact
|
|||
|
|
numerical reproduction against a no-cache reference (e.g. the diffusers
|
|||
|
|
pipeline) requires matching the cache setting on both sides. The prefix KV
|
|||
|
|
cache is orthogonal to weight quantization and works unchanged under FP8.
|
|||
|
|
|
|||
|
|
### FP8 quantization
|
|||
|
|
|
|||
|
|
Online FP8 quantization of the transformer is supported; the fused
|
|||
|
|
`to_qkv` projections load correctly, and the prefix KV cache path is
|
|||
|
|
unaffected. Only block-internal linears are eligible — the boundary
|
|||
|
|
projections (`img_in`/`txt_in`/`modulation`/`proj_out`/`time_text_embed`) stay
|
|||
|
|
in BF16 by construction:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python examples/offline_inference/text_to_image/text_to_image.py \
|
|||
|
|
--model Qwen/Qwen-Image-2.1 \
|
|||
|
|
--prompt "A ceramic teapot on a wooden table" \
|
|||
|
|
--output qwen_image_21_fp8.png \
|
|||
|
|
--num-inference-steps 50 --cfg-scale 1.0 \
|
|||
|
|
--quantization fp8 --ignored-layers "img_mlp"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`ignored_layers` entries are matched as name patterns for this model (e.g.
|
|||
|
|
`img_mlp` matches every block's `img_mlp.proj`/`img_mlp.gate_layer`/
|
|||
|
|
`img_mlp.out`); exact full prefixes like
|
|||
|
|
`transformer.transformer_blocks.3.attn.to_qkv` also work.
|
|||
|
|
|
|||
|
|
A per-module sensitivity sweep (fixed prompt embeds / latent / timestep, 5
|
|||
|
|
timesteps across the trajectory, relative L2 of the noise prediction vs BF16)
|
|||
|
|
ranks the groups: `img_mlp` is the most sensitive (3.8% max error on its own,
|
|||
|
|
driven by `img_mlp.proj` 2.8% and `img_mlp.out` 2.5%), then `attn.to_out`
|
|||
|
|
(2.7%), then `attn.to_qkv` (1.6%); errors are largest at the late (low-sigma)
|
|||
|
|
steps. End-to-end on H200, single GPU, same prompt as Quality Comparison
|
|||
|
|
(`"A ceramic teapot on a wooden table"`, 1024×1024, 50 steps, seed 42,
|
|||
|
|
eager):
|
|||
|
|
|
|||
|
|
| Config | Quantized block linears | PSNR vs BF16 | Steady-state time/image | Peak memory |
|
|||
|
|
| --- | --- | --- | --- | --- |
|
|||
|
|
| BF16 | 0 / 160 | — | **7.34 s** | **36.9 GB** |
|
|||
|
|
| FP8 all layers | 160 / 160 | **33.0 dB** | **6.06 s** | **30.4 GB** |
|
|||
|
|
| FP8, `ignored_layers=["img_mlp"]` | 64 / 160 | **34.3 dB** | **6.98 s** | **34.9 GB** |
|
|||
|
|
|
|||
|
|
Keeping `img_mlp` in BF16 is still the higher-fidelity DiT setting vs
|
|||
|
|
all-layer FP8. On H200, all-layer FP8 is both leaner and faster than BF16
|
|||
|
|
at this shape.
|
|||
|
|
|
|||
|
|
## Quantization
|
|||
|
|
|
|||
|
|
Online FP8 is supported per component via `quantization_config`:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from vllm_omni import Omni
|
|||
|
|
|
|||
|
|
omni = Omni(
|
|||
|
|
model="Qwen/Qwen-Image-2.1",
|
|||
|
|
quantization_config={
|
|||
|
|
# DiT: skip the sensitive image-stream MLPs if quality regresses.
|
|||
|
|
"transformer": {"method": "fp8", "ignored_layers": ["img_mlp"]},
|
|||
|
|
# Qwen3-VL text encoder: only the language-model linear layers are
|
|||
|
|
# quantized; the vision tower and the (unused) LM head stay BF16.
|
|||
|
|
"text_encoder": {"method": "fp8"},
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Either component can be quantized on its own. Measured on H200 at 1024×1024
|
|||
|
|
(seed 42, 50 steps, same teapot prompt, eager): text-encoder FP8 lowers peak
|
|||
|
|
GPU memory from **36.9 GB** (BF16) to **30.4 GB** (−6.5 GB); T2I PSNR vs BF16
|
|||
|
|
**32.1 dB**. DiT FP8-all is **30.4 GB** / **6.06 s**. Combined DiT+text-encoder
|
|||
|
|
FP8 was not re-measured on H200. The edit path routes condition images through
|
|||
|
|
the BF16 vision tower, so it is unaffected by text-encoder FP8. See
|
|||
|
|
[`docs/user_guide/quantization/fp8.md`](../../docs/user_guide/quantization/fp8.md)
|
|||
|
|
for the scope rules.
|
|||
|
|
|
|||
|
|
### FP8 prefix KV storage
|
|||
|
|
|
|||
|
|
The cached prefix K/V can be stored in FP8 E4M3 (with per-token-per-head fp32
|
|||
|
|
scales), halving the prefix-cache memory — relevant for long prompts (up to
|
|||
|
|
8192 text tokens) and up to 4 condition images (~4096 latent tokens each).
|
|||
|
|
Prefill quantizes once; decode dequantizes per step, so attention compute is
|
|||
|
|
unchanged. Enable it through the model-specific `extras` config (e.g. in the
|
|||
|
|
deploy YAML's stage `extras` or as an `Omni(...)` engine kwarg):
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
extras:
|
|||
|
|
prefix_kv_cache_dtype: "fp8"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Accepted values: `"fp8"` / `"fp8_e4m3"` (K and V quantized), `"fp8_v"` (V
|
|||
|
|
only — K stays in the native dtype), and `None` / `"auto"` (default, native
|
|||
|
|
dtype — behavior unchanged). This is independent of
|
|||
|
|
`diffusion_kv_cache_dtype`, which quantizes attention Q/K/V *compute* per
|
|||
|
|
forward pass on supported backends.
|
|||
|
|
|
|||
|
|
Measured on one NVIDIA H200, T2I 1024x1024 (seed 42, 50 steps, true CFG 4.0,
|
|||
|
|
eager, PSNR vs. the bf16 baseline on the same GPU): **`"fp8"` 34.2 dB**,
|
|||
|
|
**`"fp8_v"` 44.0 dB**. The error is dominated by
|
|||
|
|
K quantization: post-RoPE keys are the precision-sensitive half of the
|
|||
|
|
cache, so `"fp8_v"` buys back about 9.8 dB at 75% (instead of 50%) of the original
|
|||
|
|
cache size. The residual error is inherent e4m3 precision accumulated
|
|||
|
|
coherently over the denoising trajectory; finer scale granularity
|
|||
|
|
(per-tensor/per-head/per-token were compared) or Hadamard-rotated V did not
|
|||
|
|
improve it. The saving scales with prefix length. These sizes are calculated at the
|
|||
|
|
limit (8192 text tokens + 4 condition images, ~24.6k prefix tokens), not
|
|||
|
|
measured on the 1024 T2I run above: ~12.9 GB per CFG branch in bf16, ~6.6 GB
|
|||
|
|
in `"fp8"` and ~9.7 GB in `"fp8_v"`. That is where this option matters. Treat it as an opt-in for
|
|||
|
|
memory-bound long-prompt / multi-image workloads, not a free lunch;
|
|||
|
|
`"fp8_v"` is the better default trade-off when quality matters.
|
|||
|
|
|
|||
|
|
Note: a quantized prefix cache is not CUDA-graph capturable — requests using
|
|||
|
|
this option stay on the eager decode path (a warning is logged once).
|
|||
|
|
|
|||
|
|
### Why not the scheduler-managed paged KV (`DiffusionKVCacheMode.PAGED_SCHEDULER`)?
|
|||
|
|
|
|||
|
|
Evaluated and deliberately not adopted (see the `qwen21-p0-kv` commit message
|
|||
|
|
for the full analysis): the paged scheduler targets *cross-request* KV
|
|||
|
|
management — a statically sized page pool, block sharing/dedup, and eviction —
|
|||
|
|
whereas this prefix cache lives exactly as long as one generation and is
|
|||
|
|
sized once per request. Migrating would require routing decode attention
|
|||
|
|
through the worker paged adapter (writing fresh target K/V into pages every
|
|||
|
|
step), per-request page tables, and re-validating the block-causal
|
|||
|
|
piecewise-span masking and Ulysses/CFG constraints, for no memory benefit over
|
|||
|
|
the per-request dense tensors. The FP8 storage above captures the actual
|
|||
|
|
memory win at ~1% of the integration cost.
|
|||
|
|
|
|||
|
|
## Quality Comparison (head vs diffusers)
|
|||
|
|
|
|||
|
|
The reference is the diffusers Qwen-Image 2.1 pipeline
|
|||
|
|
([huggingface/diffusers#14804](https://github.com/huggingface/diffusers/pull/14804)).
|
|||
|
|
Both sides must pin the same checkpoint, inputs, seed, precision (BF16), and
|
|||
|
|
hardware.
|
|||
|
|
|
|||
|
|
vLLM-Omni (this recipe):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python examples/offline_inference/text_to_image/text_to_image.py \
|
|||
|
|
--model Qwen/Qwen-Image-2.1 \
|
|||
|
|
--prompt "A ceramic teapot on a wooden table" \
|
|||
|
|
--seed 42 \
|
|||
|
|
--output qwen_image_21_vllm_omni.png \
|
|||
|
|
--num-inference-steps 50 \
|
|||
|
|
--cfg-scale 1.0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
diffusers reference:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import torch
|
|||
|
|
from diffusers import QwenImage21Pipeline # diffusers PR #14804
|
|||
|
|
|
|||
|
|
pipe = QwenImage21Pipeline.from_pretrained(
|
|||
|
|
"Qwen/Qwen-Image-2.1", torch_dtype=torch.bfloat16
|
|||
|
|
).to("cuda")
|
|||
|
|
image = pipe(
|
|||
|
|
prompt="A ceramic teapot on a wooden table",
|
|||
|
|
num_inference_steps=50,
|
|||
|
|
true_cfg_scale=1.0,
|
|||
|
|
generator=torch.Generator(device="cuda").manual_seed(42),
|
|||
|
|
height=1024,
|
|||
|
|
width=1024,
|
|||
|
|
).images[0]
|
|||
|
|
image.save("qwen_image_21_diffusers.png")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Metric | vLLM-Omni | diffusers (#14804) | Status |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| Output image (1024×1024, 50 steps, seed 42, BF16, H200) | generated (eager + default CUDA-graph) | generated | measured |
|
|||
|
|
| PSNR / LPIPS vs reference | eager vs Diffusers **47.17 dB**; **default compile+graph** vs Diffusers **38.02 dB**; **default compile+graph** vs eager **38.10 dB** | — | measured (H200) |
|
|||
|
|
| Latency (time/image, same GPU, H200) | **default compile+graph** **6.04 s** (120.8 ms/step); eager **7.31 s** (146.2 ms/step) | **7.74 s** (154.9 ms/step) | measured |
|
|||
|
|
|
|||
|
|
The prefix KV cache changes the compute path (cached prefix vs. full recompute
|
|||
|
|
at every step), so exact numerical parity is not expected; match the cache
|
|||
|
|
setting on both sides or compare with PSNR/LPIPS rather than exact pixels.
|
|||
|
|
Eager stays numerically close to Diffusers (~47 dB). The **default** serving
|
|||
|
|
path stacks regional `torch.compile` with CUDA-graph decode; on H200 that
|
|||
|
|
combo lands at **~38.1 dB** vs eager (~1.21× on the Quality Comparison timed
|
|||
|
|
run). Graph-only vs eager is bit-identical; the ~38 dB drift is the compile
|
|||
|
|
fusion, not CUDA-graph replay. See "CUDA Graph decode" for the four-mode
|
|||
|
|
H200 breakdown (warmup 1 + median of 3).
|
|||
|
|
|
|||
|
|
Parity / feature measurements (same prompt/seed/steps/CFG, BF16, eager decode
|
|||
|
|
unless noted; 4× NVIDIA H200):
|
|||
|
|
|
|||
|
|
- [x] TP=2/4 output parity vs single-card — measured (1024×1024): TP1 vs TP2
|
|||
|
|
**39.32 dB**, TP1 vs TP4 **38.37 dB**, TP2 vs TP4 **45.77 dB**. Latency:
|
|||
|
|
TP1 **7.30 s**, TP2 **4.89 s**, TP4 **5.12 s** per image.
|
|||
|
|
- [x] VAE tiling vs non-tiled decode — measured: 1024×1024 nontile vs
|
|||
|
|
`--vae-use-tiling` **43.55 dB** (peak mem ~37.7 GB → ~32.7 GB); 2048×2048
|
|||
|
|
nontile vs tiled **44.14 dB** (peak mem ~57.9 GB → ~33.3 GB; gen ~36.8 s vs
|
|||
|
|
~37.1 s).
|
|||
|
|
- [x] CUDA-graph four-mode vs eager — measured on H200 (1024×1024, 50 steps,
|
|||
|
|
seed 42, BF16, warmup 1 + median of 3): compile+graph **5.93 s**,
|
|||
|
|
compile-only **6.88 s**, graph-only **9.52 s**, eager **8.28 s**. Combo /
|
|||
|
|
compile-only vs eager **38.10 dB**; graph-only vs eager bit-identical.
|
|||
|
|
Quality Comparison table uses a separate single-run A/B vs Diffusers
|
|||
|
|
(compile+graph **6.04 s**, eager **7.31 s**).
|
|||
|
|
|
|||
|
|
## Known Limitations
|
|||
|
|
|
|||
|
|
- Cache acceleration backends (`cache_dit`, `tea_cache`) are not supported;
|
|||
|
|
`QwenImage21Pipeline` is registered in `_NO_CACHE_ACCELERATION`.
|
|||
|
|
- Ulysses sequence parallelism is wired; ring attention is not a supported path.
|
|||
|
|
A short H200 smoke (1024×1024, 8 steps, seed 42, true CFG 1.0, eager) finished:
|
|||
|
|
Ulysses SP=2 vs SP=1 is **46.5 dB**. That size's target length is divisible by 4,
|
|||
|
|
so SP padding was not exercised, and this is not the 50-step parity next to the
|
|||
|
|
TP tables. The features matrix therefore stays ❓. Ring SP=2 also ran, at
|
|||
|
|
**29.9 dB** vs SP=1, which is why it stays unsupported.
|
|||
|
|
- Only true CFG is exposed; there is no `guidance_scale` knob.
|
|||
|
|
- VAE tiling (`--vae-use-tiling`, also implied by `--vae-patch-parallel-size > 1`)
|
|||
|
|
decodes in 512px tiles with 384px stride (32 latent pixels per tile at 16x
|
|||
|
|
spatial compression). On CUDA OOM the tile size is automatically halved
|
|||
|
|
(keeping 25% overlap) and decoding retried, down to 128px tiles. Tile sizes
|
|||
|
|
are not yet exposed as engine arguments.
|