[diffusion] feat: accelerate multiple-outputs generation (#23759)
This commit is contained in:
@@ -48,6 +48,20 @@ PATCH_SIZE = 16
|
||||
PATCH_AREA = PATCH_SIZE * PATCH_SIZE
|
||||
|
||||
|
||||
def _get_response_output_count(resp_json: Dict[str, Any]) -> int:
|
||||
if isinstance(resp_json.get("num_outputs"), int):
|
||||
return resp_json["num_outputs"]
|
||||
if isinstance(resp_json.get("data"), list):
|
||||
return len(resp_json["data"])
|
||||
if isinstance(resp_json.get("file_paths"), list):
|
||||
return len(resp_json["file_paths"])
|
||||
if isinstance(resp_json.get("urls"), list):
|
||||
return len(resp_json["urls"])
|
||||
if resp_json.get("file_path") or resp_json.get("url"):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _compute_scale_factor(req: RequestFuncInput, args) -> Optional[float]:
|
||||
"""Computes the composite scale factor (area × frames × steps) for a request."""
|
||||
width = req.width or args.width
|
||||
@@ -142,6 +156,7 @@ async def async_request_image_sglang(
|
||||
data.add_field("model", input.model)
|
||||
data.add_field("prompt", input.prompt)
|
||||
data.add_field("response_format", "b64_json")
|
||||
data.add_field("n", str(input.num_outputs_per_prompt))
|
||||
|
||||
if input.width and input.height:
|
||||
data.add_field("size", f"{input.width}x{input.height}")
|
||||
@@ -172,6 +187,7 @@ async def async_request_image_sglang(
|
||||
resp_json = await response.json()
|
||||
output.response_body = resp_json
|
||||
output.success = True
|
||||
output.output_count = _get_response_output_count(resp_json)
|
||||
if "peak_memory_mb" in resp_json:
|
||||
output.peak_memory_mb = resp_json["peak_memory_mb"]
|
||||
else:
|
||||
@@ -185,7 +201,7 @@ async def async_request_image_sglang(
|
||||
payload = {
|
||||
"model": input.model,
|
||||
"prompt": input.prompt,
|
||||
"n": 1,
|
||||
"n": input.num_outputs_per_prompt,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
|
||||
@@ -203,6 +219,7 @@ async def async_request_image_sglang(
|
||||
resp_json = await response.json()
|
||||
output.response_body = resp_json
|
||||
output.success = True
|
||||
output.output_count = _get_response_output_count(resp_json)
|
||||
if "peak_memory_mb" in resp_json:
|
||||
output.peak_memory_mb = resp_json["peak_memory_mb"]
|
||||
else:
|
||||
@@ -239,6 +256,7 @@ async def async_request_video_sglang(
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("model", input.model)
|
||||
data.add_field("prompt", input.prompt)
|
||||
data.add_field("num_outputs_per_prompt", str(input.num_outputs_per_prompt))
|
||||
|
||||
if input.width and input.height:
|
||||
data.add_field("size", f"{input.width}x{input.height}")
|
||||
@@ -296,6 +314,7 @@ async def async_request_video_sglang(
|
||||
payload: Dict[str, Any] = {
|
||||
"model": input.model,
|
||||
"prompt": input.prompt,
|
||||
"num_outputs_per_prompt": input.num_outputs_per_prompt,
|
||||
}
|
||||
if input.width and input.height:
|
||||
payload["size"] = f"{input.width}x{input.height}"
|
||||
@@ -350,6 +369,7 @@ async def async_request_video_sglang(
|
||||
if status == "completed":
|
||||
output.success = True
|
||||
output.response_body = status_data
|
||||
output.output_count = _get_response_output_count(status_data)
|
||||
if "peak_memory_mb" in status_data:
|
||||
output.peak_memory_mb = status_data["peak_memory_mb"]
|
||||
break
|
||||
@@ -394,17 +414,29 @@ def calculate_metrics(
|
||||
|
||||
num_success = len(success_outputs)
|
||||
latencies = [o.latency for o in success_outputs]
|
||||
peak_memories = [o.peak_memory_mb for o in success_outputs if o.peak_memory_mb > 0]
|
||||
completed_outputs = sum(o.output_count for o in success_outputs)
|
||||
peak_memories = [
|
||||
o.peak_memory_mb
|
||||
for o in success_outputs
|
||||
if o.peak_memory_mb is not None and o.peak_memory_mb > 0
|
||||
]
|
||||
|
||||
metrics = {
|
||||
"duration": total_duration,
|
||||
"completed_requests": num_success,
|
||||
"completed_outputs": completed_outputs,
|
||||
"failed_requests": len(error_outputs),
|
||||
"throughput_qps": num_success / total_duration if total_duration > 0 else 0,
|
||||
"output_throughput_ops": (
|
||||
completed_outputs / total_duration if total_duration > 0 else 0
|
||||
),
|
||||
"latency_mean": np.mean(latencies) if latencies else 0,
|
||||
"latency_median": np.median(latencies) if latencies else 0,
|
||||
"latency_p99": np.percentile(latencies, 99) if latencies else 0,
|
||||
"latency_p50": np.percentile(latencies, 50) if latencies else 0,
|
||||
"latency_p90": np.percentile(latencies, 90) if latencies else 0,
|
||||
"latency_p95": np.percentile(latencies, 95) if latencies else 0,
|
||||
"latency_p99": np.percentile(latencies, 99) if latencies else 0,
|
||||
"num_outputs_per_prompt": args.num_outputs_per_prompt,
|
||||
"peak_memory_mb_max": max(peak_memories) if peak_memories else 0,
|
||||
"peak_memory_mb_mean": np.mean(peak_memories) if peak_memories else 0,
|
||||
"peak_memory_mb_median": np.median(peak_memories) if peak_memories else 0,
|
||||
@@ -623,14 +655,21 @@ async def benchmark(args):
|
||||
"Successful requests:",
|
||||
f"{metrics['completed_requests']}/{len(requests_list)}",
|
||||
)
|
||||
print_value_formatted("Completed outputs:", metrics["completed_outputs"])
|
||||
print_value_formatted("Outputs per prompt:", metrics["num_outputs_per_prompt"])
|
||||
|
||||
# Section 3: Performance Metrics
|
||||
print_divider(50)
|
||||
|
||||
print_value_formatted("Request throughput (req/s):", metrics["throughput_qps"])
|
||||
print_value_formatted(
|
||||
"Output throughput (outputs/s):", metrics["output_throughput_ops"]
|
||||
)
|
||||
|
||||
print_value_formatted("Latency Mean (s):", metrics["latency_mean"])
|
||||
print_value_formatted("Latency Median (s):", metrics["latency_median"])
|
||||
print_value_formatted("Latency P90 (s):", metrics["latency_p90"])
|
||||
print_value_formatted("Latency P95 (s):", metrics["latency_p95"])
|
||||
print_value_formatted("Latency P99 (s):", metrics["latency_p99"])
|
||||
|
||||
if metrics["peak_memory_mb_max"] > 0:
|
||||
@@ -707,6 +746,12 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--num-prompts", type=int, default=10, help="Number of prompts to benchmark."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of generated outputs requested per prompt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-concurrency",
|
||||
type=int,
|
||||
@@ -735,7 +780,8 @@ if __name__ == "__main__":
|
||||
default=None,
|
||||
help=(
|
||||
"JSON string defining random request profiles. "
|
||||
"Each profile may contain: width, height, num_inference_steps, etc. "
|
||||
"Each profile may contain: width, height, num_inference_steps, "
|
||||
"num_outputs_per_prompt, etc. "
|
||||
"The 'weight' field controls sampling probability (relative weight). "
|
||||
"Example: "
|
||||
'[{"width":512,"height":512,"num_inference_steps":20,"weight":0.15},'
|
||||
|
||||
@@ -22,6 +22,7 @@ class RequestFuncInput:
|
||||
prompt: str
|
||||
api_url: str = ""
|
||||
model: str = ""
|
||||
num_outputs_per_prompt: int = 1
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
num_frames: Optional[int] = None
|
||||
@@ -42,6 +43,7 @@ class RequestFuncOutput:
|
||||
response_body: Dict[str, Any] = field(default_factory=dict)
|
||||
peak_memory_mb: float = 0.0
|
||||
slo_achieved: Optional[bool] = None
|
||||
output_count: int = 0
|
||||
|
||||
|
||||
def is_dir_not_empty(path: str) -> bool:
|
||||
@@ -274,6 +276,7 @@ class VBenchDataset(BaseDataset):
|
||||
prompt=item.get("prompt", ""),
|
||||
api_url=self.api_url,
|
||||
model=self.model,
|
||||
num_outputs_per_prompt=self.args.num_outputs_per_prompt,
|
||||
width=self.args.width,
|
||||
height=self.args.height,
|
||||
num_frames=self.args.num_frames,
|
||||
@@ -315,6 +318,9 @@ class RandomDataset(BaseDataset):
|
||||
prompt=f"Random prompt {idx} for benchmarking diffusion models",
|
||||
api_url=self.api_url,
|
||||
model=self.model,
|
||||
num_outputs_per_prompt=profile.get(
|
||||
"num_outputs_per_prompt", self.args.num_outputs_per_prompt
|
||||
),
|
||||
width=profile.get("width", self.args.width),
|
||||
height=profile.get("height", self.args.height),
|
||||
num_frames=profile.get("num_frames", self.args.num_frames),
|
||||
|
||||
@@ -128,7 +128,7 @@ class SamplingParams:
|
||||
|
||||
# Batch info
|
||||
num_outputs_per_prompt: int = 1
|
||||
seed: int = 42
|
||||
seed: int | list[int] = 42
|
||||
generator_device: str | None = None # None means use the pipeline/model default
|
||||
|
||||
# Original dimensions (before VAE scaling)
|
||||
@@ -313,6 +313,23 @@ class SamplingParams:
|
||||
f"num_outputs_per_prompt must be a positive int, got {self.num_outputs_per_prompt!r}"
|
||||
)
|
||||
|
||||
if isinstance(self.seed, list):
|
||||
if not self.seed:
|
||||
raise ValueError("seed list must not be empty")
|
||||
for seed in self.seed:
|
||||
if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
|
||||
raise ValueError(
|
||||
f"seed list must contain non-negative ints, got {self.seed!r}"
|
||||
)
|
||||
elif (
|
||||
isinstance(self.seed, bool)
|
||||
or not isinstance(self.seed, int)
|
||||
or self.seed < 0
|
||||
):
|
||||
raise ValueError(
|
||||
"seed must be a non-negative int or list of ints, " f"got {self.seed!r}"
|
||||
)
|
||||
|
||||
# Used by seconds() and video writer; fps <= 0 is always invalid.
|
||||
if not isinstance(self.fps, int) or self.fps <= 0:
|
||||
raise ValueError(f"fps must be a positive int, got {self.fps!r}")
|
||||
@@ -728,6 +745,7 @@ class SamplingParams:
|
||||
add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
nargs="+",
|
||||
help="Random seed for generation",
|
||||
)
|
||||
add_argument(
|
||||
@@ -961,11 +979,14 @@ class SamplingParams:
|
||||
sampling_params_fields = {attr.name for attr in dataclasses.fields(cls)}
|
||||
args_attrs = set(vars(args).keys())
|
||||
attrs = sampling_params_fields & args_attrs
|
||||
return {
|
||||
cli_args = {
|
||||
attr: getattr(args, attr)
|
||||
for attr in attrs
|
||||
if hasattr(args, attr) and getattr(args, attr) is not None
|
||||
}
|
||||
if isinstance(cli_args.get("seed"), list) and len(cli_args["seed"]) == 1:
|
||||
cli_args["seed"] = cli_args["seed"][0]
|
||||
return cli_args
|
||||
|
||||
def output_file_path(self):
|
||||
if self.output_path is None:
|
||||
|
||||
@@ -12,6 +12,7 @@ import dataclasses
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, List, Union
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
@@ -25,6 +26,7 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
SetLoraReq,
|
||||
ShutdownReq,
|
||||
UnmergeLoraWeightsReq,
|
||||
expand_request_outputs,
|
||||
format_lora_message,
|
||||
prepare_request,
|
||||
save_outputs,
|
||||
@@ -208,7 +210,7 @@ class DiffGenerator:
|
||||
**sampling_params_kwargs,
|
||||
)
|
||||
|
||||
requests: list[Req] = []
|
||||
request_groups: list[list[Req]] = []
|
||||
image_paths_per_prompt = self._resolve_image_paths_per_prompt(
|
||||
prompts, sampling_params_orig.image_path
|
||||
)
|
||||
@@ -226,19 +228,31 @@ class DiffGenerator:
|
||||
sampling_params=sampling_params,
|
||||
external_trace_header=external_trace_header,
|
||||
)
|
||||
requests.append(req)
|
||||
request_groups.append(
|
||||
expand_request_outputs(
|
||||
req,
|
||||
num_prompts=len(prompts),
|
||||
prompt_index=i,
|
||||
)
|
||||
)
|
||||
|
||||
results: list[GenerationResult] = []
|
||||
total_start_time = time.perf_counter()
|
||||
global_output_index = 0
|
||||
|
||||
# 2. send requests to scheduler one at a time
|
||||
# TODO: send batch when supported
|
||||
for request_idx, req in enumerate(requests):
|
||||
for requests in request_groups:
|
||||
try:
|
||||
with trace_req(req.trace_ctx), log_generation_timer(
|
||||
logger, req.prompt, request_idx + 1, len(requests)
|
||||
) as timer:
|
||||
output_batch = self._send_to_scheduler_and_wait_for_response([req])
|
||||
timer_prompt = [req.prompt for req in requests]
|
||||
logger.info("Processing %d grouped request(s)", len(requests))
|
||||
with ExitStack() as stack:
|
||||
for req in requests:
|
||||
stack.enter_context(trace_req(req.trace_ctx))
|
||||
timer = stack.enter_context(
|
||||
log_generation_timer(logger, timer_prompt)
|
||||
)
|
||||
output_batch = self._send_to_scheduler_and_wait_for_response(
|
||||
requests
|
||||
)
|
||||
if output_batch.error:
|
||||
raise Exception(f"{output_batch.error}")
|
||||
|
||||
@@ -246,97 +260,93 @@ class DiffGenerator:
|
||||
output_batch.output is None
|
||||
and output_batch.output_file_paths is None
|
||||
):
|
||||
logger.error(
|
||||
"Received empty output from scheduler for prompt %d",
|
||||
request_idx + 1,
|
||||
)
|
||||
logger.error("Received empty output from scheduler")
|
||||
continue
|
||||
|
||||
common = dict(
|
||||
prompt=req.prompt,
|
||||
size=(req.height, req.width, req.num_frames),
|
||||
generation_time=timer.duration,
|
||||
peak_memory_mb=output_batch.peak_memory_mb,
|
||||
metrics=(
|
||||
output_batch.metrics.to_dict()
|
||||
if output_batch.metrics
|
||||
else {}
|
||||
),
|
||||
trajectory_latents=output_batch.trajectory_latents,
|
||||
trajectory_timesteps=output_batch.trajectory_timesteps,
|
||||
rollout_trajectory_data=output_batch.rollout_trajectory_data,
|
||||
trajectory_decoded=output_batch.trajectory_decoded,
|
||||
)
|
||||
|
||||
if req.save_output and req.return_file_paths_only:
|
||||
for idx, path in enumerate(output_batch.output_file_paths):
|
||||
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)
|
||||
)
|
||||
for idx, path in enumerate(output_file_paths):
|
||||
req = requests[idx]
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**common,
|
||||
prompt_index=idx,
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
),
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if req.data_type == DataType.MESH:
|
||||
for output_idx, sample in enumerate(
|
||||
output_batch.output_file_paths
|
||||
):
|
||||
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)
|
||||
)
|
||||
for idx, sample in enumerate(output_file_paths):
|
||||
req = requests[idx]
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**common,
|
||||
prompt_index=output_idx,
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
),
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=sample,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
samples_out: list[Any] = []
|
||||
audios_out: list[Any] = []
|
||||
frames_out: list[Any] = []
|
||||
num_outputs = len(output_batch.output)
|
||||
save_outputs(
|
||||
output_batch.output,
|
||||
req.data_type,
|
||||
req.fps,
|
||||
req.save_output,
|
||||
lambda idx: req.output_file_path(num_outputs, idx),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
samples_out=samples_out,
|
||||
audios_out=audios_out,
|
||||
frames_out=frames_out,
|
||||
output_compression=req.output_compression,
|
||||
enable_frame_interpolation=req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=req.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=req.frame_interpolation_model_path,
|
||||
enable_upscaling=req.enable_upscaling,
|
||||
upscaling_model_path=req.upscaling_model_path,
|
||||
upscaling_scale=req.upscaling_scale,
|
||||
)
|
||||
|
||||
for idx in range(len(samples_out)):
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**common,
|
||||
samples=samples_out[idx],
|
||||
frames=frames_out[idx],
|
||||
audio=audios_out[idx],
|
||||
prompt_index=idx,
|
||||
output_file_path=req.output_file_path(num_outputs, idx),
|
||||
)
|
||||
else:
|
||||
self._validate_output_count(
|
||||
len(output_batch.output), len(requests)
|
||||
)
|
||||
samples_out: list[Any] = []
|
||||
audios_out: list[Any] = []
|
||||
frames_out: list[Any] = []
|
||||
save_outputs(
|
||||
output_batch.output,
|
||||
requests[0].data_type,
|
||||
requests[0].fps,
|
||||
requests[0].save_output,
|
||||
lambda idx: requests[idx].output_file_path(1, 0),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
samples_out=samples_out,
|
||||
audios_out=audios_out,
|
||||
frames_out=frames_out,
|
||||
output_compression=requests[0].output_compression,
|
||||
enable_frame_interpolation=requests[
|
||||
0
|
||||
].enable_frame_interpolation,
|
||||
frame_interpolation_exp=requests[0].frame_interpolation_exp,
|
||||
frame_interpolation_scale=requests[
|
||||
0
|
||||
].frame_interpolation_scale,
|
||||
frame_interpolation_model_path=requests[
|
||||
0
|
||||
].frame_interpolation_model_path,
|
||||
enable_upscaling=requests[0].enable_upscaling,
|
||||
upscaling_model_path=requests[0].upscaling_model_path,
|
||||
upscaling_scale=requests[0].upscaling_scale,
|
||||
)
|
||||
|
||||
for idx in range(len(samples_out)):
|
||||
req = requests[idx]
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
req, output_batch, timer.duration, idx
|
||||
),
|
||||
samples=samples_out[idx],
|
||||
frames=frames_out[idx],
|
||||
audio=audios_out[idx],
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=req.output_file_path(1, 0),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Generation failed for prompt %d/%d: %s",
|
||||
request_idx + 1,
|
||||
len(requests),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
logger.error("Generation failed: %s", e, exc_info=True)
|
||||
finally:
|
||||
global_output_index += len(requests)
|
||||
|
||||
total_gen_time = time.perf_counter() - total_start_time
|
||||
log_batch_completion(logger, len(results), total_gen_time)
|
||||
@@ -386,6 +396,39 @@ class DiffGenerator:
|
||||
f"Avg peak: {sum(peak_memories) / len(peak_memories):.2f} MB"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _result_common(
|
||||
req: Req,
|
||||
output_batch: OutputBatch,
|
||||
generation_time: float,
|
||||
output_index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metrics = output_batch.metrics
|
||||
if (
|
||||
output_index is not None
|
||||
and output_batch.metrics_list is not None
|
||||
and output_index < len(output_batch.metrics_list)
|
||||
):
|
||||
metrics = output_batch.metrics_list[output_index]
|
||||
return dict(
|
||||
prompt=req.prompt,
|
||||
size=(req.height, req.width, req.num_frames),
|
||||
generation_time=generation_time,
|
||||
peak_memory_mb=output_batch.peak_memory_mb,
|
||||
metrics=metrics.to_dict() if metrics else {},
|
||||
trajectory_latents=output_batch.trajectory_latents,
|
||||
trajectory_timesteps=output_batch.trajectory_timesteps,
|
||||
rollout_trajectory_data=output_batch.rollout_trajectory_data,
|
||||
trajectory_decoded=output_batch.trajectory_decoded,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_output_count(output_count: int, request_count: int) -> None:
|
||||
if output_count != request_count:
|
||||
raise RuntimeError(
|
||||
f"Expected {request_count} outputs, got {output_count} from scheduler"
|
||||
)
|
||||
|
||||
def _send_to_scheduler_and_wait_for_response(self, batch: list[Req]) -> OutputBatch:
|
||||
"""
|
||||
Sends a request to the scheduler and waits for a response.
|
||||
|
||||
@@ -44,7 +44,7 @@ class ImageGenerationsRequest(BaseModel):
|
||||
true_cfg_scale: Optional[float] = (
|
||||
None # for CFG vs guidance distillation (e.g., QwenImage)
|
||||
)
|
||||
seed: Optional[int] = 1024
|
||||
seed: Optional[Union[int, List[int]]] = 1024
|
||||
generator_device: Optional[str] = "cuda"
|
||||
negative_prompt: Optional[str] = None
|
||||
output_quality: Optional[str] = "default"
|
||||
@@ -76,6 +76,8 @@ class VideoResponse(BaseModel):
|
||||
expires_at: Optional[int] = None
|
||||
error: Optional[Dict[str, Any]] = None
|
||||
file_path: Optional[str] = None
|
||||
file_paths: Optional[List[str]] = None
|
||||
num_outputs: Optional[int] = None
|
||||
peak_memory_mb: Optional[float] = None
|
||||
inference_time_s: Optional[float] = None
|
||||
|
||||
@@ -85,11 +87,13 @@ class VideoGenerationsRequest(BaseModel):
|
||||
input_reference: Optional[str] = None
|
||||
reference_url: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
n: Optional[int] = 1
|
||||
num_outputs_per_prompt: Optional[int] = None
|
||||
seconds: Optional[int] = 4
|
||||
size: Optional[str] = ""
|
||||
fps: Optional[int] = None
|
||||
num_frames: Optional[int] = None
|
||||
seed: Optional[int] = 1024
|
||||
seed: Optional[Union[int, List[int]]] = 1024
|
||||
generator_device: Optional[str] = "cuda"
|
||||
# SGLang extensions
|
||||
width: Optional[int] = None
|
||||
@@ -151,7 +155,7 @@ class MeshGenerationsRequest(BaseModel):
|
||||
prompt: str = "generate 3d mesh"
|
||||
input_image: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
seed: Optional[int] = None
|
||||
seed: Optional[Union[int, List[int]]] = None
|
||||
generator_device: Optional[str] = "cuda"
|
||||
num_inference_steps: Optional[int] = None
|
||||
guidance_scale: Optional[float] = None
|
||||
|
||||
@@ -22,6 +22,7 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
SetLoraReq,
|
||||
ShutdownReq,
|
||||
UnmergeLoraWeightsReq,
|
||||
expand_request_outputs,
|
||||
format_lora_message,
|
||||
save_outputs,
|
||||
)
|
||||
@@ -325,8 +326,9 @@ async def process_generation_batch(
|
||||
batch,
|
||||
) -> tuple[list[str], OutputBatch]:
|
||||
total_start_time = time.perf_counter()
|
||||
requests = expand_request_outputs(batch)
|
||||
with trace_req(batch.trace_ctx), log_generation_timer(logger, batch.prompt):
|
||||
result = await scheduler_client.forward([batch])
|
||||
result = await scheduler_client.forward(requests)
|
||||
|
||||
if result.output is None and result.output_file_paths is None:
|
||||
error_msg = result.error or "Unknown error"
|
||||
@@ -336,14 +338,22 @@ async def process_generation_batch(
|
||||
|
||||
if result.output_file_paths:
|
||||
save_file_path_list = result.output_file_paths
|
||||
if len(save_file_path_list) < len(requests):
|
||||
raise RuntimeError(
|
||||
f"Expected at least {len(requests)} output paths, "
|
||||
f"got {len(save_file_path_list)}"
|
||||
)
|
||||
else:
|
||||
num_outputs = len(result.output)
|
||||
if len(result.output) != len(requests):
|
||||
raise RuntimeError(
|
||||
f"Expected {len(requests)} outputs, got {len(result.output)}"
|
||||
)
|
||||
save_file_path_list = save_outputs(
|
||||
result.output,
|
||||
batch.data_type,
|
||||
batch.fps,
|
||||
batch.save_output,
|
||||
lambda idx: str(batch.output_file_path(num_outputs, idx)),
|
||||
lambda idx: str(requests[idx].output_file_path(1, 0)),
|
||||
audio=result.audio,
|
||||
audio_sample_rate=result.audio_sample_rate,
|
||||
output_compression=batch.output_compression,
|
||||
@@ -357,7 +367,7 @@ async def process_generation_batch(
|
||||
)
|
||||
|
||||
total_time = time.perf_counter() - total_start_time
|
||||
log_batch_completion(logger, 1, total_time)
|
||||
log_batch_completion(logger, len(save_file_path_list), total_time)
|
||||
|
||||
if result.peak_memory_mb and result.peak_memory_mb > 0:
|
||||
logger.info(f"Peak memory usage: {result.peak_memory_mb:.2f} MB")
|
||||
|
||||
@@ -56,10 +56,14 @@ def _build_video_sampling_params(request_id: str, request: VideoGenerationsReque
|
||||
seconds = request.seconds if request.seconds is not None else DEFAULT_VIDEO_SECONDS
|
||||
fps = request.fps if request.fps is not None else DEFAULT_FPS
|
||||
num_frames = request.num_frames if request.num_frames is not None else fps * seconds
|
||||
num_outputs = request.num_outputs_per_prompt
|
||||
if num_outputs is None:
|
||||
num_outputs = request.n or 1
|
||||
|
||||
return build_sampling_params(
|
||||
request_id,
|
||||
prompt=request.prompt,
|
||||
num_outputs_per_prompt=max(1, min(int(num_outputs), 10)),
|
||||
size=request.size,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
@@ -156,6 +160,12 @@ async def _dispatch_job_async(
|
||||
"completed_at": int(time.time()),
|
||||
"url": cloud_url,
|
||||
"file_path": persistent_path,
|
||||
"file_paths": (
|
||||
[os.path.abspath(path) for path in save_file_path_list]
|
||||
if output_persistent
|
||||
else None
|
||||
),
|
||||
"num_outputs": len(save_file_path_list),
|
||||
}
|
||||
update_fields = add_common_data_to_response(
|
||||
update_fields, request_id=job_id, result=result
|
||||
@@ -180,6 +190,8 @@ async def create_video(
|
||||
input_reference: Optional[UploadFile] = File(None),
|
||||
reference_url: Optional[str] = Form(None),
|
||||
model: Optional[str] = Form(None),
|
||||
n: Optional[int] = Form(1),
|
||||
num_outputs_per_prompt: Optional[int] = Form(None),
|
||||
seconds: Optional[int] = Form(None),
|
||||
size: Optional[str] = Form(None),
|
||||
fps: Optional[int] = Form(None),
|
||||
@@ -262,6 +274,8 @@ async def create_video(
|
||||
prompt=prompt,
|
||||
input_reference=input_path,
|
||||
model=model,
|
||||
n=n,
|
||||
num_outputs_per_prompt=num_outputs_per_prompt,
|
||||
seconds=seconds if seconds is not None else 4,
|
||||
size=size,
|
||||
fps=fps_val,
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from copy import copy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, List, Optional, Sequence, Union
|
||||
|
||||
@@ -122,6 +123,124 @@ class GenerationResult:
|
||||
output_file_path: str | None = None
|
||||
|
||||
|
||||
def normalize_output_seeds(
|
||||
seed: int | list[int],
|
||||
*,
|
||||
num_outputs_per_prompt: int,
|
||||
num_prompts: int = 1,
|
||||
prompt_index: int = 0,
|
||||
) -> list[int]:
|
||||
"""
|
||||
return a list of seed with size equal to `num_outputs_per_prompt`
|
||||
"""
|
||||
if num_outputs_per_prompt <= 0:
|
||||
raise ValueError(
|
||||
f"num_outputs_per_prompt must be positive, got {num_outputs_per_prompt}"
|
||||
)
|
||||
|
||||
if isinstance(seed, list):
|
||||
seeds = [int(item) for item in seed]
|
||||
total_outputs = num_outputs_per_prompt * num_prompts
|
||||
if len(seeds) == num_outputs_per_prompt:
|
||||
return seeds
|
||||
if len(seeds) == total_outputs:
|
||||
start = prompt_index * num_outputs_per_prompt
|
||||
return seeds[start : start + num_outputs_per_prompt]
|
||||
raise ValueError(
|
||||
"seed list length must match num_outputs_per_prompt "
|
||||
f"({num_outputs_per_prompt}) or total outputs ({total_outputs}), "
|
||||
f"got {len(seeds)}"
|
||||
)
|
||||
|
||||
base_seed = int(seed)
|
||||
return [base_seed + i for i in range(num_outputs_per_prompt)]
|
||||
|
||||
|
||||
def _with_output_index_suffix(output_file_name: str, output_index: int) -> str:
|
||||
base, ext = os.path.splitext(output_file_name)
|
||||
return f"{base}_{output_index}{ext}"
|
||||
|
||||
|
||||
def _copy_trace_ctx_for_output(req: Req, request_id: str | None, output_index: int):
|
||||
trace_ctx = req.trace_ctx
|
||||
if output_index == 0 or not trace_ctx.tracing_enable:
|
||||
return trace_ctx
|
||||
|
||||
output_trace_ctx = TraceReqContext(
|
||||
rid=request_id,
|
||||
module_name=trace_ctx.module_name,
|
||||
external_trace_header=trace_ctx.external_trace_header,
|
||||
)
|
||||
output_trace_ctx.trace_req_start()
|
||||
return output_trace_ctx
|
||||
|
||||
|
||||
def _copy_req_for_output(
|
||||
req: Req,
|
||||
*,
|
||||
request_id: str | None,
|
||||
output_index: int,
|
||||
) -> Req:
|
||||
"""Create a lightweight per-output ``Req`` without deep-copying tensors."""
|
||||
output_req = copy(req)
|
||||
output_req.sampling_params = copy(req.sampling_params)
|
||||
output_req.extra = dict(req.extra)
|
||||
output_req.trace_ctx = _copy_trace_ctx_for_output(req, request_id, output_index)
|
||||
return output_req
|
||||
|
||||
|
||||
def expand_request_outputs(
|
||||
req: Req,
|
||||
*,
|
||||
num_prompts: int = 1,
|
||||
prompt_index: int = 0,
|
||||
) -> list[Req]:
|
||||
"""
|
||||
Expand a req to a list with size equal to `num_prompts`
|
||||
"""
|
||||
num_outputs = int(req.num_outputs_per_prompt)
|
||||
# each req must has different seed
|
||||
seeds = normalize_output_seeds(
|
||||
req.seed,
|
||||
num_outputs_per_prompt=num_outputs,
|
||||
num_prompts=num_prompts,
|
||||
prompt_index=prompt_index,
|
||||
)
|
||||
|
||||
if num_outputs == 1:
|
||||
req.seed = seeds[0]
|
||||
req.seeds = None
|
||||
req.generator = None
|
||||
return [req]
|
||||
|
||||
expanded: list[Req] = []
|
||||
for output_index, seed in enumerate(seeds):
|
||||
output_request_id = (
|
||||
f"{req.request_id}:{output_index}" if req.request_id is not None else None
|
||||
)
|
||||
output_req = _copy_req_for_output(
|
||||
req, request_id=output_request_id, output_index=output_index
|
||||
)
|
||||
output_req.seed = seed
|
||||
output_req.num_outputs_per_prompt = 1
|
||||
output_req.seeds = None
|
||||
output_req.generator = None
|
||||
output_req.extra["parent_request_id"] = req.request_id
|
||||
output_req.extra["output_index"] = output_index
|
||||
|
||||
if output_request_id is not None:
|
||||
output_req.request_id = output_request_id
|
||||
|
||||
if req.output_file_name:
|
||||
output_req.output_file_name = _with_output_index_suffix(
|
||||
req.output_file_name, output_index
|
||||
)
|
||||
output_req.validate()
|
||||
expanded.append(output_req)
|
||||
|
||||
return expanded
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -6,7 +6,9 @@ import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import time
|
||||
from typing import List, Union
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, List, Union
|
||||
|
||||
import torch
|
||||
from setproctitle import setproctitle
|
||||
@@ -65,6 +67,18 @@ from sglang.srt.utils.network import NetworkAddress
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ExpandedOutputParts:
|
||||
tensor_outputs: list[torch.Tensor] = field(default_factory=list)
|
||||
list_outputs: list[Any] = field(default_factory=list)
|
||||
tensor_audio: list[torch.Tensor] = field(default_factory=list)
|
||||
trajectory_latents: list[torch.Tensor] = field(default_factory=list)
|
||||
noise_preds: list[torch.Tensor] = field(default_factory=list)
|
||||
output_file_paths: list[str] = field(default_factory=list)
|
||||
metrics_list: list[Any] = field(default_factory=list)
|
||||
trajectory_decoded_parts: list[list[torch.Tensor]] | None = None
|
||||
|
||||
|
||||
class GPUWorker:
|
||||
"""
|
||||
A worker that executes the model on a single GPU.
|
||||
@@ -222,7 +236,57 @@ class GPUWorker:
|
||||
Used by disaggregated pipelines to access intermediate tensors.
|
||||
"""
|
||||
assert self.pipeline is not None
|
||||
if len(batch) > 1:
|
||||
if return_req:
|
||||
raise ValueError(
|
||||
"Grouped execute_forward does not support return_req=True"
|
||||
)
|
||||
# batched reqs is only possible with `num_outputs_per_prompt > 1` now
|
||||
self._validate_group_forward_reqs(batch)
|
||||
return self._execute_forward_batch(batch)
|
||||
|
||||
req = batch[0]
|
||||
return self._execute_forward_common(
|
||||
req,
|
||||
forward_fn=lambda: self.pipeline.forward(req, self.server_args),
|
||||
log_reqs=[req],
|
||||
return_req=return_req,
|
||||
save_output_paths=lambda output_batch: self._save_output_paths(
|
||||
req, output_batch
|
||||
),
|
||||
error_context=f"request {req.request_id}",
|
||||
)
|
||||
|
||||
def _execute_forward_batch(self, batch: list[Req]) -> OutputBatch:
|
||||
"""Execute expanded multi-output requests as one grouped forward."""
|
||||
# TODO: support early return or mix-stage execution for reqs in a group
|
||||
assert self.pipeline is not None
|
||||
req = batch[0]
|
||||
return self._execute_forward_common(
|
||||
req,
|
||||
forward_fn=lambda: self._forward_group(batch),
|
||||
log_reqs=batch,
|
||||
return_req=False,
|
||||
save_output_paths=lambda output_batch: self._save_group_output_paths(
|
||||
batch, output_batch
|
||||
),
|
||||
error_context=f"grouped request {req.request_id}",
|
||||
)
|
||||
|
||||
def _execute_forward_common(
|
||||
self,
|
||||
req: Req,
|
||||
*,
|
||||
forward_fn: Callable[[], Req | OutputBatch],
|
||||
log_reqs: list[Req],
|
||||
return_req: bool,
|
||||
save_output_paths: Callable[[OutputBatch], None],
|
||||
error_context: str,
|
||||
) -> OutputBatch | Req:
|
||||
"""
|
||||
Args:
|
||||
forward_fn: the actual forward function for reqs
|
||||
"""
|
||||
output_batch = None
|
||||
try:
|
||||
if self.rank == 0 and not current_platform.is_cpu():
|
||||
@@ -230,47 +294,33 @@ class GPUWorker:
|
||||
|
||||
start_time = time.monotonic()
|
||||
|
||||
# capture memory baseline before forward
|
||||
if self.rank == 0 and req.metrics and not current_platform.is_cpu():
|
||||
request_metrics = [
|
||||
item.metrics for item in log_reqs if item.metrics is not None
|
||||
]
|
||||
if self.rank == 0 and request_metrics and not current_platform.is_cpu():
|
||||
baseline_snapshot = capture_memory_snapshot()
|
||||
req.metrics.record_memory_snapshot("before_forward", baseline_snapshot)
|
||||
for metrics in request_metrics:
|
||||
metrics.record_memory_snapshot("before_forward", baseline_snapshot)
|
||||
|
||||
req.log(server_args=self.server_args)
|
||||
with trace_slice(req.trace_ctx, DiffStage.GPU_FORWARD):
|
||||
result = self.pipeline.forward(req, self.server_args)
|
||||
for item in log_reqs:
|
||||
item.log(server_args=self.server_args)
|
||||
with ExitStack() as stack:
|
||||
for item in log_reqs:
|
||||
stack.enter_context(
|
||||
trace_slice(item.trace_ctx, DiffStage.GPU_FORWARD)
|
||||
)
|
||||
result = forward_fn()
|
||||
|
||||
# For disagg roles, return raw Req to let the caller handle
|
||||
# the role-to-role tensor transfer before OutputBatch conversion.
|
||||
if return_req and isinstance(result, Req):
|
||||
return result
|
||||
|
||||
if isinstance(result, Req):
|
||||
output_batch = OutputBatch(
|
||||
output=result.output,
|
||||
audio=getattr(result, "audio", None),
|
||||
audio_sample_rate=getattr(result, "audio_sample_rate", None),
|
||||
metrics=result.metrics,
|
||||
trajectory_timesteps=getattr(result, "trajectory_timesteps", None),
|
||||
trajectory_latents=getattr(result, "trajectory_latents", None),
|
||||
rollout_trajectory_data=getattr(
|
||||
result, "rollout_trajectory_data", None
|
||||
),
|
||||
noise_pred=getattr(result, "noise_pred", None),
|
||||
trajectory_decoded=getattr(result, "trajectory_decoded", None),
|
||||
)
|
||||
else:
|
||||
output_batch = result
|
||||
output_batch = self._to_output_batch(result)
|
||||
|
||||
# capture memory after forward (peak)
|
||||
if (
|
||||
self.rank == 0
|
||||
and output_batch.metrics
|
||||
and not current_platform.is_cpu()
|
||||
):
|
||||
output_metrics = self._iter_output_metrics(output_batch)
|
||||
if self.rank == 0 and output_metrics and not current_platform.is_cpu():
|
||||
peak_snapshot = capture_memory_snapshot()
|
||||
output_batch.metrics.record_memory_snapshot(
|
||||
"after_forward", peak_snapshot
|
||||
)
|
||||
for metrics in output_metrics:
|
||||
metrics.record_memory_snapshot("after_forward", peak_snapshot)
|
||||
|
||||
if (
|
||||
self.rank == 0
|
||||
@@ -281,34 +331,11 @@ class GPUWorker:
|
||||
self.do_mem_analysis(output_batch)
|
||||
|
||||
duration_ms = (time.monotonic() - start_time) * 1000
|
||||
if output_batch.metrics is not None:
|
||||
output_batch.metrics.total_duration_ms = duration_ms
|
||||
for metrics in output_metrics:
|
||||
metrics.total_duration_ms = duration_ms
|
||||
|
||||
# Save output to file and return file path only if requested. Avoid the serialization
|
||||
# and deserialization overhead between scheduler_client and gpu_worker.
|
||||
if req.save_output and req.return_file_paths_only:
|
||||
if self.rank == 0 and output_batch.output is not None:
|
||||
output_paths = save_outputs(
|
||||
output_batch.output,
|
||||
req.data_type,
|
||||
req.fps,
|
||||
True,
|
||||
lambda idx: req.output_file_path(len(output_batch.output), idx),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=req.output_compression,
|
||||
enable_frame_interpolation=req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=req.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=req.frame_interpolation_model_path,
|
||||
enable_upscaling=req.enable_upscaling,
|
||||
upscaling_model_path=req.upscaling_model_path,
|
||||
upscaling_scale=req.upscaling_scale,
|
||||
)
|
||||
output_batch.output_file_paths = output_paths
|
||||
|
||||
# No rank needs to hold on to generated tensors once the file-path
|
||||
# response has been materialized on rank 0
|
||||
save_output_paths(output_batch)
|
||||
output_batch.output = None
|
||||
output_batch.audio = None
|
||||
output_batch.audio_sample_rate = None
|
||||
@@ -316,13 +343,13 @@ class GPUWorker:
|
||||
if torch.cuda.is_initialized():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# TODO: extract to avoid duplication
|
||||
if torch.cuda.is_initialized() and output_batch.output is None:
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
if req.perf_dump_path is not None or envs.SGLANG_DIFFUSION_STAGE_LOGGING:
|
||||
# Avoid logging warmup perf records that share the same request_id.
|
||||
if not req.is_warmup:
|
||||
PerformanceLogger.log_request_summary(metrics=output_batch.metrics)
|
||||
|
||||
# dump per-request perf report to specified file (server mode)
|
||||
if (
|
||||
req.perf_dump_path is not None
|
||||
and not req.is_warmup
|
||||
@@ -336,15 +363,240 @@ class GPUWorker:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error executing request {req.request_id}: {e}", exc_info=True
|
||||
f"Error executing {error_context}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
if isinstance(e, _oom_exceptions()):
|
||||
logger.warning(OOM_MSG)
|
||||
if output_batch is None:
|
||||
output_batch = OutputBatch()
|
||||
output_batch.error = f"Error executing request {req.request_id}: {e}"
|
||||
output_batch.error = f"Error executing {error_context}: {e}"
|
||||
return output_batch
|
||||
|
||||
def _forward_group(self, batch: list[Req]) -> OutputBatch:
|
||||
assert self.pipeline is not None
|
||||
results = self.pipeline.forward_batch(batch, self.server_args)
|
||||
output_batches = [self._to_output_batch(result) for result in results]
|
||||
return self._merge_expanded_output_batches(output_batches)
|
||||
|
||||
def _save_output_paths(self, req: Req, output_batch: OutputBatch) -> None:
|
||||
if self.rank != 0 or output_batch.output is None:
|
||||
return
|
||||
|
||||
output_batch.output_file_paths = save_outputs(
|
||||
output_batch.output,
|
||||
req.data_type,
|
||||
req.fps,
|
||||
True,
|
||||
lambda idx: req.output_file_path(len(output_batch.output), idx),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=req.output_compression,
|
||||
enable_frame_interpolation=req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=req.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=req.frame_interpolation_model_path,
|
||||
enable_upscaling=req.enable_upscaling,
|
||||
upscaling_model_path=req.upscaling_model_path,
|
||||
upscaling_scale=req.upscaling_scale,
|
||||
)
|
||||
|
||||
def _save_group_output_paths(
|
||||
self,
|
||||
reqs: list[Req],
|
||||
output_batch: OutputBatch,
|
||||
) -> None:
|
||||
if self.rank != 0 or output_batch.output is None:
|
||||
return
|
||||
if len(output_batch.output) != len(reqs):
|
||||
raise RuntimeError(
|
||||
f"Expected {len(reqs)} grouped outputs, got {len(output_batch.output)}"
|
||||
)
|
||||
|
||||
first_req = reqs[0]
|
||||
output_batch.output_file_paths = save_outputs(
|
||||
output_batch.output,
|
||||
first_req.data_type,
|
||||
first_req.fps,
|
||||
True,
|
||||
lambda idx: reqs[idx].output_file_path(1, 0),
|
||||
audio=output_batch.audio,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=first_req.output_compression,
|
||||
enable_frame_interpolation=first_req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=first_req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=first_req.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=first_req.frame_interpolation_model_path,
|
||||
enable_upscaling=first_req.enable_upscaling,
|
||||
upscaling_model_path=first_req.upscaling_model_path,
|
||||
upscaling_scale=first_req.upscaling_scale,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_group_forward_reqs(reqs: list[Req]) -> None:
|
||||
"""Validate fields that the grouped output/save path treats as shared."""
|
||||
first_req = reqs[0]
|
||||
shared_output_fields = (
|
||||
"save_output",
|
||||
"return_file_paths_only",
|
||||
"data_type",
|
||||
"fps",
|
||||
"output_compression",
|
||||
"enable_frame_interpolation",
|
||||
"frame_interpolation_exp",
|
||||
"frame_interpolation_scale",
|
||||
"frame_interpolation_model_path",
|
||||
"enable_upscaling",
|
||||
"upscaling_model_path",
|
||||
"upscaling_scale",
|
||||
)
|
||||
for req in reqs[1:]:
|
||||
mismatched = [
|
||||
field
|
||||
for field in shared_output_fields
|
||||
if getattr(req, field) != getattr(first_req, field)
|
||||
]
|
||||
if mismatched:
|
||||
raise ValueError(
|
||||
"Grouped execute_forward requires matching output settings; "
|
||||
f"mismatched fields: {mismatched}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iter_output_metrics(output_batch: OutputBatch):
|
||||
"""Return all metrics objects carried by an output batch."""
|
||||
if output_batch.metrics_list is not None:
|
||||
return [
|
||||
metrics for metrics in output_batch.metrics_list if metrics is not None
|
||||
]
|
||||
if output_batch.metrics is not None:
|
||||
return [output_batch.metrics]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _to_output_batch(result: Req | OutputBatch) -> OutputBatch:
|
||||
if isinstance(result, Req):
|
||||
return GPUWorker._req_to_output_batch(result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _req_to_output_batch(result: Req) -> OutputBatch:
|
||||
return OutputBatch(
|
||||
output=result.output,
|
||||
audio=getattr(result, "audio", None),
|
||||
audio_sample_rate=getattr(result, "audio_sample_rate", None),
|
||||
metrics=result.metrics,
|
||||
trajectory_timesteps=getattr(result, "trajectory_timesteps", None),
|
||||
trajectory_latents=getattr(result, "trajectory_latents", None),
|
||||
rollout_trajectory_data=getattr(result, "rollout_trajectory_data", None),
|
||||
noise_pred=getattr(result, "noise_pred", None),
|
||||
trajectory_decoded=getattr(result, "trajectory_decoded", None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_expanded_output_batches(
|
||||
output_batches: list[OutputBatch],
|
||||
) -> OutputBatch:
|
||||
"""Merge per-output batches produced by grouped execution."""
|
||||
merged = OutputBatch()
|
||||
parts = _ExpandedOutputParts()
|
||||
|
||||
for output_batch in output_batches:
|
||||
GPUWorker._merge_expanded_singletons(merged, output_batch)
|
||||
GPUWorker._collect_expanded_parts(parts, output_batch)
|
||||
|
||||
GPUWorker._finalize_expanded_parts(
|
||||
merged,
|
||||
parts,
|
||||
audio_sample_rate=output_batches[0].audio_sample_rate,
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _merge_expanded_singletons(
|
||||
merged: OutputBatch, output_batch: OutputBatch
|
||||
) -> None:
|
||||
if output_batch.error is not None and merged.error is None:
|
||||
merged.error = output_batch.error
|
||||
merged.peak_memory_mb = max(merged.peak_memory_mb, output_batch.peak_memory_mb)
|
||||
if (
|
||||
merged.trajectory_timesteps is None
|
||||
and output_batch.trajectory_timesteps is not None
|
||||
):
|
||||
merged.trajectory_timesteps = output_batch.trajectory_timesteps
|
||||
if (
|
||||
merged.rollout_trajectory_data is None
|
||||
and output_batch.rollout_trajectory_data is not None
|
||||
):
|
||||
merged.rollout_trajectory_data = output_batch.rollout_trajectory_data
|
||||
|
||||
@staticmethod
|
||||
def _collect_expanded_parts(
|
||||
parts: _ExpandedOutputParts, output_batch: OutputBatch
|
||||
) -> None:
|
||||
"""Collect expanded outputs"""
|
||||
parts.metrics_list.append(output_batch.metrics)
|
||||
if output_batch.output_file_paths:
|
||||
parts.output_file_paths.extend(output_batch.output_file_paths)
|
||||
if isinstance(output_batch.output, torch.Tensor):
|
||||
parts.tensor_outputs.append(output_batch.output)
|
||||
elif output_batch.output is not None:
|
||||
parts.list_outputs.extend(output_batch.output)
|
||||
if isinstance(output_batch.audio, torch.Tensor):
|
||||
parts.tensor_audio.append(output_batch.audio)
|
||||
if isinstance(output_batch.trajectory_latents, torch.Tensor):
|
||||
parts.trajectory_latents.append(output_batch.trajectory_latents)
|
||||
if isinstance(output_batch.noise_pred, torch.Tensor):
|
||||
parts.noise_preds.append(output_batch.noise_pred)
|
||||
if output_batch.trajectory_decoded:
|
||||
GPUWorker._collect_trajectory_decoded(
|
||||
parts, output_batch.trajectory_decoded
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_trajectory_decoded(
|
||||
parts: _ExpandedOutputParts, trajectory_decoded: list[torch.Tensor]
|
||||
) -> None:
|
||||
if parts.trajectory_decoded_parts is None:
|
||||
parts.trajectory_decoded_parts = [[] for _ in trajectory_decoded]
|
||||
for index, decoded in enumerate(trajectory_decoded):
|
||||
parts.trajectory_decoded_parts[index].append(decoded)
|
||||
|
||||
@staticmethod
|
||||
def _finalize_expanded_parts(
|
||||
merged: OutputBatch,
|
||||
parts: _ExpandedOutputParts,
|
||||
*,
|
||||
audio_sample_rate: int | None,
|
||||
) -> None:
|
||||
"""
|
||||
merge batched output
|
||||
"""
|
||||
if parts.output_file_paths:
|
||||
merged.output_file_paths = parts.output_file_paths
|
||||
if any(metrics is not None for metrics in parts.metrics_list):
|
||||
merged.metrics_list = parts.metrics_list
|
||||
merged.metrics = next(
|
||||
metrics for metrics in parts.metrics_list if metrics is not None
|
||||
)
|
||||
if parts.tensor_outputs:
|
||||
merged.output = torch.cat(parts.tensor_outputs, dim=0)
|
||||
elif parts.list_outputs:
|
||||
merged.output = parts.list_outputs
|
||||
if parts.tensor_audio:
|
||||
merged.audio = torch.cat(parts.tensor_audio, dim=0)
|
||||
merged.audio_sample_rate = audio_sample_rate
|
||||
if parts.trajectory_latents:
|
||||
merged.trajectory_latents = torch.cat(parts.trajectory_latents, dim=0)
|
||||
if parts.noise_preds:
|
||||
merged.noise_pred = torch.cat(parts.noise_preds, dim=0)
|
||||
if parts.trajectory_decoded_parts:
|
||||
merged.trajectory_decoded = [
|
||||
torch.cat(decoded_step, dim=0)
|
||||
for decoded_step in parts.trajectory_decoded_parts
|
||||
]
|
||||
|
||||
def get_can_stay_resident_components(
|
||||
self, remaining_gpu_mem_gb: float
|
||||
) -> List[str]:
|
||||
|
||||
@@ -115,7 +115,7 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
MergeLoraWeightsReq: self._handle_merge_lora,
|
||||
UnmergeLoraWeightsReq: self._handle_unmerge_lora,
|
||||
Req: self._handle_generation,
|
||||
List[Req]: self._handle_generation,
|
||||
list: self._handle_generation,
|
||||
ListLorasReq: self._handle_list_loras,
|
||||
ShutdownReq: self._handle_shutdown,
|
||||
GetDisaggStatsReq: self._handle_get_disagg_stats,
|
||||
@@ -124,7 +124,7 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
}
|
||||
|
||||
# FIFO, new reqs are appended
|
||||
self.waiting_queue: deque[tuple[bytes, Req]] = deque()
|
||||
self.waiting_queue: deque[tuple[bytes, Any]] = deque()
|
||||
|
||||
# whether we've send the necessary warmup reqs
|
||||
self.warmed_up = False
|
||||
@@ -195,7 +195,9 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
checksums = self.worker.get_weights_checksum(module_names=req.module_names)
|
||||
return OutputBatch(output=checksums)
|
||||
|
||||
def _handle_generation(self, reqs: List[Req]):
|
||||
def _handle_generation(self, reqs: List[Req] | list[list[Req]]):
|
||||
if len(reqs) == 1 and isinstance(reqs[0], list):
|
||||
reqs = reqs[0]
|
||||
warmup_reqs = [req for req in reqs if req.is_warmup]
|
||||
if warmup_reqs:
|
||||
self._warmup_processed += len(warmup_reqs)
|
||||
@@ -229,7 +231,7 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
if not is_warmup and self.receiver is not None and identity is not None:
|
||||
self.receiver.send_multipart([identity, b"", pickle.dumps(output_batch)])
|
||||
|
||||
def get_next_batch_to_run(self) -> list[tuple[bytes, Req]] | None:
|
||||
def get_next_batch_to_run(self) -> list[tuple[bytes, Any]] | None:
|
||||
"""pull a req from waiting_queue"""
|
||||
if not self.waiting_queue:
|
||||
return None
|
||||
@@ -342,7 +344,8 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
|
||||
# handle server req-based warmup by inserting an identical req to the beginning of the waiting queue
|
||||
# only the very first req through server's lifetime will be warmed up
|
||||
identity, req = recv_reqs[0]
|
||||
identity, req_or_group = recv_reqs[0]
|
||||
req = req_or_group[0] if isinstance(req_or_group, list) else req_or_group
|
||||
if isinstance(req, Req):
|
||||
warmup_req = req.copy_as_warmup(self.server_args.warmup_steps)
|
||||
recv_reqs.insert(0, (identity, warmup_req))
|
||||
@@ -371,12 +374,14 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
raise
|
||||
|
||||
if recv_reqs:
|
||||
# Ensure recv_reqs is a list
|
||||
if not isinstance(recv_reqs, list):
|
||||
recv_reqs = [recv_reqs]
|
||||
|
||||
# Pack with identity for rank 0
|
||||
recv_reqs = [(identity, req) for req in recv_reqs]
|
||||
if isinstance(recv_reqs, list) and all(
|
||||
isinstance(req, Req) for req in recv_reqs
|
||||
):
|
||||
recv_reqs = [(identity, recv_reqs)]
|
||||
else:
|
||||
if not isinstance(recv_reqs, list):
|
||||
recv_reqs = [recv_reqs]
|
||||
recv_reqs = [(identity, req) for req in recv_reqs]
|
||||
else:
|
||||
recv_reqs = None
|
||||
|
||||
@@ -463,9 +468,14 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
|
||||
try:
|
||||
processed_req = reqs[0]
|
||||
is_warmup = (
|
||||
processed_req.is_warmup if isinstance(processed_req, Req) else False
|
||||
)
|
||||
if isinstance(processed_req, list) and processed_req:
|
||||
is_warmup = processed_req[0].is_warmup
|
||||
else:
|
||||
is_warmup = (
|
||||
processed_req.is_warmup
|
||||
if isinstance(processed_req, Req)
|
||||
else False
|
||||
)
|
||||
|
||||
handler = self.request_handlers.get(type(processed_req))
|
||||
if handler:
|
||||
@@ -483,9 +493,14 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
|
||||
# 3. return results
|
||||
try:
|
||||
is_warmup = (
|
||||
processed_req.is_warmup if isinstance(processed_req, Req) else False
|
||||
)
|
||||
if isinstance(processed_req, list) and processed_req:
|
||||
is_warmup = processed_req[0].is_warmup
|
||||
else:
|
||||
is_warmup = (
|
||||
processed_req.is_warmup
|
||||
if isinstance(processed_req, Req)
|
||||
else False
|
||||
)
|
||||
if is_warmup:
|
||||
if output_batch.error is None:
|
||||
if self._warmup_total > 0:
|
||||
|
||||
@@ -739,3 +739,28 @@ class ComposedPipelineBase(ABC):
|
||||
)
|
||||
|
||||
return self.executor.execute_with_profiling(self.stages, batch, server_args)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward_batch(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
if len(batches) == 1:
|
||||
return [self.forward(batches[0], server_args)]
|
||||
|
||||
if self.is_lora_set() and not self.is_lora_effective():
|
||||
logger.warning(
|
||||
"LoRA adapter is set, but not effective. Please make sure the LoRA weights are merged"
|
||||
)
|
||||
|
||||
if not batches[0].is_warmup and not batches[0].suppress_logs:
|
||||
logger.info(
|
||||
"Running grouped pipeline stages: %s",
|
||||
list(self._stage_name_mapping.keys()),
|
||||
main_process_only=True,
|
||||
)
|
||||
|
||||
return self.executor.execute_group_with_profiling(
|
||||
self.stages, batches, server_args
|
||||
)
|
||||
|
||||
+31
-15
@@ -1,6 +1,6 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
from typing import List
|
||||
from typing import Any, Callable, List
|
||||
|
||||
import torch
|
||||
|
||||
@@ -52,15 +52,14 @@ class ParallelExecutor(PipelineExecutor):
|
||||
src=self.worker.cfg_group.ranks[0],
|
||||
)
|
||||
|
||||
def _execute(
|
||||
def _execute_stages(
|
||||
self,
|
||||
stages: List[PipelineStage],
|
||||
batch: Req,
|
||||
payload: Any,
|
||||
server_args: ServerArgs,
|
||||
) -> OutputBatch:
|
||||
"""
|
||||
Execute all pipeline stages respecting their declared parallelism type.
|
||||
"""
|
||||
run_stage: Callable[[PipelineStage, Any], Any],
|
||||
) -> Any:
|
||||
"""Execute stages while respecting their declared parallelism type."""
|
||||
if server_args.enable_cfg_parallel:
|
||||
rank = get_classifier_free_guidance_rank()
|
||||
else:
|
||||
@@ -74,23 +73,23 @@ class ParallelExecutor(PipelineExecutor):
|
||||
if paradigm == StageParallelismType.MAIN_RANK_ONLY:
|
||||
if rank == 0:
|
||||
# Only main rank executes, others just wait
|
||||
batch = stage(batch, server_args)
|
||||
payload = run_stage(stage, payload)
|
||||
torch.distributed.barrier()
|
||||
|
||||
elif paradigm == StageParallelismType.CFG_PARALLEL:
|
||||
obj_list = [batch] if rank == 0 else []
|
||||
obj_list = [payload] if rank == 0 else []
|
||||
broadcasted_list = broadcast_pyobj(
|
||||
obj_list, rank=rank, dist_group=cfg_group.cpu_group, src=0
|
||||
)
|
||||
if rank != 0:
|
||||
batch = broadcasted_list[0]
|
||||
batch = stage(batch, server_args)
|
||||
payload = broadcasted_list[0]
|
||||
payload = run_stage(stage, payload)
|
||||
|
||||
torch.distributed.barrier()
|
||||
|
||||
elif paradigm == StageParallelismType.REPLICATED:
|
||||
batch = stage(batch, server_args)
|
||||
return batch
|
||||
payload = run_stage(stage, payload)
|
||||
return payload
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -98,5 +97,22 @@ class ParallelExecutor(PipelineExecutor):
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> OutputBatch:
|
||||
batch = self._execute(stages, batch, server_args)
|
||||
return batch
|
||||
return self._execute_stages(
|
||||
stages,
|
||||
batch,
|
||||
server_args,
|
||||
lambda stage, current: stage(current, server_args),
|
||||
)
|
||||
|
||||
def execute_group(
|
||||
self,
|
||||
stages: List[PipelineStage],
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
return self._execute_stages(
|
||||
stages,
|
||||
batches,
|
||||
server_args,
|
||||
lambda stage, current: stage.run_grouped_requests(current, server_args),
|
||||
)
|
||||
|
||||
@@ -58,6 +58,17 @@ class PipelineExecutor(ABC):
|
||||
|
||||
return batch
|
||||
|
||||
def execute_group_with_profiling(
|
||||
self,
|
||||
stages: List["PipelineStage"],
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
"""Execute a grouped request under the same profiler as a single request."""
|
||||
with self.profile_execution(batches[0], dump_rank=0):
|
||||
batches = self.execute_group(stages, batches, server_args)
|
||||
return batches
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
@@ -78,6 +89,22 @@ class PipelineExecutor(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def execute_group(
|
||||
self,
|
||||
stages: List["PipelineStage"],
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
"""Execute all pipeline stages over a group of independent requests.
|
||||
|
||||
Executors own cross-rank scheduling, while stages own whether duplicate
|
||||
work can be removed. The base executor simply calls
|
||||
``stage.run_grouped_requests`` for each stage in order.
|
||||
"""
|
||||
for stage in stages:
|
||||
batches = stage.run_grouped_requests(batches, server_args)
|
||||
return batches
|
||||
|
||||
@contextlib.contextmanager
|
||||
def profile_execution(self, batch: Req, dump_rank: int = 0):
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Synchronous pipeline executor implementation.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import Any, Callable, List
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.executors.pipeline_executor import (
|
||||
PipelineExecutor,
|
||||
@@ -21,21 +21,33 @@ class SyncExecutor(PipelineExecutor):
|
||||
A simple synchronous executor that runs stages sequentially.
|
||||
"""
|
||||
|
||||
def _run_profile_all_stages(
|
||||
self,
|
||||
stages: List[PipelineStage],
|
||||
payload: Any,
|
||||
server_args: ServerArgs,
|
||||
run_stage: Callable[[PipelineStage, Any], Any],
|
||||
) -> Any:
|
||||
"""Execute all pipeline stages sequentially and step the profiler."""
|
||||
for stage in stages:
|
||||
payload = run_stage(stage, payload)
|
||||
profiler = SGLDiffusionProfiler.get_instance()
|
||||
if profiler:
|
||||
profiler.step_stage()
|
||||
return payload
|
||||
|
||||
def run_profile_all_stages(
|
||||
self,
|
||||
stages: List[PipelineStage],
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> OutputBatch:
|
||||
"""
|
||||
Execute all pipeline stages sequentially.
|
||||
"""
|
||||
for stage in stages:
|
||||
batch = stage(batch, server_args)
|
||||
profiler = SGLDiffusionProfiler.get_instance()
|
||||
if profiler:
|
||||
profiler.step_stage()
|
||||
return batch
|
||||
return self._run_profile_all_stages(
|
||||
stages,
|
||||
batch,
|
||||
server_args,
|
||||
lambda stage, current: stage(current, server_args),
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -50,3 +62,16 @@ class SyncExecutor(PipelineExecutor):
|
||||
batch = self.run_profile_all_stages(stages, batch, server_args)
|
||||
|
||||
return batch
|
||||
|
||||
def execute_group(
|
||||
self,
|
||||
stages: List[PipelineStage],
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
return self._run_profile_all_stages(
|
||||
stages,
|
||||
batches,
|
||||
server_args,
|
||||
lambda stage, current: stage.run_grouped_requests(current, server_args),
|
||||
)
|
||||
|
||||
@@ -365,6 +365,7 @@ class OutputBatch:
|
||||
|
||||
# logged metrics info, directly from Req.timings
|
||||
metrics: Optional["RequestMetrics"] = None
|
||||
metrics_list: Optional[list[Optional["RequestMetrics"]]] = None
|
||||
|
||||
# For ComfyUI integration: noise prediction from denoising stage
|
||||
noise_pred: torch.Tensor | None = None
|
||||
|
||||
@@ -15,6 +15,7 @@ import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.dedup import StageDedupMixin
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
@@ -41,7 +42,7 @@ class StageVerificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineStage(ABC):
|
||||
class PipelineStage(StageDedupMixin, ABC):
|
||||
"""
|
||||
Abstract base class for all pipeline stages.
|
||||
|
||||
@@ -181,8 +182,6 @@ class PipelineStage(ABC):
|
||||
Execute the stage's processing on the batch with optional verification and logging.
|
||||
Should not be overridden by subclasses.
|
||||
|
||||
|
||||
|
||||
Returns:
|
||||
The updated batch information after this stage's processing.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Stage-local grouped-request dedup helpers."""
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
import torch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class StageDedupMixin:
|
||||
"""Mixin for stage-local grouped-request deduplication.
|
||||
|
||||
The mixin handles only stage-local reuse. It is not a global cache and does
|
||||
not decide which requests are equivalent for a stage. A stage opts into the
|
||||
common full-stage path by declaring the ``Req`` fields it writes through the
|
||||
``deduplicated_*`` class attributes and by overriding
|
||||
``build_dedup_fingerprint``.
|
||||
|
||||
Stages that can reuse only part of their work should override
|
||||
``run_grouped_requests`` directly and may still use
|
||||
``_group_requests_by_fingerprint`` for stable grouping.
|
||||
"""
|
||||
|
||||
deduplicated_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_tensor_tree_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_deepcopy_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_extra_tensor_tree_output_keys: ClassVar[tuple[str, ...]] = ()
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list["Req"],
|
||||
server_args: "ServerArgs",
|
||||
) -> list[Any]:
|
||||
"""Run this stage for a group of independent requests.
|
||||
|
||||
A grouped request is still a list of normal ``Req`` objects. The group
|
||||
boundary only gives a stage the opportunity to reduce duplicate work.
|
||||
Stages that do not opt in keep the single-request behavior by running
|
||||
``self(batch, server_args)`` for every request.
|
||||
|
||||
Full-stage dedup is declarative: declare the stage-owned output fields
|
||||
and return a stage-local fingerprint. Partial reuse belongs in a custom
|
||||
override, because the reusable unit is smaller than the whole stage.
|
||||
"""
|
||||
if self.has_deduplicated_output_fields():
|
||||
return self.run_deduplicated_group(batches, server_args)
|
||||
|
||||
return [self(batch, server_args) for batch in batches]
|
||||
|
||||
@classmethod
|
||||
def has_deduplicated_output_fields(cls) -> bool:
|
||||
"""Return whether this stage opts into base full-stage dedup."""
|
||||
return bool(
|
||||
cls.deduplicated_output_fields
|
||||
or cls.deduplicated_tensor_tree_output_fields
|
||||
or cls.deduplicated_deepcopy_output_fields
|
||||
or cls.deduplicated_extra_tensor_tree_output_keys
|
||||
)
|
||||
|
||||
def build_dedup_fingerprint(self, batch: "Req", server_args: "ServerArgs") -> Any:
|
||||
"""Return this stage's semantic input fingerprint for grouped dedup.
|
||||
|
||||
A fingerprint is the stage-local set of input values that fully
|
||||
determines the outputs this stage writes. The default is unique per
|
||||
request, so dedup is explicit and safe by default.
|
||||
|
||||
Overrides should include every request/config field read by this stage
|
||||
and exclude fields that only matter to later stages. If a field is a
|
||||
tensor or nested container, use ``freeze_for_dedup`` so the fingerprint
|
||||
remains hashable.
|
||||
"""
|
||||
return id(batch)
|
||||
|
||||
def run_deduplicated_group(
|
||||
self,
|
||||
batches: list["Req"],
|
||||
server_args: "ServerArgs",
|
||||
copy_outputs=None,
|
||||
) -> list["Req"]:
|
||||
"""Run full-stage-equivalent requests once and fan out stage outputs."""
|
||||
if copy_outputs is None:
|
||||
copy_outputs = self.copy_deduplicated_outputs
|
||||
|
||||
results: list[Req | None] = [None] * len(batches)
|
||||
|
||||
for _, group in self._group_requests_by_fingerprint(
|
||||
batches, lambda batch: self.build_dedup_fingerprint(batch, server_args)
|
||||
):
|
||||
first_index, first_batch = group[0]
|
||||
first_result = self(first_batch, server_args)
|
||||
results[first_index] = first_result
|
||||
|
||||
for index, batch in group[1:]:
|
||||
copy_outputs(first_result, batch)
|
||||
results[index] = batch
|
||||
|
||||
return [result for result in results if result is not None]
|
||||
|
||||
def copy_deduplicated_outputs(self, src: "Req", dst: "Req") -> None:
|
||||
"""Copy declared stage outputs from a computed request to a duplicate.
|
||||
|
||||
``deduplicated_output_fields`` uses shallow container copies and shares
|
||||
tensor references, which is the low-overhead path for read-only outputs
|
||||
such as embeddings. Tensor-tree fields recursively clone tensors.
|
||||
Deepcopy fields are for mutable request-local runtime objects, such as
|
||||
scheduler instances. Extra keys clone selected ``Req.extra`` entries
|
||||
without replacing the destination extra dict.
|
||||
"""
|
||||
for field in self.deduplicated_output_fields:
|
||||
setattr(dst, field, self.copy_stage_output(getattr(src, field)))
|
||||
for field in self.deduplicated_tensor_tree_output_fields:
|
||||
setattr(dst, field, self.clone_tensor_tree(getattr(src, field)))
|
||||
for field in self.deduplicated_deepcopy_output_fields:
|
||||
setattr(dst, field, deepcopy(getattr(src, field)))
|
||||
for key in self.deduplicated_extra_tensor_tree_output_keys:
|
||||
if key in src.extra:
|
||||
dst.extra[key] = self.clone_tensor_tree(src.extra[key])
|
||||
|
||||
@classmethod
|
||||
def copy_stage_output(cls, value):
|
||||
"""Shallow-copy reusable containers while preserving tensor ownership."""
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if isinstance(value, tuple):
|
||||
return tuple(value)
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def clone_tensor_tree(cls, value):
|
||||
"""Recursively clone tensors in a small output tree."""
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.clone()
|
||||
if isinstance(value, list):
|
||||
return [cls.clone_tensor_tree(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(cls.clone_tensor_tree(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return {key: cls.clone_tensor_tree(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def freeze_for_dedup(value: Any) -> Any:
|
||||
"""Convert common nested values into a hashable fingerprint fragment."""
|
||||
if isinstance(value, torch.Tensor):
|
||||
if value.numel() <= 256:
|
||||
return (
|
||||
"tensor",
|
||||
tuple(value.shape),
|
||||
str(value.dtype),
|
||||
tuple(value.detach().cpu().reshape(-1).tolist()),
|
||||
)
|
||||
return ("tensor", tuple(value.shape), str(value.dtype), value.device.type)
|
||||
if isinstance(value, dict):
|
||||
return tuple(
|
||||
sorted(
|
||||
(key, StageDedupMixin.freeze_for_dedup(item))
|
||||
for key, item in value.items()
|
||||
)
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(StageDedupMixin.freeze_for_dedup(item) for item in value)
|
||||
if isinstance(value, set):
|
||||
return tuple(
|
||||
sorted(StageDedupMixin.freeze_for_dedup(item) for item in value)
|
||||
)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _group_requests_by_fingerprint(
|
||||
batches: list["Req"],
|
||||
fingerprint_fn,
|
||||
) -> list[tuple[Any, list[tuple[int, "Req"]]]]:
|
||||
"""Group requests by a stage-local fingerprint while preserving order."""
|
||||
groups: dict[Any, list[tuple[int, "Req"]]] = {}
|
||||
for index, batch in enumerate(batches):
|
||||
fingerprint = fingerprint_fn(batch)
|
||||
groups.setdefault(fingerprint, []).append((index, batch))
|
||||
return list(groups.items())
|
||||
@@ -8,6 +8,8 @@ This module contains implementations of image encoding stages for diffusion pipe
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import PIL
|
||||
@@ -43,6 +45,67 @@ from sglang.multimodal_gen.utils import PRECISION_TO_TYPE
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageEncodingFingerprint:
|
||||
image_source: Any
|
||||
prompt: Any
|
||||
negative_prompt: Any
|
||||
do_classifier_free_guidance: bool
|
||||
height: int | None
|
||||
width: int | None
|
||||
num_frames: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LTX2ImageEncodingFingerprint:
|
||||
image_source: Any
|
||||
height: int | None
|
||||
width: int | None
|
||||
num_frames: int | None
|
||||
latent_dtype: str
|
||||
condition_encoder_subdir: str
|
||||
encode_sample_mode: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageVAEEncodingFingerprint:
|
||||
image_source: Any
|
||||
height: int | None
|
||||
width: int | None
|
||||
num_frames: int | None
|
||||
encode_sample_mode: str
|
||||
vae_precision: Any
|
||||
vae_tiling: bool
|
||||
|
||||
|
||||
def _freeze_image_source_value(value):
|
||||
"""Build a hashable identity fragment for image inputs.
|
||||
|
||||
Image inputs are often PIL/numpy/tensor objects. For file paths we can use
|
||||
the path value; for in-memory objects we only dedup when the exact same
|
||||
object instance is shared by multiple requests. This avoids expensive image
|
||||
hashing and avoids treating two mutable image objects as equivalent just
|
||||
because they currently have the same shape.
|
||||
"""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_freeze_image_source_value(item) for item in value)
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
return ("object", id(value))
|
||||
|
||||
|
||||
def _build_image_source_fingerprint(batch: Req, *, prefer_vae_image: bool = False):
|
||||
"""Return the image input fragment used by image encoding fingerprints."""
|
||||
if batch.image_path is not None:
|
||||
return ("path", PipelineStage.freeze_for_dedup(batch.image_path))
|
||||
image = (
|
||||
batch.vae_image if prefer_vae_image and batch.vae_image is not None else None
|
||||
)
|
||||
if image is None:
|
||||
image = batch.condition_image
|
||||
return ("image", _freeze_image_source_value(image))
|
||||
|
||||
|
||||
class ImageEncodingStage(PipelineStage):
|
||||
"""
|
||||
Stage for encoding image prompts into embeddings for diffusion models.
|
||||
@@ -51,6 +114,12 @@ class ImageEncodingStage(PipelineStage):
|
||||
expected by the diffusion model.
|
||||
"""
|
||||
|
||||
deduplicated_output_fields = (
|
||||
"image_embeds",
|
||||
"prompt_embeds",
|
||||
"negative_prompt_embeds",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_processor,
|
||||
@@ -208,6 +277,19 @@ class ImageEncodingStage(PipelineStage):
|
||||
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> ImageEncodingFingerprint:
|
||||
return ImageEncodingFingerprint(
|
||||
image_source=_build_image_source_fingerprint(batch),
|
||||
prompt=self.freeze_for_dedup(batch.prompt),
|
||||
negative_prompt=self.freeze_for_dedup(batch.negative_prompt),
|
||||
do_classifier_free_guidance=bool(batch.do_classifier_free_guidance),
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
num_frames=batch.num_frames,
|
||||
)
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
"""Verify image encoding stage inputs."""
|
||||
result = VerificationResult()
|
||||
@@ -234,6 +316,12 @@ class LTX2ImageEncodingStage(PipelineStage):
|
||||
- ``batch.ltx2_num_image_tokens``
|
||||
"""
|
||||
|
||||
deduplicated_output_fields = (
|
||||
"condition_image",
|
||||
"image_latent",
|
||||
"ltx2_num_image_tokens",
|
||||
)
|
||||
|
||||
def __init__(self, vae=None, **kwargs) -> None:
|
||||
super().__init__()
|
||||
self.vae = vae
|
||||
@@ -558,6 +646,29 @@ class LTX2ImageEncodingStage(PipelineStage):
|
||||
self.offload_model()
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> LTX2ImageEncodingFingerprint | int:
|
||||
if batch.image_path is None or batch.image_latent is not None:
|
||||
return id(batch)
|
||||
|
||||
sample_mode = server_args.pipeline_config.vae_config.encode_sample_mode()
|
||||
arch_config = server_args.pipeline_config.vae_config.arch_config
|
||||
encoder_subdir = str(getattr(arch_config, "condition_encoder_subdir", ""))
|
||||
if not encoder_subdir and sample_mode == "sample":
|
||||
return id(batch)
|
||||
|
||||
latent_dtype = batch.latents.dtype if batch.latents is not None else None
|
||||
return LTX2ImageEncodingFingerprint(
|
||||
image_source=_build_image_source_fingerprint(batch),
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
num_frames=batch.num_frames,
|
||||
latent_dtype=str(latent_dtype),
|
||||
condition_encoder_subdir=encoder_subdir,
|
||||
encode_sample_mode=sample_mode,
|
||||
)
|
||||
|
||||
|
||||
class ImageVAEEncodingStage(PipelineStage):
|
||||
"""
|
||||
@@ -567,6 +678,12 @@ class ImageVAEEncodingStage(PipelineStage):
|
||||
input format (e.g., image_latents).
|
||||
"""
|
||||
|
||||
deduplicated_output_fields = (
|
||||
"image_latent",
|
||||
"condition_image_latent_ids",
|
||||
"vae_image_sizes",
|
||||
)
|
||||
|
||||
def __init__(self, vae: ParallelTiledVAE, **kwargs) -> None:
|
||||
super().__init__()
|
||||
self.vae: ParallelTiledVAE = vae
|
||||
@@ -710,6 +827,26 @@ class ImageVAEEncodingStage(PipelineStage):
|
||||
self.offload_model()
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> ImageVAEEncodingFingerprint | int:
|
||||
if batch.condition_image is None:
|
||||
return id(batch)
|
||||
|
||||
sample_mode = server_args.pipeline_config.vae_config.encode_sample_mode()
|
||||
if sample_mode == "sample":
|
||||
return id(batch)
|
||||
|
||||
return ImageVAEEncodingFingerprint(
|
||||
image_source=_build_image_source_fingerprint(batch, prefer_vae_image=True),
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
num_frames=batch.num_frames,
|
||||
encode_sample_mode=sample_mode,
|
||||
vae_precision=server_args.pipeline_config.vae_precision,
|
||||
vae_tiling=bool(server_args.pipeline_config.vae_tiling),
|
||||
)
|
||||
|
||||
def retrieve_latents(
|
||||
self,
|
||||
encoder_output: DiagonalGaussianDistribution,
|
||||
|
||||
@@ -73,7 +73,15 @@ class InputValidationStage(PipelineStage):
|
||||
num_videos_per_prompt = batch.num_outputs_per_prompt
|
||||
|
||||
assert seed is not None
|
||||
seeds = [seed + i for i in range(num_videos_per_prompt)]
|
||||
if isinstance(seed, list):
|
||||
if len(seed) != num_videos_per_prompt:
|
||||
raise ValueError(
|
||||
f"seed list length must match num_outputs_per_prompt "
|
||||
f"({num_videos_per_prompt}), got {len(seed)}"
|
||||
)
|
||||
seeds = [int(item) for item in seed]
|
||||
else:
|
||||
seeds = [int(seed) + i for i in range(num_videos_per_prompt)]
|
||||
batch.seeds = seeds
|
||||
|
||||
# Create generators based on generator_device parameter
|
||||
@@ -393,7 +401,18 @@ class InputValidationStage(PipelineStage):
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
"""Verify input validation stage inputs."""
|
||||
result = VerificationResult()
|
||||
result.add_check("seed", batch.seed, [V.not_none, V.non_negative_int])
|
||||
result.add_check(
|
||||
"seed",
|
||||
batch.seed,
|
||||
[
|
||||
V.not_none,
|
||||
lambda x: (
|
||||
V.non_negative_int(x)
|
||||
if not isinstance(x, list)
|
||||
else bool(x) and all(V.non_negative_int(item) for item in x)
|
||||
),
|
||||
],
|
||||
)
|
||||
result.add_check(
|
||||
"num_videos_per_prompt", batch.num_outputs_per_prompt, V.positive_int
|
||||
)
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
Latent preparation stage for diffusion pipelines.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
@@ -24,6 +28,16 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LatentPreparationFingerprint:
|
||||
height: int | None
|
||||
width: int | None
|
||||
num_frames: int | None
|
||||
latent_num_frames: int | None
|
||||
prompt_dtype: Any
|
||||
generator_device: str | None
|
||||
|
||||
|
||||
class LatentPreparationStage(PipelineStage):
|
||||
"""
|
||||
Stage for preparing initial latent variables for the diffusion process.
|
||||
@@ -115,6 +129,151 @@ class LatentPreparationStage(PipelineStage):
|
||||
batch.raw_latent_shape = latents.shape
|
||||
return batch
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> list[Req]:
|
||||
"""Group only the deterministic latent-preparation subprocess.
|
||||
|
||||
Latent preparation is not a pure full-stage copy: each request still
|
||||
owns its RNG stream, so raw noise must be drawn once per request with
|
||||
that request's generator. The reusable part is the deterministic work
|
||||
after raw noise generation, such as packing latent tokens and applying
|
||||
scheduler scaling. For that reason this stage uses the common
|
||||
fingerprint grouping helper but implements its own grouped execution
|
||||
instead of ``run_deduplicated_group``.
|
||||
"""
|
||||
results: list[Req | None] = [None] * len(batches)
|
||||
|
||||
for _, group in self._group_requests_by_fingerprint(
|
||||
batches, lambda batch: self.build_dedup_fingerprint(batch, server_args)
|
||||
):
|
||||
indexed_batches = group
|
||||
group_batches = [batch for _, batch in indexed_batches]
|
||||
if len(group_batches) == 1 or any(
|
||||
batch.latents is not None for batch in group_batches
|
||||
):
|
||||
for index, batch in indexed_batches:
|
||||
results[index] = self(batch, server_args)
|
||||
continue
|
||||
|
||||
first_index, first_batch = indexed_batches[0]
|
||||
first_result = self._prepare_grouped_latents(group_batches, server_args)
|
||||
self._split_batched_latents(first_result, group_batches)
|
||||
results[first_index] = first_batch
|
||||
for index, batch in indexed_batches[1:]:
|
||||
results[index] = batch
|
||||
|
||||
return [result for result in results if result is not None]
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> LatentPreparationFingerprint:
|
||||
prompt_dtype = (
|
||||
batch.prompt_embeds[0].dtype
|
||||
if isinstance(batch.prompt_embeds, list) and batch.prompt_embeds
|
||||
else None
|
||||
)
|
||||
latent_num_frames = self.adjust_video_length(batch, server_args)
|
||||
return LatentPreparationFingerprint(
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
num_frames=batch.num_frames,
|
||||
latent_num_frames=latent_num_frames,
|
||||
prompt_dtype=prompt_dtype,
|
||||
generator_device=batch.generator_device,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _single_generator(batch: Req):
|
||||
if isinstance(batch.generator, list):
|
||||
assert len(batch.generator) == 1
|
||||
return batch.generator[0]
|
||||
return batch.generator
|
||||
|
||||
def _prepare_grouped_latents(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> Req:
|
||||
"""Prepare grouped random latents without changing per-request RNG streams.
|
||||
|
||||
``randn_tensor`` accepts a list of generators, but its batched draw is not
|
||||
guaranteed to match drawing each request independently. For multi-output
|
||||
requests we need exact equivalence to the sequential seed path, so this
|
||||
helper draws one raw latent tensor per request and only batches the
|
||||
deterministic packing/scaling work.
|
||||
"""
|
||||
first_batch = batches[0]
|
||||
latent_num_frames = self.adjust_video_length(first_batch, server_args)
|
||||
batch_size = len(batches)
|
||||
|
||||
dtype = self._get_latent_dtype(first_batch, server_args)
|
||||
device = get_local_torch_device()
|
||||
num_frames = (
|
||||
latent_num_frames
|
||||
if latent_num_frames is not None
|
||||
else first_batch.num_frames
|
||||
)
|
||||
height = first_batch.height
|
||||
width = first_batch.width
|
||||
|
||||
if height is None or width is None:
|
||||
raise ValueError("Height and width must be provided")
|
||||
|
||||
raw_latents = []
|
||||
for batch in batches:
|
||||
shape = server_args.pipeline_config.prepare_latent_shape(
|
||||
batch, 1, num_frames
|
||||
)
|
||||
raw_latents.append(
|
||||
randn_tensor(
|
||||
shape,
|
||||
generator=self._single_generator(batch),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
)
|
||||
|
||||
latents = torch.cat(raw_latents, dim=0)
|
||||
latent_ids = server_args.pipeline_config.maybe_prepare_latent_ids(latents)
|
||||
if latent_ids is not None:
|
||||
first_batch.latent_ids = latent_ids.to(device=device)
|
||||
|
||||
original_num_outputs = first_batch.num_outputs_per_prompt
|
||||
try:
|
||||
first_batch.num_outputs_per_prompt = batch_size
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, first_batch
|
||||
)
|
||||
finally:
|
||||
first_batch.num_outputs_per_prompt = original_num_outputs
|
||||
|
||||
if hasattr(self.scheduler, "init_noise_sigma"):
|
||||
latents = latents * self.scheduler.init_noise_sigma
|
||||
|
||||
first_batch.latents = latents
|
||||
first_batch.raw_latent_shape = latents.shape
|
||||
return first_batch
|
||||
|
||||
@staticmethod
|
||||
def _slice_batch_tensor(tensor: torch.Tensor, index: int, total: int):
|
||||
if tensor.shape[0] == total:
|
||||
return tensor[index : index + 1].contiguous()
|
||||
return tensor
|
||||
|
||||
def _split_batched_latents(self, src: Req, batches: list[Req]) -> None:
|
||||
total = len(batches)
|
||||
assert src.latents is not None
|
||||
latents = src.latents
|
||||
latent_ids = src.latent_ids
|
||||
for index, batch in enumerate(batches):
|
||||
batch.latents = self._slice_batch_tensor(latents, index, total)
|
||||
batch.raw_latent_shape = batch.latents.shape
|
||||
if latent_ids is not None:
|
||||
batch.latent_ids = self._slice_batch_tensor(latent_ids, index, total)
|
||||
|
||||
def adjust_video_length(self, batch: Req, server_args: ServerArgs) -> int:
|
||||
"""
|
||||
Adjust video length based on VAE version.
|
||||
|
||||
@@ -8,6 +8,8 @@ This module contains implementations of prompt encoding stages for diffusion pip
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
@@ -28,6 +30,15 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextEncodingFingerprint:
|
||||
prompt: Any
|
||||
negative_prompt: Any
|
||||
do_classifier_free_guidance: bool
|
||||
prompt_template: Any
|
||||
max_sequence_length: int | None
|
||||
|
||||
|
||||
class TextEncodingStage(PipelineStage):
|
||||
"""
|
||||
Stage for encoding text prompts into embeddings for diffusion models.
|
||||
@@ -36,6 +47,18 @@ class TextEncodingStage(PipelineStage):
|
||||
expected by the diffusion model.
|
||||
"""
|
||||
|
||||
deduplicated_output_fields = (
|
||||
"prompt_embeds",
|
||||
"negative_prompt_embeds",
|
||||
"prompt_attention_mask",
|
||||
"negative_attention_mask",
|
||||
"pooled_embeds",
|
||||
"neg_pooled_embeds",
|
||||
"clip_embedding_pos",
|
||||
"clip_embedding_neg",
|
||||
"is_prompt_processed",
|
||||
)
|
||||
|
||||
def __init__(self, text_encoders, tokenizers) -> None:
|
||||
"""
|
||||
Initialize the prompt encoding stage.
|
||||
@@ -107,6 +130,17 @@ class TextEncodingStage(PipelineStage):
|
||||
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> TextEncodingFingerprint:
|
||||
return TextEncodingFingerprint(
|
||||
prompt=self.freeze_for_dedup(batch.prompt),
|
||||
negative_prompt=self.freeze_for_dedup(batch.negative_prompt),
|
||||
do_classifier_free_guidance=bool(batch.do_classifier_free_guidance),
|
||||
prompt_template=self.freeze_for_dedup(batch.prompt_template),
|
||||
max_sequence_length=batch.max_sequence_length,
|
||||
)
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
"""Verify text encoding stage inputs."""
|
||||
result = VerificationResult()
|
||||
|
||||
@@ -8,6 +8,7 @@ This module contains implementations of timestep preparation stages for diffusio
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Tuple
|
||||
|
||||
import torch
|
||||
@@ -33,6 +34,17 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimestepPreparationFingerprint:
|
||||
num_inference_steps: int
|
||||
timesteps: Any
|
||||
sigmas: Any
|
||||
n_tokens: int | None
|
||||
height: int | None
|
||||
width: int | None
|
||||
num_frames: int | None
|
||||
|
||||
|
||||
class TimestepPreparationStage(PipelineStage):
|
||||
"""
|
||||
Stage for preparing timesteps for the diffusion process.
|
||||
@@ -41,6 +53,10 @@ class TimestepPreparationStage(PipelineStage):
|
||||
during the diffusion process.
|
||||
"""
|
||||
|
||||
deduplicated_tensor_tree_output_fields = ("timesteps", "sigmas")
|
||||
deduplicated_deepcopy_output_fields = ("scheduler",)
|
||||
deduplicated_extra_tensor_tree_output_keys = ("mu",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler,
|
||||
@@ -144,6 +160,19 @@ class TimestepPreparationStage(PipelineStage):
|
||||
self.log_debug("timesteps: %s", timesteps)
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(
|
||||
self, batch: Req, server_args: ServerArgs
|
||||
) -> TimestepPreparationFingerprint:
|
||||
return TimestepPreparationFingerprint(
|
||||
num_inference_steps=batch.num_inference_steps,
|
||||
timesteps=self.freeze_for_dedup(batch.timesteps),
|
||||
sigmas=self.freeze_for_dedup(batch.sigmas),
|
||||
n_tokens=batch.n_tokens,
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
num_frames=batch.num_frames,
|
||||
)
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
"""Verify timestep preparation stage inputs."""
|
||||
result = VerificationResult()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
expand_request_outputs,
|
||||
normalize_output_seeds,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation import (
|
||||
LatentPreparationStage,
|
||||
)
|
||||
|
||||
|
||||
class CountingDedupStage(PipelineStage):
|
||||
deduplicated_output_fields = ("prompt_embeds",)
|
||||
deduplicated_tensor_tree_output_fields = ("timesteps",)
|
||||
deduplicated_deepcopy_output_fields = ("scheduler",)
|
||||
deduplicated_extra_tensor_tree_output_keys = ("mu",)
|
||||
|
||||
def __init__(self):
|
||||
self.server_args = SimpleNamespace(comfyui_mode=True)
|
||||
self.forward_calls = 0
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args):
|
||||
return batch.prompt
|
||||
|
||||
def forward(self, batch: Req, server_args) -> Req:
|
||||
self.forward_calls += 1
|
||||
value = float(self.forward_calls)
|
||||
batch.prompt_embeds = [torch.tensor([value])]
|
||||
batch.timesteps = torch.tensor([value])
|
||||
batch.scheduler = {"state": [value]}
|
||||
batch.extra["mu"] = torch.tensor([value])
|
||||
return batch
|
||||
|
||||
|
||||
class CountingLatentStage(LatentPreparationStage):
|
||||
def __init__(self):
|
||||
self.server_args = SimpleNamespace(comfyui_mode=True)
|
||||
self.prepare_group_calls = 0
|
||||
self.forward_calls = 0
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args):
|
||||
return batch.prompt
|
||||
|
||||
def _prepare_grouped_latents(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args,
|
||||
) -> Req:
|
||||
self.prepare_group_calls += 1
|
||||
first_batch = batches[0]
|
||||
first_batch.latents = torch.arange(len(batches), dtype=torch.float32).reshape(
|
||||
len(batches), 1, 1
|
||||
)
|
||||
first_batch.latent_ids = first_batch.latents + 10
|
||||
first_batch.raw_latent_shape = first_batch.latents.shape
|
||||
return first_batch
|
||||
|
||||
def forward(self, batch: Req, server_args) -> Req:
|
||||
self.forward_calls += 1
|
||||
batch.latents = torch.tensor([[[100.0 + self.forward_calls]]])
|
||||
return batch
|
||||
|
||||
|
||||
class TestMultiOutputGrouping(unittest.TestCase):
|
||||
def test_normalize_output_seeds_from_int(self):
|
||||
self.assertEqual(
|
||||
normalize_output_seeds(10, num_outputs_per_prompt=3),
|
||||
[10, 11, 12],
|
||||
)
|
||||
|
||||
def test_normalize_output_seeds_from_per_prompt_list(self):
|
||||
self.assertEqual(
|
||||
normalize_output_seeds([3, 5], num_outputs_per_prompt=2),
|
||||
[3, 5],
|
||||
)
|
||||
|
||||
def test_normalize_output_seeds_from_total_list(self):
|
||||
self.assertEqual(
|
||||
normalize_output_seeds(
|
||||
[1, 2, 3, 4],
|
||||
num_outputs_per_prompt=2,
|
||||
num_prompts=2,
|
||||
prompt_index=1,
|
||||
),
|
||||
[3, 4],
|
||||
)
|
||||
|
||||
def test_normalize_output_seeds_rejects_mismatched_list(self):
|
||||
with self.assertRaisesRegex(ValueError, r"seed list length"):
|
||||
normalize_output_seeds(
|
||||
[1, 2, 3],
|
||||
num_outputs_per_prompt=2,
|
||||
num_prompts=2,
|
||||
prompt_index=0,
|
||||
)
|
||||
|
||||
def test_expand_request_outputs_splits_seed_and_output_name(self):
|
||||
req = Req(
|
||||
sampling_params=SamplingParams(
|
||||
request_id="rid",
|
||||
prompt="p",
|
||||
output_path="/tmp",
|
||||
output_file_name="image.png",
|
||||
num_outputs_per_prompt=2,
|
||||
seed=[100, 101],
|
||||
)
|
||||
)
|
||||
|
||||
outputs = expand_request_outputs(req)
|
||||
|
||||
self.assertEqual([item.seed for item in outputs], [100, 101])
|
||||
self.assertEqual([item.num_outputs_per_prompt for item in outputs], [1, 1])
|
||||
self.assertEqual(
|
||||
[item.output_file_name for item in outputs],
|
||||
["image_0.png", "image_1.png"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.request_id for item in outputs],
|
||||
["rid:0", "rid:1"],
|
||||
)
|
||||
|
||||
def test_split_batched_latents_uses_original_batched_tensor(self):
|
||||
stage = LatentPreparationStage.__new__(LatentPreparationStage)
|
||||
src = Req(sampling_params=SamplingParams(prompt="p"))
|
||||
dst = Req(sampling_params=SamplingParams(prompt="p"))
|
||||
src.latents = torch.tensor([[[1.0]], [[2.0]]])
|
||||
src.latent_ids = torch.tensor([[[10.0]], [[20.0]]])
|
||||
|
||||
stage._split_batched_latents(src, [src, dst])
|
||||
|
||||
self.assertTrue(torch.equal(src.latents, torch.tensor([[[1.0]]])))
|
||||
self.assertTrue(torch.equal(dst.latents, torch.tensor([[[2.0]]])))
|
||||
self.assertTrue(torch.equal(src.latent_ids, torch.tensor([[[10.0]]])))
|
||||
self.assertTrue(torch.equal(dst.latent_ids, torch.tensor([[[20.0]]])))
|
||||
|
||||
def test_declarative_stage_dedup_runs_equivalent_request_once(self):
|
||||
stage = CountingDedupStage()
|
||||
reqs = [
|
||||
Req(sampling_params=SamplingParams(prompt="same")),
|
||||
Req(sampling_params=SamplingParams(prompt="same")),
|
||||
Req(sampling_params=SamplingParams(prompt="same")),
|
||||
]
|
||||
|
||||
results = stage.run_grouped_requests(reqs, SimpleNamespace())
|
||||
|
||||
self.assertEqual(stage.forward_calls, 1)
|
||||
self.assertEqual(results, reqs)
|
||||
for req in reqs:
|
||||
self.assertTrue(torch.equal(req.prompt_embeds[0], torch.tensor([1.0])))
|
||||
self.assertTrue(torch.equal(req.timesteps, torch.tensor([1.0])))
|
||||
self.assertEqual(req.scheduler, {"state": [1.0]})
|
||||
self.assertTrue(torch.equal(req.extra["mu"], torch.tensor([1.0])))
|
||||
|
||||
self.assertIsNot(reqs[0].prompt_embeds, reqs[1].prompt_embeds)
|
||||
self.assertIs(reqs[0].prompt_embeds[0], reqs[1].prompt_embeds[0])
|
||||
self.assertIsNot(reqs[0].timesteps, reqs[1].timesteps)
|
||||
self.assertIsNot(reqs[0].scheduler, reqs[1].scheduler)
|
||||
self.assertIsNot(reqs[0].extra["mu"], reqs[1].extra["mu"])
|
||||
|
||||
def test_declarative_stage_dedup_runs_distinct_fingerprints_separately(self):
|
||||
stage = CountingDedupStage()
|
||||
reqs = [
|
||||
Req(sampling_params=SamplingParams(prompt="a")),
|
||||
Req(sampling_params=SamplingParams(prompt="b")),
|
||||
]
|
||||
|
||||
stage.run_grouped_requests(reqs, SimpleNamespace())
|
||||
|
||||
self.assertEqual(stage.forward_calls, 2)
|
||||
self.assertTrue(torch.equal(reqs[0].prompt_embeds[0], torch.tensor([1.0])))
|
||||
self.assertTrue(torch.equal(reqs[1].prompt_embeds[0], torch.tensor([2.0])))
|
||||
|
||||
def test_latent_grouped_path_batches_equivalent_requests_once(self):
|
||||
stage = CountingLatentStage()
|
||||
reqs = [
|
||||
Req(sampling_params=SamplingParams(prompt="same")),
|
||||
Req(sampling_params=SamplingParams(prompt="same")),
|
||||
]
|
||||
|
||||
results = stage.run_grouped_requests(reqs, SimpleNamespace())
|
||||
|
||||
self.assertEqual(results, reqs)
|
||||
self.assertEqual(stage.prepare_group_calls, 1)
|
||||
self.assertEqual(stage.forward_calls, 0)
|
||||
self.assertTrue(torch.equal(reqs[0].latents, torch.tensor([[[0.0]]])))
|
||||
self.assertTrue(torch.equal(reqs[1].latents, torch.tensor([[[1.0]]])))
|
||||
self.assertTrue(torch.equal(reqs[0].latent_ids, torch.tensor([[[10.0]]])))
|
||||
self.assertTrue(torch.equal(reqs[1].latent_ids, torch.tensor([[[11.0]]])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -40,6 +40,14 @@ class TestSamplingParamsValidate(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, r"num_outputs_per_prompt"):
|
||||
SamplingParams(num_outputs_per_prompt=0)
|
||||
|
||||
def test_seed_accepts_int_or_non_empty_int_list(self):
|
||||
self.assertEqual(SamplingParams(seed=7).seed, 7)
|
||||
self.assertEqual(SamplingParams(seed=[7, 8]).seed, [7, 8])
|
||||
with self.assertRaisesRegex(ValueError, r"seed list"):
|
||||
SamplingParams(seed=[])
|
||||
with self.assertRaisesRegex(ValueError, r"seed"):
|
||||
SamplingParams(seed=[1, -1])
|
||||
|
||||
def test_fps_must_be_positive_int(self):
|
||||
with self.assertRaisesRegex(ValueError, r"\bfps\b"):
|
||||
SamplingParams(fps=0)
|
||||
@@ -203,6 +211,13 @@ class TestSamplingParamsCliArgs(unittest.TestCase):
|
||||
self.assertEqual(kwargs["negative_prompt"], SamplingParams.negative_prompt)
|
||||
self.assertTrue(kwargs["save_output"])
|
||||
|
||||
def test_get_cli_args_accepts_seed_list(self):
|
||||
self.assertEqual(self._parse_cli_kwargs(["--seed", "7"])["seed"], 7)
|
||||
self.assertEqual(
|
||||
self._parse_cli_kwargs(["--seed", "7", "8"])["seed"],
|
||||
[7, 8],
|
||||
)
|
||||
|
||||
def test_qwen_image_cli_path_preserves_model_defaults(self):
|
||||
params = self._make_qwen_image_params([])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user