[Diffusion] Return Qwen-Image-Layered outputs and preserve CFG2 rounding (#38549)
Co-authored-by: Mick Qian <mickqian@users.noreply.github.com>
This commit is contained in:
co-authored by
Mick Qian
parent
3e035a3513
commit
23bc4c6ed9
@@ -26,6 +26,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.model_deployment_config impo
|
||||
from sglang.multimodal_gen.configs.post_training.pipeline_configs import (
|
||||
QwenImageRolloutPipelineMixin,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.cfg_policy import CFGPolicy
|
||||
from sglang.multimodal_gen.runtime.utils.condition_expansion import (
|
||||
PromptToSampleBatchExpander,
|
||||
)
|
||||
@@ -778,6 +779,9 @@ class QwenImageEditPlus_2511_PipelineConfig(QwenImageEditPlusPipelineConfig):
|
||||
class QwenImageLayeredPipelineConfig(QwenImageEditPipelineConfig):
|
||||
resolution: int = 640
|
||||
vae_precision: str = "bf16"
|
||||
cfg_policy: CFGPolicy = field(
|
||||
default_factory=lambda: CFGPolicy(parallel_uses_serial_arithmetic=True)
|
||||
)
|
||||
# promoting the auxiliary components regresses first-request latency
|
||||
supports_auto_residency: bool = False
|
||||
|
||||
|
||||
@@ -40,3 +40,7 @@ class QwenImageLayeredSamplingParams(QwenImageSamplingParams):
|
||||
num_inference_steps: int = 50
|
||||
cfg_normalize: bool = True
|
||||
use_en_prompt: bool = True
|
||||
|
||||
@property
|
||||
def num_samples_per_request(self) -> int:
|
||||
return self.num_frames
|
||||
|
||||
@@ -507,6 +507,11 @@ class SamplingParams:
|
||||
|
||||
return frozenset()
|
||||
|
||||
@property
|
||||
def num_samples_per_request(self) -> int:
|
||||
"""Number of final samples produced by one expanded scheduler request."""
|
||||
return 1
|
||||
|
||||
@classmethod
|
||||
def default_image_output_format(cls) -> str | None:
|
||||
"""Return a model-owned default format for the image API, if any."""
|
||||
|
||||
@@ -43,6 +43,9 @@ class CFGPolicy:
|
||||
"""
|
||||
|
||||
branches: list[CFGBranch] = field(default_factory=list)
|
||||
# Gather predictions before combining when a model needs the same bf16
|
||||
# rounding as serial CFG. The default retains legacy WAN all-reduce outputs.
|
||||
parallel_uses_serial_arithmetic: bool = False
|
||||
|
||||
def build(
|
||||
self,
|
||||
@@ -83,7 +86,7 @@ class CFGPolicy:
|
||||
return predictions[0]
|
||||
pos_t = _wrap(predictions[0])
|
||||
neg_t = _wrap(predictions[1])
|
||||
if cfg_parallel:
|
||||
if cfg_parallel and not self.parallel_uses_serial_arithmetic:
|
||||
# Match the old CFG-parallel calculation: multiply the positive
|
||||
# prediction by cfg_scale and the negative prediction by
|
||||
# (1 - cfg_scale) before adding them. The serial CFG formula is
|
||||
|
||||
@@ -30,6 +30,7 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
GenerationResult,
|
||||
expand_request_outputs,
|
||||
format_lora_message,
|
||||
map_request_outputs,
|
||||
prepare_request,
|
||||
save_outputs,
|
||||
)
|
||||
@@ -234,8 +235,9 @@ class DiffGenerator:
|
||||
) -> GenerationResult | list[GenerationResult] | None:
|
||||
"""Generate image(s)/video(s) based on the given prompt(s).
|
||||
|
||||
Returns a single GenerationResult for a single prompt, a list for
|
||||
multiple prompts, or None when every request failed.
|
||||
Returns one GenerationResult per final sample, including each layer
|
||||
of a layered image. Returns a single result without a list wrapper,
|
||||
or None when every request failed.
|
||||
"""
|
||||
# 1. prepare requests
|
||||
prompts = self._resolve_prompts(
|
||||
@@ -307,7 +309,9 @@ class DiffGenerator:
|
||||
global_output_index = 0
|
||||
|
||||
for requests in request_groups:
|
||||
output_requests = []
|
||||
try:
|
||||
output_requests = map_request_outputs(requests)
|
||||
timer_prompt = [req.prompt for req in requests]
|
||||
logger.info("Processing %d grouped request(s)", len(requests))
|
||||
with ExitStack() as stack:
|
||||
@@ -332,10 +336,11 @@ class DiffGenerator:
|
||||
if requests[0].save_output and requests[0].return_file_paths_only:
|
||||
output_file_paths = output_batch.output_file_paths or []
|
||||
self._validate_output_count(
|
||||
len(output_file_paths), len(requests)
|
||||
len(output_file_paths), len(output_requests)
|
||||
)
|
||||
for idx, path in enumerate(output_file_paths):
|
||||
req = requests[idx]
|
||||
output_request = output_requests[idx]
|
||||
req = output_request.request
|
||||
if req.data_type == DataType.VIDEO:
|
||||
req.sampling_params.validate_video_final_outputs(
|
||||
[path], req
|
||||
@@ -343,7 +348,10 @@ class DiffGenerator:
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
req,
|
||||
output_batch,
|
||||
timer.duration,
|
||||
output_request.request_index,
|
||||
),
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=path,
|
||||
@@ -352,14 +360,18 @@ class DiffGenerator:
|
||||
elif requests[0].data_type == DataType.MESH:
|
||||
output_file_paths = output_batch.output_file_paths or []
|
||||
self._validate_output_count(
|
||||
len(output_file_paths), len(requests)
|
||||
len(output_file_paths), len(output_requests)
|
||||
)
|
||||
for idx, sample in enumerate(output_file_paths):
|
||||
req = requests[idx]
|
||||
output_request = output_requests[idx]
|
||||
req = output_request.request
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
req,
|
||||
output_batch,
|
||||
timer.duration,
|
||||
output_request.request_index,
|
||||
),
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=sample,
|
||||
@@ -367,7 +379,7 @@ class DiffGenerator:
|
||||
)
|
||||
else:
|
||||
self._validate_output_count(
|
||||
len(output_batch.output), len(requests)
|
||||
len(output_batch.output), len(output_requests)
|
||||
)
|
||||
samples_out: list[Any] = []
|
||||
audios_out: list[Any] = []
|
||||
@@ -377,7 +389,7 @@ class DiffGenerator:
|
||||
requests[0].data_type,
|
||||
requests[0].fps,
|
||||
requests[0].save_output,
|
||||
lambda idx: requests[idx].output_file_path(1, 0),
|
||||
lambda idx: output_requests[idx].output_file_path(),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
samples_out=samples_out,
|
||||
@@ -400,8 +412,9 @@ class DiffGenerator:
|
||||
)
|
||||
|
||||
for idx in range(len(samples_out)):
|
||||
req = requests[idx]
|
||||
output_file_path = req.output_file_path(1, 0)
|
||||
output_request = output_requests[idx]
|
||||
req = output_request.request
|
||||
output_file_path = output_request.output_file_path()
|
||||
if req.data_type == DataType.VIDEO and req.save_output:
|
||||
req.sampling_params.validate_video_final_outputs(
|
||||
[output_file_path], req
|
||||
@@ -409,7 +422,10 @@ class DiffGenerator:
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
req,
|
||||
output_batch,
|
||||
timer.duration,
|
||||
output_request.request_index,
|
||||
),
|
||||
samples=samples_out[idx],
|
||||
frames=frames_out[idx],
|
||||
@@ -432,7 +448,7 @@ class DiffGenerator:
|
||||
"Failed to clean up model-owned video request resources",
|
||||
exc_info=True,
|
||||
)
|
||||
global_output_index += len(requests)
|
||||
global_output_index += len(output_requests)
|
||||
|
||||
total_gen_time = time.perf_counter() - total_start_time
|
||||
if self.server_args.batching_max_size > 1:
|
||||
|
||||
@@ -219,6 +219,31 @@ class MaterializedOutput:
|
||||
fps: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestOutput:
|
||||
"""Map one final sample to its request, metrics and output filename."""
|
||||
|
||||
request: Req
|
||||
request_index: int
|
||||
sample_index: int
|
||||
sample_count: int
|
||||
|
||||
def output_file_path(self):
|
||||
return self.request.output_file_path(self.sample_count, self.sample_index)
|
||||
|
||||
|
||||
def map_request_outputs(requests: list[Req]) -> list[RequestOutput]:
|
||||
outputs = []
|
||||
for request_index, req in enumerate(requests):
|
||||
count = req.sampling_params.num_samples_per_request
|
||||
if count < 1:
|
||||
raise ValueError(f"num_samples_per_request must be positive, got {count}")
|
||||
outputs.extend(
|
||||
RequestOutput(req, request_index, index, count) for index in range(count)
|
||||
)
|
||||
return outputs
|
||||
|
||||
|
||||
def _normalize_audio_to_numpy(audio: Any) -> np.ndarray | None:
|
||||
"""Convert audio (torch / numpy) into a float32 numpy array in [-1, 1], best-effort."""
|
||||
if audio is None:
|
||||
|
||||
@@ -47,6 +47,7 @@ from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
map_request_outputs,
|
||||
materialize_output_sample,
|
||||
post_process_sample,
|
||||
save_outputs,
|
||||
@@ -1196,9 +1197,10 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
) -> None:
|
||||
if not self.is_output_rank or output_batch.output is None:
|
||||
return
|
||||
if len(output_batch.output) != len(reqs):
|
||||
output_requests = map_request_outputs(reqs)
|
||||
if len(output_batch.output) != len(output_requests):
|
||||
raise RuntimeError(
|
||||
f"Expected {len(reqs)} grouped outputs, got {len(output_batch.output)}"
|
||||
f"Expected {len(output_requests)} grouped outputs, got {len(output_batch.output)}"
|
||||
)
|
||||
|
||||
first_req = reqs[0]
|
||||
@@ -1207,7 +1209,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
first_req.data_type,
|
||||
first_req.fps,
|
||||
True,
|
||||
lambda idx: reqs[idx].output_file_path(1, 0),
|
||||
lambda idx: output_requests[idx].output_file_path(),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=first_req.output_compression,
|
||||
|
||||
@@ -2254,6 +2254,7 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
if (
|
||||
len(cfg_policy.branches) == 2
|
||||
and get_classifier_free_guidance_world_size() == 2
|
||||
and not cfg_policy.parallel_uses_serial_arithmetic
|
||||
):
|
||||
return run_two_branch_cfg_parallel(
|
||||
cfg_policy,
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import (
|
||||
QwenImageLayeredPipelineConfig,
|
||||
QwenImagePipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.qwenimage import (
|
||||
QwenImageLayeredSamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.cfg_policy import CFGPolicy
|
||||
from sglang.multimodal_gen.runtime.entrypoints import diffusion_generator as dg
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import map_request_outputs
|
||||
from sglang.multimodal_gen.runtime.managers import gpu_worker
|
||||
from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch, Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising import DenoisingStage
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
|
||||
class TestQwenImageLayeredPipelineConfig(unittest.TestCase):
|
||||
@@ -43,5 +62,211 @@ class TestQwenImageLayeredPipelineConfig(unittest.TestCase):
|
||||
self.assertEqual(unpacked.shape, (1, channels // 4, generated_layers, 80, 80))
|
||||
|
||||
|
||||
class TestLayeredOutputMapping(CustomTestCase):
|
||||
def _params(self, **kwargs):
|
||||
return QwenImageLayeredSamplingParams(
|
||||
prompt="layers",
|
||||
num_frames=4,
|
||||
output_path="/tmp",
|
||||
output_file_name="layers.png",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _generate(self, params, response, save_mock=None):
|
||||
generator = object.__new__(DiffGenerator)
|
||||
generator.local_scheduler_process = []
|
||||
generator.owns_scheduler_client = False
|
||||
generator.server_args = SimpleNamespace(
|
||||
model_path="Qwen/Qwen-Image-Layered",
|
||||
prompt_file_path=None,
|
||||
warmup_mode="off",
|
||||
batching_max_size=1,
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
SamplingParams, "from_user_sampling_params_args", return_value=params
|
||||
),
|
||||
patch.object(
|
||||
dg,
|
||||
"prepare_request",
|
||||
side_effect=lambda **kw: Req(sampling_params=kw["sampling_params"]),
|
||||
),
|
||||
patch.object(
|
||||
generator,
|
||||
"_send_to_scheduler_and_wait_for_response",
|
||||
return_value=response,
|
||||
),
|
||||
patch.object(dg, "save_outputs", side_effect=save_mock),
|
||||
):
|
||||
return generator.generate(
|
||||
{"prompt": "layers", "output_file_name": "layers.png"}
|
||||
)
|
||||
|
||||
def test_all_four_saved_layers_are_returned(self):
|
||||
paths = [f"/tmp/layers_{i}.png" for i in range(4)]
|
||||
result = self._generate(
|
||||
self._params(save_output=True, return_file_paths_only=True),
|
||||
OutputBatch(output_file_paths=paths),
|
||||
)
|
||||
self.assertEqual([r.output_file_path for r in result], paths)
|
||||
self.assertEqual([r.prompt_index for r in result], [0, 1, 2, 3])
|
||||
|
||||
def test_grouped_layers_keep_their_request_metrics(self):
|
||||
paths = [
|
||||
f"/tmp/layers_{draw}_{layer}.png" for draw in range(2) for layer in range(4)
|
||||
]
|
||||
metrics = [
|
||||
SimpleNamespace(to_dict=lambda index=index: {"request": index})
|
||||
for index in range(2)
|
||||
]
|
||||
result = self._generate(
|
||||
self._params(
|
||||
save_output=True, return_file_paths_only=True, num_outputs_per_prompt=2
|
||||
),
|
||||
OutputBatch(output_file_paths=paths, metrics_list=metrics),
|
||||
)
|
||||
self.assertEqual([r.output_file_path for r in result], paths)
|
||||
self.assertEqual([r.metrics["request"] for r in result], [0] * 4 + [1] * 4)
|
||||
self.assertEqual([r.prompt_index for r in result], list(range(8)))
|
||||
|
||||
def test_returned_samples_use_unique_layer_filenames(self):
|
||||
samples = [np.full((2, 2, 4), index, dtype=np.uint8) for index in range(4)]
|
||||
paths = []
|
||||
|
||||
def save(outputs, data_type, fps, should_save, build_path, **kwargs):
|
||||
paths.extend(build_path(index) for index in range(len(outputs)))
|
||||
kwargs["samples_out"].extend(samples)
|
||||
kwargs["frames_out"].extend([None] * 4)
|
||||
kwargs["audios_out"].extend([None] * 4)
|
||||
|
||||
result = self._generate(
|
||||
self._params(save_output=False, return_file_paths_only=False),
|
||||
OutputBatch(output=samples),
|
||||
save_mock=save,
|
||||
)
|
||||
self.assertEqual(paths, [f"/tmp/layers_{i}.png" for i in range(4)])
|
||||
self.assertEqual([r.output_file_path for r in result], paths)
|
||||
for actual, expected in zip(result, samples):
|
||||
self.assertIs(actual.samples, expected)
|
||||
|
||||
def test_partial_layer_response_is_rejected(self):
|
||||
with self.assertLogs(dg.logger, level="ERROR") as logs:
|
||||
result = self._generate(
|
||||
self._params(save_output=True, return_file_paths_only=True),
|
||||
OutputBatch(output_file_paths=["a.png", "b.png", "c.png"]),
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
self.assertTrue(
|
||||
any("Expected 4 outputs, got 3" in line for line in logs.output)
|
||||
)
|
||||
|
||||
def test_video_frames_do_not_expand_into_separate_results(self):
|
||||
req = Req(
|
||||
sampling_params=SamplingParams(data_type=DataType.VIDEO, num_frames=121)
|
||||
)
|
||||
outputs = map_request_outputs([req])
|
||||
self.assertEqual(len(outputs), 1)
|
||||
self.assertIs(outputs[0].request, req)
|
||||
|
||||
def test_worker_group_saves_each_layer_under_its_parent(self):
|
||||
requests = [
|
||||
Req(sampling_params=self._params(request_id=f"draw{i}")) for i in range(2)
|
||||
]
|
||||
for index, req in enumerate(requests):
|
||||
req.output_file_name = f"draw{index}.png"
|
||||
paths = []
|
||||
|
||||
def save(outputs, data_type, fps, should_save, build_path, **kwargs):
|
||||
paths.extend(build_path(index) for index in range(len(outputs)))
|
||||
return paths
|
||||
|
||||
batch = OutputBatch(output=[None] * 8)
|
||||
with patch.object(gpu_worker, "save_outputs", side_effect=save):
|
||||
GPUWorker._save_group_output_paths(
|
||||
SimpleNamespace(is_output_rank=True), requests, batch
|
||||
)
|
||||
expected = [
|
||||
f"/tmp/draw{draw}_{layer}.png" for draw in range(2) for layer in range(4)
|
||||
]
|
||||
self.assertEqual(paths, expected)
|
||||
self.assertEqual(batch.output_file_paths, expected)
|
||||
|
||||
|
||||
class TestLayeredCFGOrder(CustomTestCase):
|
||||
def test_layered_parallel_uses_serial_formula_and_postprocess(self):
|
||||
config = QwenImageLayeredPipelineConfig()
|
||||
req = SimpleNamespace(
|
||||
do_classifier_free_guidance=True,
|
||||
true_cfg_scale=7.0,
|
||||
cfg_normalization=0,
|
||||
guidance_rescale=0,
|
||||
cfg_normalize=True,
|
||||
)
|
||||
policy = config.cfg_policy.build(req, {}, {}, {})
|
||||
self.assertTrue(policy.parallel_uses_serial_arithmetic)
|
||||
self.assertFalse(
|
||||
QwenImagePipelineConfig().cfg_policy.parallel_uses_serial_arithmetic
|
||||
)
|
||||
pos = torch.tensor([[1.0, 0.3]], dtype=torch.bfloat16)
|
||||
neg = torch.tensor([[0.1, -0.2]], dtype=torch.bfloat16)
|
||||
serial = policy.combine([pos, neg], req, 7.0, config)
|
||||
parallel = policy.combine([pos, neg], req, 7.0, config, cfg_parallel=True)
|
||||
self.assertTrue(torch.equal(serial, parallel))
|
||||
self.assertFalse(torch.equal(serial, neg + 7.0 * (pos - neg)))
|
||||
|
||||
def test_dispatch_gathers_for_layered_and_preserves_legacy_fast_path(self):
|
||||
stage = DenoisingStage.__new__(DenoisingStage)
|
||||
batch = SimpleNamespace(
|
||||
do_classifier_free_guidance=True,
|
||||
cfg_normalization=0,
|
||||
guidance_rescale=0,
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
get_classifier_free_guidance_scale=lambda batch, scale: scale,
|
||||
postprocess_cfg_noise=lambda batch, pred, cond: pred,
|
||||
)
|
||||
pos = torch.tensor([1.0], dtype=torch.bfloat16)
|
||||
neg = torch.tensor([0.1], dtype=torch.bfloat16)
|
||||
module = "sglang.multimodal_gen.runtime.pipelines_core.stages.denoising"
|
||||
for same_order in [False, True]:
|
||||
with self.subTest(same_order=same_order):
|
||||
policy = CFGPolicy(parallel_uses_serial_arithmetic=same_order).build(
|
||||
batch, {}, {}, {}
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
f"{module}.get_classifier_free_guidance_world_size",
|
||||
return_value=2,
|
||||
),
|
||||
patch(
|
||||
f"{module}.run_cfg_parallel", return_value=[pos, neg]
|
||||
) as gather,
|
||||
patch(
|
||||
f"{module}.run_two_branch_cfg_parallel", return_value=pos
|
||||
) as reduce,
|
||||
):
|
||||
result = stage._predict_noise_with_cfg(
|
||||
current_model=None,
|
||||
latent_model_input=pos,
|
||||
timestep=torch.tensor(1),
|
||||
batch=batch,
|
||||
timestep_index=0,
|
||||
attn_metadata=None,
|
||||
target_dtype=torch.bfloat16,
|
||||
current_guidance_scale=7.0,
|
||||
cfg_policy=policy,
|
||||
cfg_gate_state=None,
|
||||
server_args=SimpleNamespace(
|
||||
enable_cfg_parallel=True, pipeline_config=config
|
||||
),
|
||||
guidance=None,
|
||||
latents=pos,
|
||||
)
|
||||
self.assertEqual(gather.call_count, int(same_order))
|
||||
self.assertEqual(reduce.call_count, int(not same_order))
|
||||
expected = neg + 7.0 * (pos - neg) if same_order else pos
|
||||
self.assertTrue(torch.equal(result, expected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user