[diffusion] chore: batch qwen-image 2.1 targets and document measured deployment recipes (#40408)

Co-authored-by: Mick Qian <mickqian@users.noreply.github.com>
This commit is contained in:
Mick
2026-09-20 13:59:36 +08:00
committed by GitHub
co-authored by Mick Qian
parent 99d53fe0c2
commit 031bff5dd3
10 changed files with 369 additions and 79 deletions
@@ -22,14 +22,14 @@ the hardware picker selects a tested single-GPU recipe for the full checkpoint.
Use **Setup** to select text-to-image, single-image editing, or multi-image
editing. **Server** controls placement, attention, encoder scheduling, VAE
tiling, and graph execution. **Request** controls the background, resolution,
tiling, graph execution, and request batching. **Request** controls the background, resolution,
steps, and output count. Set reference PNG paths under **Variables**; edits
upload files from the machine running cURL, so they need not exist on the server.
Hardware selection applies the recommended placement for that GPU. H200,
B200, and RTX PRO 6000 96GB keep weights resident; RTX 5090 and RTX 4090 use
offload to fit the full pipeline.
Custom two- and four-GPU topologies and unverified feature combinations remain selectable and are labeled
Untested topologies and feature combinations remain selectable and are labeled
**Unverified**. Invalid topology combinations disable Copy. This integration
currently uses the Python/source command; no published Docker image is verified.
@@ -47,7 +47,117 @@ for i, item in enumerate(json.loads(Path("response.json").read_text())["data"]):
PY
```
### Platform measurements
### Current native recipes
The picker defaults use native BF16/FP32 precision, exact attention, eager
execution, and full-image VAE decoding. The table below uses checkpoint
`840b4adb1e2c21c7d77967203188b55b678c535f` and source `1f89064b419`,
measured on 2026-09-20. These are the best configurations among the tested
candidates for this workload, not a claim of a global optimum.
| GPU | Single-output recipe | Generation median | Edit median | Request-phase peak |
| --- | --- | --- | --- | --- |
| H200 141GB | Resident / FlashAttention | 4.48 s | 5.29 s | 38.4 GiB |
| B200 192GB | Resident / FlashAttention | 2.46 s | 3.02 s | 38.5 GiB |
| RTX PRO 6000 96GB | Resident / Torch SDPA | 8.03 s | 9.63 s | 38.4 GiB |
| RTX 4090 24GB | DiT offload, 8 resident layers, encoder CPU offload / FlashAttention | 23.25 s | 24.63 s | 23.1 GiB |
Each shape uses two 1024px/40-step warmups, then five generation and five edit
requests; the RTX 4090 8-layer candidate uses three measured requests per mode.
All use seed 42, CFG 1, CPU noise, and one RGBA PNG per request. HTTP time
includes encoding and PNG serialization, excluding startup. Memory is the peak
sampled every 0.2 seconds during these requests, excluding startup. The H200, B200, and
RTX PRO 6000 batch-matrix servers use a ceiling of four images and a 20 ms batching
window; the single-output 4090 candidate has dynamic batching off.
PyTorch is 2.13.0+cu130, Transformers 5.12.1, and Diffusers 0.37.0; native
conditioning explicitly preserves the reference's Transformers 4.57.3 semantics.
FlashAttention beats SDPA on H200 (4.48 vs 4.91 s generation; 5.29 vs 6.23 s
editing) and B200 (2.46 vs 2.91 s; 3.02 vs 4.03 s). SDPA uses three measured
requests after two warmups. On RTX 4090, retaining eight DiT layers reduces
generation from 24.50 to 23.25 s and editing from 25.19 to 24.63 s. Its generation,
editing, transparent generation, and transparent-input editing pixels match
the fully streamed recipe exactly in this comparison. The tradeoff is a larger
request-phase peak: 19.9 to 23.1 GiB.
On RTX PRO 6000, resident weights are faster than DiT offload: 8.03 vs 10.24 s
for generation and 9.63 vs 10.59 s for editing. Offload lowers the request-phase
peak from 38.4 to 25.3 GiB; its timings use two warmups and three measurements.
The picker applies the 8-layer flag only to a native, eager, single-output
RTX 4090 FlashAttention recipe with request batching off. Multiple outputs or
request batching select the fully streamed recipe instead; use the updated
**Server** command when switching. Multi-reference and other untested shapes
remain marked Unverified.
The RTX 5090 recipe retains its earlier validation below; it has not been rerun
with this checkpoint. Both RTX 5090 and RTX PRO 6000 map FA selection to SDPA, so those
labels do not represent two different attention kernels.
A 1024px H200 BCG server captured its warmup graph, but these prompts and image
prefixes missed that signature and ran eagerly. Text buckets do not pad this
model's condition KV layout. Keep eager execution as the default; declaring a
resolution alone does not establish graph replay or a speedup.
### Batching
Keep **Request batching → Off** for interactive use. For a concurrent
text-to-image workload on one RTX 4090, the tested two-image server command is:
```bash Command
sglang serve \
--model-path /models/qwen-image-2.1 --model-id Qwen-Image-2.1 \
--num-gpus 1 --ulysses-degree 1 --encoder-parallel auto \
--performance-mode manual \
--dit-layerwise-offload true --text-encoder-cpu-offload true \
--attention-backend fa \
--batching-max-size 2 --batching-delay-ms 20 \
--host 0.0.0.0 --port 30010
```
Send requests concurrently to use dynamic batching. The merge limit counts
output images, including each request's `n`; a client that waits for one
response before sending the next does not supply concurrent work. Image-edit
requests are not merged across requests. Set **Outputs** (the HTTP `n` field)
to produce multiple images within one generation or edit request.
| GPU | Generation, 1 / 2 / 4 outputs | Editing, 1 / 2 / 4 outputs | Recommendation |
| --- | --- | --- | --- |
| H200 | 4.48 / 9.13 / 18.32 s | 5.29 / 10.60 / 21.13 s | Off; no throughput gain |
| B200 | 2.46 / 5.17 / 9.91 s | 3.02 / 5.98 / 12.04 s | Off; no material throughput gain |
| RTX PRO 6000 | 8.03 / 16.19 / 32.55 s | 9.63 / 19.08 / 38.11 s | Off; no material throughput gain |
| RTX 4090 | 23.25 / 38.33 / untested | 24.63 / 44.01 / untested | Off for latency; consider 2 for throughput |
The H200/B200/RTX PRO 6000 and two-output 4090 measurements use five requests after two
warmups per shape. The one-output 4090 numbers use the faster 8-layer recipe
above with three measurements; batches stream all DiT layers. Against that
single-output recipe, two outputs improve 4090 generation throughput by about
**21%** and editing throughput by about **12%**, while increasing request latency.
Two concurrent 4090 generation requests complete in **39.75 s** wall time
(median of three rounds with different prompts and seeds). Concurrent 2/4-request
batches did not improve resident H200/B200/RTX PRO 6000 throughput. All measurements use
the checkpoint, software, 1024px/40-step settings, and HTTP timing scope above.
Transparent generation and transparent-input editing also passed for these
batch sizes, with alpha values from 0 to 255.
Each sample retains its own condition-prefix KV cache, prompt, seed, and output
position. DiT target projections and MLPs run as a batch, and layerwise offload
transfers each block once per batch. This amortizes transfers on consumer GPUs.
Resident weights do not incur those transfers, so batching is not automatically
faster there. Full-checkpoint TP2 and Ulysses2 generation, editing, alpha, and
dynamic two-request batching also passed on two B200s; those are functional
checks, not latency recommendations for every multi-GPU topology.
Batching retains native BF16/FP32 precision but changes GEMM shapes and
floating-point rounding. Batched images are **not guaranteed to match singleton
pixels**, even with the same seeds. Use one output and leave request batching
off when reproducing a singleton image exactly. Quantization, SageAttention,
and approximate denoising caches remain separate options. Larger batches and
other prompts need their own memory and throughput measurements. See
[Inference batching](/docs/sglang-diffusion/dynamic_batching) for admission rules
and batch metrics.
### Earlier platform measurements
The following four-platform comparison and the fusion measurements below precede
the training-template and VAE normalization corrections in `c2a31b2693c`;
@@ -89,10 +199,11 @@ this complete recipe passed generation, editing, and transparent PNG output.
These are measurements of this small workload, not universal latency or image
quality guarantees. Different prompts, reference sizes, batching, and software
versions can change memory use and latency. Multi-reference and batched request
recipes retain their separate H200 verification scope in the picker.
versions can change memory use and latency. Multi-reference recipes retain their
separate H200 verification scope. See [Batching](#batching) for the current
multi-output and concurrent-request matrix.
### RTX PRO 6000 Blackwell 96GB
### Earlier RTX PRO 6000 Blackwell 96GB measurements
The recommended single-GPU command keeps all weights resident and selects Torch
SDPA. This is the 96GB Blackwell Server Edition (SM120). This runtime also maps
@@ -265,9 +376,9 @@ The model predicts continuous alpha values, including partly transparent edges.
No thresholding or background-removal postprocessing is applied. Transparent
generation and transparent-input editing were compared against the reference
at 1024 × 1024 and 40 steps; that check does not guarantee perfect cutouts for
every prompt. Transparent generation and single-image editing also passed on
the recommended one-H200 and one-RTX PRO 6000 servers at that resolution and
step count, with one output per request.
every prompt. The updated checkpoint also passed transparent generation and
transparent-input editing on H200, B200, RTX PRO 6000, and RTX 4090. See
[Batching](#batching) for the tested output counts and two-B200 topologies.
## 4. Offline requests
@@ -531,7 +642,10 @@ passed dynamic loading, merging, and removal on one B200 and TP2 with encoder
folding. Both removal paths restored the base image exactly. This verifies
adapter application and lifecycle, not the quality of a trained LoRA.
Cache-DiT hooks operate on target-image transformer blocks. Breakable CUDA
Cache-DiT hooks operate on target-image transformer blocks; two-output generation,
editing, transparent generation, and transparent-input editing passed with it
enabled on B200 at 1024px/40 steps. This is a functional check of an approximate
cache, not a lossless recipe. Breakable CUDA
Graph execution fills each request's prefix caches eagerly, then replays
matching warmup graphs with those cache tensors as inputs. Warmup and request
condition-prefix lengths must match, in addition to the output resolution;
@@ -37,7 +37,12 @@ bit-exact. In the tested image, spatial versus full decode gave RGBA PSNR
60.69 dB; this is one sample, not a general quality guarantee.
Request-local prefix KV reuse and new-request graph replay have CUDA regression
tests. The deployment picker marks only its exact tested HTTP combinations as
tests, including batched targets and independent variable-length prefixes.
Compatible text-to-image requests support opt-in dynamic batching; image-edit
requests remain separate, while multiple outputs within one request are supported.
See the cookbook's [batching guidance](/cookbook/diffusion/Qwen-Image/Qwen-Image-2.1#batching)
for measured throughput and floating-point reproducibility limits.
The deployment picker marks only its exact tested HTTP combinations as
verified, including H200, B200, RTX PRO 6000 96GB, RTX 5090, and RTX 4090.
CLI-only combinations remain Unverified in the picker.
@@ -77,6 +77,7 @@ An initial implementation of dynamic batching for T2I and T2V models can be foun
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>GLM-Image (external AR)</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>✅</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>-</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>✅</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>-</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image 2512</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>✅</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>-</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image 2.1</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>✅</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image Edit</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>-</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image Edit 2509</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>-</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>?</td></tr>
<tr><td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>Qwen Image Edit 2511</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>-</td><td style={{textAlign: "center", padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>?</td></tr>
@@ -98,6 +99,11 @@ An initial implementation of dynamic batching for T2I and T2V models can be foun
</tbody>
</table>
Qwen Image 2.1 supports merging compatible text-to-image requests. Image edits
are not merged across requests; `n > 1` still produces multiple outputs within
one edit request. See its [cookbook](/cookbook/diffusion/Qwen-Image/Qwen-Image-2.1#batching)
for platform measurements and when batching is useful.
### Video
<table style={{display: "table", width: "100%", borderCollapse: "collapse", tableLayout: "fixed"}}>
+1 -1
View File
@@ -2014,7 +2014,7 @@ export const Deployment = ({ config, benchmarks }) => {
const options = visibleOptions(dim, sel);
const currentOption = selectedOption(dim);
return (
<section className={`sgd-builder-context ${className}`} aria-live={direct ? undefined : "polite"}>
<section className={["sgd-builder-context", className].filter(Boolean).join(" ")} aria-live={direct ? undefined : "polite"}>
<div className="sgd-builder-context-heading">
<div>
<span>{direct ? dim.title : `${dim.title} options`}</span>
@@ -48,16 +48,19 @@ const config = {
recommendedWhen: (s) => ["h200", "b200", "rtxpro6000"].includes(s.hw),
disabled: (s) => ["rtx5090", "rtx4090"].includes(s.hw) && Number(s.gpus_per_node) === 1,
disableReason: "The full resident pipeline exceeds one consumer GPU's memory. Select CPU offload.",
flags: (s) => [Number(s.gpus_per_node) === 1 ? "--performance-mode speed" : "--performance-mode manual"],
flags: ["--performance-mode speed"],
description: "Keep all components on the GPU. Recommended for H200, B200, and RTX PRO 6000 96GB. RTX 5090 and RTX 4090 need offload.",
},
{
id: "offload", label: "CPU offload",
flags: (s) => ["--performance-mode manual", "--dit-layerwise-offload true", ...(s.hw === "rtx4090" ? ["--text-encoder-cpu-offload true"] : [])],
flags: (s) => ["--performance-mode manual", "--dit-layerwise-offload true", ...(s.hw === "rtx4090" ? ["--text-encoder-cpu-offload true"] : []),
...(s.hw === "rtx4090" && Number(s.gpus_per_node) === 1 && effectiveAttention(s) === "fa" && s.precision === "native" && s.execution === "eager"
&& ["text", "edit"].includes(s.mode) && Number(s.outputs) === 1 && (!s.batching || s.batching === "off")
? ["--dit-layerwise-resident-layers 8"] : [])],
recommendedWhen: (s) => ["rtx5090", "rtx4090"].includes(s.hw),
soft: (s) => !["rtxpro6000", "rtx5090", "rtx4090"].includes(s.hw) || Number(s.gpus_per_node) !== 1,
softReason: "This offload topology has not completed an HTTP verification run.",
description: "Streams DiT layers. RTX 4090 also offloads the encoder between requests to leave room for image editing. Requires sufficient host RAM.",
description: "Streams DiT layers. RTX 4090 also offloads the encoder. Its native single-output FlashAttention recipe keeps 8 DiT layers resident; batch recipes stream every layer for memory headroom. Requires sufficient host RAM.",
},
{
id: "all_offload", label: "All components layerwise",
@@ -207,9 +210,32 @@ const config = {
{ id: "eager", label: "Eager", recommended: true },
{
id: "bcg", label: "Breakable CUDA Graph",
flags: ["--enable-breakable-cuda-graph true", "--warmup-resolutions 512x512", "--bcg-text-buckets 64"],
soft: true, softReason: "Only a matching 512px CLI warmup was verified. Other prompts or image prefixes can fall back to eager.",
description: "Captures a 512px warmup. Text buckets do not pad condition KV; this is not a guaranteed replay recipe.",
flags: (s) => ["--enable-breakable-cuda-graph true", `--warmup-resolutions ${s.resolution || "1024"}x${s.resolution || "1024"}`, "--bcg-text-buckets 64"],
soft: true, softReason: "A 1024px H200 server captured its warmup graph, but tested requests fell back to eager because condition-prefix shapes differed.",
description: "Captures the selected resolution. Condition-prefix shapes must also match warmup; text buckets alone do not ensure replay.",
},
],
},
{
id: "batching",
title: "Request batching",
scope: "serve",
description: "Merge compatible text-to-image requests. Image edits run separately; Outputs controls multiple images within one request. Batching preserves native precision but can change floating-point rounding and output pixels.",
learnMore: "#batching",
default: "off",
options: [
{ id: "off", label: "Off", recommended: true, flags: ["--batching-max-size 1"], description: "Recommended for interactive latency. Resident H200, B200, and RTX PRO 6000 batching did not materially improve throughput in the measured workload." },
{
id: "2", label: "Up to 2 images",
flags: ["--batching-max-size 2", "--batching-delay-ms 20"],
description: "Wait up to 20 ms to merge compatible queued requests. The tested RTX 4090 offload recipe benefits under concurrent load; each response takes longer.",
},
{
id: "4", label: "Up to 4 images",
flags: ["--batching-max-size 4", "--batching-delay-ms 20"],
soft: (s) => ["rtx4090", "rtx5090"].includes(s.hw),
softReason: "Four-image batches have not been verified on this GPU and can exceed its memory.",
description: "Larger batches increase activation memory and individual request latency.",
},
],
},
@@ -257,12 +283,14 @@ const config = {
resource: {
limits: { nodes: { min: 1, max: 1 }, gpus_per_node: { min: 1, max: 4 } },
verifiedRecipes: [
{ id: "h200-1-resident", hw: "h200", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa"], default: true },
{ id: "b200-1-resident", hw: "b200", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa", "sdpa"], default: true },
{ id: "rtxpro6000-1-resident", hw: "rtxpro6000", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["sdpa"], default: true },
{ id: "h200-1-resident", hw: "h200", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa", "sdpa"], batchSizes: [1, 2, 4], batchAttentions: ["fa"], default: true },
{ id: "b200-1-resident", hw: "b200", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa", "sdpa"], batchSizes: [1, 2, 4], batchAttentions: ["fa"], default: true },
{ id: "b200-2-tp", hw: "b200", nodes: 1, gpus_per_node: 2, placement: "resident", tp_size: 2, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa"], batchSizes: [1, 2] },
{ id: "b200-2-ulysses", hw: "b200", nodes: 1, gpus_per_node: 2, placement: "resident", tp_size: 1, ulysses_degree: 2, ring_degree: 1, encoder: "auto", attentions: ["fa"], batchSizes: [1, 2] },
{ id: "rtxpro6000-1-resident", hw: "rtxpro6000", nodes: 1, gpus_per_node: 1, placement: "resident", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["sdpa"], batchSizes: [1, 2, 4], default: true },
{ id: "rtxpro6000-1-offload", hw: "rtxpro6000", nodes: 1, gpus_per_node: 1, placement: "offload", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["sdpa"] },
{ id: "rtx5090-1-offload", hw: "rtx5090", nodes: 1, gpus_per_node: 1, placement: "offload", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["sdpa"], default: true },
{ id: "rtx4090-1-offload", hw: "rtx4090", nodes: 1, gpus_per_node: 1, placement: "offload", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa"], default: true },
{ id: "rtx4090-1-offload", hw: "rtx4090", nodes: 1, gpus_per_node: 1, placement: "offload", tp_size: 1, ulysses_degree: 1, ring_degree: 1, encoder: "auto", attentions: ["fa", "sdpa"], batchSizes: [1, 2], batchAttentions: ["fa"], default: true },
],
autoTopology: (s) => ({ tp_size: 1, ulysses_degree: Number(s.gpus_per_node), ring_degree: 1 }),
validateTopology: (s, topology) => {
@@ -293,10 +321,14 @@ const config = {
&& entry.ulysses_degree === topology.ulysses_degree && entry.ring_degree === topology.ring_degree);
const serveVerified = !!recipe && errors.length === 0 && s.encoder === "auto"
&& recipe.attentions.includes(effectiveAttention(s)) && s.precision === "native"
&& s.execution === "eager" && s.vae === "full";
&& s.execution === "eager" && s.vae === "full"
&& (!s.batching || s.batching === "off" || ((recipe.batchSizes || [1]).includes(Number(s.batching))
&& (recipe.batchAttentions || recipe.attentions).includes(effectiveAttention(s))));
// Exact HTTP workloads from the validation matrix, not blanket quality coverage.
const requestVerified = serveVerified
&& ((["text", "edit"].includes(s.mode) && s.resolution === "1024" && Number(s.steps) === 40 && Number(s.outputs) === 1
&& ((recipe.batchSizes && (Number(s.outputs) === 1 || (recipe.batchAttentions || recipe.attentions).includes(effectiveAttention(s)))
&& ["text", "edit"].includes(s.mode) && s.resolution === "1024" && Number(s.steps) === 40 && recipe.batchSizes.includes(Number(s.outputs)))
|| (["text", "edit"].includes(s.mode) && s.resolution === "1024" && Number(s.steps) === 40 && Number(s.outputs) === 1
&& (["h200", "rtxpro6000"].includes(s.hw) || s.mode === "text" || s.background === "scene"))
|| (s.hw === "h200" && s.background === "scene" && s.mode === "text" && s.resolution === "512" && Number(s.steps) === 4 && Number(s.outputs) === 2)
|| (s.hw === "h200" && s.background === "scene" && s.mode === "multi" && s.resolution === "512" && Number(s.steps) === 4 && Number(s.outputs) === 1));
@@ -307,6 +339,8 @@ const config = {
if (topology.ring_degree > 1) flags.push(`--ring-degree ${topology.ring_degree}`);
flags.push("--host {{HOST_IP}}", "--port {{PORT}}");
const warnings = [];
if (s.hw === "rtx4090" && (Number(s.outputs) > 1 || (s.batching && s.batching !== "off"))) warnings.push("This recipe streams all DiT layers. Use the updated Server command if switching from the single-output recipe with 8 resident layers.");
if (s.batching && s.batching !== "off" && s.mode !== "text") warnings.push("Cross-request batching applies to text-to-image requests. Image edits run separately; use Outputs for multiple images in one edit request.");
if (!serveVerified && !errors.length) warnings.push("This server combination has not completed an exact HTTP verification run.");
if (!requestVerified && !errors.length) warnings.push("This request shape is outside the verified HTTP matrix.");
return {
+5 -1
View File
@@ -93,7 +93,11 @@ sglang generate --model-path /models/qwen-image-2.1 --model-id Qwen-Image-2.1 \
Add `--image-path /path/to/input.png` for editing. Dimensions must be multiples
of 32. Full-checkpoint generation and editing have been tested on H200; see the
[model cookbook](../../../docs/cookbook/diffusion/Qwen-Image/Qwen-Image-2.1.mdx)
for component requirements and optimization boundaries.
for component requirements and optimization boundaries. Compatible text-to-image
requests support opt-in dynamic batching with `--batching-max-size 2` when
serving. Image edits are not merged across requests; use `n` for multiple
outputs within an edit request. Batching can improve offload throughput, but
changes floating-point rounding and is not always faster with resident weights.
### Component residency
@@ -27,6 +27,10 @@ class QwenImage21PipelineConfig(ImagePipelineConfig):
text_encoder_configs: tuple = field(default_factory=lambda: (Qwen3VLConfig(),))
text_encoder_precisions: tuple[str, ...] = ("bf16",)
def supports_dynamic_batching(self):
# the scheduler excludes reference-image requests from cross-request merging
return True
def prepare_sigmas(self, sigmas, num_inference_steps):
return self._prepare_sigmas(sigmas, num_inference_steps)
@@ -102,6 +102,7 @@ def build_layout(image_slots, image_shapes, axes_dims, device):
)
rope = torch.polar(torch.ones_like(angles), angles)
return dict(
encoder_seq_len=len(image_slots),
text_indices=torch.tensor(indices, device=device, dtype=torch.long),
image_indices=torch.tensor(image_indices, device=device, dtype=torch.long),
prefix_rope=rope[:prefix_len],
@@ -307,7 +308,7 @@ class QwenImage21Attention(nn.Module):
v,
)
def forward(self, x, rope, prefix, prefix_rope, segments, cache):
def attend_sample(self, q, k, v, rope, prefix, prefix_rope, segments, cache):
if cache:
kp, vp = cache["key"], cache["value"]
prefix_output = None
@@ -319,8 +320,8 @@ class QwenImage21Attention(nn.Module):
mask = None
if not is_image:
mask = (
torch.arange(end, device=x.device)[None, :]
<= torch.arange(start, end, device=x.device)[:, None]
torch.arange(end, device=q.device)[None, :]
<= torch.arange(start, end, device=q.device)[:, None]
)
mask = mask[None, None]
outputs.append(
@@ -331,7 +332,6 @@ class QwenImage21Attention(nn.Module):
prefix_output = self.to_out[0](torch.cat(outputs, dim=1).flatten(2))[0]
if cache is not None:
cache.update(key=kp, value=vp)
q, k, v = self.project_qkv(x)
q = apply_qk_norm_rope(q, self.norm_q, rope)
packed = None
if (
@@ -358,7 +358,26 @@ class QwenImage21Attention(nn.Module):
else:
k = apply_qk_norm_rope(k, self.norm_k, rope)
out = self.target_attn.forward_with_replicated_kv_prefix(q, kp, vp, k, v)
return self.to_out[0](out.flatten(2))[0], prefix_output
return out, prefix_output
def forward(self, x, ropes, prefixes, layouts, caches):
# batch target projections while retaining each sample's unpadded prefix
q, k, v = self.project_qkv(x)
outputs, prefix_outputs = [], []
for sample, layout in enumerate(layouts):
out, prefix_out = self.attend_sample(
q[sample : sample + 1],
k[sample : sample + 1],
v[sample : sample + 1],
ropes[sample],
prefixes[sample],
layout["prefix_rope"],
layout["segments"],
caches[sample],
)
outputs.append(out)
prefix_outputs.append(prefix_out)
return self.to_out[0](torch.cat(outputs).flatten(2))[0], prefix_outputs
class QwenImage21TransformerBlock(nn.Module):
@@ -379,25 +398,27 @@ class QwenImage21TransformerBlock(nn.Module):
self,
hidden_states,
modulation,
prefix_state,
prefix_states,
prefix_modulation,
layout,
rope,
cache,
layouts,
ropes,
caches,
):
prefix = prefix_state.get("hidden_states")
scale1, gate1, scale2, gate2 = modulation
p = None
if not cache:
ps1, pg1, ps2, pg2 = prefix_modulation
p = apply_modulation(prefix, self.img_norm1, ps1)
attention, prefix_attention = self.attn(
prefixes = [
apply_modulation(
state["hidden_states"], self.img_norm1, prefix_modulation[0]
)
if not cache
else None
for state, cache in zip(prefix_states, caches, strict=True)
]
attention, prefix_attentions = self.attn(
apply_modulation(hidden_states, self.img_norm1, scale1),
rope,
p,
layout["prefix_rope"],
layout["segments"],
cache,
ropes,
prefixes,
layouts,
caches,
)
hidden_states = residual_gate_add(hidden_states, attention, gate1)
hidden_states = residual_gate_add(
@@ -405,14 +426,15 @@ class QwenImage21TransformerBlock(nn.Module):
self.img_mlp(apply_modulation(hidden_states, self.img_norm2, scale2)),
gate2,
)
if prefix_attention is not None:
prefix = residual_gate_add(prefix, prefix_attention, pg1)
prefix = residual_gate_add(
prefix,
self.img_mlp(apply_modulation(prefix, self.img_norm2, ps2)),
pg2,
)
prefix_state["hidden_states"] = prefix
for state, attention in zip(prefix_states, prefix_attentions, strict=True):
if attention is not None:
_, pg1, ps2, pg2 = prefix_modulation
prefix = residual_gate_add(state["hidden_states"], attention, pg1)
state["hidden_states"] = residual_gate_add(
prefix,
self.img_mlp(apply_modulation(prefix, self.img_norm2, ps2)),
pg2,
)
return hidden_states
@@ -505,39 +527,35 @@ class QwenImage21Transformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin
timestep.new_zeros(1).to(images.dtype), images.dtype
)
prefix_modulation = self.prepare_modulation(zero_temb)
outputs = []
if prefix_caches is None:
prefix_caches = [[None] * len(self.transformer_blocks) for _ in layouts]
prefix_states, ropes = [], []
for sample, layout in enumerate(layouts):
caches = (
prefix_caches[sample]
if prefix_caches is not None
else [None] * len(self.transformer_blocks)
)
prefix = None
if not caches[0]:
if not prefix_caches[sample][0]:
prefix = self.txt_in(
encoder_hidden_states[sample : sample + 1]
encoder_hidden_states[
sample : sample + 1, : layout["encoder_seq_len"]
]
).index_select(1, layout["text_indices"])
if condition_latents is not None:
prefix[:, layout["image_indices"]] = self.img_in(
condition_latents[sample : sample + 1]
)
prefix_state = {"hidden_states": prefix}
x = images[sample : sample + 1]
sample_modulation = tuple(
value[sample : sample + 1] for value in modulation
prefix_states.append({"hidden_states": prefix})
ropes.append(layout["target_rope"][start:end])
# visit each block once so layerwise offload transfers weights once per batch
for i, block in enumerate(self.transformer_blocks):
images = block(
images,
modulation,
prefix_states,
prefix_modulation,
layouts,
ropes,
[cache[i] for cache in prefix_caches],
)
for i, block in enumerate(self.transformer_blocks):
x = block(
x,
sample_modulation,
prefix_state,
prefix_modulation,
layout,
layout["target_rope"][start:end],
caches[i],
)
outputs.append(self.proj_out(self.norm_out(x, temb[sample : sample + 1])))
output = torch.cat(outputs)
output = self.proj_out(self.norm_out(images, temb))
if sp > 1:
output = sequence_model_parallel_all_gather(output, dim=1)
return output
@@ -20,6 +20,7 @@ from sglang.multimodal_gen.configs.models.vaes.qwenimage21 import (
from sglang.multimodal_gen.configs.pipeline_configs.qwen_image21 import (
QwenImage21PipelineConfig,
)
from sglang.multimodal_gen.configs.sample.qwenimage21 import QwenImage21SamplingParams
from sglang.multimodal_gen.registry import _get_config_info
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
ResidencyState,
@@ -27,6 +28,7 @@ from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager im
from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency_strategies import (
ComponentOffloadStrategy,
)
from sglang.multimodal_gen.runtime.managers.scheduler import Scheduler
from sglang.multimodal_gen.runtime.models.dits.qwen_image21 import build_layout
from sglang.multimodal_gen.runtime.models.encoders.qwen3vl_vision import (
Qwen3VLVisionRotaryEmbedding,
@@ -37,6 +39,7 @@ from sglang.multimodal_gen.runtime.models.vaes.autoencoder_kl_qwenimage21 import
_patchify,
_unpatchify,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch, Req
from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import (
InputValidationStage,
)
@@ -184,6 +187,31 @@ def test_latent_pack_decode_contract():
)
@pytest.mark.parametrize("outputs", [1, 2])
def test_dynamic_batching_preserves_output_order_and_seeds(outputs):
scheduler = object.__new__(Scheduler)
config = QwenImage21PipelineConfig()
scheduler.server_args = SimpleNamespace(pipeline_config=config)
scheduler._batch_admission = SimpleNamespace(enabled=True)
assert scheduler._dynamic_batching_enabled()
requests = []
for i, prompt in enumerate(["short", "a longer prompt"]):
params = QwenImage21SamplingParams(
prompt=prompt, seed=7 + i * 10, num_outputs_per_prompt=outputs
)
requests.append(Req(request_id=f"request-{i}", sampling_params=params))
merged = scheduler._try_merge_generation_reqs(requests)
assert merged.prompt == ["short", "a longer prompt"]
assert merged.extra["dynamic_batch_seeds"] == [7, 17]
result = torch.arange(outputs * 2).reshape(-1, 1)
split = scheduler._split_batched_output(OutputBatch(output=result), requests)
assert len(split) == 2
torch.testing.assert_close(split[0].output, result[:outputs])
torch.testing.assert_close(split[1].output, result[outputs:])
requests[1].image_path = "reference.png"
assert scheduler._try_merge_generation_reqs(requests) is None
@pytest.mark.parametrize("channels", [3, 4])
@pytest.mark.parametrize("tiling", [False, True])
def test_native_vae_roundtrip_shapes_and_checkpoint_names(channels, tiling):
@@ -101,6 +101,81 @@ def inputs(seed, edit):
)
def batched_inputs(samples):
max_length = max(sample["encoder_hidden_states"].shape[1] for sample in samples)
return dict(
hidden_states=torch.cat([sample["hidden_states"] for sample in samples]),
encoder_hidden_states=torch.cat(
[
torch.nn.functional.pad(
sample["encoder_hidden_states"],
(0, 0, 0, max_length - sample["encoder_hidden_states"].shape[1]),
)
for sample in samples
]
),
condition_latents=(
torch.cat([sample["condition_latents"] for sample in samples])
if samples[0]["condition_latents"] is not None
else None
),
layouts=[sample["layouts"][0] for sample in samples],
prefix_caches=[sample["prefix_caches"][0] for sample in samples],
timestep=torch.cat([sample["timestep"] for sample in samples]),
)
@pytest.mark.parametrize("edit", [False, True])
@torch.no_grad()
def test_batched_targets_preserve_ragged_prefixes_and_cache_ownership(model, edit):
samples = [inputs(seed, edit) for seed in (5, 9)]
slots = [False] * 5 + ([True, False, False] if edit else [])
samples[1]["encoder_hidden_states"] = torch.randn(1, len(slots), 16, device="cuda")
samples[1]["layouts"] = [
build_layout(
slots, ([(1, 2, 4)] if edit else []) + [(1, 4, 4)], (8, 12, 12), "cuda"
)
]
batch = batched_inputs(deepcopy(samples))
for timestep in (700, 300, 10):
for sample in samples:
sample["timestep"].fill_(timestep)
batch["timestep"].fill_(timestep)
with set_forward_context(None, None):
expected = torch.cat([model(**sample) for sample in samples])
calls = []
handles = [
block.register_forward_pre_hook(
lambda module, args: calls.append(args[0].shape)
)
for block in model.transformer_blocks
]
try:
with set_forward_context(None, None):
actual = model(**batch)
finally:
for handle in handles:
handle.remove()
assert calls == [torch.Size([2, 16, model.hidden_size])] * len(
model.transformer_blocks
)
torch.testing.assert_close(actual, expected, atol=2e-6, rtol=1e-5)
for sample, caches in zip(samples, batch["prefix_caches"], strict=True):
for cache, reference in zip(
caches, sample["prefix_caches"][0], strict=True
):
torch.testing.assert_close(
cache["key"], reference["key"], atol=0, rtol=0
)
torch.testing.assert_close(
cache["value"], reference["value"], atol=0, rtol=0
)
assert (
batch["prefix_caches"][0][0]["key"].data_ptr()
!= batch["prefix_caches"][1][0]["key"].data_ptr()
)
def test_bf16_qk_norm_matches_reference(model):
norm = deepcopy(model.transformer_blocks[0].attn.norm_q).bfloat16()
reference = ReferenceRMSNorm(32, eps=1e-6).cuda().bfloat16()
@@ -182,8 +257,10 @@ def test_cached_prefix_matches_full_recomputation(model, edit):
@pytest.mark.parametrize("edit", [False, True])
def test_graph_replay_uses_new_request_prefix(model, edit):
first, second = inputs(5, edit), inputs(9, edit)
@pytest.mark.parametrize("sample_count", [1, 2])
def test_graph_replay_uses_new_request_prefix(model, edit, sample_count):
first = batched_inputs([inputs(5 + i, edit) for i in range(sample_count)])
second = batched_inputs([inputs(9 + i, edit) for i in range(sample_count)])
runner = DiffusionBreakableCudaGraphRunner(model, torch.device("cuda"))
try:
with torch.no_grad(), set_forward_context(None, None):