[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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user