chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1 @@
"""Diffusion breakable CUDA graph runtime helpers."""
@@ -0,0 +1 @@
"""Model-specific prompt padders for diffusion breakable CUDA graph."""
@@ -0,0 +1,131 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0
# ==============================================================================
"""Ideogram-4 breakable CUDA graph (BCG) prompt padding."""
from __future__ import annotations
from typing import Any
import torch
from sglang.multimodal_gen.runtime.breakable_cuda_graph import (
prompt_padding as bcg_utils,
)
from sglang.multimodal_gen.runtime.layers.attention import DynamicVarlenMaskMeta
_SEQUENCE_PADDING_INDICATOR = -1
_OUTPUT_IMAGE_INDICATOR = 2
_LLM_TOKEN_INDICATOR = 3
_DYNAMIC_MASK_META_ATTR = "_sglang_bcg_ideogram_attn_mask_meta"
def is_ideogram_transformer(current_model: Any, call_kwargs: dict) -> bool:
return (
bcg_utils.transformer_class_name_matches(current_model, "ideogram")
and "llm_features" in call_kwargs
and "x" in call_kwargs
and "indicator" in call_kwargs
and "position_ids" in call_kwargs
)
def _unwrap_model(current_model: Any) -> Any:
for attr in ("module", "_orig_mod"):
wrapped = getattr(current_model, attr, None)
if wrapped is not None:
current_model = wrapped
return current_model
def _dynamic_mask_meta(current_model: Any) -> DynamicVarlenMaskMeta:
model = _unwrap_model(current_model)
meta = getattr(model, _DYNAMIC_MASK_META_ATTR, None)
if not isinstance(meta, DynamicVarlenMaskMeta):
meta = DynamicVarlenMaskMeta()
setattr(model, _DYNAMIC_MASK_META_ATTR, meta)
return meta
def _first_indicator(call_kwargs: dict) -> torch.Tensor | None:
indicator = bcg_utils.first_tensor(call_kwargs.get("indicator"))
if not torch.is_tensor(indicator) or indicator.dim() < 2:
return None
return indicator
def _text_and_image_lengths(indicator: torch.Tensor) -> tuple[int, int] | None:
row = indicator[0]
if not torch.any(row == _LLM_TOKEN_INDICATOR):
return None
image_positions = (row == _OUTPUT_IMAGE_INDICATOR).nonzero(as_tuple=False)
if image_positions.numel() == 0:
return None
text_seq = int(image_positions[0].item())
if text_seq <= 0:
return None
image_seq = int(row.numel()) - text_seq
if image_seq <= 0:
return None
return text_seq, image_seq
def _pad_total_dim(obj: Any, *, source: int, target: int, value: float = 0) -> Any:
return bcg_utils.pad_nested_dim(
obj, dim=1, source=source, target=target, value=value
)
def pad_ideogram_prompt_kwargs(
call_kwargs: dict, current_model: Any, buckets: tuple[int, ...]
) -> dict:
indicator = _first_indicator(call_kwargs)
if indicator is None:
return call_kwargs
lengths = _text_and_image_lengths(indicator)
if lengths is None:
return call_kwargs
text_seq, image_seq = lengths
bucket = bcg_utils.select_text_bucket(text_seq, buckets)
if bucket is None:
return call_kwargs
source_total = text_seq + image_seq
target_total = bucket + image_seq
out = dict(call_kwargs)
if source_total < target_total:
for key in ("llm_features", "x"):
if key in out and out[key] is not None:
out[key] = _pad_total_dim(
out[key], source=source_total, target=target_total
)
if out.get("position_ids") is not None:
out["position_ids"] = _pad_total_dim(
out["position_ids"], source=source_total, target=target_total
)
if out.get("segment_ids") is not None:
out["segment_ids"] = _pad_total_dim(
out["segment_ids"],
source=source_total,
target=target_total,
value=_SEQUENCE_PADDING_INDICATOR,
)
if out.get("indicator") is not None:
out["indicator"] = _pad_total_dim(
out["indicator"], source=source_total, target=target_total
)
if out.get("attn_mask") is not None:
out["attn_mask"] = _pad_total_dim(
out["attn_mask"], source=source_total, target=target_total
)
if out.get("attn_mask") is not None:
out["attn_mask_meta"] = _dynamic_mask_meta(current_model)
return out
bcg_utils.register_prompt_padder(is_ideogram_transformer, pad_ideogram_prompt_kwargs)
@@ -0,0 +1,102 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Qwen-Image breakable CUDA graph (BCG) prompt padding.
Qwen-Image / Qwen-Image-Edit carry text length on dim 1 of
``encoder_hidden_states`` and a separate ``freqs_cis`` text-rope cache plus
``txt_seq_lens``; they may not pass an explicit prompt mask, so this padder
synthesizes one. Registered with the base denoising stage's padder registry.
"""
from __future__ import annotations
from typing import Any
import torch
from sglang.multimodal_gen.runtime.breakable_cuda_graph import (
prompt_padding as bcg_utils,
)
def is_qwen_transformer(current_model: Any, call_kwargs: dict) -> bool:
return (
bcg_utils.transformer_class_name_matches(current_model, "qwen")
and "txt_seq_lens" in call_kwargs
and "freqs_cis" in call_kwargs
)
def pad_qwen_prompt_kwargs(
call_kwargs: dict, current_model: Any, buckets: tuple[int, ...]
) -> dict:
ehs = call_kwargs.get("encoder_hidden_states")
ehs_tensor = bcg_utils.first_tensor(ehs)
if not torch.is_tensor(ehs_tensor) or ehs_tensor.dim() < 2:
return call_kwargs
seq = ehs_tensor.shape[1]
bucket = bcg_utils.select_text_bucket(seq, buckets)
if bucket is None:
return call_kwargs
out = dict(call_kwargs)
if seq < bucket:
out["encoder_hidden_states"] = bcg_utils.pad_nested_dim(
ehs, dim=1, source=seq, target=bucket
)
if (
"encoder_hidden_states_2" in out
and out["encoder_hidden_states_2"] is not None
):
out["encoder_hidden_states_2"] = bcg_utils.pad_nested_dim(
out["encoder_hidden_states_2"], dim=1, source=seq, target=bucket
)
mask = out.get("encoder_hidden_states_mask")
if mask is None:
mask = torch.ones(
ehs_tensor.shape[:2],
device=ehs_tensor.device,
dtype=torch.bool,
)
if mask is not None:
out["encoder_hidden_states_mask"] = bcg_utils.pad_nested_dim(
mask, dim=1, source=seq, target=bucket
)
if "encoder_attention_mask" in out and out["encoder_attention_mask"] is not None:
out["encoder_attention_mask"] = bcg_utils.pad_nested_dim(
out["encoder_attention_mask"], dim=1, source=seq, target=bucket
)
freqs_cis = out.get("freqs_cis")
if isinstance(freqs_cis, tuple) and len(freqs_cis) == 2:
img_cache, txt_cache = freqs_cis
txt_cache = bcg_utils.pad_nested_dim(
txt_cache, dim=0, source=seq, target=bucket
)
out["freqs_cis"] = (img_cache, txt_cache)
elif isinstance(freqs_cis, list) and len(freqs_cis) == 2:
img_cache, txt_cache = freqs_cis
txt_cache = bcg_utils.pad_nested_dim(
txt_cache, dim=0, source=seq, target=bucket
)
out["freqs_cis"] = [img_cache, txt_cache]
out["txt_seq_lens"] = bcg_utils.bucket_txt_seq_lens(out.get("txt_seq_lens"), bucket)
return out
bcg_utils.register_prompt_padder(is_qwen_transformer, pad_qwen_prompt_kwargs)
@@ -0,0 +1,169 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0
# ==============================================================================
"""Z-Image breakable CUDA graph (BCG) prompt padding."""
from __future__ import annotations
from typing import Any
import torch
from sglang.multimodal_gen.runtime.breakable_cuda_graph import (
prompt_padding as bcg_utils,
)
def is_zimage_transformer(current_model: Any, call_kwargs: dict) -> bool:
return (
bcg_utils.transformer_class_name_matches(current_model, "zimage")
and "encoder_hidden_states" in call_kwargs
and "freqs_cis" in call_kwargs
)
def _first_caption_tensor(encoder_hidden_states: Any) -> torch.Tensor | None:
tensor = bcg_utils.first_tensor(encoder_hidden_states)
if not torch.is_tensor(tensor):
return None
if tensor.dim() == 2:
return tensor
if tensor.dim() == 3:
return tensor[0]
return None
def _caption_seq_len(tensor: torch.Tensor) -> int:
if tensor.dim() == 2:
return int(tensor.shape[0])
if tensor.dim() == 3:
return int(tensor.shape[1])
raise ValueError("Z-Image caption tensor must have rank 2 or 3")
def _pad_caption(obj: Any, *, target: int) -> Any:
if torch.is_tensor(obj):
if obj.dim() == 2:
return bcg_utils.pad_tensor_dim(obj, 0, target)
if obj.dim() == 3:
return bcg_utils.pad_tensor_dim(obj, 1, target)
return obj
if isinstance(obj, list):
return [_pad_caption(item, target=target) for item in obj]
if isinstance(obj, tuple):
return tuple(_pad_caption(item, target=target) for item in obj)
return obj
def _unwrap_model(current_model: Any) -> Any:
for attr in ("module", "_orig_mod"):
wrapped = getattr(current_model, attr, None)
if wrapped is not None:
current_model = wrapped
return current_model
def _build_caption_freqs(current_model: Any, *, target: int, device: torch.device):
rotary_emb = getattr(_unwrap_model(current_model), "rotary_emb", None)
if rotary_emb is None:
return None
axes = [
torch.arange(1, target + 1, dtype=torch.int32, device=device),
torch.zeros(target, dtype=torch.int32, device=device),
torch.zeros(target, dtype=torch.int32, device=device),
]
cap_pos_ids = torch.stack(axes, dim=-1)
return rotary_emb(cap_pos_ids)
def _pad_caption_freqs(freqs_cis: Any, current_model: Any, *, target: int) -> Any:
if not isinstance(freqs_cis, (tuple, list)) or len(freqs_cis) != 2:
return freqs_cis
cap_cache, image_cache = freqs_cis
cap_tensor = bcg_utils.first_tensor(cap_cache)
if torch.is_tensor(cap_tensor) and cap_tensor.dim() >= 1:
cap_freqs = _build_caption_freqs(
current_model, target=target, device=cap_tensor.device
)
if cap_freqs is not None:
cap_cache = cap_freqs
if isinstance(freqs_cis, tuple):
return (cap_cache, image_cache)
return [cap_cache, image_cache]
def _caption_mask(
call_kwargs: dict, *, caption: torch.Tensor, seq: int, bucket: int
) -> torch.Tensor:
mask = bcg_utils.first_tensor(call_kwargs.get("encoder_hidden_states_mask"))
if not torch.is_tensor(mask):
mask = bcg_utils.first_tensor(call_kwargs.get("encoder_attention_mask"))
if torch.is_tensor(mask):
if mask.dim() == 1:
mask = mask[:seq].unsqueeze(0)
elif mask.dim() >= 2:
mask = mask[:, :seq]
mask = mask.to(device=caption.device, dtype=torch.bool)
else:
batch = int(caption.shape[0]) if caption.dim() == 3 else 1
mask = torch.ones((batch, seq), device=caption.device, dtype=torch.bool)
return bcg_utils.pad_tensor_dim(mask, 1, bucket)
def pad_zimage_prompt_kwargs(
call_kwargs: dict, current_model: Any, buckets: tuple[int, ...]
) -> dict:
caption = _first_caption_tensor(call_kwargs.get("encoder_hidden_states"))
if caption is None:
return call_kwargs
seq = _caption_seq_len(caption)
cap_freq = None
freqs_cis = call_kwargs.get("freqs_cis")
if isinstance(freqs_cis, (tuple, list)) and len(freqs_cis) == 2:
cap_freq = bcg_utils.first_tensor(freqs_cis[0])
cap_freq_len = int(cap_freq.shape[0]) if torch.is_tensor(cap_freq) else seq
bucket = bcg_utils.select_text_bucket(max(seq, cap_freq_len), buckets)
if bucket is None:
return call_kwargs
out = {
key: value
for key, value in call_kwargs.items()
if key
in {
"hidden_states",
"timestep",
"guidance",
"encoder_hidden_states",
"encoder_attention_mask",
"encoder_hidden_states_mask",
"freqs_cis",
"image_seq_len_target",
"patch_size",
"f_patch_size",
}
}
if seq < bucket:
out["encoder_hidden_states"] = _pad_caption(
out["encoder_hidden_states"], target=bucket
)
caption_mask = _caption_mask(call_kwargs, caption=caption, seq=seq, bucket=bucket)
out["encoder_hidden_states_mask"] = caption_mask
out["caption_valid_lens"] = caption_mask.sum(dim=1).to(dtype=torch.long)
out["_use_caption_valid_mask"] = True
if out.get("encoder_attention_mask") is not None:
out["encoder_attention_mask"] = out["encoder_hidden_states_mask"]
out["freqs_cis"] = _pad_caption_freqs(
out.get("freqs_cis"), current_model, target=bucket
)
return out
bcg_utils.register_prompt_padder(is_zimage_transformer, pad_zimage_prompt_kwargs)
@@ -0,0 +1,306 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Utilities for breakable CUDA graph (BCG) prompt padding.
These helpers bucket prompt-conditioning inputs by sequence length so diffusion
DiT forward calls with different prompt lengths can reuse captured CUDA graphs.
Model-specific padders can register custom handling under
``breakable_cuda_graph.model_padders``.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
import torch
logger = logging.getLogger(__name__)
# Prompt-conditioning kwarg keys, grouped by which dim carries the text length.
PROMPT_MASK_KEYS = (
"encoder_attention_mask",
"encoder_hidden_states_mask",
"attention_mask",
"text_mask",
"prompt_attention_mask",
"negative_attention_mask",
"prompt_embeds_mask",
"negative_prompt_embeds_mask",
)
TEXT_DIM1_KEYS = (
"encoder_hidden_states",
"encoder_hidden_states_2",
"encoder_attention_mask",
"encoder_hidden_states_mask",
"attention_mask",
"text_mask",
"text_ids",
"text_pos_ids",
"txt_ids",
"prompt_embeds",
"negative_prompt_embeds",
"prompt_attention_mask",
"negative_attention_mask",
"prompt_embeds_mask",
"negative_prompt_embeds_mask",
"audio_encoder_hidden_states",
"audio_encoder_attention_mask",
)
TEXT_DIM0_KEYS = (
"txt_freqs_cis",
"text_freqs_cis",
)
TEXT_SEQ_LEN_KEYS = (
"txt_seq_lens",
"text_seq_lens",
)
def first_tensor(obj: Any) -> torch.Tensor | None:
"""First tensor leaf found by depth-first traversal (dicts in sorted-key
order), or ``None``."""
if torch.is_tensor(obj):
return obj
if isinstance(obj, (list, tuple)):
for item in obj:
tensor = first_tensor(item)
if tensor is not None:
return tensor
if isinstance(obj, dict):
for key in sorted(obj):
tensor = first_tensor(obj[key])
if tensor is not None:
return tensor
return None
def select_text_bucket(seq: int, buckets: tuple[int, ...]) -> int | None:
"""Smallest bucket that fits ``seq``; ``None`` (and a warning) when ``seq``
exceeds the largest bucket so the caller runs that length eagerly."""
for bucket in buckets:
if seq <= bucket:
return bucket
logger.warning(
"[Diffusion BCG] text length %d exceeds max bucket %d; not padding "
"(this length captures its own graph). Raise --bcg-text-buckets.",
seq,
buckets[-1],
)
return None
def pad_tensor_dim(tensor: Any, dim: int, target: int, value: float = 0) -> Any:
if not torch.is_tensor(tensor) or tensor.dim() <= dim:
return tensor
seq = tensor.shape[dim]
if seq >= target:
return tensor
pad = [0, 0] * tensor.dim()
pad_index = 2 * (tensor.dim() - dim - 1) + 1
pad[pad_index] = target - seq
return torch.nn.functional.pad(tensor, tuple(pad), value=value)
def pad_nested_dim(
obj: Any,
*,
dim: int,
source: int,
target: int,
value: float = 0,
) -> Any:
if torch.is_tensor(obj):
if obj.dim() > dim and obj.shape[dim] == source:
return pad_tensor_dim(obj, dim, target, value)
return obj
if isinstance(obj, list):
return [
pad_nested_dim(item, dim=dim, source=source, target=target, value=value)
for item in obj
]
if isinstance(obj, tuple):
return tuple(
pad_nested_dim(item, dim=dim, source=source, target=target, value=value)
for item in obj
)
return obj
def bucket_txt_seq_lens(txt_seq_lens: Any, bucket: int) -> Any:
if txt_seq_lens is None:
return txt_seq_lens
if torch.is_tensor(txt_seq_lens):
return torch.full_like(txt_seq_lens, bucket)
if isinstance(txt_seq_lens, list):
return [bucket_txt_seq_lens(seq_len, bucket) for seq_len in txt_seq_lens]
if isinstance(txt_seq_lens, tuple):
return tuple(bucket_txt_seq_lens(seq_len, bucket) for seq_len in txt_seq_lens)
if isinstance(txt_seq_lens, int):
return bucket
return txt_seq_lens
def prompt_seq_and_dim(call_kwargs: dict) -> tuple[int, int] | None:
"""Return ``(text_seq_len, seq_dim)`` inferred from the prompt embeddings or
a prompt mask, or ``None`` when no text conditioning is present."""
ehs_tensor = first_tensor(call_kwargs.get("encoder_hidden_states"))
if torch.is_tensor(ehs_tensor) and ehs_tensor.dim() >= 2:
if ehs_tensor.dim() == 2:
return int(ehs_tensor.shape[0]), 0
return int(ehs_tensor.shape[1]), 1
for key in PROMPT_MASK_KEYS:
tensor = first_tensor(call_kwargs.get(key))
if torch.is_tensor(tensor) and tensor.dim() >= 2:
if tensor.shape[0] == 1:
return int(tensor.shape[1]), 1
return int(tensor.shape[0]), 0
return None
def pad_nested_text_dim(
obj: Any,
*,
source: int,
target: int,
preferred_dim: int,
) -> Any:
if torch.is_tensor(obj):
if obj.dim() > preferred_dim and obj.shape[preferred_dim] == source:
return pad_tensor_dim(obj, preferred_dim, target)
for dim in (1, 0):
if dim != preferred_dim and obj.dim() > dim and obj.shape[dim] == source:
return pad_tensor_dim(obj, dim, target)
return obj
if isinstance(obj, list):
return [
pad_nested_text_dim(
item, source=source, target=target, preferred_dim=preferred_dim
)
for item in obj
]
if isinstance(obj, tuple):
return tuple(
pad_nested_text_dim(
item, source=source, target=target, preferred_dim=preferred_dim
)
for item in obj
)
if isinstance(obj, dict):
return {
key: pad_nested_text_dim(
value, source=source, target=target, preferred_dim=preferred_dim
)
for key, value in obj.items()
}
return obj
def bucket_text_seq_lens(obj: Any, *, target: int) -> Any:
if isinstance(obj, int) and not isinstance(obj, bool):
return target
if isinstance(obj, list):
return [bucket_text_seq_lens(item, target=target) for item in obj]
if isinstance(obj, tuple):
return tuple(bucket_text_seq_lens(item, target=target) for item in obj)
return obj
def pad_masked_prompt_kwargs(call_kwargs: dict, buckets: tuple[int, ...]) -> dict:
"""Generic, model-agnostic prompt padding for models that pass a prompt
attention mask alongside their text embeddings."""
seq_and_dim = prompt_seq_and_dim(call_kwargs)
if seq_and_dim is None:
return call_kwargs
seq, seq_dim = seq_and_dim
has_mask = any(
first_tensor(call_kwargs.get(key)) is not None for key in PROMPT_MASK_KEYS
)
if not has_mask:
return call_kwargs
bucket = select_text_bucket(seq, buckets)
if bucket is None or seq == bucket:
return call_kwargs
out = dict(call_kwargs)
for key in TEXT_DIM1_KEYS:
if key in out and out[key] is not None:
out[key] = pad_nested_text_dim(
out[key], source=seq, target=bucket, preferred_dim=seq_dim
)
for key in TEXT_DIM0_KEYS:
if key in out and out[key] is not None:
out[key] = pad_nested_dim(out[key], dim=0, source=seq, target=bucket)
for key in TEXT_SEQ_LEN_KEYS:
if key in out and out[key] is not None:
out[key] = bucket_text_seq_lens(out[key], target=bucket)
return out
def transformer_class_name_matches(current_model: Any, needle: str) -> bool:
"""True when ``current_model`` (or its ``module`` / ``_orig_mod`` wrapper)
is a transformer whose qualified class name contains ``needle``."""
candidates = [current_model]
for attr in ("module", "_orig_mod"):
wrapped = getattr(current_model, attr, None)
if wrapped is not None:
candidates.append(wrapped)
for candidate in candidates:
cls = type(candidate)
name = f"{cls.__module__}.{cls.__qualname__}".lower()
if needle in name:
return True
return False
# --- Model-specific prompt-padder registry ------------------------------- #
# Each model that needs custom prompt padding registers a (predicate, padder)
# pair from its own module in ``model_specific_stages`` so the base denoising
# stage stays model-agnostic. ``padder(call_kwargs, current_model, buckets)``
# returns the padded kwargs.
PromptPadder = Callable[[dict, Any, tuple], dict]
_PROMPT_PADDERS: list[tuple[Callable[[Any, dict], bool], PromptPadder]] = []
def register_prompt_padder(
predicate: Callable[[Any, dict], bool], padder: PromptPadder
) -> None:
_PROMPT_PADDERS.append((predicate, padder))
def select_prompt_padder(current_model: Any, call_kwargs: dict) -> PromptPadder | None:
"""Return the registered model-specific padder for ``current_model``, or
``None`` to fall back to :func:`pad_masked_prompt_kwargs`."""
_ensure_model_padders_registered()
for predicate, padder in _PROMPT_PADDERS:
if predicate(current_model, call_kwargs):
return padder
return None
_model_padders_registered = False
def _ensure_model_padders_registered() -> None:
"""Import the model-specific padder modules once so they register."""
global _model_padders_registered
if _model_padders_registered:
return
_model_padders_registered = True
from sglang.multimodal_gen.runtime.breakable_cuda_graph.model_padders import ( # noqa: F401
ideogram,
qwen_image,
zimage,
)
@@ -0,0 +1,31 @@
"""Replay-token tracking for diffusion BCG replays.
The SRT BCG core does not stamp replays; the diffusion runner sets a fresh
token around each graph replay so replay-local caches (e.g. varlen attention
mask metadata in ``DynamicVarlenMaskMeta``) can be rebuilt once per replay
while still being reused across the break points of that same replay.
``get_current_replay_token`` returns ``None`` outside a replay (including
during capture).
"""
import itertools
from contextlib import contextmanager
from contextvars import ContextVar
_current_replay_token_var: ContextVar[int | None] = ContextVar(
"mm_bcg_replay_token", default=None
)
_replay_token_counter = itertools.count(1)
def get_current_replay_token() -> int | None:
return _current_replay_token_var.get()
@contextmanager
def replay_token_scope():
token = _current_replay_token_var.set(next(_replay_token_counter))
try:
yield
finally:
_current_replay_token_var.reset(token)
@@ -0,0 +1,472 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Breakable CUDA graph (BCG) runner for diffusion DiT transformers.
A runner wraps a callable ``nn.Module`` and turns it into an *eager runner* that
transparently proxies every attribute to the wrapped module and, when called,
replays a previously captured graph for the input signature — or runs the
module eagerly when no graph was captured for that signature. Capture is an
explicit, idempotent ``capture()`` call (driven at warmup) so that serving never
triggers a fresh capture.
This file is intentionally local to ``multimodal_gen``: diffusion reuses the
low-level SRT BCG primitives, but the capture/replay runner owns diffusion DiT
signature handling, static tensor buffers, prompt-bucket warmup, and fallback
behavior.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
import torch
import torch.nn as nn
from sglang.multimodal_gen.runtime.breakable_cuda_graph.replay_token import (
replay_token_scope,
)
from sglang.srt.model_executor.runner_backend_utils.breakable_cuda_graph.breakable_cuda_graph import (
BreakableCUDAGraph,
BreakableCUDAGraphCapture,
)
from sglang.srt.model_executor.runner_backend_utils.breakable_cuda_graph.context import (
enable_breakable_cuda_graph,
)
# Log under the multimodal_gen namespace so the diffusion server's logging
# config surfaces the "[Diffusion BCG] captured ..." lines.
logger = logging.getLogger(__name__)
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
logger.warning("[BCG] ignoring invalid integer %s=%r", name, raw)
return default
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name)
if raw is None:
return default
try:
return float(raw)
except ValueError:
logger.warning("[BCG] ignoring invalid float %s=%r", name, raw)
return default
def _map_tensors(obj, fn):
"""Rebuild ``obj`` applying ``fn`` to every tensor leaf, recursing into
list/tuple/dict containers; everything else passes through unchanged."""
if torch.is_tensor(obj):
return fn(obj)
if isinstance(obj, tuple):
return tuple(_map_tensors(o, fn) for o in obj)
if isinstance(obj, list):
return [_map_tensors(o, fn) for o in obj]
if isinstance(obj, dict):
return {k: _map_tensors(v, fn) for k, v in obj.items()}
return obj
def _flatten_tensors(obj, out: list):
"""Depth-first collect every tensor leaf into ``out`` (deterministic order:
dicts traversed in sorted-key order to match across calls)."""
if torch.is_tensor(obj):
out.append(obj)
elif isinstance(obj, (list, tuple)):
for o in obj:
_flatten_tensors(o, out)
elif isinstance(obj, dict):
for k in sorted(obj):
_flatten_tensors(obj[k], out)
def _flatten_kwargs(kwargs: dict[str, Any]) -> list[torch.Tensor]:
out: list[torch.Tensor] = []
for name in sorted(kwargs):
_flatten_tensors(kwargs[name], out)
return out
def _signature_leaf(obj: Any) -> Any:
if torch.is_tensor(obj):
return ("tensor", tuple(obj.shape), str(obj.dtype))
if isinstance(obj, tuple):
return ("tuple", tuple(_signature_leaf(o) for o in obj))
if isinstance(obj, list):
return ("list", tuple(_signature_leaf(o) for o in obj))
if isinstance(obj, dict):
return (
"dict",
tuple((k, _signature_leaf(obj[k])) for k in sorted(obj)),
)
if obj is None or isinstance(obj, (bool, int, float, str)):
return ("const", obj)
return ("object", type(obj).__module__, type(obj).__qualname__, id(obj))
def _signature_kwargs(kwargs: dict[str, Any]) -> tuple:
return tuple((name, _signature_leaf(kwargs[name])) for name in sorted(kwargs))
def _signature_summary_leaf(sig: Any, *, depth: int = 0) -> Any:
if not isinstance(sig, tuple) or not sig:
return sig
tag = sig[0]
if tag == "tensor":
return sig
if tag == "const":
value = sig[1]
if isinstance(value, str) and len(value) > 64:
value = value[:61] + "..."
return (tag, value)
if tag == "object":
return sig[:3]
if depth >= 2:
return (tag, "...")
if tag in ("tuple", "list"):
items = sig[1]
preview = tuple(
_signature_summary_leaf(item, depth=depth + 1) for item in items[:4]
)
if len(items) > 4:
preview += (("...", len(items) - 4),)
return (tag, len(items), preview)
if tag == "dict":
items = sig[1]
preview = tuple(
(key, _signature_summary_leaf(value, depth=depth + 1))
for key, value in items[:4]
)
if len(items) > 4:
preview += (("...", len(items) - 4),)
return (tag, len(items), preview)
return sig
def _signature_summary(key: tuple) -> tuple:
return tuple((name, _signature_summary_leaf(value)) for name, value in key[:16]) + (
(("...", len(key) - 16),) if len(key) > 16 else ()
)
def _clone_output(out: Any) -> Any:
if torch.is_tensor(out):
return out.clone()
if isinstance(out, tuple):
return tuple(_clone_output(o) for o in out)
if isinstance(out, list):
return [_clone_output(o) for o in out]
return out
@dataclass
class _CaptureEntry:
graph: BreakableCUDAGraph
# full captured kwargs with persistent static buffers at every tensor leaf
static_kwargs: dict[str, Any]
# the same static buffers, flattened in _flatten_kwargs order (replay copies
# live tensors into these positionally)
static_leaves: list[torch.Tensor]
output: Any
num_segments: int
class _CaptureRejected(RuntimeError):
pass
class BaseBreakableCudaGraphRunner:
"""Eager runner around ``transformer`` with an explicit capture/replay API.
The capture/replay contract:
* :meth:`capture` captures a BCG graph for the given input signature, once
(idempotent). It is intended to be driven at warmup so that every
signature served later is already captured.
* :meth:`replay` copies live inputs into the captured static buffers and
replays the graph, returning a clone of the captured output.
* :meth:`__call__` is the *eager runner*: it replays when a graph exists for
the signature and otherwise runs ``transformer`` eagerly. It never
captures, so serving never pays a capture cost.
Any attribute not defined on the runner is proxied to ``transformer`` so the
runner can stand in for the wrapped module ("other functions directly
pass").
"""
def __init__(
self,
transformer: nn.Module,
device: torch.device,
pool=None,
) -> None:
self.transformer = transformer
self.device = device
self.device_module = torch.get_device_module(device)
# One shared mempool across all captured graphs/segments so per-block
# intermediates can be reclaimed and weak-ref'd safely.
self._pool = (
pool if pool is not None else self.device_module.graph_pool_handle()
)
self._capture_stream = self.device_module.Stream(device=device)
self.entries: dict[tuple, _CaptureEntry] = {}
# Signatures we have given up capturing (capture raised); run eager.
self._blocked: set[tuple] = set()
self._disabled_reason: str | None = None
self.max_entries = max(0, _env_int("SGLANG_DIFFUSION_BCG_MAX_ENTRIES", 32))
self.max_segments = max(0, _env_int("SGLANG_DIFFUSION_BCG_MAX_SEGMENTS", 128))
def __getattr__(self, name: str) -> Any:
# Only reached for attributes the runner itself does not define; proxy
# them to the wrapped transformer so callers can treat the runner as a
# transparent stand-in. Use __dict__ to avoid recursing through
# __getattr__ before ``transformer`` is assigned in __init__.
try:
transformer = self.__dict__["transformer"]
except KeyError as e: # pragma: no cover - during/ before __init__
raise AttributeError(name) from e
return getattr(transformer, name)
# ------------------------------------------------------------------ #
# Public capture / replay API
# ------------------------------------------------------------------ #
@torch.no_grad()
def capture(self, **kwargs) -> bool:
"""Capture a graph for ``kwargs``'s signature if not already captured.
Idempotent: returns ``True`` when a graph is available for the
signature afterwards (already captured or newly captured), ``False``
when capture is disabled/blocked or failed (the caller then runs eager).
"""
if self._disabled_reason is not None:
return False
key = self._signature(kwargs)
if key in self._blocked:
return False
if key in self.entries:
return True
try:
entry = self._capture(kwargs, key)
except Exception as e: # noqa: BLE001 — never break generation on capture
logger.warning(
"[Diffusion BCG] capture failed for signature %s (%s); "
"this signature will run eager.",
_signature_summary(key),
e,
)
self._blocked.add(key)
return False
self.entries[key] = entry
self._evict_entries_if_needed()
return True
def _should_capture_on_call(self, key: tuple) -> bool:
"""Whether ``__call__`` may lazily capture an unseen signature.
Base runners only ever capture through the explicit :meth:`capture`
API, so this returns ``False``: serving never records a fresh graph.
Subclasses gate lazy capture on a warmup window (see the diffusion
runner) so warmup can capture by simply driving the forward as usual.
"""
return False
@torch.no_grad()
def __call__(self, **kwargs) -> Any:
"""Eager runner: replay a captured graph, else run ``transformer``.
While serving this never captures, so no new graph is recorded once
warmup is over. During the warmup window subclasses opt into lazy
capture via :meth:`_should_capture_on_call`.
"""
if self._disabled_reason is not None:
return self.transformer(**kwargs)
key = self._signature(kwargs)
entry = self.entries.get(key)
if entry is None:
if not self._should_capture_on_call(key):
return self.transformer(**kwargs)
if not self.capture(**kwargs):
return self.transformer(**kwargs)
entry = self.entries[key]
return self.replay(entry, kwargs)
def replay(self, entry: _CaptureEntry, kwargs: dict[str, Any]) -> Any:
live_leaves = _flatten_kwargs(kwargs)
if len(live_leaves) != len(entry.static_leaves):
# Structure changed under a matching shape key — should not happen;
# fall back to eager rather than copy mismatched buffers.
return self.transformer(**kwargs)
for buf, live in zip(entry.static_leaves, live_leaves):
buf.copy_(live, non_blocking=True)
with replay_token_scope():
entry.graph.replay()
# Clone so the caller can hold the result across the next replay / the
# other CFG branch (which shares this static output buffer when shapes
# match). The clone is one cheap DtoD copy relative to the full DiT.
return _clone_output(entry.output)
# ------------------------------------------------------------------ #
# Internals
# ------------------------------------------------------------------ #
def _signature(self, kwargs: dict[str, Any]) -> tuple:
"""Capture key for tensor leaves and non-tensor control values.
Tensor leaves are keyed by shape+dtype so their values can change per
replay. Non-tensor leaves are baked into the captured Python control
flow, so simple constants must be part of the key as well. Mutable
objects are keyed by identity to avoid replaying a graph whose eager
break points still reference a previous request's state object.
"""
return _signature_kwargs(kwargs)
def _empty_cache(self) -> None:
empty_cache = getattr(self.device_module, "empty_cache", None)
if callable(empty_cache):
empty_cache()
@staticmethod
def _drop_entry(entry: _CaptureEntry) -> None:
entry.graph._break_fns.clear()
entry.graph._segments.clear()
entry.static_kwargs.clear()
entry.static_leaves.clear()
entry.output = None
def reset(self, *, disabled_reason: str | None = None) -> None:
for entry in self.entries.values():
self._drop_entry(entry)
self.entries.clear()
self._blocked.clear()
self._pool = None
self._empty_cache()
if disabled_reason is not None:
self._disabled_reason = disabled_reason
def _capture_limit_reason(self, entry: _CaptureEntry) -> str | None:
if self.max_segments and entry.num_segments > self.max_segments:
return (
f"captured {entry.num_segments} segments, above "
f"SGLANG_DIFFUSION_BCG_MAX_SEGMENTS={self.max_segments}"
)
return None
def _evict_entries_if_needed(self) -> None:
if not self.max_entries:
return
while len(self.entries) > self.max_entries:
evicted_key = next(iter(self.entries))
entry = self.entries.pop(evicted_key)
self._drop_entry(entry)
logger.info(
"[Diffusion BCG] evicted oldest capture for signature %s "
"(SGLANG_DIFFUSION_BCG_MAX_ENTRIES=%d)",
_signature_summary(evicted_key),
self.max_entries,
)
self._empty_cache()
def _capture(self, kwargs: dict[str, Any], key: tuple) -> _CaptureEntry:
if self._pool is None:
self._pool = self.device_module.graph_pool_handle()
# Persistent static buffers at every tensor leaf; bake non-tensors.
def _to_static(t: torch.Tensor) -> torch.Tensor:
# Static buffers live on the capture device. A CPU input (e.g. a
# scalar timestep/sigma or an index tensor built on the host)
# would otherwise force a CPU->CUDA copy inside the captured
# region, which is illegal; place its buffer on the device so the
# only host->device copy happens here, before capture, and replay
# is device-to-device.
if t.device.type == "cpu":
buf = torch.empty(t.shape, dtype=t.dtype, device=self.device)
else:
buf = torch.empty_like(t)
buf.copy_(t)
return buf
static_kwargs = {
name: _map_tensors(v, _to_static) for name, v in kwargs.items()
}
static_leaves = _flatten_kwargs(static_kwargs)
# Warm up on the capture stream so cuBLAS/cuDNN/Triton workspaces and
# any lazy JIT are materialized before capture (mirrors the LLM runner
# and torch.cuda.make_graphed_callables).
self.device_module.synchronize()
with self.device_module.stream(self._capture_stream):
for _ in range(2):
self.transformer(**static_kwargs)
self._capture_stream.synchronize()
self.device_module.synchronize()
graph = BreakableCUDAGraph()
with enable_breakable_cuda_graph():
with BreakableCUDAGraphCapture(
cuda_graph=graph, pool=self._pool, stream=self._capture_stream
):
output = self.transformer(**static_kwargs)
self.device_module.synchronize()
logger.info(
"[Diffusion BCG] captured %d segment(s), %d tensor input(s) for "
"signature %s",
len(graph._segments),
len(static_leaves),
_signature_summary(key),
)
entry = _CaptureEntry(
graph=graph,
static_kwargs=static_kwargs,
static_leaves=static_leaves,
output=output,
num_segments=len(graph._segments),
)
limit_reason = self._capture_limit_reason(entry)
if limit_reason is not None:
self._drop_entry(entry)
self.reset(disabled_reason=limit_reason)
raise _CaptureRejected(
f"{limit_reason}; disabling this BCG runner and using eager"
)
return entry
class DiffusionBreakableCudaGraphRunner(BaseBreakableCudaGraphRunner):
"""Capture/replay a diffusion DiT ``transformer`` with BCG.
Unknown attributes proxy to the wrapped transformer, so the runner can
stand in for the module while only intercepting ``forward`` calls.
"""
def _should_capture_on_call(self, key) -> bool:
try:
from sglang.multimodal_gen.runtime.managers.forward_context import (
get_forward_context,
)
forward_batch = get_forward_context().forward_batch
except Exception:
return False
return bool(getattr(forward_batch, "is_warmup", False))
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
"""
Cache acceleration module for SGLang-diffusion
This module provides various caching strategies to accelerate
diffusion transformer (DiT) inference:
- TeaCache: Temporal similarity-based caching for diffusion models
- cache-dit integration: Block-level caching with DBCache and TaylorSeer
"""
from sglang.multimodal_gen.runtime.cache.cache_dit_integration import (
CacheDitConfig,
enable_cache_on_dual_transformer,
enable_cache_on_transformer,
get_scm_mask,
)
from sglang.multimodal_gen.runtime.cache.teacache import TeaCacheContext, TeaCacheMixin
__all__ = [
# TeaCache (always available)
"TeaCacheContext",
"TeaCacheMixin",
# cache-dit integration (lazy-loaded, requires cache-dit package)
"CacheDitConfig",
"enable_cache_on_transformer",
"enable_cache_on_dual_transformer",
"get_scm_mask",
]
@@ -0,0 +1,683 @@
# SPDX-License-Identifier: Apache-2.0
"""
cache-dit integration module for SGLang DiT pipelines.
This module provides helper functions to enable cache-dit acceleration
on transformer modules in SGLang's modular pipeline architecture.
"""
from dataclasses import dataclass
from typing import List, Optional
import torch
import torch.distributed as dist
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_ring_parallel_world_size,
get_tp_world_size,
get_ulysses_parallel_world_size,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
import cache_dit
from cache_dit import (
BlockAdapter,
DBCacheConfig,
ForwardPattern,
ParamsModifier,
TaylorSeerCalibratorConfig,
steps_mask,
)
from cache_dit.caching.block_adapters import BlockAdapterRegister
from cache_dit.parallelism import ParallelismBackend, ParallelismConfig
from sglang.multimodal_gen.runtime.distributed.parallel_state import get_dit_group
_original_similarity = None
def _patch_cache_dit_similarity():
from cache_dit.caching.cache_contexts import cache_manager
global _original_similarity
if _original_similarity is not None:
return
_original_similarity = cache_manager.CachedContextManager.similarity
def patched_similarity(self, t1, t2, *, threshold, parallelized=False, prefix="Fn"):
if not parallelized:
return _original_similarity(
self,
t1,
t2,
threshold=threshold,
parallelized=parallelized,
prefix=prefix,
)
sp_group = getattr(self, "_sglang_sp_group", None)
tp_group = getattr(self, "_sglang_tp_group", None)
tp_sp_group = getattr(self, "_sglang_tp_sp_group", None)
target_group = tp_sp_group or sp_group or tp_group
if target_group is None:
return _original_similarity(
self,
t1,
t2,
threshold=threshold,
parallelized=parallelized,
prefix=prefix,
)
# Adapted from https://github.com/vipshop/cache-dit/blob/main/src/cache_dit/caching/cache_contexts/cache_manager.py#L495-L523
condition_thresh = self.get_important_condition_threshold()
if condition_thresh > 0.0:
raw_diff = (t1 - t2).abs()
token_m_df = raw_diff.mean(dim=-1)
token_m_t1 = t1.abs().mean(dim=-1)
token_diff = token_m_df / token_m_t1
condition = token_diff > condition_thresh
if condition.sum() > 0:
condition = condition.unsqueeze(-1).expand_as(raw_diff)
mean_diff = raw_diff[condition].mean()
mean_t1 = t1[condition].abs().mean()
else:
mean_diff = (t1 - t2).abs().mean()
mean_t1 = t1.abs().mean()
else:
mean_diff = (t1 - t2).abs().mean()
mean_t1 = t1.abs().mean()
dist.all_reduce(mean_diff, op=dist.ReduceOp.AVG, group=target_group)
dist.all_reduce(mean_t1, op=dist.ReduceOp.AVG, group=target_group)
diff = (mean_diff / mean_t1).item()
self.add_residual_diff(diff)
return diff < threshold
cache_manager.CachedContextManager.similarity = patched_similarity
def _build_parallelism_config(
sp_group: Optional[torch.distributed.ProcessGroup],
tp_group: Optional[torch.distributed.ProcessGroup],
):
if sp_group is None and tp_group is None:
return None
ulysses_size = None
ring_size = None
if sp_group is not None:
ulysses_size = get_ulysses_parallel_world_size()
ring_size = get_ring_parallel_world_size()
tp_size = None
if tp_group is not None:
tp_size = get_tp_world_size()
return ParallelismConfig(
backend=ParallelismBackend.AUTO,
ulysses_size=ulysses_size,
ring_size=ring_size,
tp_size=tp_size,
)
def _mark_transformer_parallelized(transformer, config, sp_group, tp_group):
if config is None:
return
transformer._is_parallelized = True
transformer._parallelism_config = config
def get_scm_mask(
preset: str,
num_inference_steps: int,
compute_bins: Optional[List[int]] = None,
cache_bins: Optional[List[int]] = None,
) -> Optional[List[int]]:
"""
Get SCM mask using cache-dit's steps_mask().
This is a thin wrapper that delegates to cache-dit's built-in
steps_mask() function which handles all presets and scaling logic.
Args:
preset: Preset name ("none", "slow", "medium", "fast", "ultra").
compute_bins: Custom compute bins (overrides preset).
cache_bins: Custom cache bins (overrides preset).
Returns:
SCM mask list (1=compute, 0=cache), or None if disabled.
"""
if preset == "none" and not (compute_bins and cache_bins):
return None
# Use cache-dit's steps_mask() directly
mask = steps_mask(
compute_bins=compute_bins,
cache_bins=cache_bins,
total_steps=num_inference_steps,
mask_policy=preset if preset != "none" else "medium",
)
compute_count = sum(mask)
cache_count = len(mask) - compute_count
logger.info(
"SCM: generated mask with %d compute steps, %d cache steps (preset=%s)",
compute_count,
cache_count,
preset,
)
return mask
@dataclass
class CacheDitConfig:
"""Configuration for cache-dit integration.
Attributes:
enabled: Whether to enable cache-dit acceleration.
Fn_compute_blocks: Number of first blocks to always compute (DBCache F).
Bn_compute_blocks: Number of last blocks to always compute (DBCache B).
max_warmup_steps: Number of warmup steps before caching starts (DBCache W).
residual_diff_threshold: Threshold for residual difference (DBCache R).
max_continuous_cached_steps: Maximum consecutive cached steps (DBCache MC).
enable_taylorseer: Whether to enable TaylorSeer calibrator.
taylorseer_order: Order of Taylor expansion (1 or 2).
num_inference_steps: Total number of inference steps (required for transformer-only mode).
steps_computation_mask: Binary mask for step-level caching (1=compute, 0=cache).
Generated by get_scm_mask() (wrapper around cache_dit.steps_mask()).
steps_computation_policy: Caching policy for SCM ("dynamic" or "static").
"""
enabled: bool = False
Fn_compute_blocks: int = 1
Bn_compute_blocks: int = 0
# Use 4 as default warmup steps instead of 8 in cache-dit, thus making
# DBCache work for few steps distilled models, e.g., Z-Image w/ 8-steps.
max_warmup_steps: int = 4
# Use a relatively higher residual diff threshold (namely, 0.24) as default
# to allow more aggressive caching due to we have already applied max continuous
# cached steps limit, otherwise, we should use a lower threshold here like 0.12.
residual_diff_threshold: float = 0.24
max_continuous_cached_steps: int = 3
# TaylorSeer is not suitable for few steps distilled models, so, we choose
# to disable it by default. Reference:
# - From Reusing to Forecasting: Accelerating Diffusion Models with TaylorSeers,
# https://arxiv.org/pdf/2503.06923
# - FoCa: Forecast then Calibrate: Feature Caching as ODE for Efficient
# Diffusion Transformers, https://arxiv.org/pdf/2508.16211
enable_taylorseer: bool = False
taylorseer_order: int = 1
num_inference_steps: Optional[int] = None
# SCM fields (generated by _maybe_enable_cache_dit from env configuration)
steps_computation_mask: Optional[List[int]] = None
steps_computation_policy: str = "dynamic"
@dataclass(frozen=True)
class DualTransformerBlockAdapterSpec:
"""BlockAdapter metadata for dual-transformer DiT pipelines.
This describes the cache-dit-facing structure of a pair of transformers.
The denoising loop semantics live in DenoisingStage; this spec only covers
how cache-dit should find blocks and interpret each block's forward.
"""
blocks_attr: tuple[str, str]
blocks_name: Optional[List[str]]
forward_pattern: List[ForwardPattern]
check_forward_pattern: bool
check_num_outputs: bool
has_separate_cfg: bool
DUAL_TRANSFORMER_BLOCK_ADAPTER_SPECS: dict[str, DualTransformerBlockAdapterSpec] = {
"wan2.2": DualTransformerBlockAdapterSpec(
blocks_attr=("blocks", "blocks"),
blocks_name=None,
forward_pattern=[ForwardPattern.Pattern_2, ForwardPattern.Pattern_2],
check_forward_pattern=True,
check_num_outputs=False,
has_separate_cfg=True,
),
"ideogram4": DualTransformerBlockAdapterSpec(
blocks_attr=("layers", "layers"),
blocks_name=["layers", "layers"],
forward_pattern=[ForwardPattern.Pattern_3, ForwardPattern.Pattern_3],
check_forward_pattern=False,
check_num_outputs=False,
has_separate_cfg=False,
),
}
# Custom BlockAdapter for DiT models absent from cache-dit's BlockAdapterRegister.
# Value: (blocks attr, forward_pattern). forward_pattern must
# match the block's forward signature (see cache_dit.ForwardPattern; e.g., ERNIE
# uses Pattern_3). has_separate_cfg follows the run (passed by
# enable_cache_on_transformer); cache-dit auto-resolves the remaining
# fields.
_CUSTOM_BLOCK_ADAPTER_SPECS: dict[str, tuple[str, ForwardPattern]] = {
"ErnieImageTransformer2DModel": ("layers", ForwardPattern.Pattern_3),
"Krea2Transformer2DModel": ("transformer_blocks", ForwardPattern.Pattern_3),
}
def _build_custom_block_adapter(
transformer: torch.nn.Module,
has_separate_cfg: bool = False,
) -> Optional[BlockAdapter]:
"""Build a manual BlockAdapter for a model absent from cache-dit's registry,
or None if the class is unknown."""
spec = _CUSTOM_BLOCK_ADAPTER_SPECS.get(transformer.__class__.__name__)
if spec is None:
return None
blocks_attr, forward_pattern = spec
blocks = getattr(transformer, blocks_attr, None)
if blocks is None:
raise ValueError(
f"Transformer {transformer.__class__.__name__} has no attribute "
f"{blocks_attr!r} for cache-dit blocks."
)
return BlockAdapter(
transformer=transformer,
blocks=blocks,
forward_pattern=forward_pattern,
has_separate_cfg=has_separate_cfg,
)
def enable_cache_on_transformer(
transformer: torch.nn.Module,
config: CacheDitConfig,
model_name: str = "transformer",
sp_group: Optional[torch.distributed.ProcessGroup] = None,
tp_group: Optional[torch.distributed.ProcessGroup] = None,
has_separate_cfg: bool = False,
) -> torch.nn.Module:
"""Enable cache-dit on a transformer module, by wrapping the module with cache-dit
This function enables cache-dit acceleration using the BlockAdapterRegister
for pre-registered models
Args:
model_name: Name of the model for logging purposes.
sp_group: Sequence parallel process group (for Ulysses/Ring).
tp_group: Tensor parallel process group.
has_separate_cfg: Whether the run issues separate conditional/unconditional
passes per step (CFG). Used by custom adapters (ERNIE, Krea-2); a
mismatch only disables caching, never corrupts output.
"""
if not config.enabled:
return transformer
if config.num_inference_steps is None:
raise ValueError(
"num_inference_steps is required for transformer-only mode. "
"Please provide it in CacheDitConfig."
)
# Prefer the standard path (transformer pre-registered in cache-dit). For
# models absent from the registry, fall back to a manual BlockAdapter (see
# _build_custom_block_adapter).
custom_adapter = None
if not BlockAdapterRegister.is_supported(transformer):
custom_adapter = _build_custom_block_adapter(
transformer, has_separate_cfg=has_separate_cfg
)
if custom_adapter is None:
transformer_cls_name = transformer.__class__.__name__
raise ValueError(
f"{transformer_cls_name} is not officially supported by cache-dit. "
"Supported cache-dit DiT families include Flux, QwenImage, HunyuanDiT, "
"HunyuanVideo, Wan, CogVideoX, Mochi, and others. "
"Please ensure your transformer belongs to one of these families or "
"define a custom BlockAdapter."
)
# Build cache config (including SCM fields if provided)
cache_config = DBCacheConfig(
num_inference_steps=config.num_inference_steps,
Fn_compute_blocks=config.Fn_compute_blocks,
Bn_compute_blocks=config.Bn_compute_blocks,
max_warmup_steps=config.max_warmup_steps,
residual_diff_threshold=config.residual_diff_threshold,
max_continuous_cached_steps=config.max_continuous_cached_steps,
# SCM fields
steps_computation_mask=config.steps_computation_mask,
steps_computation_policy=config.steps_computation_policy,
)
# Build calibrator config if TaylorSeer is enabled
calibrator_config = None
if config.enable_taylorseer:
calibrator_config = TaylorSeerCalibratorConfig(
taylorseer_order=config.taylorseer_order,
)
# Enable cache-dit on the transformer
logger.info(
"Enabling cache-dit on %s with config: Fn=%d, Bn=%d, W=%d, R=%.2f, MC=%d, "
"TaylorSeer=%s (order=%d), steps=%d",
model_name,
config.Fn_compute_blocks,
config.Bn_compute_blocks,
config.max_warmup_steps,
config.residual_diff_threshold,
config.max_continuous_cached_steps,
config.enable_taylorseer,
config.taylorseer_order,
config.num_inference_steps,
)
# Log SCM configuration if enabled
if config.steps_computation_mask:
compute_steps = sum(config.steps_computation_mask)
cache_steps = len(config.steps_computation_mask) - compute_steps
logger.info(
"SCM enabled: %d compute steps, %d cache steps, policy=%s",
compute_steps,
cache_steps,
config.steps_computation_policy,
)
parallelism_config = _build_parallelism_config(sp_group, tp_group)
if parallelism_config is not None:
_patch_cache_dit_similarity()
_mark_transformer_parallelized(transformer, parallelism_config, sp_group, tp_group)
# Custom path: pass a pre-built BlockAdapter, bypassing the registry.
# Standard path: let enable_cache discover the registered adapter.
target = transformer
if custom_adapter is not None:
target = custom_adapter
logger.info(
"Enabling cache-dit on %s via custom BlockAdapter (%s).",
model_name,
custom_adapter.forward_pattern,
)
cache_dit.enable_cache(
target,
cache_config=cache_config,
calibrator_config=calibrator_config,
parallelism_config=None,
)
if parallelism_config is not None:
context_manager = getattr(transformer, "_context_manager", None)
if context_manager is not None:
context_manager._sglang_sp_group = sp_group
context_manager._sglang_tp_group = tp_group
# In mixed TP + SP (Ulysses/Ring) mode, cache-dit decisions must be consistent
# across the full TP×SP model-parallel slice. Prefer using SGLang's DIT group
# as a conservative superset group; fallback to None.
tp_sp_group = None
if sp_group is not None and tp_group is not None:
tp_sp_group = get_dit_group()
context_manager._sglang_tp_sp_group = tp_sp_group
return transformer
def enable_cache_on_dual_transformer(
transformer: torch.nn.Module,
transformer_2: torch.nn.Module,
primary_config: CacheDitConfig,
secondary_config: CacheDitConfig,
model_name: str = "wan2.2",
sp_group: Optional[torch.distributed.ProcessGroup] = None,
tp_group: Optional[torch.distributed.ProcessGroup] = None,
) -> tuple[torch.nn.Module, torch.nn.Module]:
"""Enable cache-dit on dual transformers using BlockAdapter.
For models with two transformers, cache-dit requires enabling cache on both
simultaneously via BlockAdapter. The two transformers may be split by denoising
range, or run as paired conditional/unconditional branches. This cannot be done
by calling enable_cache separately on each transformer.
Args:
primary_config: CacheDitConfig for primary transformer.
secondary_config: CacheDitConfig for secondary transformer.
sp_group: Sequence parallel process group (for Ulysses/Ring).
tp_group: Tensor parallel process group.
"""
adapter_spec = DUAL_TRANSFORMER_BLOCK_ADAPTER_SPECS.get(model_name)
if adapter_spec is None:
raise ValueError(
f"Dual-transformer cache-dit is only supported for "
f"{sorted(DUAL_TRANSFORMER_BLOCK_ADAPTER_SPECS)}, got {model_name}."
)
if not primary_config.enabled:
return transformer, transformer_2
if primary_config.num_inference_steps is None:
raise ValueError(
"num_inference_steps is required for dual-transformer mode. "
"Please provide it in CacheDitConfig."
)
# Build DBCacheConfig for primary transformer
primary_cache_config = DBCacheConfig(
num_inference_steps=primary_config.num_inference_steps,
Fn_compute_blocks=primary_config.Fn_compute_blocks,
Bn_compute_blocks=primary_config.Bn_compute_blocks,
max_warmup_steps=primary_config.max_warmup_steps,
residual_diff_threshold=primary_config.residual_diff_threshold,
max_continuous_cached_steps=primary_config.max_continuous_cached_steps,
steps_computation_mask=primary_config.steps_computation_mask,
steps_computation_policy=primary_config.steps_computation_policy,
)
# Build DBCacheConfig for secondary transformer
secondary_cache_config = DBCacheConfig(
num_inference_steps=secondary_config.num_inference_steps,
Fn_compute_blocks=secondary_config.Fn_compute_blocks,
Bn_compute_blocks=secondary_config.Bn_compute_blocks,
max_warmup_steps=secondary_config.max_warmup_steps,
residual_diff_threshold=secondary_config.residual_diff_threshold,
max_continuous_cached_steps=secondary_config.max_continuous_cached_steps,
steps_computation_mask=secondary_config.steps_computation_mask,
steps_computation_policy=secondary_config.steps_computation_policy,
)
# Build calibrator configs if TaylorSeer is enabled
primary_calibrator = None
if primary_config.enable_taylorseer:
primary_calibrator = TaylorSeerCalibratorConfig(
taylorseer_order=primary_config.taylorseer_order,
)
secondary_calibrator = None
if secondary_config.enable_taylorseer:
secondary_calibrator = TaylorSeerCalibratorConfig(
taylorseer_order=secondary_config.taylorseer_order,
)
# Build ParamsModifier for each transformer
primary_modifier = ParamsModifier(
cache_config=primary_cache_config,
calibrator_config=primary_calibrator,
)
secondary_modifier = ParamsModifier(
cache_config=secondary_cache_config,
calibrator_config=secondary_calibrator,
)
# Log configuration
logger.info(
"Enabling cache-dit on %s dual transformers with BlockAdapter",
model_name,
)
logger.info(
" Primary (transformer): Fn=%d, Bn=%d, W=%d, R=%.2f, MC=%d, TaylorSeer=%s",
primary_config.Fn_compute_blocks,
primary_config.Bn_compute_blocks,
primary_config.max_warmup_steps,
primary_config.residual_diff_threshold,
primary_config.max_continuous_cached_steps,
primary_config.enable_taylorseer,
)
logger.info(
" Secondary transformer: Fn=%d, Bn=%d, W=%d, R=%.2f, MC=%d, TaylorSeer=%s",
secondary_config.Fn_compute_blocks,
secondary_config.Bn_compute_blocks,
secondary_config.max_warmup_steps,
secondary_config.residual_diff_threshold,
secondary_config.max_continuous_cached_steps,
secondary_config.enable_taylorseer,
)
# Log SCM configuration if enabled
if primary_config.steps_computation_mask:
compute_steps = sum(primary_config.steps_computation_mask)
cache_steps = len(primary_config.steps_computation_mask) - compute_steps
logger.info(
" SCM enabled for primary transformer: %d compute steps, %d cache steps, policy=%s",
compute_steps,
cache_steps,
primary_config.steps_computation_policy,
)
if secondary_config.steps_computation_mask:
compute_steps = sum(secondary_config.steps_computation_mask)
cache_steps = len(secondary_config.steps_computation_mask) - compute_steps
logger.info(
" SCM enabled for secondary transformer: %d compute steps, %d cache steps, policy=%s",
compute_steps,
cache_steps,
secondary_config.steps_computation_policy,
)
parallelism_config = _build_parallelism_config(sp_group, tp_group)
if parallelism_config is not None:
_patch_cache_dit_similarity()
_mark_transformer_parallelized(transformer, parallelism_config, sp_group, tp_group)
_mark_transformer_parallelized(
transformer_2, parallelism_config, sp_group, tp_group
)
transformer_blocks_attr, transformer_2_blocks_attr = adapter_spec.blocks_attr
transformer_blocks = getattr(transformer, transformer_blocks_attr, None)
transformer_2_blocks = getattr(transformer_2, transformer_2_blocks_attr, None)
if transformer_blocks is None or transformer_2_blocks is None:
raise ValueError(
f"Dual transformers for {model_name} must expose cache-dit block "
f"attributes {adapter_spec.blocks_attr}. "
f"transformer has {transformer_blocks_attr}: "
f"{transformer_blocks is not None}, secondary transformer has "
f"{transformer_2_blocks_attr}: {transformer_2_blocks is not None}"
)
cache_dit.enable_cache(
BlockAdapter(
transformer=[transformer, transformer_2],
blocks=[transformer_blocks, transformer_2_blocks],
blocks_name=adapter_spec.blocks_name,
forward_pattern=adapter_spec.forward_pattern,
params_modifiers=[primary_modifier, secondary_modifier],
check_forward_pattern=adapter_spec.check_forward_pattern,
check_num_outputs=adapter_spec.check_num_outputs,
has_separate_cfg=adapter_spec.has_separate_cfg,
),
parallelism_config=None,
)
if parallelism_config is not None:
for t in [transformer, transformer_2]:
context_manager = getattr(t, "_context_manager", None)
if context_manager is not None:
context_manager._sglang_sp_group = sp_group
context_manager._sglang_tp_group = tp_group
tp_sp_group = None
if sp_group is not None and tp_group is not None:
try:
tp_sp_group = get_dit_group()
except Exception:
tp_sp_group = None
context_manager._sglang_tp_sp_group = tp_sp_group
return transformer, transformer_2
def refresh_context_on_transformer(
transformer: torch.nn.Module,
num_inference_steps: int,
scm_preset: str | None = None,
verbose: bool = False,
) -> None:
"""Refresh cache-dit context for transformer."""
steps_computation_mask = None
if scm_preset is not None:
steps_computation_mask = cache_dit.steps_mask(
mask_policy=scm_preset, total_steps=num_inference_steps
)
cache_dit.refresh_context(
transformer,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_inference_steps,
steps_computation_mask=steps_computation_mask,
steps_computation_policy=scm_preset,
),
verbose=verbose,
)
logger.debug(f"cache-dit refreshed on transformer (steps={num_inference_steps})")
def refresh_context_on_dual_transformer(
transformer: torch.nn.Module,
transformer_2: torch.nn.Module,
num_high_noise_steps: int,
num_low_noise_steps: int,
scm_preset: str | None = None,
verbose: bool = False,
steps_computation_mask: Optional[List[int]] = None,
steps_computation_mask_2: Optional[List[int]] = None,
steps_computation_policy: str | None = None,
) -> None:
"""Refresh cache-dit context for dual transformers."""
high_noise_steps_computation_mask = steps_computation_mask
low_noise_steps_computation_mask = steps_computation_mask_2
if high_noise_steps_computation_mask is None and scm_preset is not None:
high_noise_steps_computation_mask = cache_dit.steps_mask(
mask_policy=scm_preset, total_steps=num_high_noise_steps
)
if low_noise_steps_computation_mask is None and scm_preset is not None:
low_noise_steps_computation_mask = cache_dit.steps_mask(
mask_policy=scm_preset, total_steps=num_low_noise_steps
)
policy = (
steps_computation_policy if steps_computation_policy is not None else scm_preset
)
cache_dit.refresh_context(
transformer,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_high_noise_steps,
steps_computation_mask=high_noise_steps_computation_mask,
steps_computation_policy=policy,
),
verbose=verbose,
)
cache_dit.refresh_context(
transformer_2,
cache_config=DBCacheConfig().reset(
num_inference_steps=num_low_noise_steps,
steps_computation_mask=low_noise_steps_computation_mask,
steps_computation_policy=policy,
),
verbose=verbose,
)
logger.debug(
f"cache-dit refreshed on dual transformers (steps={num_high_noise_steps}, {num_low_noise_steps})"
)
+316
View File
@@ -0,0 +1,316 @@
# SPDX-License-Identifier: Apache-2.0
"""
TeaCache: Temporal similarity-based caching for diffusion models.
TeaCache accelerates diffusion inference by selectively skipping redundant
computation when consecutive diffusion steps are similar enough. This is
achieved by tracking the L1 distance between modulated inputs across timesteps.
Key concepts:
- Modulated input: The input to transformer blocks after timestep conditioning
- L1 distance: Measures how different consecutive timesteps are
- Threshold: When accumulated L1 distance exceeds threshold, force computation
- CFG support: Separate caches for positive and negative branches
References:
- TeaCache: Accelerating Diffusion Models with Temporal Similarity
https://arxiv.org/abs/2411.14324
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
from sglang.multimodal_gen.configs.models import DiTConfig
if TYPE_CHECKING:
from sglang.multimodal_gen.configs.sample.teacache import TeaCacheParams
@dataclass
class TeaCacheContext:
"""Common context extracted for TeaCache skip decision.
This context is populated from the forward_batch and forward_context
during each denoising step, providing all information needed to make
cache decisions.
Attributes:
current_timestep: Current denoising timestep index (0-indexed).
num_inference_steps: Total number of inference steps.
do_cfg: Whether classifier-free guidance is enabled.
is_cfg_negative: True if currently processing negative CFG branch.
teacache_thresh: Threshold for accumulated L1 distance.
coefficients: Polynomial coefficients for L1 rescaling.
teacache_params: Full TeaCacheParams for model-specific access.
"""
current_timestep: int
num_inference_steps: int
do_cfg: bool
is_cfg_negative: bool # For CFG branch selection
teacache_thresh: float
coefficients: list[float]
teacache_params: "TeaCacheParams" # Full params for model-specific access
class TeaCacheMixin:
"""
Mixin class providing TeaCache optimization functionality.
TeaCache accelerates diffusion inference by selectively skipping redundant
computation when consecutive diffusion steps are similar enough.
This mixin should be inherited by DiT model classes that want to support
TeaCache optimization. It provides:
- State management for tracking L1 distances
- CFG-aware caching (separate caches for positive/negative branches)
- Decision logic for when to compute vs. use cache
Example usage in a DiT model:
class MyDiT(TeaCacheMixin, BaseDiT):
def __init__(self, config, **kwargs):
super().__init__(config, **kwargs)
self._init_teacache_state()
def forward(self, hidden_states, timestep, ...):
ctx = self._get_teacache_context()
if ctx is not None:
# Compute modulated input (model-specific, e.g., after timestep embedding)
modulated_input = self._compute_modulated_input(hidden_states, timestep)
is_boundary = (ctx.current_timestep == 0 or
ctx.current_timestep >= ctx.num_inference_steps - 1)
should_calc = self._compute_teacache_decision(
modulated_inp=modulated_input,
is_boundary_step=is_boundary,
coefficients=ctx.coefficients,
teacache_thresh=ctx.teacache_thresh,
)
if not should_calc:
# Use cached residual (must implement retrieve_cached_states)
return self.retrieve_cached_states(hidden_states)
# Normal forward pass...
output = self._transformer_forward(hidden_states, timestep, ...)
# Cache states for next step
if ctx is not None:
self.maybe_cache_states(output, hidden_states)
return output
Subclass implementation notes:
- `_compute_modulated_input()`: Model-specific method to compute the input
after timestep conditioning (used for L1 distance calculation)
- `retrieve_cached_states()`: Must be overridden to return cached output
- `maybe_cache_states()`: Override to store states for cache retrieval
Attributes:
cnt: Counter for tracking steps.
enable_teacache: Whether TeaCache is enabled.
previous_modulated_input: Cached modulated input for positive branch.
previous_residual: Cached residual for positive branch.
accumulated_rel_l1_distance: Accumulated L1 distance for positive branch.
is_cfg_negative: Whether currently processing negative CFG branch.
_supports_cfg_cache: Whether this model supports CFG cache separation.
CFG-specific attributes (only when _supports_cfg_cache is True):
previous_modulated_input_negative: Cached input for negative branch.
previous_residual_negative: Cached residual for negative branch.
accumulated_rel_l1_distance_negative: L1 distance for negative branch.
"""
# Models that support CFG cache separation (wan/hunyuan/zimage)
# Models not in this set (flux/qwen) auto-disable TeaCache when CFG is enabled
_CFG_SUPPORTED_PREFIXES: set[str] = {"wan", "hunyuan", "zimage"}
config: DiTConfig
def _init_teacache_state(self) -> None:
"""Initialize TeaCache state. Call this in subclass __init__."""
# Common TeaCache state
self.cnt = 0
self.enable_teacache = True
# Flag indicating if this model supports CFG cache separation
self._supports_cfg_cache = (
self.config.prefix.lower() in self._CFG_SUPPORTED_PREFIXES
)
# Always initialize positive cache fields (used in all modes)
self.previous_modulated_input: torch.Tensor | None = None
self.previous_residual: torch.Tensor | None = None
self.accumulated_rel_l1_distance: float = 0.0
self.is_cfg_negative = False
# CFG-specific fields initialized to None (created when CFG is used)
# These are only used when _supports_cfg_cache is True AND do_cfg is True
if self._supports_cfg_cache:
self.previous_modulated_input_negative: torch.Tensor | None = None
self.previous_residual_negative: torch.Tensor | None = None
self.accumulated_rel_l1_distance_negative: float = 0.0
def reset_teacache_state(self) -> None:
"""Reset all TeaCache state at the start of each generation task."""
self.cnt = 0
# Primary cache fields (always present)
self.previous_modulated_input = None
self.previous_residual = None
self.accumulated_rel_l1_distance = 0.0
self.is_cfg_negative = False
self.enable_teacache = True
# CFG negative cache fields (always reset, may be unused)
if self._supports_cfg_cache:
self.previous_modulated_input_negative = None
self.previous_residual_negative = None
self.accumulated_rel_l1_distance_negative = 0.0
def _compute_l1_and_decide(
self,
modulated_inp: torch.Tensor,
coefficients: list[float],
teacache_thresh: float,
) -> tuple[float, bool]:
"""
Compute L1 distance and decide whether to calculate or use cache.
Args:
modulated_inp: Current timestep's modulated input.
coefficients: Polynomial coefficients for L1 rescaling.
teacache_thresh: Threshold for cache decision.
Returns:
Tuple of (new_accumulated_distance, should_calc).
"""
prev_modulated_inp = (
self.previous_modulated_input_negative
if self.is_cfg_negative
else self.previous_modulated_input
)
# Defensive check: if previous input is not set, force calculation
if prev_modulated_inp is None:
return 0.0, True
# Compute relative L1 distance
diff = modulated_inp - prev_modulated_inp
rel_l1 = (diff.abs().mean() / prev_modulated_inp.abs().mean()).cpu().item()
# Apply polynomial rescaling
rescale_func = np.poly1d(coefficients)
accumulated_rel_l1_distance = (
self.accumulated_rel_l1_distance_negative
if self.is_cfg_negative
else self.accumulated_rel_l1_distance
)
accumulated_rel_l1_distance = accumulated_rel_l1_distance + rescale_func(rel_l1)
if accumulated_rel_l1_distance >= teacache_thresh:
# Threshold exceeded: force compute and reset accumulator
return 0.0, True
# Cache hit: keep accumulated distance
return accumulated_rel_l1_distance, False
def _compute_teacache_decision(
self,
modulated_inp: torch.Tensor,
is_boundary_step: bool,
coefficients: list[float],
teacache_thresh: float,
) -> bool:
"""
Compute cache decision for TeaCache.
Args:
modulated_inp: Current timestep's modulated input.
is_boundary_step: True for boundary timesteps that always compute.
coefficients: Polynomial coefficients for L1 rescaling.
teacache_thresh: Threshold for cache decision.
Returns:
True if forward computation is needed, False to use cache.
"""
if not self.enable_teacache:
return True
if is_boundary_step:
new_accum, should_calc = 0.0, True
else:
new_accum, should_calc = self._compute_l1_and_decide(
modulated_inp=modulated_inp,
coefficients=coefficients,
teacache_thresh=teacache_thresh,
)
# Advance baseline and accumulator for the active branch
if not self.is_cfg_negative:
self.previous_modulated_input = modulated_inp.clone()
self.accumulated_rel_l1_distance = new_accum
elif self._supports_cfg_cache:
self.previous_modulated_input_negative = modulated_inp.clone()
self.accumulated_rel_l1_distance_negative = new_accum
return should_calc
def _get_teacache_context(self) -> TeaCacheContext | None:
"""
Check TeaCache preconditions and extract common context.
Returns:
TeaCacheContext if TeaCache is enabled and properly configured,
None if should skip TeaCache logic entirely.
"""
from sglang.multimodal_gen.runtime.managers.forward_context import (
get_forward_context,
)
forward_context = get_forward_context()
forward_batch = forward_context.forward_batch
# Early return checks
if (
forward_batch is None
or not forward_batch.enable_teacache
or forward_batch.teacache_params is None
):
return None
teacache_params = forward_batch.teacache_params
# Extract common values
current_timestep = forward_context.current_timestep
num_inference_steps = forward_batch.num_inference_steps
do_cfg = forward_batch.do_classifier_free_guidance
is_cfg_negative = forward_batch.is_cfg_negative
# Reset at first timestep
if current_timestep == 0 and not self.is_cfg_negative:
self.reset_teacache_state()
return TeaCacheContext(
current_timestep=current_timestep,
num_inference_steps=num_inference_steps,
do_cfg=do_cfg,
is_cfg_negative=is_cfg_negative,
teacache_thresh=teacache_params.teacache_thresh,
coefficients=teacache_params.get_coefficients(),
teacache_params=teacache_params,
)
def maybe_cache_states(
self, hidden_states: torch.Tensor, original_hidden_states: torch.Tensor
) -> None:
"""Cache states for later retrieval. Override in subclass if needed."""
pass
def should_skip_forward_for_cached_states(self, **kwargs: dict[str, Any]) -> bool:
"""Check if forward can be skipped using cached states."""
return False
def retrieve_cached_states(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Retrieve cached states. Must be implemented by subclass."""
raise NotImplementedError("retrieve_cached_states is not implemented")
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Disaggregation support for diffusion pipelines."""
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
"""Compatibility shim for disaggregated diffusion argument helpers."""
from __future__ import annotations
import argparse
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.server_args.disagg import DisaggServerArgsMixin
# Keep the historical disagg_args import path working.
DISAGG_RESULT_PORT_OFFSETS = DisaggServerArgsMixin.DISAGG_RESULT_PORT_OFFSETS
DisaggArgsMixin = DisaggServerArgsMixin
def add_disagg_cli_args(parser: argparse.ArgumentParser) -> None:
"""Register disaggregated-diffusion CLI args through ServerArgs."""
from sglang.multimodal_gen.runtime.server_args import ServerArgs
ServerArgs.add_disagg_cli_args(parser)
def convert_disagg_role_string(kwargs: dict) -> None:
"""Convert ``disagg_role`` from string to ``RoleType`` enum in-place."""
if "disagg_role" in kwargs and isinstance(kwargs["disagg_role"], str):
kwargs["disagg_role"] = RoleType.from_string(kwargs["disagg_role"])
@@ -0,0 +1,165 @@
# SPDX-License-Identifier: Apache-2.0
"""Dispatch policies for multi-instance disaggregated diffusion pipelines."""
import abc
import logging
import threading
logger = logging.getLogger(__name__)
class DispatchPolicy(abc.ABC):
def __init__(self, num_instances: int):
if num_instances < 1:
raise ValueError(f"num_instances must be >= 1, got {num_instances}")
self._num_instances = num_instances
@property
def num_instances(self) -> int:
return self._num_instances
@abc.abstractmethod
def select(self, active_counts: list[int] | None = None) -> int: ...
def select_with_capacity(self, free_slots: list[int]) -> int | None:
"""Select an instance that has free capacity, or None if all full."""
if not any(s > 0 for s in free_slots):
return None
return self.select(active_counts=None)
def record_completion(self, instance_id: int) -> None:
pass
class RoundRobin(DispatchPolicy):
def __init__(self, num_instances: int):
super().__init__(num_instances)
self._lock = threading.Lock()
self._next = 0
def select(self, active_counts: list[int] | None = None) -> int:
with self._lock:
chosen = self._next
self._next = (self._next + 1) % self._num_instances
return chosen
def select_with_capacity(self, free_slots: list[int]) -> int | None:
with self._lock:
for _ in range(self._num_instances):
idx = self._next
self._next = (self._next + 1) % self._num_instances
if free_slots[idx] > 0:
return idx
return None
class MaxFreeSlotsFirst(DispatchPolicy):
"""Dispatch to the instance with the most free slots."""
def __init__(self, num_instances: int, max_slots_per_instance: int = 1):
super().__init__(num_instances)
self._max_slots = max_slots_per_instance
self._lock = threading.Lock()
self._tiebreak = 0
def select(self, active_counts: list[int] | None = None) -> int:
with self._lock:
if active_counts is None or len(active_counts) != self._num_instances:
chosen = self._tiebreak % self._num_instances
self._tiebreak += 1
return chosen
best_id = 0
best_free = self._max_slots - active_counts[0]
for i in range(1, self._num_instances):
free = self._max_slots - active_counts[i]
if free > best_free:
best_free = free
best_id = i
elif free == best_free:
if i == (self._tiebreak % self._num_instances):
best_id = i
self._tiebreak += 1
if best_free <= 0:
logger.warning(
"All %d instances are at capacity (%d slots each), "
"dispatching to instance %d anyway",
self._num_instances,
self._max_slots,
best_id,
)
return best_id
def select_with_capacity(self, free_slots: list[int]) -> int | None:
with self._lock:
best_id = -1
best_free = 0
for i in range(self._num_instances):
if free_slots[i] > best_free:
best_free = free_slots[i]
best_id = i
elif free_slots[i] == best_free and best_free > 0:
if i == (self._tiebreak % self._num_instances):
best_id = i
self._tiebreak += 1
if best_id < 0:
return None
return best_id
class PoolDispatcher:
"""Wraps three independent dispatch policies for encoder/denoiser/decoder pools."""
def __init__(
self,
num_encoders: int,
num_denoisers: int,
num_decoders: int,
policy_name: str = "round_robin",
**kwargs,
):
self.encoder_policy = create_dispatch_policy(
policy_name, num_encoders, **kwargs
)
self.denoiser_policy = create_dispatch_policy(
policy_name, num_denoisers, **kwargs
)
self.decoder_policy = create_dispatch_policy(
policy_name, num_decoders, **kwargs
)
def select_encoder(self, active_counts: list[int] | None = None) -> int:
return self.encoder_policy.select(active_counts)
def select_denoiser(self, active_counts: list[int] | None = None) -> int:
return self.denoiser_policy.select(active_counts)
def select_decoder(self, active_counts: list[int] | None = None) -> int:
return self.decoder_policy.select(active_counts)
def select_encoder_with_capacity(self, free_slots: list[int]) -> int | None:
return self.encoder_policy.select_with_capacity(free_slots)
def select_denoiser_with_capacity(self, free_slots: list[int]) -> int | None:
return self.denoiser_policy.select_with_capacity(free_slots)
def select_decoder_with_capacity(self, free_slots: list[int]) -> int | None:
return self.decoder_policy.select_with_capacity(free_slots)
def create_dispatch_policy(name: str, num_instances: int, **kwargs) -> DispatchPolicy:
policies = {
"round_robin": RoundRobin,
"max_free_slots": MaxFreeSlotsFirst,
}
cls = policies.get(name)
if cls is None:
raise ValueError(
f"Unknown dispatch policy '{name}'. Available: {list(policies.keys())}"
)
return cls(num_instances=num_instances, **kwargs)
@@ -0,0 +1,133 @@
# SPDX-License-Identifier: Apache-2.0
"""Observability metrics for disaggregated diffusion pipelines."""
import threading
import time
from dataclasses import dataclass
@dataclass
class _RequestTiming:
start_time: float
stage_start: float = 0.0
@dataclass
class RoleStats:
role: str
requests_completed: int = 0
requests_failed: int = 0
requests_in_flight: int = 0
requests_timed_out: int = 0
queue_depth: int = 0
last_latency_s: float = 0.0
avg_latency_s: float = 0.0
max_latency_s: float = 0.0
throughput_rps: float = 0.0
uptime_s: float = 0.0
def to_dict(self) -> dict:
return {
"role": self.role,
"requests_completed": self.requests_completed,
"requests_failed": self.requests_failed,
"requests_in_flight": self.requests_in_flight,
"requests_timed_out": self.requests_timed_out,
"queue_depth": self.queue_depth,
"last_latency_s": round(self.last_latency_s, 4),
"avg_latency_s": round(self.avg_latency_s, 4),
"max_latency_s": round(self.max_latency_s, 4),
"throughput_rps": round(self.throughput_rps, 4),
"uptime_s": round(self.uptime_s, 1),
}
class DisaggMetrics:
"""Thread-safe metrics collector for a single disagg role."""
def __init__(self, role: str):
self._role = role
self._lock = threading.Lock()
self._start_time = time.monotonic()
self._completed = 0
self._failed = 0
self._timed_out = 0
self._in_flight: dict[str, _RequestTiming] = {}
self._last_latency = 0.0
self._max_latency = 0.0
self._total_latency = 0.0
self._completion_times: list[float] = []
self._throughput_window_s = 60.0
self._queue_depth = 0
@property
def role(self) -> str:
return self._role
def record_request_start(self, request_id: str) -> None:
with self._lock:
self._in_flight[request_id] = _RequestTiming(start_time=time.monotonic())
def record_request_complete(self, request_id: str) -> None:
now = time.monotonic()
with self._lock:
timing = self._in_flight.pop(request_id, None)
if timing is not None:
latency = now - timing.start_time
self._last_latency = latency
self._max_latency = max(self._max_latency, latency)
self._total_latency += latency
self._completed += 1
self._completion_times.append(now)
self._prune_completion_times(now)
def record_request_failed(self, request_id: str) -> None:
with self._lock:
self._in_flight.pop(request_id, None)
self._failed += 1
def record_request_timeout(self, request_id: str) -> None:
with self._lock:
self._in_flight.pop(request_id, None)
self._timed_out += 1
def update_queue_depth(self, depth: int) -> None:
with self._lock:
self._queue_depth = depth
def snapshot(self) -> RoleStats:
now = time.monotonic()
with self._lock:
self._prune_completion_times(now)
total = self._completed + self._failed
avg_latency = self._total_latency / total if total > 0 else 0.0
rps = (
len(self._completion_times) / self._throughput_window_s
if self._completion_times
else 0.0
)
return RoleStats(
role=self._role,
requests_completed=self._completed,
requests_failed=self._failed,
requests_in_flight=len(self._in_flight),
requests_timed_out=self._timed_out,
queue_depth=self._queue_depth,
last_latency_s=self._last_latency,
avg_latency_s=avg_latency,
max_latency_s=self._max_latency,
throughput_rps=rps,
uptime_s=now - self._start_time,
)
def _prune_completion_times(self, now: float) -> None:
cutoff = now - self._throughput_window_s
while self._completion_times and self._completion_times[0] < cutoff:
self._completion_times.pop(0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
# SPDX-License-Identifier: Apache-2.0
"""Request state machine for disaggregated diffusion pipelines."""
import enum
import logging
import threading
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
class RequestState(enum.Enum):
"""Lifecycle states for a disagg pipeline request.
*_WAITING: request queued, awaiting a free buffer slot.
*_RUNNING: request dispatched to a specific instance.
"""
PENDING = "pending"
ENCODER_WAITING = "encoder_waiting"
ENCODER_RUNNING = "encoder_running"
ENCODER_DONE = "encoder_done"
DENOISING_WAITING = "denoising_waiting"
DENOISING_RUNNING = "denoising_running"
DENOISING_DONE = "denoising_done"
DECODER_WAITING = "decoder_waiting"
DECODER_RUNNING = "decoder_running"
DONE = "done"
FAILED = "failed"
TIMED_OUT = "timed_out"
_TERMINAL_STATES = {RequestState.DONE, RequestState.FAILED, RequestState.TIMED_OUT}
_ACTIVE_STATES = set(RequestState) - _TERMINAL_STATES
# Normal (non-failure) transitions. FAILED and TIMED_OUT are handled
# separately in transition() — any active state can reach them.
_VALID_TRANSITIONS: dict[RequestState, set[RequestState]] = {
RequestState.PENDING: {RequestState.ENCODER_WAITING, RequestState.ENCODER_RUNNING},
RequestState.ENCODER_WAITING: {RequestState.ENCODER_RUNNING},
RequestState.ENCODER_RUNNING: {RequestState.ENCODER_DONE},
RequestState.ENCODER_DONE: {
RequestState.DENOISING_WAITING,
RequestState.DENOISING_RUNNING,
},
RequestState.DENOISING_WAITING: {RequestState.DENOISING_RUNNING},
RequestState.DENOISING_RUNNING: {RequestState.DENOISING_DONE},
RequestState.DENOISING_DONE: {
RequestState.DECODER_WAITING,
RequestState.DECODER_RUNNING,
},
RequestState.DECODER_WAITING: {RequestState.DECODER_RUNNING},
RequestState.DECODER_RUNNING: {RequestState.DONE},
}
@dataclass
class RequestRecord:
request_id: str
state: RequestState = RequestState.PENDING
submit_time: float = field(default_factory=time.monotonic)
last_transition_time: float = field(default_factory=time.monotonic)
encoder_instance: int | None = None
denoiser_instance: int | None = None
decoder_instance: int | None = None
error: str | None = None
def elapsed_s(self) -> float:
return time.monotonic() - self.submit_time
def is_terminal(self) -> bool:
return self.state in _TERMINAL_STATES
class RequestTracker:
"""Thread-safe tracker for request state machines."""
def __init__(self):
self._lock = threading.Lock()
self._requests: dict[str, RequestRecord] = {}
def submit(self, request_id: str) -> RequestRecord:
with self._lock:
if request_id in self._requests:
raise ValueError(f"Duplicate request_id: {request_id}")
record = RequestRecord(request_id=request_id)
self._requests[request_id] = record
return record
def transition(
self,
request_id: str,
new_state: RequestState,
*,
error: str | None = None,
encoder_instance: int | None = None,
denoiser_instance: int | None = None,
decoder_instance: int | None = None,
) -> RequestRecord:
with self._lock:
record = self._requests.get(request_id)
if record is None:
raise ValueError(f"Unknown request_id: {request_id}")
old_state = record.state
if new_state in _TERMINAL_STATES and new_state != RequestState.DONE:
# FAILED / TIMED_OUT: allowed from any active state
if old_state not in _ACTIVE_STATES:
raise ValueError(
f"Cannot transition {request_id} from terminal state "
f"{old_state.value} to {new_state.value}"
)
elif new_state not in _VALID_TRANSITIONS.get(old_state, set()):
raise ValueError(
f"Invalid transition for {request_id}: "
f"{old_state.value} -> {new_state.value}"
)
record.state = new_state
record.last_transition_time = time.monotonic()
if error is not None:
record.error = error
if encoder_instance is not None:
record.encoder_instance = encoder_instance
if denoiser_instance is not None:
record.denoiser_instance = denoiser_instance
if decoder_instance is not None:
record.decoder_instance = decoder_instance
logger.debug(
"Request %s: %s -> %s", request_id, old_state.value, new_state.value
)
return record
def get(self, request_id: str) -> RequestRecord | None:
with self._lock:
return self._requests.get(request_id)
def remove(self, request_id: str) -> RequestRecord | None:
with self._lock:
return self._requests.pop(request_id, None)
def find_timed_out(self, timeout_s: float) -> list[str]:
now = time.monotonic()
with self._lock:
return [
r.request_id
for r in self._requests.values()
if r.state in _ACTIVE_STATES and (now - r.submit_time) > timeout_s
]
def snapshot(self) -> dict:
with self._lock:
state_counts = {}
for r in self._requests.values():
state_counts[r.state.value] = state_counts.get(r.state.value, 0) + 1
return {
"total": len(self._requests),
"active": sum(
1 for r in self._requests.values() if not r.is_terminal()
),
"by_state": state_counts,
}
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
"""Role definitions for diffusion pipeline disaggregation."""
from enum import Enum
_ROLE_ALIASES = {"denoising": "denoiser"}
class RoleType(str, Enum):
MONOLITHIC = "monolithic"
ENCODER = "encoder"
DENOISER = "denoiser"
DECODER = "decoder"
SERVER = "server" # Head node (no GPU, routes requests)
@classmethod
def from_string(cls, value: str) -> "RoleType":
v = _ROLE_ALIASES.get(value.lower(), value.lower())
try:
return cls(v)
except ValueError:
raise ValueError(
f"Invalid role: {value}. Must be one of: {', '.join([r.value for r in cls])}"
) from None
@classmethod
def choices(cls) -> list[str]:
return [role.value for role in cls] + sorted(_ROLE_ALIASES)
def get_module_role(module_name: str) -> "RoleType | None":
"""Classify a module name to its primary role. Returns None for shared modules."""
encoder_prefixes = (
"text_encoder",
"tokenizer",
"image_encoder",
"image_processor",
"processor",
"connectors",
"vision_language_encoder",
)
if any(
module_name == p or module_name.startswith(p + "_") for p in encoder_prefixes
):
return RoleType.ENCODER
if module_name in {"hy3dshape_conditioner", "hy3dshape_image_processor"}:
return RoleType.ENCODER
denoising_prefixes = (
"transformer",
"unconditional_transformer",
"video_dit",
"audio_dit",
"dual_tower_bridge",
)
if any(
module_name == p or module_name.startswith(p + "_") for p in denoising_prefixes
):
return RoleType.DENOISER
if module_name == "hy3dshape_model":
return RoleType.DENOISER
decoder_prefixes = ("vae", "audio_vae", "video_vae", "vocoder")
if any(
module_name == p or module_name.startswith(p + "_") for p in decoder_prefixes
):
return RoleType.DECODER
if module_name == "hy3dshape_vae":
return RoleType.DECODER
return None
def filter_modules_for_role(
module_names: list[str],
role: "RoleType",
*,
extra_allowed_modules: set[str] | None = None,
) -> list[str]:
"""Filter module names to only those needed by the given role."""
if role in (RoleType.MONOLITHIC, RoleType.SERVER):
return module_names
extra_allowed_modules = extra_allowed_modules or set()
filtered = []
for name in module_names:
module_role = get_module_role(name)
if module_role is None:
filtered.append(name)
elif module_role == role:
filtered.append(name)
elif name in extra_allowed_modules:
filtered.append(name)
return filtered
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Transport layer for disaggregated diffusion pipelines."""
@@ -0,0 +1,200 @@
# SPDX-License-Identifier: Apache-2.0
"""Buddy-system memory allocator for TransferTensorBuffer."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from functools import lru_cache
logger = logging.getLogger(__name__)
@dataclass
class Block:
offset: int # byte offset from pool start
size: int
allocated: bool = False
request_id: str | None = None
class BuddyAllocator:
"""Power-of-2 buddy-system allocator for pinned memory."""
def __init__(self, pool_size: int, min_block_size: int = 1 << 20):
if min_block_size <= 0 or (min_block_size & (min_block_size - 1)) != 0:
raise ValueError(
f"min_block_size must be a power of 2, got {min_block_size}"
)
self._min_block_size = min_block_size
self._pool_size = self._next_power_of_2(max(pool_size, min_block_size))
self._lock = threading.Lock()
# Free lists indexed by order: order 0 = min_block_size, order 1 = 2*min_block_size, ...
self._max_order = self._size_to_order(self._pool_size)
self._free_lists: list[list[int]] = [[] for _ in range(self._max_order + 1)]
self._blocks: dict[int, Block] = {}
root = Block(offset=0, size=self._pool_size)
self._blocks[0] = root
self._free_lists[self._max_order].append(0)
self._allocated_bytes = 0
self._num_allocations = 0
@property
def pool_size(self) -> int:
return self._pool_size
def allocate(self, size: int, request_id: str | None = None) -> int | None:
"""Allocate a block of at least `size` bytes. Returns offset or None."""
if size <= 0:
raise ValueError(f"Allocation size must be positive, got {size}")
alloc_size = max(self._next_power_of_2(size), self._min_block_size)
target_order = self._size_to_order(alloc_size)
if target_order > self._max_order:
logger.warning(
"Requested size %d exceeds pool size %d", size, self._pool_size
)
return None
with self._lock:
return self._allocate_locked(target_order, request_id)
def free(self, offset: int) -> bool:
"""Free the block at the given offset and coalesce with buddy if possible."""
with self._lock:
return self._free_locked(offset)
def get_block_info(self, offset: int) -> Block | None:
with self._lock:
return self._blocks.get(offset)
def get_stats(self) -> dict:
with self._lock:
free_blocks_by_order = {}
for order, offsets in enumerate(self._free_lists):
if offsets:
block_size = self._min_block_size << order
free_blocks_by_order[block_size] = len(offsets)
return {
"pool_size": self._pool_size,
"min_block_size": self._min_block_size,
"allocated_bytes": self._allocated_bytes,
"free_bytes": self._pool_size - self._allocated_bytes,
"num_allocations": self._num_allocations,
"num_blocks": len(self._blocks),
"free_blocks_by_size": free_blocks_by_order,
}
def count_free_slots(self, slot_size: int) -> int:
"""Count how many allocations of the given size can fit."""
if slot_size <= 0:
return 0
alloc_size = max(self._next_power_of_2(slot_size), self._min_block_size)
with self._lock:
count = 0
for order in range(self._size_to_order(alloc_size), self._max_order + 1):
for _ in self._free_lists[order]:
block_size = self._min_block_size << order
count += block_size // alloc_size
return count
# --- Internal (caller must hold self._lock) ---
def _allocate_locked(self, target_order: int, request_id: str | None) -> int | None:
found_order = -1
for order in range(target_order, self._max_order + 1):
if self._free_lists[order]:
found_order = order
break
if found_order < 0:
return None
offset = self._free_lists[found_order].pop(0)
block = self._blocks[offset]
# Split down to target_order
while found_order > target_order:
found_order -= 1
buddy_size = self._min_block_size << found_order
buddy_offset = offset + buddy_size
buddy = Block(offset=buddy_offset, size=buddy_size)
self._blocks[buddy_offset] = buddy
self._free_lists[found_order].append(buddy_offset)
block.size = buddy_size
block.allocated = True
block.request_id = request_id
self._allocated_bytes += block.size
self._num_allocations += 1
return offset
def _free_locked(self, offset: int) -> bool:
block = self._blocks.get(offset)
if block is None or not block.allocated:
return False
block.allocated = False
block.request_id = None
self._allocated_bytes -= block.size
self._num_allocations -= 1
self._coalesce(block)
return True
def _coalesce(self, block: Block) -> None:
"""Recursively merge with buddy if both are free."""
while block.size < self._pool_size:
buddy_offset = block.offset ^ block.size
buddy = self._blocks.get(buddy_offset)
if buddy is None or buddy.allocated or buddy.size != block.size:
break
order = self._size_to_order(buddy.size)
self._free_lists[order].remove(buddy_offset)
if buddy_offset < block.offset:
del self._blocks[block.offset]
buddy.size *= 2
block = buddy
else:
del self._blocks[buddy_offset]
block.size *= 2
order = self._size_to_order(block.size)
self._free_lists[order].append(block.offset)
def _size_to_order(self, size: int) -> int:
order = 0
s = self._min_block_size
while s < size:
s <<= 1
order += 1
return order
@staticmethod
@lru_cache(maxsize=256)
def _next_power_of_2(n: int) -> int:
if n <= 0:
return 1
n -= 1
n |= n >> 1
n |= n >> 2
n |= n >> 4
n |= n >> 8
n |= n >> 16
n |= n >> 32
return n + 1
@@ -0,0 +1,272 @@
# SPDX-License-Identifier: Apache-2.0
"""TransferTensorBuffer: memory staging area for disaggregated tensor transfer."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import torch
from sglang.multimodal_gen.runtime.disaggregation.transport.allocator import (
BuddyAllocator,
)
from sglang.multimodal_gen.runtime.disaggregation.transport.codec import (
str_to_dtype,
)
logger = logging.getLogger(__name__)
@dataclass
class SlotHandle:
request_id: str
offset: int # byte offset in the pool
size: int # allocated size in bytes
tensor_views: dict[str, torch.Tensor | list[torch.Tensor]] = field(
default_factory=dict
)
class TransferTensorBuffer:
"""Memory pool for staging tensor payloads between roles.
Wraps a contiguous block of memory (CPU pinned or GPU) with a BuddyAllocator.
"""
def __init__(
self,
pool_size: int,
min_block_size: int = 1 << 20,
role_name: str = "unknown",
device: str = "cpu",
):
self._role_name = role_name
self._device = device
self._allocator = BuddyAllocator(pool_size, min_block_size)
actual_size = self._allocator.pool_size
if device == "cpu":
self._pool = torch.empty(actual_size, dtype=torch.uint8, pin_memory=True)
else:
self._pool = torch.empty(actual_size, dtype=torch.uint8, device=device)
self._pool_ptr = self._pool.data_ptr()
pool_location = "pinned CPU" if device == "cpu" else f"GPU ({device})"
logger.info(
"TransferTensorBuffer[%s]: allocated %d MiB %s memory "
"(min_block=%d KiB)",
role_name,
actual_size >> 20,
pool_location,
min_block_size >> 10,
)
@property
def pool_size(self) -> int:
return self._allocator.pool_size
@property
def device(self) -> str:
return self._device
@property
def pool_data_ptr(self) -> int:
return self._pool_ptr
def allocate(self, size: int, request_id: str) -> SlotHandle | None:
"""Allocate a slot. Returns None if pool is full."""
offset = self._allocator.allocate(size, request_id=request_id)
if offset is None:
logger.warning(
"TransferTensorBuffer[%s]: allocation failed for %s (%d bytes). "
"Pool stats: %s",
self._role_name,
request_id,
size,
self._allocator.get_stats(),
)
return None
block = self._allocator.get_block_info(offset)
return SlotHandle(
request_id=request_id,
offset=offset,
size=block.size if block else size,
)
def free(self, handle: SlotHandle) -> bool:
return self._allocator.free(handle.offset)
def write_tensor(
self,
handle: SlotHandle,
name: str,
tensor: torch.Tensor,
byte_offset: int = 0,
stream: torch.Stream | None = None,
) -> int:
"""Copy a tensor into the pool slot. Returns bytes written."""
src_tensor = tensor.contiguous()
nbytes = src_tensor.numel() * src_tensor.element_size()
if byte_offset + nbytes > handle.size:
raise ValueError(
f"Write exceeds slot: offset={byte_offset}, nbytes={nbytes}, "
f"slot_size={handle.size}"
)
dst = self._pool[
handle.offset + byte_offset : handle.offset + byte_offset + nbytes
]
src_bytes = src_tensor.view(torch.uint8).reshape(-1)
if stream is not None:
with torch.get_device_module().stream(stream):
dst.copy_(src_bytes, non_blocking=True)
else:
dst.copy_(src_bytes, non_blocking=True)
return nbytes
def read_tensor(
self,
handle: SlotHandle,
shape: list[int],
dtype: torch.dtype,
byte_offset: int = 0,
device: torch.device | str = "cpu",
stream: torch.Stream | None = None,
) -> torch.Tensor:
"""Read a tensor from the pool slot. Returns a clone on target device."""
nbytes = 1
for s in shape:
nbytes *= s
nbytes *= torch.tensor([], dtype=dtype).element_size()
raw = self._pool[
handle.offset + byte_offset : handle.offset + byte_offset + nbytes
]
src = raw.view(dtype).reshape(shape)
pool_dev = str(self._pool.device)
target_dev = str(device)
same_device = pool_dev == target_dev
if same_device:
# Clone to decouple tensor lifetime from pool slot
if stream is not None:
with torch.get_device_module().stream(stream):
return src.clone()
return src.clone()
if stream is not None:
with torch.get_device_module().stream(stream):
return src.to(device, non_blocking=True)
return src.to(device, non_blocking=True)
def write_tensors_from_gpu(
self,
handle: SlotHandle,
tensors: dict[str, torch.Tensor | list[torch.Tensor] | None],
stream: torch.Stream | None = None,
) -> dict[str, list[dict]]:
"""Batch-write GPU tensors into a slot. Returns a manifest for later reads."""
manifest: dict[str, list[dict]] = {}
byte_offset = 0
# Ensure copy stream sees all prior compute kernels
if stream is not None:
stream.wait_stream(torch.get_device_module().current_stream())
for name, value in tensors.items():
if value is None:
continue
entries = []
if isinstance(value, torch.Tensor):
nbytes = self.write_tensor(handle, name, value, byte_offset, stream)
entries.append(
{
"offset": byte_offset,
"shape": list(value.shape),
"dtype": str(value.dtype).replace("torch.", ""),
}
)
byte_offset += nbytes
byte_offset = (byte_offset + 511) & ~511 # align to 512B
elif isinstance(value, list):
for i, t in enumerate(value):
if t is None:
continue
nbytes = self.write_tensor(
handle, f"{name}[{i}]", t, byte_offset, stream
)
entries.append(
{
"offset": byte_offset,
"shape": list(t.shape),
"dtype": str(t.dtype).replace("torch.", ""),
"list_index": i,
}
)
byte_offset += nbytes
byte_offset = (byte_offset + 511) & ~511
if entries:
manifest[name] = entries
return manifest
def read_tensors_from_manifest(
self,
handle: SlotHandle,
manifest: dict[str, list[dict]],
device: torch.device | str = "cpu",
stream: torch.Stream | None = None,
) -> dict[str, torch.Tensor | list[torch.Tensor]]:
"""Batch-read tensors from a slot using a manifest."""
result: dict[str, torch.Tensor | list[torch.Tensor]] = {}
for name, entries in manifest.items():
if not entries:
continue
has_list_index = any("list_index" in e for e in entries)
if has_list_index:
max_idx = max(e.get("list_index", 0) for e in entries) + 1
tensors = [None] * max_idx
for entry in entries:
t = self.read_tensor(
handle,
entry["shape"],
str_to_dtype(entry["dtype"]),
entry["offset"],
device,
stream,
)
tensors[entry["list_index"]] = t
result[name] = tensors
else:
entry = entries[0]
result[name] = self.read_tensor(
handle,
entry["shape"],
str_to_dtype(entry["dtype"]),
entry["offset"],
device,
stream,
)
return result
def free_slots_count(self, typical_request_size: int) -> int:
"""Estimate how many requests of typical size can still be buffered."""
return self._allocator.count_free_slots(typical_request_size)
def get_stats(self) -> dict:
alloc_stats = self._allocator.get_stats()
alloc_stats["role"] = self._role_name
return alloc_stats
@@ -0,0 +1,198 @@
# SPDX-License-Identifier: Apache-2.0
"""Zero-copy tensor codec for ZMQ multipart messages.
Frame 0: JSON metadata (tensor descriptors + scalar fields)
Frame 1-N: Raw tensor data buffers (one per tensor)
"""
import ctypes
import json
import logging
from dataclasses import dataclass
import torch
import zmq
logger = logging.getLogger(__name__)
_DTYPE_TO_STR = {
torch.float16: "float16",
torch.float32: "float32",
torch.float64: "float64",
torch.bfloat16: "bfloat16",
torch.int8: "int8",
torch.int16: "int16",
torch.int32: "int32",
torch.int64: "int64",
torch.uint8: "uint8",
torch.bool: "bool",
}
_STR_TO_DTYPE = {v: k for k, v in _DTYPE_TO_STR.items()}
def dtype_to_str(dtype: torch.dtype) -> str:
s = _DTYPE_TO_STR.get(dtype)
if s is None:
raise ValueError(f"Unsupported dtype: {dtype}")
return s
def str_to_dtype(s: str) -> torch.dtype:
d = _STR_TO_DTYPE.get(s)
if d is None:
raise ValueError(f"Unknown dtype string: {s}")
return d
class TensorWrapper:
"""Expose a CPU-contiguous tensor's data buffer for zero-copy ZMQ send."""
def __init__(self, tensor: torch.Tensor):
if tensor.is_cuda or tensor.is_npu:
tensor = tensor.cpu()
if not tensor.is_contiguous():
tensor = tensor.contiguous()
self.tensor = tensor
data_ptr = tensor.data_ptr()
total_bytes = tensor.numel() * tensor.element_size()
self._c_buf = (ctypes.c_char * total_bytes).from_address(data_ptr)
self._view = memoryview(self._c_buf)
@dataclass
class TensorDescriptor:
field_name: str
shape: list[int]
dtype: str
list_index: int = -1 # -1 means not part of a list
def to_dict(self) -> dict:
return {
"field_name": self.field_name,
"shape": self.shape,
"dtype": self.dtype,
"list_index": self.list_index,
}
@classmethod
def from_dict(cls, d: dict) -> "TensorDescriptor":
return cls(
field_name=d["field_name"],
shape=d["shape"],
dtype=d["dtype"],
list_index=d.get("list_index", -1),
)
def pack_tensors(
tensor_fields: dict[str, torch.Tensor | list[torch.Tensor] | None],
scalar_fields: dict | None = None,
) -> tuple[bytes, list[TensorWrapper]]:
"""Pack tensor fields into metadata + buffer list for send_multipart."""
descriptors = []
buffers = []
for field_name, value in tensor_fields.items():
if value is None:
continue
if isinstance(value, torch.Tensor):
wrapper = TensorWrapper(value)
descriptors.append(
TensorDescriptor(
field_name=field_name,
shape=list(value.shape),
dtype=dtype_to_str(value.dtype),
)
)
buffers.append(wrapper)
elif isinstance(value, list):
for i, t in enumerate(value):
if t is None:
continue
if not isinstance(t, torch.Tensor):
raise TypeError(
f"Expected Tensor in list for field '{field_name}', "
f"got {type(t)}"
)
wrapper = TensorWrapper(t)
descriptors.append(
TensorDescriptor(
field_name=field_name,
shape=list(t.shape),
dtype=dtype_to_str(t.dtype),
list_index=i,
)
)
buffers.append(wrapper)
metadata = {
"tensor_descriptors": [d.to_dict() for d in descriptors],
"scalar_fields": scalar_fields or {},
}
metadata_bytes = json.dumps(metadata, separators=(",", ":")).encode("utf-8")
return metadata_bytes, buffers
def send_tensors(
socket: zmq.Socket,
tensor_fields: dict[str, torch.Tensor | list[torch.Tensor] | None],
scalar_fields: dict | None = None,
flags: int = 0,
) -> None:
"""Send tensors over ZMQ using multipart with zero-copy."""
metadata_bytes, buffers = pack_tensors(tensor_fields, scalar_fields)
parts: list = [metadata_bytes]
parts.extend(w._view if isinstance(w, TensorWrapper) else w for w in buffers)
socket.send_multipart(parts, flags=flags, copy=True)
def unpack_tensors(
parts: list,
device: str | torch.device = "cpu",
) -> tuple[dict[str, torch.Tensor | list[torch.Tensor]], dict]:
"""Unpack multipart message frames into tensor fields and scalar fields."""
metadata_frame = parts[0]
metadata_bytes = (
bytes(metadata_frame.buffer)
if hasattr(metadata_frame, "buffer")
else bytes(metadata_frame)
)
metadata = json.loads(metadata_bytes)
descriptors = [
TensorDescriptor.from_dict(d) for d in metadata["tensor_descriptors"]
]
scalar_fields = metadata.get("scalar_fields", {})
if len(parts) - 1 != len(descriptors):
raise ValueError(
f"Expected {len(descriptors)} tensor frames, got {len(parts) - 1}"
)
tensor_fields: dict[str, torch.Tensor | list[torch.Tensor]] = {}
list_sizes: dict[str, int] = {}
for desc in descriptors:
if desc.list_index >= 0:
current_max = list_sizes.get(desc.field_name, 0)
list_sizes[desc.field_name] = max(current_max, desc.list_index + 1)
for field_name, size in list_sizes.items():
tensor_fields[field_name] = [None] * size
for i, desc in enumerate(descriptors):
frame = parts[i + 1]
buf = frame.buffer if hasattr(frame, "buffer") else bytes(frame)
dtype = str_to_dtype(desc.dtype)
# clone() to own the memory (decouple from ZMQ buffer lifetime)
tensor = torch.frombuffer(buf, dtype=dtype).reshape(desc.shape).clone()
if device != "cpu" and device != torch.device("cpu"):
tensor = tensor.to(device)
if desc.list_index >= 0:
tensor_fields[desc.field_name][desc.list_index] = tensor
else:
tensor_fields[desc.field_name] = tensor
return tensor_fields, scalar_fields
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: Apache-2.0
"""Transfer engine abstraction for tensor transfer between role instances."""
import logging
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
_MOONCAKE_AVAILABLE = None
def _check_mooncake() -> bool:
global _MOONCAKE_AVAILABLE
if _MOONCAKE_AVAILABLE is None:
try:
from sglang.srt.distributed.device_communicators.mooncake_transfer_engine import ( # noqa: F401
MooncakeTransferEngine as _MTE,
)
_MOONCAKE_AVAILABLE = True
except ImportError:
_MOONCAKE_AVAILABLE = False
return _MOONCAKE_AVAILABLE
class BaseTransferEngine(ABC):
"""Abstract transfer engine for data movement between roles."""
@property
def supports_gpu_direct(self) -> bool:
return False
@property
@abstractmethod
def session_id(self) -> str: ...
@abstractmethod
def register_buffer(self, ptr: int, length: int) -> None: ...
@abstractmethod
def deregister_buffer(self, ptr: int) -> None: ...
@abstractmethod
def transfer_sync(
self, dst_session_id: str, src_addr: int, dst_addr: int, length: int
) -> int:
"""Returns 0 on success, negative on failure."""
@abstractmethod
def batch_transfer_sync(
self,
dst_session_id: str,
src_addrs: list[int],
dst_addrs: list[int],
lengths: list[int],
) -> int: ...
class MooncakeDiffusionEngine(BaseTransferEngine):
"""Production engine backed by MooncakeTransferEngine (RDMA)."""
@property
def supports_gpu_direct(self) -> bool:
return True
def __init__(
self,
hostname: str,
gpu_id: int = 0,
ib_device: str | None = None,
):
from sglang.srt.distributed.device_communicators.mooncake_transfer_engine import (
MooncakeTransferEngine,
)
self._engine = MooncakeTransferEngine(
hostname=hostname,
gpu_id=gpu_id,
ib_device=ib_device,
)
logger.info(
"MooncakeDiffusionEngine initialized: session_id=%s",
self._engine.session_id,
)
@property
def session_id(self) -> str:
return self._engine.session_id
def register_buffer(self, ptr: int, length: int) -> None:
self._engine.register(ptr, length)
def deregister_buffer(self, ptr: int) -> None:
self._engine.deregister(ptr)
def transfer_sync(
self, dst_session_id: str, src_addr: int, dst_addr: int, length: int
) -> int:
return self._engine.transfer_sync(dst_session_id, src_addr, dst_addr, length)
def batch_transfer_sync(
self,
dst_session_id: str,
src_addrs: list[int],
dst_addrs: list[int],
lengths: list[int],
) -> int:
return self._engine.batch_transfer_sync(
dst_session_id, src_addrs, dst_addrs, lengths
)
def create_transfer_engine(
hostname: str = "127.0.0.1",
gpu_id: int = 0,
ib_device: str | None = None,
) -> BaseTransferEngine:
"""Factory: returns MooncakeDiffusionEngine if mooncake is available."""
if not _check_mooncake():
raise RuntimeError(
"Mooncake transfer engine is required for disaggregated diffusion "
"but is not installed. Please install mooncake first."
)
return MooncakeDiffusionEngine(
hostname=hostname, gpu_id=gpu_id, ib_device=ib_device
)
@@ -0,0 +1,391 @@
# SPDX-License-Identifier: Apache-2.0
"""Per-instance transfer manager for disaggregated diffusion roles."""
import logging
import threading
from dataclasses import dataclass, field
import torch
from sglang.multimodal_gen.runtime.disaggregation.transport.buffer import (
SlotHandle,
TransferTensorBuffer,
)
from sglang.multimodal_gen.runtime.disaggregation.transport.engine import (
BaseTransferEngine,
)
from sglang.multimodal_gen.runtime.platforms import current_platform
logger = logging.getLogger(__name__)
@dataclass
class StagedTransfer:
request_id: str
slot: SlotHandle
manifest: dict
scalar_fields: dict = field(default_factory=dict)
@dataclass
class PendingReceive:
request_id: str
slot: SlotHandle
class DiffusionTransferManager:
"""Manages tensor transfers for a single role instance.
Owns a TransferTensorBuffer (memory pool) and a BaseTransferEngine (RDMA or mock).
"""
def __init__(
self,
engine: BaseTransferEngine,
buffer: TransferTensorBuffer,
):
self._engine = engine
self._buffer = buffer
self._lock = threading.Lock()
self._engine.register_buffer(self._buffer.pool_data_ptr, self._buffer.pool_size)
self._staged: dict[str, StagedTransfer] = {}
self._pending_receives: dict[str, PendingReceive] = {}
logger.info(
"DiffusionTransferManager initialized: session=%s, pool=%d bytes",
self._engine.session_id,
self._buffer.pool_size,
)
@property
def session_id(self) -> str:
return self._engine.session_id
@property
def pool_data_ptr(self) -> int:
return self._buffer.pool_data_ptr
@property
def pool_size(self) -> int:
return self._buffer.pool_size
def stage_tensors(
self,
request_id: str,
tensor_fields: dict[str, torch.Tensor | list[torch.Tensor] | None],
scalar_fields: dict | None = None,
stream: torch.Stream | None = None,
) -> StagedTransfer | None:
"""Stage GPU tensors into the local TransferBuffer. Returns None on allocation failure."""
total_size = 0
for name, t in tensor_fields.items():
if t is None:
continue
if isinstance(t, list):
for ti in t:
total_size += ti.nelement() * ti.element_size()
else:
total_size += t.nelement() * t.element_size()
if total_size == 0:
staged = StagedTransfer(
request_id=request_id,
slot=None,
manifest={},
scalar_fields=scalar_fields or {},
)
with self._lock:
self._staged[request_id] = staged
return staged
slot = self._buffer.allocate(total_size, request_id)
if slot is None:
logger.warning(
"TransferManager: failed to allocate %d bytes for %s",
total_size,
request_id,
)
return None
manifest = self._buffer.write_tensors_from_gpu(slot, tensor_fields, stream)
if stream is not None:
stream.synchronize()
elif torch.get_device_module().is_available():
torch.get_device_module().synchronize()
staged = StagedTransfer(
request_id=request_id,
slot=slot,
manifest=manifest,
scalar_fields=scalar_fields or {},
)
with self._lock:
self._staged[request_id] = staged
logger.debug(
"TransferManager: staged %s (%d bytes, offset=%d)",
request_id,
total_size,
slot.offset,
)
return staged
def stage_tensors_async(
self,
request_id: str,
tensor_fields: dict[str, torch.Tensor | list[torch.Tensor] | None],
scalar_fields: dict | None = None,
stream: torch.Stream | None = None,
) -> tuple[StagedTransfer | None, torch.Event | None]:
"""Stage GPU tensors, returning a CUDA event instead of blocking.
Caller MUST wait on the event before reading buffer data.
"""
total_size = 0
for name, t in tensor_fields.items():
if t is None:
continue
if isinstance(t, list):
for ti in t:
total_size += ti.nelement() * ti.element_size()
else:
total_size += t.nelement() * t.element_size()
if total_size == 0:
staged = StagedTransfer(
request_id=request_id,
slot=None,
manifest={},
scalar_fields=scalar_fields or {},
)
with self._lock:
self._staged[request_id] = staged
return staged, None
slot = self._buffer.allocate(total_size, request_id)
if slot is None:
logger.warning(
"TransferManager: failed to allocate %d bytes for %s",
total_size,
request_id,
)
return None, None
manifest = self._buffer.write_tensors_from_gpu(slot, tensor_fields, stream)
d2h_event = None
if stream is not None:
d2h_event = torch.get_device_module().Event()
d2h_event.record(stream)
elif torch.get_device_module().is_available():
d2h_event = torch.get_device_module().Event()
d2h_event.record(torch.get_device_module().current_stream())
staged = StagedTransfer(
request_id=request_id,
slot=slot,
manifest=manifest,
scalar_fields=scalar_fields or {},
)
with self._lock:
self._staged[request_id] = staged
logger.debug(
"TransferManager: staged_async %s (%d bytes, offset=%d)",
request_id,
total_size,
slot.offset,
)
return staged, d2h_event
def load_tensors_async(
self,
request_id: str,
manifest: dict,
device: torch.device | str = current_platform.device_type,
stream: torch.Stream | None = None,
) -> tuple[
dict[str, torch.Tensor | list[torch.Tensor]],
torch.get_device_module().Event | None,
]:
"""Load tensors from receive slot to GPU, returning a CUDA event.
Caller MUST wait on the event before using the returned tensors.
"""
with self._lock:
pending = self._pending_receives.get(request_id)
if pending is None:
raise ValueError(
f"TransferManager: no pending receive slot for {request_id}"
)
tensors = self._buffer.read_tensors_from_manifest(
pending.slot, manifest, device=device, stream=stream
)
load_event = None
if stream is not None:
load_event = torch.get_device_module().Event()
load_event.record(stream)
elif torch.get_device_module().is_available():
load_event = torch.get_device_module().Event()
load_event.record(torch.get_device_module().current_stream())
logger.debug(
"TransferManager: loaded_async %d tensor fields for %s to %s",
len(tensors),
request_id,
device,
)
return tensors, load_event
def push_to_peer(
self,
request_id: str,
dest_session_id: str,
dest_addr: int,
transfer_size: int,
) -> bool:
"""Push staged data to a remote peer's buffer via RDMA. Returns True on success."""
with self._lock:
staged = self._staged.get(request_id)
if staged is None:
logger.error("TransferManager: no staged transfer for %s", request_id)
return False
if staged.slot is None:
return True
src_addr = self._buffer.pool_data_ptr + staged.slot.offset
ret = self._engine.transfer_sync(
dest_session_id, src_addr, dest_addr, transfer_size
)
if ret == 0:
logger.debug(
"TransferManager: pushed %s (%d bytes) to %s",
request_id,
transfer_size,
dest_session_id,
)
else:
logger.error(
"TransferManager: RDMA push failed for %s (ret=%d)",
request_id,
ret,
)
return ret == 0
def free_staged(self, request_id: str) -> None:
with self._lock:
staged = self._staged.pop(request_id, None)
if staged and staged.slot is not None:
self._buffer.free(staged.slot)
logger.debug("TransferManager: freed staged slot for %s", request_id)
def allocate_receive_slot(
self, request_id: str, size: int
) -> PendingReceive | None:
"""Allocate a local buffer slot to receive incoming data."""
slot = self._buffer.allocate(size, request_id)
if slot is None:
logger.warning(
"TransferManager: failed to allocate receive slot (%d bytes) for %s",
size,
request_id,
)
return None
pending = PendingReceive(request_id=request_id, slot=slot)
with self._lock:
self._pending_receives[request_id] = pending
logger.debug(
"TransferManager: allocated receive slot for %s (offset=%d, size=%d)",
request_id,
slot.offset,
slot.size,
)
return pending
def load_tensors(
self,
request_id: str,
manifest: dict,
device: torch.device | str = current_platform.device_type,
stream: torch.Stream | None = None,
) -> dict[str, torch.Tensor | list[torch.Tensor]]:
"""Load tensors from a receive slot into GPU memory."""
with self._lock:
pending = self._pending_receives.get(request_id)
if pending is None:
raise ValueError(
f"TransferManager: no pending receive slot for {request_id}"
)
tensors = self._buffer.read_tensors_from_manifest(
pending.slot, manifest, device=device, stream=stream
)
if stream is not None:
stream.synchronize()
elif torch.get_device_module().is_available():
torch.get_device_module().synchronize()
logger.debug(
"TransferManager: loaded %d tensor fields for %s to %s",
len(tensors),
request_id,
device,
)
return tensors
def register_prealloc_as_receive(
self, request_id: str, slot: "SlotHandle"
) -> "PendingReceive":
"""Register a pre-allocated slot as a pending receive (fast path)."""
pending = PendingReceive(request_id=request_id, slot=slot)
with self._lock:
self._pending_receives[request_id] = pending
return pending
def free_receive_slot(self, request_id: str) -> None:
with self._lock:
pending = self._pending_receives.pop(request_id, None)
if pending:
self._buffer.free(pending.slot)
logger.debug("TransferManager: freed receive slot for %s", request_id)
def get_receive_slot_addr(self, request_id: str) -> int | None:
with self._lock:
pending = self._pending_receives.get(request_id)
if pending is None:
return None
return self._buffer.pool_data_ptr + pending.slot.offset
def get_receive_slot_offset(self, request_id: str) -> int | None:
with self._lock:
pending = self._pending_receives.get(request_id)
if pending is None:
return None
return pending.slot.offset
def get_staged_info(self, request_id: str) -> StagedTransfer | None:
with self._lock:
return self._staged.get(request_id)
def free_slots_count(self, typical_size: int = 64 * 1024 * 1024) -> int:
return self._buffer.free_slots_count(typical_size)
def cleanup(self) -> None:
self._engine.deregister_buffer(self._buffer.pool_data_ptr)
logger.info("DiffusionTransferManager cleaned up")
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
"""Transfer protocol messages for disaggregated diffusion.
All messages are sent as ZMQ multipart with a b"__transfer__" discriminator
in frame[0] and JSON payload in frame[1].
"""
import json
import logging
from dataclasses import asdict, dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
TRANSFER_MAGIC = b"__transfer__"
class TransferMsgType:
# Instance → DiffusionServer
STAGED = "transfer_staged"
ALLOCATED = "transfer_allocated"
PUSHED = "transfer_pushed"
DONE = "transfer_done"
# DiffusionServer → Instance
ALLOC = "transfer_alloc"
PUSH = "transfer_push"
READY = "transfer_ready"
# Registration
REGISTER = "transfer_register"
REGISTER_ACK = "transfer_register_ack"
@dataclass
class TransferStagedMsg:
msg_type: str = TransferMsgType.STAGED
request_id: str = ""
data_size: int = 0
manifest: dict = None
session_id: str = ""
pool_ptr: int = 0
slot_offset: int = 0
def __post_init__(self):
if self.manifest is None:
self.manifest = {}
@dataclass
class TransferAllocMsg:
msg_type: str = TransferMsgType.ALLOC
request_id: str = ""
data_size: int = 0
source_role: str = ""
@dataclass
class TransferAllocatedMsg:
msg_type: str = TransferMsgType.ALLOCATED
request_id: str = ""
session_id: str = ""
pool_ptr: int = 0
slot_offset: int = 0
slot_size: int = 0
@dataclass
class TransferPushMsg:
msg_type: str = TransferMsgType.PUSH
request_id: str = ""
dest_session_id: str = ""
dest_addr: int = 0
transfer_size: int = 0
@dataclass
class TransferPushedMsg:
msg_type: str = TransferMsgType.PUSHED
request_id: str = ""
@dataclass
class TransferReadyMsg:
msg_type: str = TransferMsgType.READY
request_id: str = ""
manifest: dict = None
slot_offset: int = 0
scalar_fields: dict = None
def __post_init__(self):
if self.manifest is None:
self.manifest = {}
if self.scalar_fields is None:
self.scalar_fields = {}
@dataclass
class TransferDoneMsg:
msg_type: str = TransferMsgType.DONE
request_id: str = ""
error: str | None = None
@dataclass
class TransferRegisterMsg:
msg_type: str = TransferMsgType.REGISTER
role: str = ""
session_id: str = ""
pool_ptr: int = 0
pool_size: int = 0
# The instance's own work endpoint (e.g. tcp://host:port). Used by the
# DiffusionServer to key peer info by URL index (i.e. the same index used
# to build the PUSH work-socket list), so the control plane and the RDMA
# data plane cannot drift when instances register in a different order
# than --*-urls.
work_endpoint: str = ""
# Pre-allocated receive slots: [{"offset": int, "size": int, "slot_id": int, "addr": int}]
preallocated_slots: list = field(default_factory=list)
def encode_transfer_msg(msg: Any) -> list[bytes]:
"""Encode as [TRANSFER_MAGIC, json_payload_bytes]."""
if hasattr(msg, "__dataclass_fields__"):
d = asdict(msg)
elif isinstance(msg, dict):
d = msg
else:
raise TypeError(f"Cannot encode transfer message: {type(msg)}")
return [TRANSFER_MAGIC, json.dumps(d, separators=(",", ":")).encode("utf-8")]
def decode_transfer_msg(frames: list[bytes]) -> dict:
if len(frames) < 2 or frames[0] != TRANSFER_MAGIC:
raise ValueError(f"Not a transfer message: frame[0]={frames[0]!r}")
return json.loads(frames[1])
def is_transfer_message(frames: list) -> bool:
return len(frames) >= 2 and (
frames[0] == TRANSFER_MAGIC
or (isinstance(frames[0], memoryview) and bytes(frames[0]) == TRANSFER_MAGIC)
or (hasattr(frames[0], "bytes") and frames[0].bytes == TRANSFER_MAGIC)
)
@@ -0,0 +1,63 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
from sglang.multimodal_gen.runtime.distributed.communication_op import *
from sglang.multimodal_gen.runtime.distributed.group_coordinator import (
get_local_torch_device,
)
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
cleanup_dist_env_and_memory,
get_decode_parallel_group_coordinator,
get_decode_parallel_rank,
get_decode_parallel_world_size,
get_dp_group,
get_dp_rank,
get_dp_world_size,
get_sp_group,
get_sp_parallel_rank,
get_sp_world_size,
get_tp_group,
get_tp_rank,
get_tp_world_size,
get_world_group,
get_world_rank,
get_world_size,
init_distributed_environment,
initialize_model_parallel,
maybe_init_distributed_environment_and_model_parallel,
model_parallel_is_initialized,
)
from sglang.multimodal_gen.runtime.distributed.utils import *
# SPDX-License-Identifier: Apache-2.0
__all__ = [
# Initialization
"init_distributed_environment",
"initialize_model_parallel",
"cleanup_dist_env_and_memory",
"model_parallel_is_initialized",
"maybe_init_distributed_environment_and_model_parallel",
# World group
"get_world_group",
"get_world_rank",
"get_world_size",
# Data parallel group
"get_dp_group",
"get_dp_rank",
"get_dp_world_size",
# Sequence parallel group
"get_sp_group",
"get_sp_parallel_rank",
"get_sp_world_size",
# Tensor parallel group
"get_tp_group",
"get_tp_rank",
"get_tp_world_size",
# Decode parallel group
"get_decode_parallel_group_coordinator",
"get_decode_parallel_rank",
"get_decode_parallel_world_size",
# Get torch device
"get_local_torch_device",
]
@@ -0,0 +1,181 @@
from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING, Callable
import torch
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
from sglang.multimodal_gen.runtime.distributed.cfg_policy import (
_apply_cfg_postprocess,
_unwrap,
_wrap,
)
from sglang.multimodal_gen.runtime.distributed.communication_op import (
cfg_model_parallel_all_gather,
cfg_model_parallel_all_reduce,
)
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_cfg_group,
get_classifier_free_guidance_rank,
get_classifier_free_guidance_world_size,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.distributed.cfg_policy import (
CFGBranch,
CFGPolicy,
)
# Tracks (n_branches, cfg_world_size, cfg_rank) tuples already logged so the
# dispatch table is printed once per unique configuration, not once per step.
_logged_dispatch_keys: set[tuple[int, int, int]] = set()
def _run(
predict_fn: Callable[[CFGBranch], torch.Tensor | tuple[torch.Tensor, ...]],
bid: int,
branches,
) -> tuple[torch.Tensor, ...]:
branch = branches[bid]
device = get_local_torch_device()
local_branch = dataclasses.replace(
branch,
kwargs={
k: v.to(device) if isinstance(v, torch.Tensor) else v
for k, v in branch.kwargs.items()
},
)
raw = predict_fn(local_branch)
return _wrap(raw)
def run_cfg_parallel(
policy: CFGPolicy,
predict_fn: Callable[[CFGBranch], torch.Tensor | tuple[torch.Tensor, ...]],
) -> list[torch.Tensor | tuple[torch.Tensor, ...]]:
"""Dispatch CFG branches across ranks, all-gather results, return in branch order.
``predict_fn`` is a closure capturing all step-varying state
(latent_model_input, timestep, model, etc.). It is called with each
assigned ``CFGBranch`` and must return the raw ``_predict_noise`` output.
Idle ranks (cfg_world_size > n_branches) run branch 0 as a dummy forward
to obtain tensor shapes for the all-gather.
Returns a list indexed to match ``policy.branches``, identical on every rank.
"""
cfg_rank = get_classifier_free_guidance_rank()
cfg_world_size = get_classifier_free_guidance_world_size()
branches = policy.branches
n_branches = len(branches)
assignments = dispatch_branches(n_branches, cfg_world_size)
branches_assigned_to_local_rank = assignments[cfg_rank]
max_num_branches_per_rank = max(len(a) for a in assignments)
if cfg_world_size > n_branches:
logger.warning_once(
"cfg_parallel_size=%d > n_branches=%d; %d GPU(s) will be idle for CFG",
cfg_world_size,
n_branches,
cfg_world_size - n_branches,
)
dispatch_key = (n_branches, cfg_world_size, cfg_rank)
if dispatch_key not in _logged_dispatch_keys:
_logged_dispatch_keys.add(dispatch_key)
branch_names = (
[branches[i].name for i in branches_assigned_to_local_rank]
if branches_assigned_to_local_rank
else ["(idle)"]
)
logger.info(
"CFG parallel dispatch: rank %d/%d -> [%s]",
cfg_rank,
cfg_world_size,
", ".join(branch_names),
)
# perform the forward for local branches
predicts_from_local_branches: list[tuple[torch.Tensor, ...]] = [
_run(predict_fn, bid, branches) for bid in branches_assigned_to_local_rank
]
if not predicts_from_local_branches: # idle rank: run branch 0 for tensor shapes
predicts_from_local_branches.append(_run(predict_fn, 0, branches))
# pad the predicts to the length of max_num_branches_per_rank, to prepare for the all-gather later
ref = predicts_from_local_branches[0]
while len(predicts_from_local_branches) < max_num_branches_per_rank:
# TODO: cache this zero
predicts_from_local_branches.append(tuple(torch.zeros_like(t) for t in ref))
# All-gather each slot and output element with separate_tensors=True.
# all_slots[slot][elem] = list[Tensor] indexed by CFG rank; no reshape.
all_slots: list[list[list[torch.Tensor]]] = [
[
cfg_model_parallel_all_gather(p, dim=0, separate_tensors=True)
for p in slot_pred
]
for slot_pred in predicts_from_local_branches
]
# reorder the results in branch order: branch bid -> owner rank, slot.
n_elems = len(ref)
final: list[torch.Tensor | tuple[torch.Tensor, ...]] = []
for bid in range(n_branches):
owner = bid % cfg_world_size
slot = bid // cfg_world_size
elems = tuple(all_slots[slot][ei][owner] for ei in range(n_elems))
final.append(_unwrap(elems))
return final
def run_two_branch_cfg_parallel(
policy: CFGPolicy,
predict_fn: Callable[[CFGBranch], torch.Tensor | tuple[torch.Tensor, ...]],
cfg_scale: float,
batch,
pipeline_config,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""Run standard two-pass CFG with the old all-reduce combine.
This keeps the existing WAN baselines: it avoids gathering both branch
predictions, and it preserves the bf16 arithmetic order used before the
multi-branch CFG dispatcher was added.
"""
cfg_rank = get_classifier_free_guidance_rank()
pred_t = _run(predict_fn, cfg_rank, policy.branches)
if cfg_rank == 0:
partial = tuple(cfg_scale * p for p in pred_t)
cond_t = pred_t
else:
partial = tuple((1 - cfg_scale) * p for p in pred_t)
cond_t = tuple(torch.empty_like(p) for p in pred_t)
results = [cfg_model_parallel_all_reduce(p) for p in partial]
cond_t = tuple(get_cfg_group().broadcast(p, src=0) for p in cond_t)
results[0] = _apply_cfg_postprocess(results[0], cond_t[0], batch, pipeline_config)
return _unwrap(tuple(results))
def dispatch_branches(n_branches: int, n_ranks: int) -> list[list[int]]:
"""Assign branches to ranks in Round-robin fashion
Returns a list of length ``n_ranks`` where element ``r`` contains the
branch indices assigned to rank ``r``. Branch ``i`` goes to rank
``i % n_ranks``.
Example: 4 passes, 2 GPUs:
rank 0 -> [0, 2], rank 1 -> [1, 3]
"""
assignments: list[list[int]] = [[] for _ in range(n_ranks)]
for i in range(n_branches):
assignments[i % n_ranks].append(i)
return assignments
@@ -0,0 +1,159 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import torch
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
@dataclass
class CFGBranch:
"""Immutable specification of one CFG branch forward pass.
Built once before the denoising loop; read-only across all steps.
"""
name: str
is_conditional: bool
kwargs: dict[str, Any]
def configure_batch(self, batch: Req) -> None:
"""Set batch state before this branch's forward pass.
Override for richer per-branch context (e.g. a branch index instead of
a single boolean) when a model needs more than two guidance modes.
"""
batch.is_cfg_negative = not self.is_conditional
@dataclass
class CFGPolicy:
"""Owns the CFG branches for one generation run and combines their predictions.
Built once before the denoising loop via ``build()``, then used read-only
across all steps. Subclass and override ``build()`` / ``combine()`` for
custom CFG schemes (N-branch, multi-output, etc.).
The default implementation handles standard 2-branch CFG. With a single
branch (CFG disabled) ``combine()`` returns the prediction unchanged.
"""
branches: list[CFGBranch] = field(default_factory=list)
def build(
self,
batch: Req,
image_kwargs: dict[str, Any],
pos_cond_kwargs: dict[str, Any],
neg_cond_kwargs: dict[str, Any],
) -> CFGPolicy:
"""Return a new policy with branches populated.
Called once before the denoising loop. The returned policy is
immutable for the lifetime of the run. Override to declare N branches.
"""
branches = [CFGBranch("conditional", True, {**image_kwargs, **pos_cond_kwargs})]
if batch.do_classifier_free_guidance:
branches.append(
CFGBranch("unconditional", False, {**image_kwargs, **neg_cond_kwargs})
)
return dataclasses.replace(self, branches=branches)
def combine(
self,
predictions: list[torch.Tensor | tuple[torch.Tensor, ...]],
batch: Req,
cfg_scale: float,
pipeline_config: Any,
*,
cfg_parallel: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""Combine branch predictions into the final noise estimate.
Default: standard 2-branch CFG formula applied element-wise, followed
by normalization / rescale / model-specific postprocess.
Single-branch (CFG disabled): returns the prediction unchanged.
Override for N-branch or multi-output models.
"""
if len(predictions) == 1:
return predictions[0]
pos_t = _wrap(predictions[0])
neg_t = _wrap(predictions[1])
if cfg_parallel:
# Match the old CFG-parallel calculation: multiply the positive
# prediction by cfg_scale and the negative prediction by
# (1 - cfg_scale) before adding them. The serial CFG formula is
# mathematically equivalent, but bf16 rounding changes WAN outputs.
results = [
cfg_scale * p + (1 - cfg_scale) * n for p, n in zip(pos_t, neg_t)
]
else:
results = [n + cfg_scale * (p - n) for p, n in zip(pos_t, neg_t)]
results[0] = _apply_cfg_postprocess(
results[0], pos_t[0], batch, pipeline_config
)
return _unwrap(tuple(results))
# Helpers used by CFGPolicy and run_cfg_parallel.
def _wrap(
pred: torch.Tensor | tuple[torch.Tensor, ...],
) -> tuple[torch.Tensor, ...]:
return pred if isinstance(pred, tuple) else (pred,)
def _unwrap(
pred: tuple[torch.Tensor, ...],
) -> torch.Tensor | tuple[torch.Tensor, ...]:
return pred[0] if len(pred) == 1 else pred
def _apply_cfg_postprocess(
noise_pred: torch.Tensor,
noise_pred_cond: torch.Tensor,
batch: Req,
pipeline_config: Any,
) -> torch.Tensor:
if batch.cfg_normalization and float(batch.cfg_normalization) > 0:
noise_pred = _apply_cfg_normalization(
noise_pred, noise_pred_cond, float(batch.cfg_normalization)
)
if batch.guidance_rescale > 0.0:
noise_pred = _rescale_noise_cfg(
noise_pred, noise_pred_cond, guidance_rescale=batch.guidance_rescale
)
return pipeline_config.postprocess_cfg_noise(batch, noise_pred, noise_pred_cond)
def _apply_cfg_normalization(
noise_pred: torch.Tensor,
noise_pred_cond: torch.Tensor,
cfg_normalization: float,
) -> torch.Tensor:
cond_f = noise_pred_cond.float()
pred_f = noise_pred.float()
ori_norm = torch.linalg.vector_norm(cond_f)
new_norm = torch.linalg.vector_norm(pred_f)
max_norm = ori_norm * cfg_normalization
if new_norm > max_norm:
noise_pred = noise_pred * (max_norm / new_norm)
return noise_pred
def _rescale_noise_cfg(
noise_cfg: torch.Tensor,
noise_pred_text: torch.Tensor,
guidance_rescale: float = 0.0,
) -> torch.Tensor:
std_text = noise_pred_text.std(
dim=list(range(1, noise_pred_text.ndim)), keepdim=True
)
std_cfg = noise_cfg.std(dim=list(range(1, noise_cfg.ndim)), keepdim=True)
noise_pred_rescaled = noise_cfg * (std_text / std_cfg)
return guidance_rescale * noise_pred_rescaled + (1 - guidance_rescale) * noise_cfg
@@ -0,0 +1,67 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/communication_op.py
import torch
import torch.distributed as dist
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_cfg_group,
get_sp_group,
get_tp_group,
)
def tensor_model_parallel_all_reduce(
input_: torch.Tensor, tp_group: dist.ProcessGroup = None
) -> torch.Tensor:
"""All-reduce the input tensor across model parallel group."""
tp_group = tp_group or get_tp_group()
return tp_group.all_reduce(input_)
def tensor_model_parallel_all_gather(
input_: torch.Tensor, dim: int = -1, tp_group: dist.ProcessGroup = None
) -> torch.Tensor:
"""All-gather the input tensor across model parallel group."""
tp_group = tp_group or get_tp_group()
return tp_group.all_gather(input_, dim)
# TODO: remove model, make it sequence_parallel
def sequence_model_parallel_all_to_all_4D(
input_: torch.Tensor, scatter_dim: int = 2, gather_dim: int = 1
) -> torch.Tensor:
"""All-to-all communication of 4D tensors (e.g. QKV matrices) across sequence parallel group."""
return get_sp_group().all_to_all_4D(input_, scatter_dim, gather_dim)
def sequence_model_parallel_all_gather(
input_: torch.Tensor, dim: int = -1
) -> torch.Tensor:
"""All-gather the input tensor across model parallel group."""
return get_sp_group().all_gather(input_, dim)
def sequence_model_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across model parallel group."""
return get_sp_group().all_reduce(input_)
def cfg_model_parallel_all_gather(
input_: torch.Tensor, dim: int = -1, separate_tensors: bool = False
) -> torch.Tensor:
"""All-gather the input tensor across model parallel group."""
return get_cfg_group().all_gather(input_, dim, separate_tensors)
def cfg_model_parallel_all_reduce(
input_: torch.Tensor,
op: torch._C._distributed_c10d.ReduceOp = torch._C._distributed_c10d.ReduceOp.SUM,
) -> torch.Tensor:
"""All-reduce the input tensor across CFG parallel group."""
if not input_.is_contiguous():
input_ = input_.contiguous()
return get_cfg_group().all_reduce(input_, op=op)
@@ -0,0 +1 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
@@ -0,0 +1,306 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/base_device_communicator.py
from typing import Any
import torch
import torch.distributed as dist
from torch import Tensor
from torch.distributed import ProcessGroup, ReduceOp
class DistributedAutograd:
"""Collection of autograd functions for distributed operations.
This class provides custom autograd functions for distributed operations like all_reduce,
all_gather, and all_to_all. Each operation is implemented as a static inner class with
proper forward and backward implementations.
"""
class AllReduce(torch.autograd.Function):
"""Differentiable all_reduce operation.
The gradient of all_reduce is another all_reduce operation since the operation
combines values from all ranks equally.
"""
@staticmethod
def forward(
ctx: Any,
group: ProcessGroup,
input_: Tensor,
op: dist.ReduceOp | None = None,
) -> Tensor:
ctx.group = group
ctx.op = op
output = input_.clone()
dist.all_reduce(output, group=group, op=op)
return output
@staticmethod
def backward(ctx: Any, grad_output: Tensor) -> tuple[None, Tensor, None]:
grad_output = grad_output.clone()
dist.all_reduce(grad_output, group=ctx.group, op=ctx.op)
return None, grad_output, None
class AllGather(torch.autograd.Function):
"""Differentiable all_gather operation.
The operation gathers tensors from all ranks and concatenates them along a specified dimension.
The backward pass uses reduce_scatter to efficiently distribute gradients back to source ranks.
"""
@staticmethod
def forward(
ctx: Any, group: ProcessGroup, input_: Tensor, world_size: int, dim: int
) -> Tensor:
ctx.group = group
ctx.world_size = world_size
ctx.dim = dim
ctx.input_shape = input_.shape
input_size = input_.size()
output_size = (input_size[0] * world_size,) + input_size[1:]
output_tensor = torch.empty(
output_size, dtype=input_.dtype, device=input_.device
)
dist.all_gather_into_tensor(output_tensor, input_, group=group)
output_tensor = output_tensor.reshape((world_size,) + input_size)
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim]
+ (world_size * input_size[dim],)
+ input_size[dim + 1 :]
)
return output_tensor
@staticmethod
def backward(ctx: Any, grad_output: Tensor) -> tuple[None, Tensor, None, None]:
# Split the gradient tensor along the gathered dimension
dim_size = grad_output.size(ctx.dim) // ctx.world_size
grad_chunks = grad_output.reshape(
grad_output.shape[: ctx.dim]
+ (ctx.world_size, dim_size)
+ grad_output.shape[ctx.dim + 1 :]
)
grad_chunks = grad_chunks.movedim(ctx.dim, 0)
# Each rank only needs its corresponding gradient
grad_input = torch.empty(
ctx.input_shape, dtype=grad_output.dtype, device=grad_output.device
)
dist.reduce_scatter_tensor(
grad_input, grad_chunks.contiguous(), group=ctx.group
)
return None, grad_input, None, None
class AllToAll4D(torch.autograd.Function):
"""Differentiable all_to_all operation specialized for 4D tensors.
This operation is particularly useful for attention operations where we need to
redistribute data across ranks for efficient parallel processing.
The operation supports two modes:
1. scatter_dim=2, gather_dim=1: Used for redistributing attention heads
2. scatter_dim=1, gather_dim=2: Used for redistributing sequence dimensions
"""
@staticmethod
def forward(
ctx: Any,
group: ProcessGroup,
input_: Tensor,
world_size: int,
scatter_dim: int,
gather_dim: int,
) -> Tensor:
ctx.group = group
ctx.world_size = world_size
ctx.scatter_dim = scatter_dim
ctx.gather_dim = gather_dim
if world_size == 1:
return input_
assert (
input_.dim() == 4
), f"input must be 4D tensor, got {input_.dim()} and shape {input_.shape}"
if scatter_dim == 2 and gather_dim == 1:
bs, shard_seqlen, hn, hd = input_.shape
assert hn % world_size == 0, (
f"head dimension ({hn}) must be divisible by sequence "
f"parallel world size ({world_size})"
)
seqlen = shard_seqlen * world_size
shard_hn = hn // world_size
input_ = input_.transpose(0, 2).contiguous() # hn, shard_seqlen, bs, hd
output = torch.empty_like(input_)
dist.all_to_all_single(
output, input_, group=group
) # hn, shard_seqlen, bs, hd
output = torch.cat(
output.split(shard_hn), dim=1
) # sharded hn, seqlen, bs, hd
output = output.transpose(
0, 2
).contiguous() # bs, seqlen, sharded_hn, hd
return output
elif scatter_dim == 1 and gather_dim == 2:
bs, seqlen, shard_hn, hd = input_.shape
assert seqlen % world_size == 0, (
f"sequence dimension ({seqlen}) must be divisible by sequence "
f"parallel world size ({world_size})"
)
hn = shard_hn * world_size
shard_seqlen = seqlen // world_size
input_ = input_.transpose(0, 2).contiguous() # shard_hn, seqlen, bs, hd
input_ = (
input_.reshape(shard_hn, world_size, shard_seqlen, bs, hd)
.transpose(0, 1)
.reshape(shard_hn * world_size, shard_seqlen, bs, hd)
.contiguous()
)
output = torch.empty_like(input_)
dist.all_to_all_single(output, input_, group=group)
output = output.transpose(
0, 2
).contiguous() # bs, seqlen, sharded_hn, hd
return output
else:
raise RuntimeError(
f"Invalid scatter_dim={scatter_dim}, gather_dim={gather_dim}. "
f"Only (scatter_dim=2, gather_dim=1) and (scatter_dim=1, gather_dim=2) are supported."
)
@staticmethod
def backward(
ctx: Any, grad_output: Tensor
) -> tuple[None, Tensor, None, None, None]:
if ctx.world_size == 1:
return None, grad_output, None, None, None
# For backward pass, we swap scatter_dim and gather_dim
output = DistributedAutograd.AllToAll4D.apply(
ctx.group, grad_output, ctx.world_size, ctx.gather_dim, ctx.scatter_dim
)
return None, output, None, None, None
class DeviceCommunicatorBase:
"""
Base class for device-specific communicator with autograd support.
It can use the `cpu_group` to initialize the communicator.
If the device has PyTorch integration (PyTorch can recognize its
communication backend), the `device_group` will also be given.
"""
def __init__(
self,
cpu_group: ProcessGroup,
device: torch.device | None = None,
device_group: ProcessGroup | None = None,
unique_name: str = "",
):
self.device = device or torch.device("cpu")
self.cpu_group = cpu_group
self.device_group = device_group
self.unique_name = unique_name
self.rank = dist.get_rank(cpu_group)
self.world_size = dist.get_world_size(cpu_group)
self.ranks = dist.get_process_group_ranks(cpu_group)
self.global_rank = dist.get_rank()
self.global_world_size = dist.get_world_size()
self.rank_in_group = dist.get_group_rank(self.cpu_group, self.global_rank)
def all_reduce(
self, input_: torch.Tensor, op: dist.ReduceOp | None = ReduceOp.SUM
) -> torch.Tensor:
"""Performs an all_reduce operation with gradient support."""
return DistributedAutograd.AllReduce.apply(self.device_group, input_, op)
def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
"""Performs an all_gather operation with gradient support."""
if dim < 0:
dim += input_.dim()
return DistributedAutograd.AllGather.apply(
self.device_group, input_, self.world_size, dim
)
def all_to_all_4D(
self, input_: torch.Tensor, scatter_dim: int = 2, gather_dim: int = 1
) -> torch.Tensor:
"""Performs a 4D all-to-all operation with gradient support."""
return DistributedAutograd.AllToAll4D.apply(
self.device_group, input_, self.world_size, scatter_dim, gather_dim
)
def gather(
self, input_: torch.Tensor, dst: int = 0, dim: int = -1
) -> torch.Tensor | None:
"""
NOTE: We assume that the input tensor is on the same device across
all the ranks.
NOTE: `dst` is the local rank of the destination rank.
"""
world_size = self.world_size
assert (
-input_.dim() <= dim < input_.dim()
), f"Invalid dim ({dim}) for input tensor with shape {input_.size()}"
if dim < 0:
# Convert negative dim to positive.
dim += input_.dim()
# Allocate output tensor.
if self.rank_in_group == dst:
gather_list = [torch.empty_like(input_) for _ in range(world_size)]
else:
gather_list = None
# Gather.
torch.distributed.gather(
input_, gather_list, dst=self.ranks[dst], group=self.device_group
)
if self.rank_in_group == dst:
output_tensor = torch.cat(gather_list, dim=dim)
else:
output_tensor = None
return output_tensor
def send(self, tensor: torch.Tensor, dst: int | None = None) -> None:
"""Sends a tensor to the destination rank in a non-blocking way"""
"""NOTE: `dst` is the local rank of the destination rank."""
if dst is None:
dst = (self.rank_in_group + 1) % self.world_size
torch.distributed.send(tensor, self.ranks[dst], self.device_group)
def recv(
self, size: torch.Size, dtype: torch.dtype, src: int | None = None
) -> torch.Tensor:
"""Receives a tensor from the source rank."""
"""NOTE: `src` is the local rank of the source rank."""
if src is None:
src = (self.rank_in_group - 1) % self.world_size
tensor = torch.empty(size, dtype=dtype, device=self.device)
torch.distributed.recv(tensor, self.ranks[src], self.device_group)
return tensor
def destroy(self) -> None:
pass
@@ -0,0 +1,162 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from: https://github.com/vllm-project/vllm/blob/main/vllm/distributed/device_communicators/cpu_communicator.py
import os
import torch
from torch.distributed import ProcessGroup
from .base_device_communicator import DeviceCommunicatorBase
class CpuCommunicator(DeviceCommunicatorBase):
def __init__(
self,
cpu_group: ProcessGroup,
device: torch.device | None = None,
device_group: ProcessGroup | None = None,
unique_name: str = "",
):
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.platforms.interface import CpuArchEnum
super().__init__(cpu_group, device, device_group, unique_name)
self.dist_module = torch.distributed
if (
(current_platform.get_cpu_architecture() == CpuArchEnum.X86)
and hasattr(torch.ops._C, "init_shm_manager")
and unique_name.startswith("tp")
):
self.dist_module = _CPUSHMDistributed(self)
def all_reduce(
self,
input_: torch.Tensor,
op: torch.distributed.ReduceOp | None = torch.distributed.ReduceOp.SUM,
) -> torch.Tensor:
self.dist_module.all_reduce(input_, group=self.device_group, op=op)
return input_
def gather(
self, input_: torch.Tensor, dst: int = 0, dim: int = -1
) -> torch.Tensor | None:
"""
NOTE: We assume that the input tensor is on the same device across
all the ranks.
NOTE: `dst` is the local rank of the destination rank.
"""
world_size = self.world_size
assert (
-input_.dim() <= dim < input_.dim()
), f"Invalid dim ({dim}) for input tensor with shape {input_.size()}"
if dim < 0:
# Convert negative dim to positive.
dim += input_.dim()
# Allocate output tensor.
if self.rank_in_group == dst:
gather_list = [torch.empty_like(input_) for _ in range(world_size)]
else:
gather_list = None
# Gather.
self.dist_module.gather(
input_, gather_list, dst=self.ranks[dst], group=self.device_group
)
if self.rank_in_group == dst:
output_tensor = torch.cat(gather_list, dim=dim)
else:
output_tensor = None
return output_tensor
def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
if dim < 0:
# Convert negative dim to positive.
dim += input_.dim()
input_size = input_.size()
# NOTE: we have to use concat-style all-gather here,
# stack-style all-gather has compatibility issues with
# torch.compile . see https://github.com/pytorch/pytorch/issues/138795
output_size = (input_size[0] * self.world_size,) + input_size[1:]
# Allocate output tensor.
output_tensor = torch.empty(
output_size, dtype=input_.dtype, device=input_.device
)
# All-gather.
self.dist_module.all_gather_into_tensor(
output_tensor, input_, group=self.device_group
)
# Reshape
output_tensor = output_tensor.reshape((self.world_size,) + input_size)
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim]
+ (self.world_size * input_size[dim],)
+ input_size[dim + 1 :]
)
return output_tensor
class _CPUSHMDistributed:
def __init__(self, communicator: CpuCommunicator):
instance_identifier = os.environ["VLLM_DIST_IDENT"]
unique_name = communicator.unique_name
instance_identifier = f"{instance_identifier}-{unique_name}"
self.communicator = communicator
group_ranks = [str(rank) for rank in self.communicator.ranks]
shm_group_identifier = f"[{'-'.join(group_ranks)}]"
self.group_name = f"{instance_identifier}-{shm_group_identifier}-cpushm"
self.handle = self._init_cpu_shm()
def _init_cpu_shm(self) -> int:
handle = torch.ops._C.init_shm_manager(
self.group_name,
self.communicator.world_size,
self.communicator.rank,
)
torch.distributed.barrier(self.communicator.device_group)
torch.ops._C.join_shm_manager(
handle,
self.group_name,
)
torch.distributed.barrier(self.communicator.device_group)
return int(handle)
def all_reduce(
self, input: torch.Tensor, group: ProcessGroup | None = None
) -> None:
torch.ops._C.shm_allreduce(self.handle, input)
def gather(
self,
input: torch.Tensor,
gather_list: list[torch.Tensor] | None,
dst: int = -1,
group: ProcessGroup | None = None,
) -> None:
# Note: different from the torch gather, here we use local dst rank.
torch.ops._C.shm_gather(
self.handle,
input,
gather_list,
torch.distributed.get_group_rank(group, dst),
)
def all_gather_into_tensor(
self,
output: torch.Tensor,
input: torch.Tensor,
group: ProcessGroup | None = None,
) -> None:
torch.ops._C.shm_all_gather(self.handle, input, output)
@@ -0,0 +1,80 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/cuda_communicator.py
import torch
from torch.distributed import ProcessGroup
from sglang.multimodal_gen.runtime.distributed.device_communicators.base_device_communicator import (
DeviceCommunicatorBase,
)
class CudaCommunicator(DeviceCommunicatorBase):
def __init__(
self,
cpu_group: ProcessGroup,
device: torch.device | None = None,
device_group: ProcessGroup | None = None,
unique_name: str = "",
):
super().__init__(cpu_group, device, device_group, unique_name)
from sglang.multimodal_gen.runtime.distributed.device_communicators.pynccl import (
PyNcclCommunicator,
)
self.pynccl_comm: PyNcclCommunicator | None = None
if self.world_size > 1:
self.pynccl_comm = PyNcclCommunicator(
group=self.cpu_group,
device=self.device,
)
def all_reduce(self, input_, op: torch.distributed.ReduceOp | None = None):
pynccl_comm = self.pynccl_comm
assert pynccl_comm is not None
out = pynccl_comm.all_reduce(input_, op=op)
if out is None:
# fall back to the default all-reduce using PyTorch.
# this usually happens during testing.
# when we run the model, allreduce only happens for the TP
# group, where we always have either custom allreduce or pynccl.
out = input_.clone()
torch.distributed.all_reduce(out, group=self.device_group, op=op)
return out
def send(self, tensor: torch.Tensor, dst: int | None = None) -> None:
"""Sends a tensor to the destination rank in a non-blocking way"""
"""NOTE: `dst` is the local rank of the destination rank."""
if dst is None:
dst = (self.rank_in_group + 1) % self.world_size
pynccl_comm = self.pynccl_comm
if pynccl_comm is not None and not pynccl_comm.disabled:
pynccl_comm.send(tensor, dst)
else:
torch.distributed.send(tensor, self.ranks[dst], self.device_group)
def recv(
self, size: torch.Size, dtype: torch.dtype, src: int | None = None
) -> torch.Tensor:
"""Receives a tensor from the source rank."""
"""NOTE: `src` is the local rank of the source rank."""
if src is None:
src = (self.rank_in_group - 1) % self.world_size
tensor = torch.empty(size, dtype=dtype, device=self.device)
pynccl_comm = self.pynccl_comm
if pynccl_comm is not None and not pynccl_comm.disabled:
pynccl_comm.recv(tensor, src)
else:
torch.distributed.recv(tensor, self.ranks[src], self.device_group)
return tensor
def destroy(self) -> None:
if self.pynccl_comm is not None:
self.pynccl_comm = None
@@ -0,0 +1,259 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/pynccl.py
# ===================== import region =====================
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup, ReduceOp
from sglang.multimodal_gen.runtime.distributed.device_communicators.pynccl_wrapper import (
NCCLLibrary,
buffer_type,
cudaStream_t,
ncclComm_t,
ncclDataTypeEnum,
ncclRedOpTypeEnum,
ncclUniqueId,
)
from sglang.multimodal_gen.runtime.distributed.utils import StatelessProcessGroup
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.utils import current_stream
logger = init_logger(__name__)
class PyNcclCommunicator:
def __init__(
self,
group: ProcessGroup | StatelessProcessGroup,
device: int | str | torch.device,
library_path: str | None = None,
):
"""
Args:
group: the process group to work on. If None, it will use the
default process group.
device: the device to bind the PyNcclCommunicator to. If None,
it will be bind to f"cuda:{local_rank}".
library_path: the path to the NCCL library. If None, it will
use the default library path.
It is the caller's responsibility to make sure each communicator
is bind to a unique device.
"""
if not isinstance(group, StatelessProcessGroup):
assert dist.is_initialized()
assert (
dist.get_backend(group) != dist.Backend.NCCL
), "PyNcclCommunicator should be attached to a non-NCCL group."
# note: this rank is the rank in the group
self.rank = dist.get_rank(group)
self.world_size = dist.get_world_size(group)
else:
self.rank = group.rank
self.world_size = group.world_size
self.group = group
# if world_size == 1, no need to create communicator
if self.world_size == 1:
self.available = False
self.disabled = True
return
try:
self.nccl = NCCLLibrary(library_path)
except Exception:
# disable because of missing NCCL library
# e.g. in a non-GPU environment
self.available = False
self.disabled = True
return
self.available = True
self.disabled = False
logger.info("sglang-diffusion is using nccl==%s", self.nccl.ncclGetVersion())
if self.rank == 0:
# get the unique id from NCCL
self.unique_id = self.nccl.ncclGetUniqueId()
else:
# construct an empty unique id
self.unique_id = ncclUniqueId()
if not isinstance(group, StatelessProcessGroup):
tensor = torch.ByteTensor(list(self.unique_id.internal))
ranks = dist.get_process_group_ranks(group)
# arg `src` in `broadcast` is the global rank
dist.broadcast(tensor, src=ranks[0], group=group)
byte_list = tensor.tolist()
for i, byte in enumerate(byte_list):
self.unique_id.internal[i] = byte
else:
self.unique_id = group.broadcast_obj(self.unique_id, src=0)
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
# now `device` is a `torch.device` object
assert isinstance(device, torch.device)
self.device = device
# nccl communicator and stream will use this device
# `torch.cuda.device` is a context manager that changes the
# current cuda device to the specified one
with torch.cuda.device(device):
self.comm: ncclComm_t = self.nccl.ncclCommInitRank(
self.world_size, self.unique_id, self.rank
)
stream = current_stream()
# A small all_reduce for warmup.
data = torch.zeros(1, device=device)
self.all_reduce(data)
if stream is not None:
stream.synchronize()
del data
def all_reduce(
self, in_tensor: torch.Tensor, op: ReduceOp = ReduceOp.SUM, stream=None
) -> torch.Tensor:
if self.disabled:
return None
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert in_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {in_tensor.device}"
)
out_tensor = torch.empty_like(in_tensor)
if stream is None:
stream = current_stream()
self.nccl.ncclAllReduce(
buffer_type(in_tensor.data_ptr()),
buffer_type(out_tensor.data_ptr()),
in_tensor.numel(),
ncclDataTypeEnum.from_torch(in_tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
self.comm,
cudaStream_t(stream.cuda_stream),
)
return out_tensor
def all_gather(
self, output_tensor: torch.Tensor, input_tensor: torch.Tensor, stream=None
):
if self.disabled:
return
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert input_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {input_tensor.device}"
)
if stream is None:
stream = current_stream()
self.nccl.ncclAllGather(
buffer_type(input_tensor.data_ptr()),
buffer_type(output_tensor.data_ptr()),
input_tensor.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def reduce_scatter(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
op: ReduceOp = ReduceOp.SUM,
stream=None,
):
if self.disabled:
return
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert input_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {input_tensor.device}"
)
if stream is None:
stream = current_stream()
self.nccl.ncclReduceScatter(
buffer_type(input_tensor.data_ptr()),
buffer_type(output_tensor.data_ptr()),
output_tensor.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def send(self, tensor: torch.Tensor, dst: int, stream=None):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
if stream is None:
stream = current_stream()
self.nccl.ncclSend(
buffer_type(tensor.data_ptr()),
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
dst,
self.comm,
cudaStream_t(stream.cuda_stream),
)
def recv(self, tensor: torch.Tensor, src: int, stream=None):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
if stream is None:
stream = current_stream()
self.nccl.ncclRecv(
buffer_type(tensor.data_ptr()),
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
src,
self.comm,
cudaStream_t(stream.cuda_stream),
)
def broadcast(self, tensor: torch.Tensor, src: int, stream=None):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
if stream is None:
stream = current_stream()
if src == self.rank:
sendbuff = buffer_type(tensor.data_ptr())
# NCCL requires the sender also to have a receive buffer
recvbuff = buffer_type(tensor.data_ptr())
else:
sendbuff = buffer_type()
recvbuff = buffer_type(tensor.data_ptr())
self.nccl.ncclBroadcast(
sendbuff,
recvbuff,
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
src,
self.comm,
cudaStream_t(stream.cuda_stream),
)
@@ -0,0 +1,451 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/device_communicators/pynccl_wrapper.py
# This file is a pure Python wrapper for the NCCL library.
# The main purpose is to use NCCL combined with CUDA graph.
# Before writing this script, we tried the following approach:
# 1. We tried to use `cupy`, it calls NCCL correctly, but `cupy` itself
# often gets stuck when initializing the NCCL communicator.
# 2. We tried to use `torch.distributed`, but `torch.distributed.all_reduce`
# contains many other potential cuda APIs, that are not allowed during
# capturing the CUDA graph. For further details, please check
# https://discuss.pytorch.org/t/pytorch-cudagraph-with-nccl-operation-failed/ .
#
# Another rejected idea is to write a C/C++ binding for NCCL. It is usually
# doable, but we often encounter issues related with nccl versions, and need
# to switch between different versions of NCCL. See
# https://github.com/NVIDIA/nccl/issues/1234 for more details.
# A C/C++ binding is not flexible enough to handle this. It requires
# recompilation of the code every time we want to switch between different
# versions. This current implementation, with a **pure** Python wrapper, is
# more flexible. We can easily switch between different versions of NCCL by
# changing the environment variable `SGLANG_DIFFUSION_NCCL_SO_PATH`, or the `so_file`
# variable in the code.
# TODO(will): support SGLANG_DIFFUSION_NCCL_SO_PATH
import ctypes
import platform
from dataclasses import dataclass
from typing import Any
import torch
from torch.distributed import ReduceOp
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.utils import find_nccl_library
logger = init_logger(__name__)
# === export types and functions from nccl to Python ===
# for the original nccl definition, please check
# https://github.com/NVIDIA/nccl/blob/master/src/nccl.h.in
ncclResult_t = ctypes.c_int
ncclComm_t = ctypes.c_void_p
class ncclUniqueId(ctypes.Structure):
_fields_ = [("internal", ctypes.c_byte * 128)]
cudaStream_t = ctypes.c_void_p
buffer_type = ctypes.c_void_p
ncclDataType_t = ctypes.c_int
class ncclDataTypeEnum:
ncclInt8 = 0
ncclChar = 0
ncclUint8 = 1
ncclInt32 = 2
ncclInt = 2
ncclUint32 = 3
ncclInt64 = 4
ncclUint64 = 5
ncclFloat16 = 6
ncclHalf = 6
ncclFloat32 = 7
ncclFloat = 7
ncclFloat64 = 8
ncclDouble = 8
ncclBfloat16 = 9
ncclNumTypes = 10
@classmethod
def from_torch(cls, dtype: torch.dtype) -> int:
if dtype == torch.int8:
return cls.ncclInt8
if dtype == torch.uint8:
return cls.ncclUint8
if dtype == torch.int32:
return cls.ncclInt32
if dtype == torch.int64:
return cls.ncclInt64
if dtype == torch.float16:
return cls.ncclFloat16
if dtype == torch.float32:
return cls.ncclFloat32
if dtype == torch.float64:
return cls.ncclFloat64
if dtype == torch.bfloat16:
return cls.ncclBfloat16
raise ValueError(f"Unsupported dtype: {dtype}")
ncclRedOp_t = ctypes.c_int
class ncclRedOpTypeEnum:
ncclSum = 0
ncclProd = 1
ncclMax = 2
ncclMin = 3
ncclAvg = 4
ncclNumOps = 5
@classmethod
def from_torch(cls, op: ReduceOp) -> int:
if op == ReduceOp.SUM:
return cls.ncclSum
if op == ReduceOp.PRODUCT:
return cls.ncclProd
if op == ReduceOp.MAX:
return cls.ncclMax
if op == ReduceOp.MIN:
return cls.ncclMin
if op == ReduceOp.AVG:
return cls.ncclAvg
raise ValueError(f"Unsupported op: {op}")
@dataclass
class Function:
name: str
restype: Any
argtypes: list[Any]
class NCCLLibrary:
exported_functions = [
# const char* ncclGetErrorString(ncclResult_t result)
Function("ncclGetErrorString", ctypes.c_char_p, [ncclResult_t]),
# ncclResult_t ncclGetVersion(int *version);
Function("ncclGetVersion", ncclResult_t, [ctypes.POINTER(ctypes.c_int)]),
# ncclResult_t ncclGetUniqueId(ncclUniqueId* uniqueId);
Function("ncclGetUniqueId", ncclResult_t, [ctypes.POINTER(ncclUniqueId)]),
# ncclResult_t ncclCommInitRank(
# ncclComm_t* comm, int nranks, ncclUniqueId commId, int rank);
# note that ncclComm_t is a pointer type, so the first argument
# is a pointer to a pointer
Function(
"ncclCommInitRank",
ncclResult_t,
[ctypes.POINTER(ncclComm_t), ctypes.c_int, ncclUniqueId, ctypes.c_int],
),
# ncclResult_t ncclAllReduce(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclRedOp_t op, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclAllReduce",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclRedOp_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclAllGather(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclAllGather",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclReduceScatter(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclRedOp_t op, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclReduceScatter",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclRedOp_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclSend(
# const void* sendbuff, size_t count, ncclDataType_t datatype,
# int dest, ncclComm_t comm, cudaStream_t stream);
Function(
"ncclSend",
ncclResult_t,
[
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclRecv(
# void* recvbuff, size_t count, ncclDataType_t datatype,
# int src, ncclComm_t comm, cudaStream_t stream);
Function(
"ncclRecv",
ncclResult_t,
[
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclBroadcast(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, int root, ncclComm_t comm,
# cudaStream_t stream);
Function(
"ncclBroadcast",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# be cautious! this is a collective call, it will block until all
# processes in the communicator have called this function.
# because Python object destruction can happen in random order,
# it is better not to call it at all.
# ncclResult_t ncclCommDestroy(ncclComm_t comm);
Function("ncclCommDestroy", ncclResult_t, [ncclComm_t]),
]
# class attribute to store the mapping from the path to the library
# to avoid loading the same library multiple times
path_to_library_cache: dict[str, Any] = {}
# class attribute to store the mapping from library path
# to the corresponding dictionary
path_to_dict_mapping: dict[str, dict[str, Any]] = {}
def __init__(self, so_file: str | None = None):
so_file = so_file or find_nccl_library()
try:
if so_file not in NCCLLibrary.path_to_dict_mapping:
lib = ctypes.CDLL(so_file)
NCCLLibrary.path_to_library_cache[so_file] = lib
self.lib = NCCLLibrary.path_to_library_cache[so_file]
except Exception as e:
logger.error(
"Failed to load NCCL library from %s ."
"It is expected if you are not running on NVIDIA/AMD/MTHREADS GPUs."
"Otherwise, the nccl library might not exist, be corrupted "
"or it does not support the current platform %s."
"If you already have the library, please set the "
"environment variable SGLANG_DIFFUSION_NCCL_SO_PATH"
" to point to the correct nccl library path.",
so_file,
platform.platform(),
)
raise e
if so_file not in NCCLLibrary.path_to_dict_mapping:
_funcs: dict[str, Any] = {}
for func in NCCLLibrary.exported_functions:
f = getattr(self.lib, func.name)
f.restype = func.restype
f.argtypes = func.argtypes
_funcs[func.name] = f
NCCLLibrary.path_to_dict_mapping[so_file] = _funcs
self._funcs = NCCLLibrary.path_to_dict_mapping[so_file]
def ncclGetErrorString(self, result: ncclResult_t) -> str:
return str(self._funcs["ncclGetErrorString"](result).decode("utf-8"))
def NCCL_CHECK(self, result: ncclResult_t) -> None:
if result != 0:
error_str = self.ncclGetErrorString(result)
raise RuntimeError(f"NCCL error: {error_str}")
def ncclGetVersion(self) -> str:
version = ctypes.c_int()
self.NCCL_CHECK(self._funcs["ncclGetVersion"](ctypes.byref(version)))
version_str = str(version.value)
# something like 21903 --> "2.19.3"
major = version_str[0].lstrip("0")
minor = version_str[1:3].lstrip("0")
patch = version_str[3:].lstrip("0")
return f"{major}.{minor}.{patch}"
def ncclGetUniqueId(self) -> ncclUniqueId:
unique_id = ncclUniqueId()
self.NCCL_CHECK(self._funcs["ncclGetUniqueId"](ctypes.byref(unique_id)))
return unique_id
def ncclCommInitRank(
self, world_size: int, unique_id: ncclUniqueId, rank: int
) -> ncclComm_t:
comm = ncclComm_t()
self.NCCL_CHECK(
self._funcs["ncclCommInitRank"](
ctypes.byref(comm), world_size, unique_id, rank
)
)
return comm
def ncclAllReduce(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
op: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# and `op` should be `ncclRedOp_t`
# both are aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclAllReduce"](
sendbuff, recvbuff, count, datatype, op, comm, stream
)
)
def ncclReduceScatter(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
op: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# and `op` should be `ncclRedOp_t`
# both are aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclReduceScatter"](
sendbuff, recvbuff, count, datatype, op, comm, stream
)
)
def ncclAllGather(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# which is an aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclAllGather"](
sendbuff, recvbuff, count, datatype, comm, stream
)
)
def ncclSend(
self,
sendbuff: buffer_type,
count: int,
datatype: int,
dest: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclSend"](sendbuff, count, datatype, dest, comm, stream)
)
def ncclRecv(
self,
recvbuff: buffer_type,
count: int,
datatype: int,
src: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclRecv"](recvbuff, count, datatype, src, comm, stream)
)
def ncclBroadcast(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
root: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclBroadcast"](
sendbuff, recvbuff, count, datatype, root, comm, stream
)
)
def ncclCommDestroy(self, comm: ncclComm_t) -> None:
self.NCCL_CHECK(self._funcs["ncclCommDestroy"](comm))
__all__ = [
"NCCLLibrary",
"ncclDataTypeEnum",
"ncclRedOpTypeEnum",
"ncclUniqueId",
"ncclComm_t",
"cudaStream_t",
"buffer_type",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
# Reference: https://github.com/feifeibear/long-context-attention/blob/main/yunchang/globals.py
import torch
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Singleton, cls).__new__(cls, *args, **kwargs)
return cls._instance
class ProcessGroupSingleton(Singleton):
def __init__(self):
self.ULYSSES_PG = None
self.RING_PG = None
PROCESS_GROUP = ProcessGroupSingleton()
def set_seq_parallel_pg_by_sp_groups(
sp_ulysses_degree,
sp_ring_degree,
rank: int,
sp_groups: list[list[int]],
use_ulysses_low: bool = True,
):
"""Create Ulysses/Ring process groups inside each SP group.
This is required when TP>1, because SP groups are not necessarily made of
consecutive global ranks (e.g., tp-sp order makes SP ranks strided).
Args:
sp_ulysses_degree: ulysses degree inside SP.
sp_ring_degree: ring degree inside SP.
rank: global rank of current process.
sp_groups: list of global-rank lists for each SP group.
use_ulysses_low: keep the same semantics as the original function.
"""
sp_degree = sp_ring_degree * sp_ulysses_degree
assert sp_degree > 0
assert all(
len(g) == sp_degree for g in sp_groups
), f"Each SP group must have size {sp_degree}, got sizes {[len(g) for g in sp_groups]}"
ulyssess_pg = None
ring_pg = None
num_ulysses_pgs = sp_ring_degree
num_ring_pgs = sp_ulysses_degree
def _map_indices_to_ranks(ranks: list[int], indices: list[int]) -> list[int]:
return [ranks[i] for i in indices]
# Important: call torch.distributed.new_group in the same order on all ranks.
for sp_ranks in sp_groups:
if use_ulysses_low:
for i in range(num_ulysses_pgs):
idx = list(range(i * sp_ulysses_degree, (i + 1) * sp_ulysses_degree))
ulysses_ranks = _map_indices_to_ranks(sp_ranks, idx)
group = torch.distributed.new_group(ulysses_ranks)
if rank in ulysses_ranks:
ulyssess_pg = group
for i in range(num_ring_pgs):
idx = list(range(i, sp_degree, num_ring_pgs))
ring_ranks = _map_indices_to_ranks(sp_ranks, idx)
group = torch.distributed.new_group(ring_ranks)
if rank in ring_ranks:
ring_pg = group
else:
for i in range(num_ring_pgs):
idx = list(range(i * sp_ring_degree, (i + 1) * sp_ring_degree))
ring_ranks = _map_indices_to_ranks(sp_ranks, idx)
group = torch.distributed.new_group(ring_ranks)
if rank in ring_ranks:
ring_pg = group
for i in range(num_ulysses_pgs):
idx = list(range(i, sp_degree, num_ulysses_pgs))
ulysses_ranks = _map_indices_to_ranks(sp_ranks, idx)
group = torch.distributed.new_group(ulysses_ranks)
if rank in ulysses_ranks:
ulyssess_pg = group
PROCESS_GROUP.ULYSSES_PG = ulyssess_pg
PROCESS_GROUP.RING_PG = ring_pg
@@ -0,0 +1,912 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/parallel_state.py
# Copyright 2023 The vLLM team.
# Adapted from
# https://github.com/NVIDIA/Megatron-LM/blob/main/megatron/core/parallel_state.py
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
# Adapted from
# Copyright 2024 xDiT team.
# Adapted from
# https://github.com/vllm-project/vllm/blob/main/vllm/distributed/parallel_state.py
# Copyright 2023 The vLLM team.
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
"""sglang-diffusion distributed state.
It takes over the control of the distributed environment from PyTorch.
The typical workflow is:
- call `init_distributed_environment` to initialize the distributed environment.
- call `initialize_model_parallel` or `ensure_model_parallel_initialized` to
initialize the model parallel groups.
- any code dealing with the distributed stuff
- call `destroy_model_parallel` to destroy the model parallel groups.
- call `destroy_distributed_environment` to destroy the distributed environment.
If you only need to use the distributed environment without model parallelism,
you can skip the model parallel initialization and destruction steps.
"""
import contextlib
import datetime
import os
import weakref
from collections import namedtuple
from collections.abc import Callable
from contextlib import contextmanager
from multiprocessing import shared_memory
from typing import Any, List, Optional
from unittest.mock import patch
import torch
import torch.distributed
from torch.distributed import ProcessGroup
import sglang.multimodal_gen.envs as envs
from sglang.multimodal_gen.runtime.distributed.utils import StatelessProcessGroup
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from ..utils.distributed import RankGenerator
from .group_coordinator import (
GroupCoordinator,
PipelineGroupCoordinator,
SequenceParallelGroupCoordinator,
get_local_torch_device,
)
logger = init_logger(__name__)
_WORLD: GroupCoordinator | None = None
_TP: GroupCoordinator | None = None
_SP: SequenceParallelGroupCoordinator | None = None
_PP: PipelineGroupCoordinator | None = None
_CFG: GroupCoordinator | None = None
_DP: GroupCoordinator | None = None
_VAE_DECODE: GroupCoordinator | None = None
_DIT: ProcessGroup | None = None
_VAE: ProcessGroup | None = None
_VAE_DECODE_PARALLEL_AXES = "tp-sp-pp-cfg"
TensorMetadata = namedtuple("TensorMetadata", ["device", "dtype", "size"])
def _split_tensor_dict(
tensor_dict: dict[str, torch.Tensor | Any],
) -> tuple[list[tuple[str, Any]], list[torch.Tensor]]:
"""Split the tensor dictionary into two parts:
1. A list of (key, value) pairs. If the value is a tensor, it is replaced
by its metadata.
2. A list of tensors.
"""
metadata_list: list[tuple[str, Any]] = []
tensor_list: list[torch.Tensor] = []
for key, value in tensor_dict.items():
if isinstance(value, torch.Tensor):
# Note: we cannot use `value.device` here,
# because it contains not only the device type but also the device
# index (e.g. "cuda:0"). We only need the device type.
# receiving side will set the device index.
device = value.device.type
metadata_list.append(
(key, TensorMetadata(device, value.dtype, value.size()))
)
tensor_list.append(value)
else:
metadata_list.append((key, value))
return metadata_list, tensor_list
_groups: dict[str, Callable[[], Optional["GroupCoordinator"]]] = {}
def _register_group(group: "GroupCoordinator") -> None:
_groups[group.unique_name] = weakref.ref(group)
def all_reduce(tensor: torch.Tensor, group_name: str) -> torch.Tensor:
assert group_name in _groups, f"Group {group_name} is not found."
group = _groups[group_name]()
if group is None:
raise ValueError(f"Group {group_name} is destroyed.")
return group._all_reduce_out_place(tensor)
def all_reduce_fake(tensor: torch.Tensor, group_name: str) -> torch.Tensor:
return torch.empty_like(tensor)
def get_world_group() -> GroupCoordinator:
assert _WORLD is not None, "world group is not initialized"
return _WORLD
def world_group_is_initialized() -> bool:
return _WORLD is not None
def init_world_group(
ranks: list[int], local_rank: int, backend: str
) -> GroupCoordinator:
return GroupCoordinator(
group_ranks=[ranks],
local_rank=local_rank,
torch_distributed_backend=backend,
use_device_communicator=True,
group_name="world",
)
def _sync_srt_world_group() -> None:
import sglang.srt.distributed.parallel_state as srt_parallel_state
if srt_parallel_state._WORLD is None:
srt_parallel_state._WORLD = _WORLD
def _clear_srt_world_group() -> None:
import sglang.srt.distributed.parallel_state as srt_parallel_state
if srt_parallel_state._WORLD is _WORLD:
srt_parallel_state._WORLD = None
def init_parallel_group_coordinator(
group_ranks: List[List[int]],
local_rank: int,
backend: str,
parallel_mode: str,
**kwargs,
) -> GroupCoordinator:
"""Return a group coordinator for the given parallel mode."""
assert parallel_mode in [
"data",
"pipeline",
"tensor",
"sequence",
"classifier_free_guidance",
"vae_decode",
], f"parallel_mode {parallel_mode} is not supported"
if parallel_mode == "pipeline":
return PipelineGroupCoordinator(
group_ranks=group_ranks,
local_rank=local_rank,
torch_distributed_backend=backend,
group_name="pp_group",
)
elif parallel_mode == "sequence":
return SequenceParallelGroupCoordinator(
group_ranks=group_ranks,
local_rank=local_rank,
torch_distributed_backend=backend,
group_name="sp_group",
**kwargs,
)
else:
return GroupCoordinator(
group_ranks=group_ranks,
local_rank=local_rank,
torch_distributed_backend=backend,
use_device_communicator=parallel_mode != "tensor",
use_srt_custom_allreduce=parallel_mode == "tensor",
group_name=(
"tp_group"
if parallel_mode == "tensor"
else (
"vae_decode_group" if parallel_mode == "vae_decode" else "cfg_group"
)
),
)
def _get_vae_decode_group_ranks(
rank_generator: RankGenerator,
) -> list[list[int]]:
# VAE decode happens after each DP replica owns a different request result.
# Decode can shard one request across TP/SP/PP/CFG ranks, but must not cross DP.
return rank_generator.get_ranks(_VAE_DECODE_PARALLEL_AXES)
def get_tp_group() -> GroupCoordinator:
assert _TP is not None, "tensor model parallel group is not initialized"
return _TP
def init_distributed_environment(
world_size: int = 1,
rank: int = 0,
distributed_init_method: str = "env://",
local_rank: int = 0,
backend: str | None = None,
device_id: torch.device | None = None,
timeout: int | None = None,
):
# Determine the appropriate backend based on the platform
from sglang.multimodal_gen.runtime.platforms import current_platform
if backend is None:
backend = current_platform.get_torch_distributed_backend_str()
logger.info(
"Using %s backend for %s platform", backend, current_platform.device_name
)
logger.debug(
"world_size=%d rank=%d local_rank=%d "
"distributed_init_method=%s backend=%s timeout=%s",
world_size,
rank,
local_rank,
distributed_init_method,
backend,
timeout,
)
if not torch.distributed.is_initialized():
assert distributed_init_method is not None, (
"distributed_init_method must be provided when initializing "
"distributed environment"
)
# For MPS, MUSA, and XPU, don't pass device_id as it doesn't support device indices
extra_args = (
{}
if (
current_platform.is_mps()
or current_platform.is_musa()
or current_platform.is_npu()
or current_platform.is_cpu()
or current_platform.is_xpu()
)
else dict(device_id=device_id)
)
if timeout is not None:
extra_args["timeout"] = datetime.timedelta(seconds=timeout)
logger.info(f"Setting distributed timeout to {timeout} seconds")
torch.distributed.init_process_group(
backend=backend,
init_method=distributed_init_method,
world_size=world_size,
rank=rank,
**extra_args,
)
# set the local rank
# local_rank is not available in torch ProcessGroup,
# see https://github.com/pytorch/pytorch/issues/122816
if local_rank == -1:
# local rank not set, this usually happens in single-node
# setting, where we can use rank as local rank
if distributed_init_method == "env://":
local_rank = envs.LOCAL_RANK
else:
local_rank = rank
global _WORLD
if _WORLD is None:
ranks = list(range(torch.distributed.get_world_size()))
_WORLD = init_world_group(ranks, local_rank, backend)
else:
assert (
_WORLD.world_size == torch.distributed.get_world_size()
), "world group already initialized with a different world size"
_sync_srt_world_group()
def get_sp_group() -> SequenceParallelGroupCoordinator:
assert _SP is not None, "sequence parallel group is not initialized"
return _SP
def get_dp_group() -> GroupCoordinator:
assert _DP is not None, "data parallel group is not initialized"
return _DP
# xDiT
def initialize_model_parallel(
data_parallel_size: int = 1,
classifier_free_guidance_degree: int = 1,
sequence_parallel_degree: Optional[int] = None,
ulysses_degree: int = 1,
ring_degree: int = 1,
tensor_parallel_degree: int = 1,
pipeline_parallel_degree: int = 1,
vae_parallel_size: int = 0,
backend: Optional[str] = None,
) -> None:
"""
Initialize model parallel groups.
Arguments:
data_parallel_size: number of data parallelism groups.
classifier_free_guidance_degree: number of GPUs used for Classifier Free Guidance (CFG)
sequence_parallel_degree: number of GPUs used for sequence parallelism. sequence_parallel_degree = ulysses_degree * ring_degree
ulysses_degree: number of GPUs used for ulysses sequence parallelism.
ring_degree: number of GPUs used for ring sequence parallelism.
tensor_parallel_degree: number of GPUs used for tensor parallelism.
pipeline_parallel_degree: number of GPUs used for pipeline parallelism.
backend: distributed backend of pytorch collective comm.
Let's say we have a total of 16 GPUs denoted by g0 ... g15 and we
use 2 groups to parallelize the batch dim(dp), 2 groups to parallelize
split batch caused by CFG, and 2 GPUs to parallelize sequence.
dp_degree (2) * cfg_degree (2) * sp_degree (2) * pp_degree (2) = 16.
The present function will create 8 data-parallel groups,
8 CFG group, 8 pipeline-parallel group, and
8 sequence-parallel groups:
8 data-parallel groups:
[g0, g8], [g1, g9], [g2, g10], [g3, g11],
[g4, g12], [g5, g13], [g6, g14], [g7, g15]
8 CFG-parallel groups:
[g0, g4], [g1, g5], [g2, g6], [g3, g7],
[g8, g12], [g9, g13], [g10, g14], [g11, g15]
8 sequence-parallel groups:
[g0, g1], [g2, g3], [g4, g5], [g6, g7],
[g8, g9], [g10, g11], [g12, g13], [g14, g15]
8 pipeline-parallel groups:
[g0, g2], [g4, g6], [g8, g10], [g12, g14],
[g1, g3], [g5, g7], [g9, g11], [g13, g15]
Note that for efficiency, the caller should make sure adjacent ranks
are on the same DGX box. For example if we are using 2 DGX-1 boxes
with a total of 16 GPUs, rank 0 to 7 belong to the first box and
ranks 8 to 15 belong to the second box.
"""
if backend is None:
from sglang.multimodal_gen.runtime.platforms import current_platform
backend = current_platform.get_torch_distributed_backend_str()
# Get world size and rank. Ensure some consistencies.
assert torch.distributed.is_initialized()
world_size: int = torch.distributed.get_world_size()
backend = backend or torch.distributed.get_backend(get_world_group().device_group)
dit_parallel_size = (
data_parallel_size
* classifier_free_guidance_degree
* sequence_parallel_degree
* pipeline_parallel_degree
* tensor_parallel_degree
)
if world_size < dit_parallel_size:
raise RuntimeError(
f"world_size ({world_size}) is less than "
f"tensor_parallel_degree ({tensor_parallel_degree}) x "
f"pipeline_parallel_degree ({pipeline_parallel_degree}) x"
f"sequence_parallel_degree ({sequence_parallel_degree}) x"
f"classifier_free_guidance_degree "
f"({classifier_free_guidance_degree}) x"
f"data_parallel_degree ({data_parallel_size})"
)
rank_generator: RankGenerator = RankGenerator(
tensor_parallel_degree,
sequence_parallel_degree,
pipeline_parallel_degree,
classifier_free_guidance_degree,
data_parallel_size,
"tp-sp-pp-cfg-dp",
)
global _DP
assert _DP is None, "data parallel group is already initialized"
_DP = init_parallel_group_coordinator(
group_ranks=rank_generator.get_ranks("dp"),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="data",
)
global _CFG
assert _CFG is None, "classifier_free_guidance group is already initialized"
_CFG = init_parallel_group_coordinator(
group_ranks=rank_generator.get_ranks("cfg"),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="classifier_free_guidance",
)
global _PP
assert _PP is None, "pipeline model parallel group is already initialized"
_PP = init_parallel_group_coordinator(
group_ranks=rank_generator.get_ranks("pp"),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="pipeline",
)
global _SP
assert _SP is None, "sequence parallel group is already initialized"
try:
from .parallel_groups import PROCESS_GROUP as _YC_PROCESS_GROUP
from .parallel_groups import (
set_seq_parallel_pg_by_sp_groups as _set_seq_parallel_pg_by_sp_groups,
)
except ImportError:
_set_seq_parallel_pg_by_sp_groups = None
class _DummyProcessGroup:
ULYSSES_PG = torch.distributed.group.WORLD
RING_PG = torch.distributed.group.WORLD
PROCESS_GROUP = _DummyProcessGroup()
else:
# Build SGLang Diffusion SP sub-groups based on the true SP groups. This is
# critical when TP>1, because SP groups may be strided in global ranks
# (e.g., tp-sp order).
sp_groups = rank_generator.get_ranks("sp")
_set_seq_parallel_pg_by_sp_groups(
sp_ulysses_degree=ulysses_degree,
sp_ring_degree=ring_degree,
rank=get_world_group().rank,
sp_groups=sp_groups,
)
PROCESS_GROUP = _YC_PROCESS_GROUP
_SP = init_parallel_group_coordinator(
group_ranks=rank_generator.get_ranks("sp"),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="sequence",
ulysses_group=PROCESS_GROUP.ULYSSES_PG,
ring_group=PROCESS_GROUP.RING_PG,
)
global _TP
assert _TP is None, "Tensor parallel group is already initialized"
_TP = init_parallel_group_coordinator(
group_ranks=rank_generator.get_ranks("tp"),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="tensor",
)
global _VAE_DECODE
assert _VAE_DECODE is None, "VAE decode parallel group is already initialized"
_VAE_DECODE = init_parallel_group_coordinator(
group_ranks=_get_vae_decode_group_ranks(rank_generator),
local_rank=get_world_group().local_rank,
backend=backend,
parallel_mode="vae_decode",
)
if vae_parallel_size > 0:
init_vae_group(dit_parallel_size, vae_parallel_size, backend)
init_dit_group(dit_parallel_size, backend)
def get_sp_world_size() -> int:
"""Return world size for the sequence model parallel group."""
return get_sp_group().world_size
def get_sp_parallel_rank() -> int:
"""Return my rank for the sequence model parallel group."""
return get_sp_group().rank_in_group
def get_world_size() -> int:
"""Return world size for the world group."""
return get_world_group().world_size
def get_world_rank() -> int:
"""Return my rank for the world group."""
return get_world_group().rank
def get_dp_world_size() -> int:
"""Return world size for the data parallel group."""
return get_dp_group().world_size
def get_dp_rank() -> int:
"""Return my rank for the data parallel group."""
return get_dp_group().rank_in_group
def maybe_init_distributed_environment_and_model_parallel(
tp_size: int,
sp_size: int,
cfg_degree: int = 1,
ulysses_degree: int = 1,
ring_degree: int = 1,
dp_size: int = 1,
distributed_init_method: str = "env://",
dist_timeout: int | None = None,
):
from sglang.multimodal_gen.runtime.platforms import current_platform
if _WORLD is not None and model_parallel_is_initialized():
# make sure the tp and sp sizes are correct
assert (
get_tp_world_size() == tp_size
), f"You are trying to initialize model parallel groups with size {tp_size}, but they are already initialized with size {get_tp_world_size()}"
assert (
get_sp_world_size() == sp_size
), f"You are trying to initialize model parallel groups with size {sp_size}, but they are already initialized with size {get_sp_world_size()}"
return
local_rank = int(os.environ.get("LOCAL_RANK", 0))
world_size = int(os.environ.get("WORLD_SIZE", 1))
rank = int(os.environ.get("RANK", 0))
device = get_local_torch_device()
logger.info(
"Initializing distributed environment with world_size=%d, device=%s, timeout=%s",
world_size,
device,
dist_timeout,
main_process_only=False,
)
init_distributed_environment(
world_size=world_size,
rank=rank,
local_rank=local_rank,
distributed_init_method=distributed_init_method,
device_id=device,
backend=current_platform.get_torch_distributed_backend_str(),
timeout=dist_timeout,
)
initialize_model_parallel(
data_parallel_size=dp_size,
classifier_free_guidance_degree=cfg_degree,
tensor_parallel_degree=tp_size,
ulysses_degree=ulysses_degree,
ring_degree=ring_degree,
sequence_parallel_degree=sp_size,
)
# Only set CUDA device if we're on a CUDA platform
if current_platform.is_cuda_alike():
device = torch.device(f"cuda:{local_rank}")
torch.cuda.set_device(device)
elif current_platform.is_npu():
device = torch.device(f"npu:{local_rank}")
torch.npu.set_device(device)
def model_parallel_is_initialized() -> bool:
"""Check if model parallel groups are initialized."""
return (
_DP is not None
and _CFG is not None
and _SP is not None
and _PP is not None
and _TP is not None
and _VAE_DECODE is not None
)
_TP_STATE_PATCHED = False
@contextmanager
def patch_tensor_parallel_group(tp_group: GroupCoordinator):
"""Patch the tp group temporarily until this function ends.
This method is for draft workers of speculative decoding to run draft model
with different tp degree from that of target model workers.
"""
global _TP_STATE_PATCHED
assert not _TP_STATE_PATCHED, "Should not call when it's already patched"
_TP_STATE_PATCHED = True
old_tp_group = get_tp_group()
global _TP
_TP = tp_group
try:
yield
finally:
# restore the original state
_TP_STATE_PATCHED = False
_TP = old_tp_group
def get_tp_world_size() -> int:
"""Return world size for the tensor model parallel group."""
return get_tp_group().world_size
def get_tp_rank() -> int:
"""Return my rank for the tensor model parallel group."""
return get_tp_group().rank_in_group
def destroy_distributed_environment() -> None:
global _WORLD
_clear_srt_world_group()
if _WORLD:
_WORLD.destroy()
_WORLD = None
if torch.distributed.is_initialized():
torch.distributed.destroy_process_group()
def cleanup_dist_env_and_memory(shutdown_ray: bool = False):
destroy_model_parallel()
destroy_distributed_environment()
with contextlib.suppress(AssertionError):
torch.distributed.destroy_process_group()
if shutdown_ray:
import ray # Lazy import Ray
ray.shutdown()
def is_the_same_node_as(
pg: ProcessGroup | StatelessProcessGroup, source_rank: int = 0
) -> list[int]:
"""
This is a collective operation that returns if each rank is in the same node
as the source rank. It tests if processes are attached to the same
memory system (shared access to shared memory).
"""
if isinstance(pg, ProcessGroup):
assert (
torch.distributed.get_backend(pg) != torch.distributed.Backend.NCCL
), "in_the_same_node_as should be tested with a non-NCCL group."
# local rank inside the group
rank = torch.distributed.get_rank(group=pg)
world_size = torch.distributed.get_world_size(group=pg)
# global ranks of the processes in the group
ranks = torch.distributed.get_process_group_ranks(pg)
else:
rank = pg.rank
world_size = pg.world_size
ranks = list(range(world_size))
# local tensor in each process to store the result
is_in_the_same_node = torch.tensor([0] * world_size, dtype=torch.int32)
magic_message = b"magic_message"
shm = None
try:
with contextlib.suppress(OSError):
if rank == source_rank:
# create a shared memory segment
shm = shared_memory.SharedMemory(create=True, size=128)
shm.buf[: len(magic_message)] = magic_message
if isinstance(pg, ProcessGroup):
torch.distributed.broadcast_object_list(
[shm.name], src=ranks[source_rank], group=pg
)
else:
pg.broadcast_obj(shm.name, src=source_rank)
is_in_the_same_node[rank] = 1
else:
# try to open the shared memory segment
if isinstance(pg, ProcessGroup):
recv = [None]
torch.distributed.broadcast_object_list(
recv, src=ranks[source_rank], group=pg
)
name = recv[0]
else:
name = pg.broadcast_obj(None, src=source_rank)
# fix to https://stackoverflow.com/q/62748654/9191338
# Python incorrectly tracks shared memory even if it is not
# created by the process. The following patch is a workaround.
with patch(
"multiprocessing.resource_tracker.register",
lambda *args, **kwargs: None,
):
shm = shared_memory.SharedMemory(name=name)
if shm.buf[: len(magic_message)] == magic_message:
is_in_the_same_node[rank] = 1
except Exception as e:
logger.error("Error ignored in is_in_the_same_node: %s", e)
finally:
if shm:
shm.close()
if isinstance(pg, ProcessGroup):
torch.distributed.barrier(group=pg)
else:
pg.barrier()
# clean up the shared memory segment
with contextlib.suppress(OSError):
if rank == source_rank and shm:
shm.unlink()
if isinstance(pg, ProcessGroup):
torch.distributed.all_reduce(is_in_the_same_node, group=pg)
aggregated_data = is_in_the_same_node
else:
aggregated_data = torch.zeros_like(is_in_the_same_node)
for i in range(world_size):
rank_data = pg.broadcast_obj(is_in_the_same_node, src=i)
aggregated_data += rank_data
return [x == 1 for x in aggregated_data.tolist()]
def get_tensor_model_parallel_world_size() -> int:
"""Return world size for the tensor model parallel group."""
return get_tp_world_size()
def get_tensor_model_parallel_rank() -> int:
"""Return my rank for the tensor model parallel group."""
return get_tp_rank()
def get_sequence_parallel_world_size() -> int:
"""Return world size for the sequence parallel group."""
return get_sp_world_size()
def get_sequence_parallel_rank() -> int:
"""Return my rank for the sequence parallel group."""
return get_sp_parallel_rank()
def get_ulysses_parallel_world_size() -> int:
return get_sp_group().ulysses_world_size
def get_ulysses_parallel_rank() -> int:
return get_sp_group().ulysses_rank
def get_ring_parallel_world_size() -> int:
return get_sp_group().ring_world_size
def get_ring_parallel_rank() -> int:
return get_sp_group().ring_rank
# PP
def get_pp_group() -> PipelineGroupCoordinator:
assert _PP is not None, "pipeline model parallel group is not initialized"
return _PP
def get_pipeline_parallel_world_size() -> int:
"""Return world size for the pipeline model parallel group."""
return get_pp_group().world_size
def get_pipeline_parallel_rank() -> int:
"""Return my rank for the pipeline model parallel group."""
return get_pp_group().rank_in_group
def is_pipeline_first_stage() -> bool:
"""Return True if in the first pipeline model parallel stage, False otherwise."""
return get_pipeline_parallel_rank() == 0
def is_pipeline_last_stage() -> bool:
"""Return True if in the last pipeline model parallel stage, False otherwise."""
return get_pipeline_parallel_rank() == (get_pipeline_parallel_world_size() - 1)
# CFG
def get_cfg_group() -> GroupCoordinator:
assert (
_CFG is not None
), "classifier_free_guidance parallel group is not initialized"
return _CFG
def get_classifier_free_guidance_world_size() -> int:
"""Return world size for the classifier_free_guidance parallel group."""
return get_cfg_group().world_size
def get_classifier_free_guidance_rank() -> int:
"""Return my rank for the classifier_free_guidance parallel group."""
return get_cfg_group().rank_in_group
def get_data_parallel_world_size() -> int:
"""Return world size for the data parallel group."""
return get_dp_world_size()
def get_data_parallel_rank() -> int:
"""Return my rank for the data parallel group."""
return get_dp_rank()
def is_dp_last_group() -> bool:
"""Return True if in the last data parallel group, False otherwise."""
return (
get_sequence_parallel_rank() == (get_sequence_parallel_world_size() - 1)
and get_classifier_free_guidance_rank()
== (get_classifier_free_guidance_world_size() - 1)
and get_pipeline_parallel_rank() == (get_pipeline_parallel_world_size() - 1)
)
def get_dit_world_size() -> int:
"""Return world size for the DiT model (excluding VAE)."""
return (
get_data_parallel_world_size()
* get_classifier_free_guidance_world_size()
* get_sequence_parallel_world_size()
* get_pipeline_parallel_world_size()
* get_tensor_model_parallel_world_size()
)
def get_vae_parallel_group() -> ProcessGroup:
assert _VAE is not None, "VAE parallel group is not initialized"
return _VAE
def get_vae_parallel_world_size() -> int:
"""Return world size for the VAE parallel group."""
return torch.distributed.get_world_size(group=get_vae_parallel_group())
def get_vae_parallel_rank() -> int:
"""Return my rank for the VAE parallel group."""
return torch.distributed.get_rank(group=get_vae_parallel_group())
def get_decode_parallel_group_coordinator() -> GroupCoordinator:
assert _VAE_DECODE is not None, "VAE decode parallel group is not initialized"
return _VAE_DECODE
def get_decode_parallel_world_size() -> int:
return get_decode_parallel_group_coordinator().world_size
def get_decode_parallel_rank() -> int:
return get_decode_parallel_group_coordinator().rank_in_group
def init_dit_group(
dit_parallel_size: int,
backend: str,
) -> None:
global _DIT
assert _DIT is None, "DIT group is already initialized"
_DIT = torch.distributed.new_group(
ranks=list(range(dit_parallel_size)), backend=backend
)
def get_dit_group() -> ProcessGroup:
assert _DIT is not None, "DIT group is not initialized"
return _DIT
def init_vae_group(
dit_parallel_size: int,
vae_parallel_size: int,
backend: str,
):
# Initialize VAE group first
global _VAE
assert _VAE is None, "VAE parallel group is already initialized"
vae_ranks = list(range(dit_parallel_size, dit_parallel_size + vae_parallel_size))
_VAE = torch.distributed.new_group(ranks=vae_ranks, backend=backend)
def destroy_model_parallel() -> None:
"""Set the groups to none and destroy them."""
global _TP, _SP, _DP, _CFG, _PP, _VAE_DECODE, _DIT, _VAE
for group in (_TP, _SP, _DP, _CFG, _PP, _VAE_DECODE):
if group is not None:
group.destroy()
for group in (_DIT, _VAE):
if group is not None:
torch.distributed.destroy_process_group(group)
_TP, _SP, _DP, _CFG, _PP, _VAE_DECODE, _DIT, _VAE = (None,) * 8
@@ -0,0 +1,225 @@
# SPDX-License-Identifier: Apache-2.0
"""Unified sequence-parallel shard / pad / gather helpers.
Layout invariant: padding always sits at the end of the LAST rank's local
chunk, so the ulysses-gathered sequence carries one contiguous pad block at its
global tail. `tail_attn_meta` then lets attention skip that block for free
(the pad becomes its own varlen segment - no repacking, no mask compute).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
import torch
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.distributed.communication_op import (
sequence_model_parallel_all_gather,
)
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_ring_parallel_world_size,
get_sp_parallel_rank,
get_sp_world_size,
)
# Text shorter than this stays replicated instead of SP-sharded (see
# plan_text_strategy). 0 = always shard when legal; H100 bench showed sharding
# wins from trivial lengths on, so the knob exists only as an escape hatch.
_TEXT_SHARD_MIN = int(os.environ.get("SGLANG_SP_TEXT_SHARD_MIN", "0"))
@dataclass(frozen=True)
class SpShard:
"""Facts of one tail-padded even shard, shared by tensors of that stream."""
orig_len: int # real tokens (global)
local_len: int # per-rank chunk length (equal on every rank)
num_pad: int # pad tokens, all at the last rank's local tail
sp_size: int
sp_rank: int
@property
def local_pad(self) -> int:
"""Pad rows inside THIS rank's chunk (tail rows of the last rank)."""
return self.num_pad if self.sp_rank == self.sp_size - 1 else 0
@property
def local_real_len(self) -> int:
return self.local_len - self.local_pad
def build_shard_plan(seq_len: int) -> SpShard:
"""Shard math only; tensors are sliced separately via `shard_like`."""
sp_size = get_sp_world_size()
if sp_size <= 1:
return SpShard(seq_len, seq_len, 0, 1, 0)
local_len = (seq_len + sp_size - 1) // sp_size
return SpShard(
orig_len=seq_len,
local_len=local_len,
num_pad=local_len * sp_size - seq_len,
sp_size=sp_size,
sp_rank=get_sp_parallel_rank(),
)
def shard_like(
x: torch.Tensor, shard: SpShard, dim: int = 1, pad_mode: str = "zeros"
) -> torch.Tensor:
"""Apply a planned shard to one tensor (RoPE caches use the same plan as
hidden states so their chunks stay aligned)."""
if shard.sp_size <= 1:
return x
if shard.num_pad > 0:
if pad_mode == "repeat_last":
pad = x.narrow(dim, x.shape[dim] - 1, 1)
pad = pad.expand(
*[shard.num_pad if i == dim else -1 for i in range(x.dim())]
)
x = torch.cat([x, pad], dim=dim)
else:
# F.pad pads dims last-to-first: (left, right) pairs from dim -1.
pads = [0, 0] * (x.dim() - 1 - dim) + [0, shard.num_pad]
x = F.pad(x, pads)
return x.narrow(dim, shard.sp_rank * shard.local_len, shard.local_len)
def shard_seq(
x: torch.Tensor, dim: int = 1, pad_mode: str = "zeros"
) -> tuple[torch.Tensor, SpShard]:
"""
mode:
zeroes: pad with zeroes at tail
repeat_last: repeat the last token, only for rotary embedding
"""
shard = build_shard_plan(x.shape[dim])
return shard_like(x, shard, dim=dim, pad_mode=pad_mode), shard
def gather_seq(local: torch.Tensor, orig_len: int, dim: int = 1) -> torch.Tensor:
"""All-gather an SP-sharded sequence and trim the tail padding"""
if get_sp_world_size() <= 1:
return local
full = sequence_model_parallel_all_gather(local.contiguous(), dim=dim)
if full.shape[dim] > orig_len:
full = full.narrow(dim, 0, orig_len)
return full
def shard_seq_prefix(
x: torch.Tensor, prefix_len: int, shard: SpShard, dim: int = 0
) -> torch.Tensor:
"""Shard only the leading ``prefix_len`` rows (e.g. the text segment of a
joint RoPE cache) with an existing plan; the remainder is kept as-is."""
rest = x.shape[dim] - prefix_len
return torch.cat(
[
shard_like(x.narrow(dim, 0, prefix_len), shard, dim=dim),
x.narrow(dim, prefix_len, rest),
],
dim=dim,
)
def join_seqs(
prefix: torch.Tensor, body: torch.Tensor, local_pad: int, dim: int = 1
) -> torch.Tensor:
"""Concatenate local sharded ``[prefix (txt tokens, padding tokens), body (img tokens)]`` for joint attention, while relocating the
prefix's ``local_pad`` tail rows behind the body.
Why leave the padding at tail: the shard pads the *text* chunk, but the local joint layout is
[text, image].
In naive implementation, after the ulysses all-to-all, that pad would sit mid-sequence (of last rank)
([... txt_last, PAD, img_last]), which required further mem copy (for the padding tokens), inefficient in this case
With the pad relocated behind the image, the padding forms one global-tail block that the zero-copy varlen
path (tail_attn_meta, implemented in USPAttention.forward) skips for free
"""
if local_pad > 0:
real = prefix.shape[dim] - local_pad
return torch.cat(
[
# txt tokens
prefix.narrow(dim, 0, real),
body,
# leave the padding at global-tail
prefix.narrow(dim, real, local_pad),
],
dim=dim,
)
return torch.cat([prefix, body], dim=dim)
def split_seqs(
joint: torch.Tensor, prefix_len: int, local_pad: int, dim: int = 1
) -> tuple[torch.Tensor, torch.Tensor]:
"""Inverse of ``join_seqs``: recover ``(prefix, body)`` from the joint output, with the pad rows rejoining the prefix tail so the residual text
stream keeps its per-rank shape.
([... txt_last, PAD, img_last]) -> prefix (txt + pad), body (img)
"""
total = joint.shape[dim]
if local_pad > 0:
real = prefix_len - local_pad
body_end = total - local_pad
prefix = torch.cat(
[joint.narrow(dim, 0, real), joint.narrow(dim, body_end, local_pad)],
dim=dim,
)
return prefix, joint.narrow(dim, real, body_end - real)
return (
joint.narrow(dim, 0, prefix_len),
joint.narrow(dim, prefix_len, total - prefix_len),
)
def should_shard_text(txt_len: int) -> bool:
"""True when the joint-attention text stream should be SP-sharded here
(see plan_text_strategy for the policy)."""
return get_sp_world_size() > 1 and plan_text_strategy(txt_len) == "shard"
def tail_attn_meta(
shard: SpShard,
batch_size: int,
device: torch.device,
image_seq_len: int = 0,
) -> dict | None:
"""Per-request attention meta for a tail-padded shard: `cu_seqlens_tail`
splits each batch row into [valid | pad] varlen segments over the gathered
layout, so USPAttention runs varlen FA on the padded q/k/v with zero
repacking. Built once per request, reused by every block."""
if shard.sp_size <= 1 or shard.num_pad == 0:
return None
seq = shard.sp_size * (shard.local_len + image_seq_len)
valid = seq - shard.num_pad
row = torch.tensor([valid, shard.num_pad], dtype=torch.int32, device=device)
seglens = row.repeat(batch_size)
cu_seqlens = torch.zeros(2 * batch_size + 1, dtype=torch.int32, device=device)
cu_seqlens[1:] = torch.cumsum(seglens, dim=0)
return {
"pad_start": valid,
"pad_end": seq,
"local_pad": shard.local_pad,
"cu_seqlens_tail": cu_seqlens,
"max_seqlen_tail": max(valid, shard.num_pad),
}
def plan_text_strategy(txt_len: int) -> str:
"""Choose "shard" or "replicate" for the joint-attention text stream.
Prefer "shard" by default. for small sequence (shorter than SGLANG_SP_TEXT_SHARD_MIN), choose "replicate" for better performance
"""
sp_size = get_sp_world_size()
if sp_size <= 1:
return "replicate"
if txt_len % sp_size != 0 and get_ring_parallel_world_size() > 1:
return "replicate"
if txt_len < _TEXT_SHARD_MIN:
return "replicate"
return "shard"
@@ -0,0 +1,196 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/distributed/utils.py
# Copyright 2023 The vLLM team.
# Adapted from
# https://github.com/NVIDIA/Megatron-LM/blob/main/megatron/core/tensor_parallel/utils.py
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
import dataclasses
import pickle
import time
from collections import deque
from collections.abc import Sequence
from typing import Any
import torch
from torch.distributed import TCPStore
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
def ensure_divisibility(numerator, denominator) -> None:
"""Ensure that numerator is divisible by the denominator."""
assert numerator % denominator == 0, "{} is not divisible by {}".format(
numerator, denominator
)
def divide(numerator: int, denominator: int) -> int:
"""Ensure that numerator is divisible by the denominator and return
the division value."""
ensure_divisibility(numerator, denominator)
return numerator // denominator
def split_tensor_along_last_dim(
tensor: torch.Tensor,
num_partitions: int,
contiguous_split_chunks: bool = False,
) -> Sequence[torch.Tensor]:
"""Split a tensor along its last dimension.
Arguments:
tensor: input tensor.
num_partitions: number of partitions to split the tensor
contiguous_split_chunks: If True, make each chunk contiguous
in memory.
Returns:
A list of Tensors
"""
# Get the size and dimension.
last_dim = tensor.dim() - 1
last_dim_size = divide(tensor.size()[last_dim], num_partitions)
# Split.
tensor_list = torch.split(tensor, last_dim_size, dim=last_dim)
# NOTE: torch.split does not create contiguous tensors by default.
if contiguous_split_chunks:
return tuple(chunk.contiguous() for chunk in tensor_list)
return tuple(tensor_list)
@dataclasses.dataclass
class StatelessProcessGroup:
"""A dataclass to hold a metadata store, and the rank, world_size of the
group. Only use it to communicate metadata between processes.
For data-plane communication, create NCCL-related objects.
"""
rank: int
world_size: int
store: torch._C._distributed_c10d.Store
data_expiration_seconds: int = 3600 # 1 hour
# dst rank -> counter
send_dst_counter: dict[int, int] = dataclasses.field(default_factory=dict)
# src rank -> counter
recv_src_counter: dict[int, int] = dataclasses.field(default_factory=dict)
broadcast_send_counter: int = 0
broadcast_recv_src_counter: dict[int, int] = dataclasses.field(default_factory=dict)
# A deque to store the data entries, with key and timestamp.
entries: deque[tuple[str, float]] = dataclasses.field(default_factory=deque)
def __post_init__(self):
assert self.rank < self.world_size
self.send_dst_counter = {i: 0 for i in range(self.world_size)}
self.recv_src_counter = {i: 0 for i in range(self.world_size)}
self.broadcast_recv_src_counter = {i: 0 for i in range(self.world_size)}
def send_obj(self, obj: Any, dst: int):
"""Send an object to a destination rank."""
self.expire_data()
key = f"send_to/{dst}/{self.send_dst_counter[dst]}"
self.store.set(key, pickle.dumps(obj))
self.send_dst_counter[dst] += 1
self.entries.append((key, time.perf_counter()))
def expire_data(self) -> None:
"""Expire data that is older than `data_expiration_seconds` seconds."""
while self.entries:
# check the oldest entry
key, timestamp = self.entries[0]
if time.perf_counter() - timestamp > self.data_expiration_seconds:
self.store.delete_key(key)
self.entries.popleft()
else:
break
def recv_obj(self, src: int) -> Any:
"""Receive an object from a source rank."""
obj = pickle.loads(
self.store.get(f"send_to/{self.rank}/{self.recv_src_counter[src]}")
)
self.recv_src_counter[src] += 1
return obj
def broadcast_obj(self, obj: Any | None, src: int) -> Any:
"""Broadcast an object from a source rank to all other ranks.
It does not clean up after all ranks have received the object.
Use it for limited times, e.g., for initialization.
"""
if self.rank == src:
self.expire_data()
key = f"broadcast_from/{src}/" f"{self.broadcast_send_counter}"
self.store.set(key, pickle.dumps(obj))
self.broadcast_send_counter += 1
self.entries.append((key, time.perf_counter()))
return obj
else:
key = f"broadcast_from/{src}/" f"{self.broadcast_recv_src_counter[src]}"
recv_obj = pickle.loads(self.store.get(key))
self.broadcast_recv_src_counter[src] += 1
return recv_obj
def all_gather_obj(self, obj: Any) -> list[Any]:
"""All gather an object from all ranks."""
gathered_objs = []
for i in range(self.world_size):
if i == self.rank:
gathered_objs.append(obj)
self.broadcast_obj(obj, src=self.rank)
else:
recv_obj = self.broadcast_obj(None, src=i)
gathered_objs.append(recv_obj)
return gathered_objs
def barrier(self):
"""A barrier to synchronize all ranks."""
for i in range(self.world_size):
if i == self.rank:
self.broadcast_obj(None, src=self.rank)
else:
self.broadcast_obj(None, src=i)
@staticmethod
def create(
host: str,
port: int,
rank: int,
world_size: int,
data_expiration_seconds: int = 3600,
) -> "StatelessProcessGroup":
"""A replacement for `torch.distributed.init_process_group` that does not
pollute the global state.
If we have process A and process B called `torch.distributed.init_process_group`
to form a group, and then we want to form another group with process A, B, C,
D, it is not possible in PyTorch, because process A and process B have already
formed a group, and process C and process D cannot join that group. This
function is a workaround for this issue.
`torch.distributed.init_process_group` is a global call, while this function
is a stateless call. It will return a `StatelessProcessGroup` object that can be
used for exchanging metadata. With this function, process A and process B
can call `StatelessProcessGroup.create` to form a group, and then process A, B,
C, and D can call `StatelessProcessGroup.create` to form another group.
""" # noqa
store = TCPStore(
host_name=host,
port=port,
world_size=world_size,
is_master=(rank == 0),
)
return StatelessProcessGroup(
rank=rank,
world_size=world_size,
store=store,
data_expiration_seconds=data_expiration_seconds,
)
@@ -0,0 +1,4 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
from sglang.multimodal_gen.runtime.utils.logging_utils import globally_suppress_loggers
globally_suppress_loggers()
@@ -0,0 +1 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
@@ -0,0 +1,30 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/types.py
import argparse
from sglang.multimodal_gen.utils import FlexibleArgumentParser
class CLISubcommand:
"""Base class for CLI subcommands"""
name: str
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
"""Execute the command with the given arguments"""
raise NotImplementedError
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
pass
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
"""Initialize the subparser for this command"""
raise NotImplementedError
@@ -0,0 +1,248 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/serve.py
import argparse
import dataclasses
import json
import os
from typing import cast
from sglang.multimodal_gen import DiffGenerator
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.utils import (
RaiseNotImplementedAction,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import GenerationResult
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.perf_logger import (
MemorySnapshot,
PerformanceLogger,
RequestMetrics,
)
from sglang.multimodal_gen.utils import FlexibleArgumentParser
logger = init_logger(__name__)
def _resolve_cli_sampling_params_cls(server_args: ServerArgs) -> type[SamplingParams]:
pipeline_class_name = getattr(server_args, "pipeline_class_name", None)
if pipeline_class_name:
from sglang.multimodal_gen.registry import get_pipeline_config_classes
config_classes = get_pipeline_config_classes(pipeline_class_name)
if config_classes is not None:
_, sampling_params_cls = config_classes
return sampling_params_cls
try:
from sglang.multimodal_gen.registry import get_model_info
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
if model_info is not None:
return model_info.sampling_param_cls
except Exception as exc:
logger.debug("Falling back to base SamplingParams for CLI parsing: %s", exc)
return SamplingParams
def add_multimodal_gen_generate_args(parser: argparse.ArgumentParser):
"""Add the arguments for the generate command."""
parser.add_argument(
"--config",
type=str,
default="",
required=False,
help="Read CLI options from a config JSON or YAML file. If provided, --model-path and --prompt are optional.",
)
parser.add_argument(
"--perf-dump-path",
type=str,
default=None,
required=False,
help="Path to dump the performance metrics (JSON) for the run.",
)
parser.add_argument(
"--output-file-path",
type=str,
default=None,
required=False,
help="Convenience alias that sets both --output-path and --output-file-name.",
)
parser = ServerArgs.add_cli_args(parser)
parser = SamplingParams.add_cli_args(parser)
parser.add_argument(
"--text-encoder-configs",
action=RaiseNotImplementedAction,
help="JSON array of text encoder configurations (NOT YET IMPLEMENTED)",
)
return parser
def _apply_output_file_path_override(
args: argparse.Namespace, sampling_params_kwargs: dict
):
output_file_path = args.output_file_path
if not output_file_path:
return
output_path = os.path.dirname(output_file_path) or "."
sampling_params_kwargs["output_path"] = output_path
sampling_params_kwargs["output_file_name"] = os.path.basename(output_file_path)
def maybe_dump_performance(
args: argparse.Namespace,
server_args,
prompt: str,
results: GenerationResult | list[GenerationResult] | None,
):
"""dump performance if necessary"""
if not (args.perf_dump_path and results):
return
if isinstance(results, list):
result = results[0] if results else None
else:
result = results
metrics_dict = result.metrics
if not (args.perf_dump_path and metrics_dict):
return
metrics = RequestMetrics(request_id=metrics_dict.get("request_id"))
metrics.stages = metrics_dict.get("stages", {})
metrics.steps = metrics_dict.get("steps", [])
metrics.total_duration_ms = metrics_dict.get("total_duration_ms", 0)
# restore memory snapshots from serialized dict
memory_snapshots_dict = metrics_dict.get("memory_snapshots", {})
for checkpoint_name, snapshot_dict in memory_snapshots_dict.items():
snapshot = MemorySnapshot(
allocated_mb=snapshot_dict.get("allocated_mb", 0.0),
reserved_mb=snapshot_dict.get("reserved_mb", 0.0),
peak_allocated_mb=snapshot_dict.get("peak_allocated_mb", 0.0),
peak_reserved_mb=snapshot_dict.get("peak_reserved_mb", 0.0),
)
metrics.memory_snapshots[checkpoint_name] = snapshot
PerformanceLogger.dump_benchmark_report(
file_path=args.perf_dump_path,
metrics=metrics,
meta={
"prompt": prompt,
"model": server_args.model_path,
},
tag="cli_generate",
)
def generate_cmd(args: argparse.Namespace, unknown_args: list[str] | None = None):
"""The entry point for the generate command."""
args.request_id = "mocked_fake_id_for_offline_generate"
server_args = ServerArgs.from_cli_args(args, unknown_args)
sampling_params_cls = _resolve_cli_sampling_params_cls(server_args)
sampling_params_kwargs = {}
config_file = getattr(args, "config", None)
# respect config file by overriding args with args parsed from it
if config_file:
config_args = ServerArgs.load_config_file(config_file) or {}
sampling_param_fields = {
field.name for field in dataclasses.fields(sampling_params_cls)
}
sampling_params_kwargs.update(
{
key: value
for key, value in config_args.items()
if key in sampling_param_fields and value is not None
}
)
sampling_params_kwargs.update(sampling_params_cls.get_cli_args(args))
_apply_output_file_path_override(args, sampling_params_kwargs)
sampling_params_kwargs["request_id"] = generate_request_id()
# Handle diffusers-specific kwargs passed via CLI
if hasattr(args, "diffusers_kwargs") and args.diffusers_kwargs:
try:
sampling_params_kwargs["diffusers_kwargs"] = json.loads(
args.diffusers_kwargs
)
logger.info(
"Parsed diffusers_kwargs: %s",
sampling_params_kwargs["diffusers_kwargs"],
)
except json.JSONDecodeError as e:
logger.error("Failed to parse --diffusers-kwargs as JSON: %s", e)
raise ValueError(
f"--diffusers-kwargs must be valid JSON. Got: {args.diffusers_kwargs}"
) from e
generator = DiffGenerator.from_pretrained(
model_path=server_args.model_path, server_args=server_args, local_mode=True
)
results = generator.generate(sampling_params_kwargs=sampling_params_kwargs)
prompt = sampling_params_kwargs.get("prompt")
maybe_dump_performance(args, server_args, prompt, results)
class GenerateSubcommand(CLISubcommand):
"""The `generate` subcommand for the sglang-diffusion CLI"""
def __init__(self) -> None:
self.name = "generate"
super().__init__()
self.init_arg_names = self._get_init_arg_names()
self.generation_arg_names = self._get_generation_arg_names()
def _get_init_arg_names(self) -> list[str]:
"""Get names of arguments for DiffGenerator initialization"""
return ["num_gpus", "tp_size", "sp_size", "model_path"]
def _get_generation_arg_names(self) -> list[str]:
"""Get names of arguments for generate_video method"""
return [field.name for field in dataclasses.fields(SamplingParams)]
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
generate_cmd(args, unknown_args)
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
if args.num_gpus is not None and args.num_gpus <= 0:
raise ValueError("Number of gpus must be positive")
if args.config and not os.path.exists(args.config):
raise ValueError(f"Config file not found: {args.config}")
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
generate_parser = subparsers.add_parser(
"generate",
help="Run inference on a model",
usage="sglang generate (--model-path MODEL_PATH_OR_ID --prompt PROMPT) | --config CONFIG_FILE [OPTIONS]",
)
generate_parser = add_multimodal_gen_generate_args(generate_parser)
return cast(FlexibleArgumentParser, generate_parser)
@@ -0,0 +1,44 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/main.py
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.generate import GenerateSubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.serve import ServeSubcommand
from sglang.multimodal_gen.utils import FlexibleArgumentParser
def generate_cmd_init() -> list[CLISubcommand]:
return [GenerateSubcommand(), ServeSubcommand()]
def cmd_init() -> list[CLISubcommand]:
"""Initialize all commands from separate modules"""
commands = []
commands.extend(generate_cmd_init())
return commands
def main() -> None:
parser = FlexibleArgumentParser(description="sglang-diffusion CLI")
parser.add_argument("-v", "--version", action="version", version="0.1.0")
subparsers = parser.add_subparsers(required=False, dest="subparser")
cmds = {}
for cmd in cmd_init():
cmd.subparser_init(subparsers).set_defaults(dispatch_function=cmd.cmd)
cmds[cmd.name] = cmd
args, unknown_args = parser.parse_known_args()
if args.subparser in cmds:
cmds[args.subparser].validate(args)
if hasattr(args, "dispatch_function"):
args.dispatch_function(args, unknown_args=unknown_args)
else:
parser.print_help()
if __name__ == "__main__":
main()
@@ -0,0 +1,75 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import argparse
import os
from typing import cast
from sglang.multimodal_gen.apps.webui import run_sgl_diffusion_webui
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.launch_server import (
dispatch_launch,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.utils import FlexibleArgumentParser
def add_multimodal_gen_serve_args(parser: argparse.ArgumentParser):
"""Add the arguments for the serve command."""
parser.add_argument(
"--config",
type=str,
default="",
required=False,
help="Read CLI options from a config JSON or YAML file.",
)
return ServerArgs.add_cli_args(parser)
def execute_serve_cmd(args: argparse.Namespace, unknown_args: list[str] | None = None):
"""The entry point for the serve command."""
# use server-based warmup for production
server_args = ServerArgs.from_cli_args(
args, unknown_args, default_args={"warmup_mode": "server"}
)
dispatch_launch(server_args)
if server_args.webui:
run_sgl_diffusion_webui(server_args)
class ServeSubcommand(CLISubcommand):
"""The `serve` subcommand for the sglang-diffusion CLI"""
def __init__(self) -> None:
self.name = "serve"
super().__init__()
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
execute_serve_cmd(args, unknown_args)
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
if args.config and not os.path.exists(args.config):
raise ValueError(f"Config file not found: {args.config}")
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
serve_parser = subparsers.add_parser(
"serve",
help="Launch the server and start FastAPI listener.",
usage="sglang serve --model-path MODEL_PATH_OR_ID [OPTIONS]",
)
serve_parser = add_multimodal_gen_serve_args(serve_parser)
return cast(FlexibleArgumentParser, serve_parser)
def cmd_init() -> list[CLISubcommand]:
return [ServeSubcommand()]
@@ -0,0 +1,75 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import argparse
import os
import shlex
import subprocess
import sys
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class RaiseNotImplementedAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
raise NotImplementedError(f"The {option_string} option is not yet implemented")
def launch_distributed(
num_gpus: int, args: list[str], master_port: int | None = None
) -> int:
"""
Launch a distributed job with the given arguments
Args:
num_gpus: Number of GPUs to use
args: Arguments to pass to v1_sgl_diffusion_inference.py (defaults to sys.argv[1:])
master_port: Port for the master process (default: random)
"""
current_env = os.environ.copy()
python_executable = sys.executable
project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../../..")
)
main_script = os.path.join(
project_root, "sgl_diffusion/sample/v1_sgl_diffusion_inference.py"
)
cmd = [
python_executable,
"-m",
"torch.distributed.run",
f"--nproc_per_node={num_gpus}",
]
if master_port is not None:
cmd.append(f"--master_port={master_port}")
cmd.append(main_script)
cmd.extend(args)
logger.info("Running inference with %d GPU(s)", num_gpus)
logger.info("Launching command: %s", shlex.join(cmd))
current_env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
cmd,
env=current_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
encoding="utf-8",
errors="replace",
)
if process.stdout:
for line in iter(process.stdout.readline, ""):
print(line.strip())
return process.wait()
@@ -0,0 +1,713 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
"""
DiffGenerator module for sglang-diffusion.
This module provides a consolidated interface for generating images/videos using
diffusion models.
"""
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 (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
GenerationResult,
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
expand_request_outputs,
format_lora_message,
prepare_request,
save_outputs,
)
from sglang.multimodal_gen.runtime.launch_server import launch_server
from sglang.multimodal_gen.runtime.pipelines_core import Req
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import sync_scheduler_client
from sglang.multimodal_gen.runtime.server_args import PortArgs, ServerArgs
from sglang.multimodal_gen.runtime.server_warmup import (
run_sync_client_warmup,
should_run_explicit_client_warmup,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import (
GREEN,
RESET,
init_logger,
log_batch_completion,
log_generation_timer,
)
from sglang.multimodal_gen.runtime.utils.trace_wrapper import (
init_diffusion_tracing,
trace_req,
)
logger = init_logger(__name__)
try:
# Set the start method to 'spawn' to avoid CUDA errors in forked processes.
# This must be done at the top level of the module, before any CUDA context
# or other processes are initialized.
mp.set_start_method("spawn", force=True)
except RuntimeError:
# The start method can only be set once per program execution.
pass
class DiffGenerator:
"""
A unified class for generating images/videos using diffusion models.
This class provides a simple interface for image/video generation with rich
customization options, similar to popular frameworks like HF Diffusers.
"""
def __init__(
self,
server_args: ServerArgs,
):
"""
Initialize the generator.
Args:
server_args: The inference arguments
"""
self.server_args = server_args
self.port_args = PortArgs.from_server_args(server_args)
# The executor is now a client to the Scheduler service
self.local_scheduler_process: list[mp.Process] | None = None
self.owns_scheduler_client: bool = False
@classmethod
def from_pretrained(
cls,
local_mode: bool = True,
**kwargs,
) -> "DiffGenerator":
"""
Create a DiffGenerator from a pretrained model.
Priority level: Default pipeline config < User's pipeline config < User's kwargs
"""
# If users also provide some kwargs, it will override the ServerArgs and PipelineConfig.
if (server_args := kwargs.get("server_args", None)) is not None:
if isinstance(server_args, ServerArgs):
pass
elif isinstance(server_args, dict):
server_args = ServerArgs.from_kwargs(**server_args)
else:
server_args = ServerArgs.from_kwargs(**kwargs)
return cls.from_server_args(server_args, local_mode=local_mode)
@classmethod
def from_server_args(
cls, server_args: ServerArgs, local_mode: bool = True
) -> "DiffGenerator":
"""
Create a DiffGenerator with the specified arguments.
Args:
server_args: The inference arguments
Returns:
The created DiffGenerator
"""
instance = cls(
server_args=server_args,
)
init_diffusion_tracing(server_args, "DiffGenerator")
logger.info(f"Local mode: {local_mode}")
if local_mode:
instance.local_scheduler_process = instance._start_local_server_if_needed()
instance.owns_scheduler_client = True
instance._run_client_warmup_if_needed()
else:
# In remote mode, we just need to connect and check.
sync_scheduler_client.initialize(server_args)
instance._check_remote_scheduler()
instance.owns_scheduler_client = True
return instance
def _start_local_server_if_needed(
self,
) -> list[mp.Process]:
"""Check if a local server is running; if not, start it and return the process handles."""
# First, we need a client to test the server. Initialize it temporarily.
sync_scheduler_client.initialize(self.server_args)
processes = launch_server(self.server_args, launch_http_server=False)
return processes
def _run_client_warmup_if_needed(self) -> None:
if not should_run_explicit_client_warmup(self.server_args):
return
run_sync_client_warmup(self.server_args, sync_scheduler_client.forward)
def _check_remote_scheduler(self):
"""Check if the remote scheduler is accessible."""
if not sync_scheduler_client.ping():
raise ConnectionError(
f"Could not connect to remote scheduler at "
f"{self.server_args.scheduler_endpoint} with `local mode` as False. "
"Please ensure the server is running."
)
logger.info(
f"Successfully connected to remote scheduler at "
f"{self.server_args.scheduler_endpoint}."
)
@staticmethod
def _resolve_image_paths_per_prompt(
prompts: list[str], image_paths: str | list[str] | None
) -> list[str | list[str] | None]:
if len(prompts) <= 1:
return [image_paths]
if not isinstance(image_paths, list) or len(image_paths) <= 1:
return [image_paths for _ in prompts]
if len(image_paths) != len(prompts):
raise ValueError(
"When using multiple prompts with multiple input images, "
"provide either one shared image or exactly one image per prompt."
)
return [[image_path] for image_path in image_paths]
def generate(
self,
sampling_params_kwargs: dict | None = None,
external_trace_header: dict[str, str] | None = None,
) -> GenerationResult | list[GenerationResult] | None:
"""Generate image(s)/video(s) based on the given prompt(s).
Returns a single GenerationResult for a single prompt, a list for
multiple prompts, or None when every request failed.
"""
# 1. prepare requests
prompts = self._resolve_prompts(
sampling_params_kwargs.get("prompt"),
sampling_params_kwargs.get("prompt_path"),
)
user_output_file_name = sampling_params_kwargs.get("output_file_name")
if len(prompts) > 1 and user_output_file_name is not None:
raise ValueError(
"Cannot use multiple prompts with a fixed output_file_name. "
"Either remove --output-file-name or use a single prompt."
)
sampling_params_orig = SamplingParams.from_user_sampling_params_args(
self.server_args.model_path,
server_args=self.server_args,
**sampling_params_kwargs,
)
request_groups: list[list[Req]] = []
image_paths_per_prompt = self._resolve_image_paths_per_prompt(
prompts, sampling_params_orig.image_path
)
for i, p in enumerate(prompts):
sampling_params = dataclasses.replace(
sampling_params_orig,
prompt=p,
output_file_name=user_output_file_name,
image_path=image_paths_per_prompt[i],
)
# `dataclasses.replace` drops non-field attrs; restore
# `_explicit_fields` so InputValidationStage honors user-supplied
# width/height, and mark the keys overridden above as explicit.
sampling_params._explicit_fields = getattr(
sampling_params_orig, "_explicit_fields", set()
) | {"prompt", "output_file_name", "image_path"}
sampling_params._set_output_file_name()
req = prepare_request(
server_args=self.server_args,
sampling_params=sampling_params,
external_trace_header=external_trace_header,
)
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
for requests in request_groups:
try:
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}")
if (
output_batch.output is None
and output_batch.output_file_paths is None
):
logger.error("Received empty output from scheduler")
continue
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(
**self._result_common(
req, output_batch, timer.duration, idx
),
prompt_index=global_output_index + idx,
output_file_path=path,
)
)
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(
**self._result_common(
req, output_batch, timer.duration, idx
),
prompt_index=global_output_index + idx,
output_file_path=sample,
)
)
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: %s", e, exc_info=True)
finally:
global_output_index += len(requests)
total_gen_time = time.perf_counter() - total_start_time
if self.server_args.batching_max_size > 1:
log_batch_completion(
logger,
len(results),
total_gen_time,
)
self._log_summary(results)
if not results:
return None
return results[0] if len(results) == 1 else results
def generate_action(
self,
sampling_params_kwargs: dict | None = None,
external_trace_header: dict[str, str] | None = None,
) -> dict[str, Any]:
sampling_params_kwargs = sampling_params_kwargs or {}
sampling_params = SamplingParams.from_user_sampling_params_args(
self.server_args.model_path,
server_args=self.server_args,
**sampling_params_kwargs,
)
if sampling_params.data_type != DataType.ACTION:
raise ValueError(
f"generate_action requires an ACTION pipeline, got {sampling_params.data_type}"
)
req = prepare_request(
server_args=self.server_args,
sampling_params=sampling_params,
external_trace_header=external_trace_header,
)
output_batch = self._send_to_scheduler_and_wait_for_response(req)
if output_batch.error:
raise RuntimeError(output_batch.error)
if output_batch.output is None:
raise RuntimeError("action policy returned no output")
return output_batch.output[0]
def _resolve_prompts(
self,
prompt: str | list[str] | None,
prompt_path: str | None = None,
) -> list[str]:
"""Collect prompts from the argument or from a prompt file."""
path = prompt_path or self.server_args.prompt_file_path
if path is not None:
if not os.path.exists(path):
raise FileNotFoundError(f"Prompt text file not found: {path}")
with open(path, encoding="utf-8") as f:
prompts = [line.strip() for line in f if line.strip()]
if not prompts:
raise ValueError(f"No prompts found in file: {path}")
logger.info("Found %d prompts in %s", len(prompts), path)
return prompts
if prompt is None:
return [" "]
if isinstance(prompt, str):
return [prompt]
return list(prompt)
def _log_summary(self, results: list[GenerationResult]) -> None:
if not results:
return
if self.server_args.warmup:
total_duration_ms = results[0].metrics.get("total_duration_ms", 0)
logger.info(
f"Warmed-up request processed in {GREEN}%.2f{RESET} seconds (with warmup excluded)",
total_duration_ms / 1000.0,
)
peak_memories = [r.peak_memory_mb for r in results if r.peak_memory_mb]
if peak_memories:
logger.info(
f"Memory usage - Max peak: {max(peak_memories):.2f} MB, "
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]
if req.data_type == DataType.ACTION:
size = ("action",)
else:
size = (req.height, req.width, req.num_frames)
return dict(
prompt=req.prompt,
size=size,
generation_time=generation_time,
peak_memory_mb=output_batch.peak_memory_mb,
metrics=metrics.to_dict() if metrics else {},
action=output_batch.action_pred,
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.
"""
return sync_scheduler_client.forward(batch)
# LoRA
def _send_lora_request(self, req: Any, success_msg: str, failure_msg: str):
response = sync_scheduler_client.forward(req)
if response.error is None:
logger.info(success_msg)
return response
else:
error_msg = response.error
raise RuntimeError(f"{failure_msg}: {error_msg}")
def set_lora(
self,
lora_nickname: Union[str, List[str]],
lora_path: Union[str, None, List[Union[str, None]]] = None,
target: Union[str, List[str]] = "all",
strength: Union[float, List[float]] = 1.0,
merge_mode: str | None = None,
) -> None:
"""
Set LoRA adapter(s) for the specified transformer(s).
Supports both single LoRA (backward compatible) and multiple LoRA adapters.
Args:
lora_nickname: The nickname(s) of the adapter(s). Can be a string or a list of strings.
lora_path: Path(s) to the LoRA adapter(s). Can be a string, None, or a list of strings/None.
target: Which transformer(s) to apply the LoRA to. Can be a string or a list of strings.
Valid values:
- "all": Apply to all transformers (default)
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
- "critic": Apply only to the critic model
strength: LoRA strength(s) for merge, default 1.0. Can be a float or a list of floats.
merge_mode: Optional LoRA merge mode: "auto", "merge", or "dynamic".
"""
req = SetLoraReq(
lora_nickname=lora_nickname,
lora_path=lora_path,
target=target,
strength=strength,
merge_mode=merge_mode,
)
nickname_str, target_str, strength_str = format_lora_message(
lora_nickname, target, strength
)
self._send_lora_request(
req,
f"Successfully set LoRA adapter(s): {nickname_str} (target: {target_str}, strength: {strength_str})",
"Failed to set LoRA adapter",
)
def unmerge_lora_weights(self, target: str = "all") -> None:
"""
Unmerge LoRA weights from the base model.
Args:
target: Which transformer(s) to unmerge.
"""
req = UnmergeLoraWeightsReq(target=target)
self._send_lora_request(
req,
f"Successfully unmerged LoRA weights (target: {target})",
"Failed to unmerge LoRA weights",
)
def merge_lora_weights(self, target: str = "all", strength: float = 1.0) -> None:
"""
Merge LoRA weights into the base model.
Args:
target: Which transformer(s) to merge.
strength: LoRA strength for merge, default 1.0.
"""
req = MergeLoraWeightsReq(target=target, strength=strength)
self._send_lora_request(
req,
f"Successfully merged LoRA weights (target: {target}, strength: {strength})",
"Failed to merge LoRA weights",
)
def list_loras(self) -> dict:
"""List loaded LoRA adapters and current application status per module."""
output = self._send_lora_request(
req=ListLorasReq(),
success_msg="Successfully listed LoRA adapters",
failure_msg="Failed to list LoRA adapters",
)
# _send_lora_request already raises on error, so output.error is always None here
return output.output or {}
def _ensure_lora_state(
self,
lora_path: str | None,
lora_nickname: str | None = None,
merge_lora: bool = True,
) -> None:
"""
Ensure the LoRA state matches the desired configuration.
Note: This method does not cache client-side state. The server handles
idempotent operations, so redundant calls are safe but may have minor overhead.
"""
if lora_path is None:
# Unmerge all LoRA weights when no lora_path is provided
self.unmerge_lora_weights()
return
lora_nickname = lora_nickname or self.server_args.lora_nickname
# Set the LoRA adapter (server handles idempotent logic)
self.set_lora(lora_nickname, lora_path)
# Merge or unmerge based on the merge_lora flag
if merge_lora:
self.merge_lora_weights()
else:
self.unmerge_lora_weights()
def generate_with_lora(
self,
prompt: str | list[str] | None = None,
sampling_params: SamplingParams | None = None,
*,
lora_path: str | None = None,
lora_nickname: str | None = None,
merge_lora: bool = True,
**kwargs,
):
self._ensure_lora_state(
lora_path=lora_path, lora_nickname=lora_nickname, merge_lora=merge_lora
)
return self.generate(
sampling_params_kwargs=dict(
prompt=prompt,
sampling_params=sampling_params,
**kwargs,
)
)
def shutdown(self):
"""
Shutdown the generator.
If in local mode, it also shuts down the scheduler server.
"""
# sends the shutdown command to the server
if self.local_scheduler_process and self.owns_scheduler_client:
try:
sync_scheduler_client.forward(ShutdownReq(), timeout_ms=5000)
except Exception:
pass
if self.local_scheduler_process:
for process in self.local_scheduler_process:
process.join(timeout=10)
if process.is_alive():
logger.warning(
f"Local worker {process.name} did not terminate gracefully, forcing."
)
process.terminate()
process.join(timeout=1)
if process.is_alive():
process.kill()
process.join(timeout=1)
self.local_scheduler_process = None
if self.owns_scheduler_client:
sync_scheduler_client.close()
self.owns_scheduler_client = False
def _force_shutdown_local_processes(self) -> None:
local_scheduler_process = getattr(self, "local_scheduler_process", None)
log = globals().get("logger")
if local_scheduler_process:
for process in local_scheduler_process:
if process.is_alive():
if log is not None:
log.warning(
f"Local worker {process.name} did not terminate gracefully, forcing."
)
process.terminate()
for process in local_scheduler_process:
process.join(timeout=1)
if process.is_alive():
if log is not None:
log.warning(
f"Local worker {process.name} did not terminate after terminate(), killing."
)
process.kill()
process.join(timeout=1)
self.local_scheduler_process = None
if getattr(self, "owns_scheduler_client", False):
try:
client = globals().get("sync_scheduler_client")
if client is not None:
client.close()
finally:
self.owns_scheduler_client = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.shutdown()
def __del__(self):
owns_scheduler_client = bool(getattr(self, "owns_scheduler_client", False))
local_scheduler_process = getattr(self, "local_scheduler_process", None)
log = globals().get("logger")
if owns_scheduler_client:
if log is not None:
log.warning(
"Generator was garbage collected without being shut down. "
"Forcing local server and client cleanup."
)
self._force_shutdown_local_processes()
elif local_scheduler_process:
if log is not None:
log.warning(
"Generator was garbage collected without being shut down. "
"Forcing local server cleanup."
)
self._force_shutdown_local_processes()
@@ -0,0 +1,413 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import base64
import os
import signal
import uuid
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
import httpx
import torch
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.openai import image_api, video_api
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
VertexGenerateReqInput,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime import (
realtime_video_api,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import build_sampling_params
from sglang.multimodal_gen.runtime.entrypoints.post_training import (
rollout_api,
weights_api,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request,
save_outputs,
)
from sglang.multimodal_gen.runtime.entrypoints.vla import api as vla_api
from sglang.multimodal_gen.runtime.entrypoints.vla import openpi
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs, get_global_server_args
from sglang.multimodal_gen.runtime.server_warmup import (
run_async_client_warmup,
should_run_synthetic_server_warmup,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.utils.json_response import orjson_response
from sglang.version import __version__
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
logger = init_logger(__name__)
VERTEX_ROUTE = os.environ.get("AIP_PREDICT_ROUTE", "/vertex_generate")
SERVER_WARMUP_BYPASS_PATHS = (
"/health",
"/health_generate",
"/model_info",
"/server_info",
)
async def _wait_until_http_ready(server_args: ServerArgs) -> None:
"""for server warmup"""
health_url = f"{server_args.url()}/health"
# Probe the local server directly: a loopback readiness check must never be
# routed through an HTTP proxy. trust_env=False also avoids crashing startup
# on a malformed proxy env var, since httpx parses *_PROXY/NO_PROXY when the
# client is constructed (raising httpx.InvalidURL before any request). See #28493.
async with httpx.AsyncClient(trust_env=False) as client:
for _ in range(120):
try:
response = await client.get(health_url, timeout=5.0)
if response.status_code == 200:
return
except httpx.HTTPError:
pass
await asyncio.sleep(1.0)
raise RuntimeError(f"HTTP server did not become ready at {health_url}")
async def _run_server_warmup_after_http_ready(
server_args: ServerArgs, warmup_done: asyncio.Event
) -> None:
try:
if not should_run_synthetic_server_warmup(server_args):
warmup_done.set()
return
await _wait_until_http_ready(server_args)
await run_async_client_warmup(
server_args,
async_scheduler_client.forward,
fail_open=server_args.warmup_resolutions is None,
)
logger.info("The server is fired up and ready to roll!")
warmup_done.set()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("Server warmup failed; aborting startup: %s", e, exc_info=True)
os.kill(os.getpid(), signal.SIGTERM)
@asynccontextmanager
async def lifespan(app: FastAPI):
from sglang.multimodal_gen.runtime.scheduler_client import (
async_scheduler_client,
run_zeromq_broker,
)
# 1. Initialize the singleton client that connects to the backend Scheduler
server_args = app.state.server_args
async_scheduler_client.initialize(server_args)
warmup_done = asyncio.Event()
app.state.server_warmup_done = warmup_done
# 2. Start the ZMQ Broker in the background to handle offline requests
broker_task = asyncio.create_task(run_zeromq_broker(server_args))
warmup_task = None
if server_args.server_warmup:
warmup_task = asyncio.create_task(
_run_server_warmup_after_http_ready(server_args, warmup_done)
)
else:
warmup_done.set()
try:
yield
finally:
if warmup_task is not None and not warmup_task.done():
warmup_task.cancel()
with suppress(asyncio.CancelledError):
await warmup_task
# On shutdown
logger.info("FastAPI app is shutting down...")
broker_task.cancel()
async_scheduler_client.close()
# Health router
health_router = APIRouter()
@health_router.get("/health")
async def health():
return {"status": "ok"}
@health_router.get("/models", deprecated=True)
async def get_models(request: Request):
"""
Get information about the model served by this server.
.. deprecated::
Use /v1/models instead for OpenAI-compatible model discovery.
This endpoint will be removed in a future version.
"""
from sglang.multimodal_gen.registry import get_model_info
server_args: ServerArgs = request.app.state.server_args
model_info = get_model_info(server_args.model_path, model_id=server_args.model_id)
response = {
"model_path": server_args.model_path,
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
response["pipeline_name"] = model_info.pipeline_cls.pipeline_name
response["pipeline_class"] = model_info.pipeline_cls.__name__
return response
@health_router.get("/server_info")
async def server_info_endpoint(request: Request):
"""Get server information.
Returns fields compatible with the LLM engine's /server_info so that
the model gateway can discover diffusion workers.
"""
server_args: ServerArgs = request.app.state.server_args
return {
"model_path": server_args.model_path,
"served_model_name": server_args.model_id or server_args.model_path,
"tp_size": server_args.tp_size,
"dp_size": server_args.dp_size,
"version": __version__,
}
@health_router.get("/model_info")
async def model_info_endpoint(request: Request):
"""Get model information.
Returns fields compatible with the LLM engine's /model_info so that
the model gateway can detect capabilities for diffusion workers.
"""
from sglang.multimodal_gen.registry import get_model_info
server_args: ServerArgs = request.app.state.server_args
task_type = server_args.pipeline_config.task_type
try:
registry_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
except Exception:
logger.warning("Failed to resolve model info from registry", exc_info=True)
registry_info = None
return {
# Fields consumed by the model gateway for worker discovery
"model_path": server_args.model_path,
"is_generation": True,
"model_type": "diffusion",
"architectures": (
[registry_info.pipeline_cls.__name__] if registry_info else None
),
# Fields matching the LLM engine's /model_info shape
"has_image_understanding": task_type.accepts_image_input(),
"has_audio_understanding": False,
# Diffusion-specific fields
"task_type": task_type.name,
"is_image_gen": task_type.is_image_gen(),
}
@health_router.get("/health_generate")
async def health_generate():
# TODO : health generate endpoint
return {"status": "ok"}
@health_router.get("/stats")
async def stats_endpoint(request: Request):
"""Get runtime statistics including disagg pipeline metrics.
Returns queue depth, request counts, latency, throughput, etc.
Sends a GetDisaggStatsReq to the scheduler via ZMQ and returns the result.
"""
from sglang.multimodal_gen.runtime.entrypoints.utils import GetDisaggStatsReq
server_args: ServerArgs = request.app.state.server_args
response: dict = {
"status": "ok",
"model_path": server_args.model_path,
}
# Query the scheduler for disagg metrics
try:
stats_response = await async_scheduler_client.forward(GetDisaggStatsReq())
if hasattr(stats_response, "output") and stats_response.output is not None:
response["disagg"] = stats_response.output
except Exception as e:
response["disagg"] = {"error": str(e)}
return response
def make_serializable(obj):
"""Recursively converts Tensors to None for JSON serialization."""
if isinstance(obj, torch.Tensor):
return None
if isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
if isinstance(obj, list):
return [make_serializable(v) for v in obj]
return obj
def encode_video_to_base64(file_path: str):
if not os.path.exists(file_path):
return None
with open(file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
async def forward_to_scheduler(
req_obj: "Req",
sp: SamplingParams,
):
"""Forwards request to scheduler and processes the result."""
try:
response = await async_scheduler_client.forward(req_obj)
if response.output is None and response.output_file_paths is None:
raise RuntimeError("Model generation returned no output.")
if response.output_file_paths:
output_file_path = response.output_file_paths[0]
else:
output_file_path = sp.output_file_path()
save_outputs(
[response.output[0]],
sp.data_type,
sp.fps,
True,
lambda _idx: output_file_path,
audio=response.audio,
audio_sample_rate=response.audio_sample_rate,
enable_frame_interpolation=sp.enable_frame_interpolation,
frame_interpolation_exp=sp.frame_interpolation_exp,
frame_interpolation_scale=sp.frame_interpolation_scale,
frame_interpolation_model_path=sp.frame_interpolation_model_path,
enable_upscaling=sp.enable_upscaling,
upscaling_model_path=sp.upscaling_model_path,
upscaling_scale=sp.upscaling_scale,
)
if hasattr(response, "model_dump"):
data = response.model_dump()
else:
data = response if isinstance(response, dict) else vars(response)
if output_file_path:
logger.info("Processing output file: %s", output_file_path)
b64_video = encode_video_to_base64(output_file_path)
if b64_video:
data["output"] = b64_video
data.pop("video_data", None)
data.pop("video_tensor", None)
return make_serializable(data)
except Exception as e:
logger.error("Error during generation: %s", e, exc_info=True)
return {"error": str(e)}
vertex_router = APIRouter()
@vertex_router.post(VERTEX_ROUTE)
async def vertex_generate(vertex_req: VertexGenerateReqInput):
if not vertex_req.instances:
return orjson_response({"predictions": []})
server_args = get_global_server_args()
params = vertex_req.parameters or {}
futures = []
for inst in vertex_req.instances:
rid = f"vertex_{uuid.uuid4()}"
sp = build_sampling_params(
rid,
prompt=inst.get("prompt") or inst.get("text"),
image_path=inst.get("image") or inst.get("image_url"),
num_frames=params.get("num_frames"),
fps=params.get("fps"),
width=params.get("width"),
height=params.get("height"),
guidance_scale=params.get("guidance_scale"),
save_output=params.get("save_output"),
)
backend_req = prepare_request(server_args, sampling_params=sp)
futures.append(forward_to_scheduler(backend_req, sp))
results = await asyncio.gather(*futures)
return orjson_response({"predictions": results})
def create_app(server_args: ServerArgs):
"""
Create and configure the FastAPI application instance.
"""
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def wait_for_server_warmup(request: Request, call_next):
warmup_done = getattr(request.app.state, "server_warmup_done", None)
if (
warmup_done is not None
and not warmup_done.is_set()
and request.url.path not in SERVER_WARMUP_BYPASS_PATHS
):
await warmup_done.wait()
return await call_next(request)
app.include_router(health_router)
app.include_router(vertex_router)
from sglang.multimodal_gen.runtime.entrypoints.openai import common_api, mesh_api
app.include_router(common_api.router)
app.include_router(image_api.router)
app.include_router(video_api.router)
app.include_router(realtime_video_api.router)
if server_args.pipeline_config.task_type.is_action_gen():
app.include_router(vla_api.router)
app.include_router(openpi.router)
app.include_router(mesh_api.router)
app.include_router(weights_api.router)
app.include_router(rollout_api.router)
app.state.server_args = server_args
return app
@@ -0,0 +1,252 @@
import time
from typing import Any, List, Optional, Union
from fastapi import APIRouter, Body, HTTPException
from pydantic import BaseModel, Field
from sglang.multimodal_gen.registry import get_model_info
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
UnmergeLoraWeightsReq,
format_lora_message,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.utils.json_response import orjson_response
router = APIRouter(prefix="/v1")
logger = init_logger(__name__)
class ModelCard(BaseModel):
"""Model cards."""
id: str
object: str = "model"
created: int = Field(default_factory=lambda: int(time.time()))
owned_by: str = "sglang"
root: Optional[str] = None
parent: Optional[str] = None
max_model_len: Optional[int] = None
class DiffusionModelCard(ModelCard):
"""Extended ModelCard with diffusion-specific fields."""
num_gpus: Optional[int] = None
task_type: Optional[str] = None
dit_precision: Optional[str] = None
vae_precision: Optional[str] = None
pipeline_name: Optional[str] = None
pipeline_class: Optional[str] = None
async def _handle_lora_request(req: Any, success_msg: str, failure_msg: str):
try:
output: OutputBatch = await async_scheduler_client.forward(req)
if output.error is None:
return {"status": "ok", "message": success_msg}
else:
error_msg = output.error
raise HTTPException(status_code=500, detail=f"{failure_msg}: {error_msg}")
except Exception as e:
if isinstance(e, HTTPException):
raise
logger.error(f"Error during '{failure_msg}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/set_lora")
async def set_lora(
lora_nickname: Union[str, List[str]] = Body(..., embed=True),
lora_path: Optional[Union[str, List[Optional[str]]]] = Body(None, embed=True),
target: Union[str, List[str]] = Body("all", embed=True),
strength: Union[float, List[float]] = Body(1.0, embed=True),
merge_mode: Optional[str] = Body(None, embed=True),
):
"""
Set LoRA adapter(s) for the specified transformer(s).
Supports both single LoRA (backward compatible) and multiple LoRA adapters.
Args:
lora_nickname: The nickname(s) of the adapter(s). Can be a string or a list of strings.
lora_path: Path(s) to the LoRA adapter(s) (local path or HF repo id).
Can be a string, None, or a list of strings/None. Must match the length of lora_nickname.
target: Which transformer(s) to apply the LoRA to. Can be a string or a list of strings.
If a list, must match the length of lora_nickname. Valid values:
- "all": Apply to all transformers (default)
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
- "critic": Apply only to the critic model
strength: LoRA strength(s) for merge, default 1.0. Can be a float or a list of floats.
If a list, must match the length of lora_nickname. Values < 1.0 reduce the effect,
values > 1.0 amplify the effect.
merge_mode: Optional LoRA merge mode: "auto", "merge", or "dynamic".
"""
req = SetLoraReq(
lora_nickname=lora_nickname,
lora_path=lora_path,
target=target,
strength=strength,
merge_mode=merge_mode,
)
nickname_str, target_str, strength_str = format_lora_message(
lora_nickname, target, strength
)
return await _handle_lora_request(
req,
f"Successfully set LoRA adapter(s): {nickname_str} (target: {target_str}, strength: {strength_str})",
"Failed to set LoRA adapter",
)
@router.post("/merge_lora_weights")
async def merge_lora_weights(
target: str = Body("all", embed=True),
strength: float = Body(1.0, embed=True),
):
"""
Merge LoRA weights into the base model.
Args:
target: Which transformer(s) to merge. One of "all", "transformer",
"transformer_2", "critic".
strength: LoRA strength for merge, default 1.0. Values < 1.0 reduce the effect,
values > 1.0 amplify the effect.
"""
req = MergeLoraWeightsReq(target=target, strength=strength)
return await _handle_lora_request(
req,
f"Successfully merged LoRA weights (target: {target}, strength: {strength})",
"Failed to merge LoRA weights",
)
@router.post("/unmerge_lora_weights")
async def unmerge_lora_weights(
target: str = Body("all", embed=True),
):
"""
Unmerge LoRA weights from the base model.
Args:
target: Which transformer(s) to unmerge. One of "all", "transformer",
"transformer_2", "critic".
"""
req = UnmergeLoraWeightsReq(target=target)
return await _handle_lora_request(
req,
f"Successfully unmerged LoRA weights (target: {target})",
"Failed to unmerge LoRA weights",
)
@router.get("/model_info")
async def model_info():
"""Get the model information."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
result = {
"model_path": server_args.model_path,
}
return result
@router.get("/list_loras")
async def list_loras():
"""List loaded LoRA adapters and current application status per module."""
try:
req = ListLorasReq()
output: OutputBatch = await async_scheduler_client.forward(req)
if output.error is None:
return output.output or {}
else:
raise HTTPException(status_code=500, detail=output.error)
except Exception as e:
if isinstance(e, HTTPException):
raise
logger.error(f"Error during 'list_loras': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/models")
async def available_models():
"""Show available models. OpenAI-compatible endpoint with extended diffusion info."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
card_kwargs = {
"id": server_args.model_path,
"root": server_args.model_path,
# Extended diffusion-specific fields
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name
card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__
model_card = DiffusionModelCard(**card_kwargs)
# Return dict directly to preserve extended fields (ModelList strips them)
return {"object": "list", "data": [model_card.model_dump()]}
@router.get("/models/{model:path}")
async def retrieve_model(model: str):
"""Retrieve a model instance. OpenAI-compatible endpoint with extended diffusion info."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
if model != server_args.model_path:
return orjson_response(
{
"error": {
"message": f"The model '{model}' does not exist",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_found",
}
},
status_code=404,
)
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
card_kwargs = {
"id": model,
"root": model,
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name
card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__
# Return dict to preserve extended fields
return DiffusionModelCard(**card_kwargs).model_dump()
@@ -0,0 +1,447 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import base64
import contextlib
import json
import os
import time
from typing import Any, List, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import generate_request_id
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
ImageGenerationsRequest,
ImageResponse,
ImageResponseData,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import IMAGE_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
add_common_data_to_response,
build_sampling_params,
choose_output_image_ext,
flatten_extra_params,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
temp_dir_if_disabled,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.srt.observability.trace import extract_trace_headers
router = APIRouter(prefix="/v1/images", tags=["images"])
def _get_extra_field(request, field_name):
"""Get a field from model_extra, with fallback to nested extra_body dict."""
extra = request.model_extra or {}
value = extra.get(field_name)
if value is not None:
return value
if field_name == "use_guardrails" and extra.get("guardrails") is not None:
return extra["guardrails"]
for container_name in ("extra_body", "extra_json", "extra_args", "extra_params"):
value = _parse_extra_container(extra.get(container_name)).get(field_name)
if value is not None:
return value
return value
def _parse_extra_container(value: Any) -> dict[str, Any]:
if isinstance(value, str):
try:
value = json.loads(value)
except Exception:
return {}
if isinstance(value, dict):
return flatten_extra_params(dict(value))
return {}
def _read_b64_for_paths(paths: list[str]) -> list[str]:
"""Read and base64-encode each file. Must be called before cloud upload deletes them."""
result = []
for path in paths:
with open(path, "rb") as f:
result.append(base64.b64encode(f.read()).decode("utf-8"))
return result
def _build_image_response_kwargs(
save_file_path_list: list[str],
resp_format: str,
prompt: str,
request_id: str,
result: OutputBatch,
*,
b64_list: list[str] | None = None,
cloud_url: str | None = None,
fallback_url: str | None = None,
is_persistent: bool = True,
) -> dict:
"""Build ImageResponse data list.
For b64_json: uses pre-read b64_list (call _read_b64_for_paths first).
For url: uses cloud_url or fallback_url.
file_path is omitted when is_persistent=False to avoid exposing stale temp paths.
"""
ret = None
if resp_format == "b64_json":
if not b64_list:
raise ValueError("b64_list required for b64_json response_format")
data = [
ImageResponseData(
b64_json=b64,
revised_prompt=prompt,
file_path=os.path.abspath(path) if is_persistent else None,
)
for b64, path in zip(b64_list, save_file_path_list)
]
ret = {"data": data}
elif resp_format == "url":
url = cloud_url or fallback_url
if not url:
raise HTTPException(
status_code=400,
detail="response_format='url' requires cloud storage to be configured.",
)
ret = {
"data": [
ImageResponseData(
url=url,
revised_prompt=prompt,
file_path=(
os.path.abspath(save_file_path_list[0])
if is_persistent
else None
),
)
],
}
else:
raise HTTPException(
status_code=400, detail=f"response_format={resp_format} is not supported"
)
ret = add_common_data_to_response(ret, request_id=request_id, result=result)
return ret
@router.post("/generations", response_model=ImageResponse)
async def generations(
request: ImageGenerationsRequest,
raw_request: Request,
):
request_id = generate_request_id()
server_args = get_global_server_args()
is_cosmos3 = "cosmos3" in (server_args.model_path or "").lower()
ext = (
"png"
if is_cosmos3 and request.output_format is None
else choose_output_image_ext(request.output_format, request.background)
)
with temp_dir_if_disabled(server_args.output_path) as output_dir:
sampling = build_sampling_params(
request_id,
prompt=request.prompt,
size=request.size,
width=request.width,
height=request.height,
num_outputs_per_prompt=max(1, min(int(request.n or 1), 10)),
output_file_name=f"{request_id}.{ext}",
output_path=output_dir,
num_frames=1,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps,
guidance_scale=request.guidance_scale,
true_cfg_scale=request.true_cfg_scale,
negative_prompt=request.negative_prompt,
max_sequence_length=(
request.max_sequence_length
if request.max_sequence_length is not None
else _get_extra_field(request, "max_sequence_length")
),
flow_shift=(
request.flow_shift
if request.flow_shift is not None
else _get_extra_field(request, "flow_shift")
),
use_duration_template=_get_extra_field(request, "use_duration_template"),
use_resolution_template=_get_extra_field(
request, "use_resolution_template"
),
use_system_prompt=_get_extra_field(request, "use_system_prompt"),
use_guardrails=_get_extra_field(request, "use_guardrails"),
enable_teacache=request.enable_teacache,
output_compression=request.output_compression,
output_quality=request.output_quality,
diffusers_kwargs=request.diffusers_kwargs,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
perf_dump_path=request.perf_dump_path,
use_pe=_get_extra_field(request, "use_pe"),
preset=_get_extra_field(request, "preset"),
progressive_mode=(
request.progressive_mode
if request.progressive_mode is not None
else _get_extra_field(request, "progressive_mode")
),
progressive_levels=(
request.progressive_levels
if request.progressive_levels is not None
else _get_extra_field(request, "progressive_levels")
),
progressive_delta=(
request.progressive_delta
if request.progressive_delta is not None
else _get_extra_field(request, "progressive_delta")
),
)
trace_headers = extract_trace_headers(raw_request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling,
external_trace_header=trace_headers,
)
# Add diffusers_kwargs if provided
if request.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = request.diffusers_kwargs
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
resp_format = (request.response_format or "b64_json").lower()
if (
is_cosmos3
and "response_format" not in request.model_fields_set
and request.response_format == "url"
):
resp_format = "b64_json"
# read b64 before cloud upload may delete the local file
b64_list = (
_read_b64_for_paths(save_file_path_list)
if resp_format == "b64_json"
else None
)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
is_persistent = server_args.output_path is not None
await IMAGE_STORE.upsert(
request_id,
{
"id": request_id,
"created_at": int(time.time()),
"file_path": None if cloud_url or not is_persistent else save_file_path,
"url": cloud_url,
},
)
response_kwargs = _build_image_response_kwargs(
save_file_path_list,
resp_format,
request.prompt,
request_id,
result,
b64_list=b64_list,
cloud_url=cloud_url,
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
is_persistent=is_persistent,
)
return ImageResponse(**response_kwargs)
@router.post("/edits", response_model=ImageResponse)
async def edits(
raw_request: Request,
image: Optional[List[UploadFile]] = File(None),
image_array: Optional[List[UploadFile]] = File(None, alias="image[]"),
url: Optional[List[str]] = Form(None),
url_array: Optional[List[str]] = Form(None, alias="url[]"),
prompt: str = Form(...),
mask: Optional[UploadFile] = File(None),
model: Optional[str] = Form(None),
n: Optional[int] = Form(1),
response_format: Optional[str] = Form(None),
size: Optional[str] = Form(None),
output_format: Optional[str] = Form(None),
background: Optional[str] = Form("auto"),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
user: Optional[str] = Form(None),
negative_prompt: Optional[str] = Form(None),
guidance_scale: Optional[float] = Form(None),
true_cfg_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
output_quality: Optional[str] = Form("default"),
output_compression: Optional[int] = Form(None),
enable_teacache: Optional[bool] = Form(False),
enable_upscaling: Optional[bool] = Form(False),
upscaling_model_path: Optional[str] = Form(None),
upscaling_scale: Optional[int] = Form(4),
num_frames: int = Form(1),
):
request_id = generate_request_id()
server_args = get_global_server_args()
# Resolve images from either `image` or `image[]` (OpenAI SDK sends `image[]` when list is provided)
images = image or image_array
urls = url or url_array
if (not images or len(images) == 0) and (not urls or len(urls) == 0):
raise HTTPException(
status_code=422, detail="Field 'image' or 'url' is required"
)
image_list = merge_image_input_list(images, urls)
with contextlib.ExitStack() as stack:
uploads_dir = stack.enter_context(
temp_dir_if_disabled(server_args.input_save_path)
)
output_dir = stack.enter_context(temp_dir_if_disabled(server_args.output_path))
input_paths = []
try:
for idx, img in enumerate(image_list):
filename = img.filename if hasattr(img, "filename") else f"image_{idx}"
input_path = await save_image_to_path(
img,
os.path.join(uploads_dir, f"{request_id}_{idx}_{filename}"),
prefer_remote_source=server_args.input_save_path is None,
)
input_paths.append(input_path)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to process image source: {str(e)}",
)
ext = choose_output_image_ext(output_format, background)
sampling = build_sampling_params(
request_id,
prompt=prompt,
size=size,
num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
output_file_name=f"{request_id}.{ext}",
output_path=output_dir,
image_path=input_paths,
seed=seed,
generator_device=generator_device,
negative_prompt=negative_prompt,
guidance_scale=guidance_scale,
true_cfg_scale=true_cfg_scale,
num_inference_steps=num_inference_steps,
enable_teacache=enable_teacache,
num_frames=num_frames,
output_compression=output_compression,
output_quality=output_quality,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
trace_headers = extract_trace_headers(raw_request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling,
external_trace_header=trace_headers,
)
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
resp_format = (response_format or "b64_json").lower()
# read b64 before cloud upload may delete the local file
b64_list = (
_read_b64_for_paths(save_file_path_list)
if resp_format == "b64_json"
else None
)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
is_persistent = server_args.output_path is not None
is_input_persistent = server_args.input_save_path is not None
await IMAGE_STORE.upsert(
request_id,
{
"id": request_id,
"created_at": int(time.time()),
"file_path": None if cloud_url or not is_persistent else save_file_path,
"url": cloud_url,
"input_image_paths": input_paths if is_input_persistent else None,
"num_input_images": len(input_paths),
},
)
response_kwargs = _build_image_response_kwargs(
save_file_path_list,
resp_format,
prompt,
request_id,
result,
b64_list=b64_list,
cloud_url=cloud_url,
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
is_persistent=is_persistent,
)
return ImageResponse(**response_kwargs)
@router.get("/{image_id}/content")
async def download_image_content(
image_id: str = Path(...), variant: Optional[str] = Query(None)
):
item = await IMAGE_STORE.get(image_id)
if not item:
raise HTTPException(status_code=404, detail="Image not found")
if item.get("url"):
raise HTTPException(
status_code=400,
detail=f"Image has been uploaded to cloud storage. Please use the cloud URL: {item.get('url')}",
)
file_path = item.get("file_path")
if not file_path:
raise HTTPException(
status_code=404,
detail="Image was not persisted on disk (output_path is disabled). Use b64_json response_format or configure cloud storage.",
)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Image is still being generated")
ext = os.path.splitext(file_path)[1].lower()
media_type = "image/jpeg"
if ext == ".png":
media_type = "image/png"
elif ext == ".webp":
media_type = "image/webp"
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,296 @@
import asyncio
import os
import time
from typing import Any, Dict, List, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
MeshGenerationsRequest,
MeshListResponse,
MeshResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import MESH_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
add_common_data_to_response,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/meshes", tags=["meshes"])
def _normalize_format(fmt: Optional[str]) -> str:
fmt = (fmt or "glb").lower()
return fmt if fmt in ("glb", "obj") else "glb"
def _build_sampling_params_from_request(
request_id: str, req: MeshGenerationsRequest, image_path: Optional[str] = None
) -> SamplingParams:
ext = _normalize_format(req.output_format)
server_args = get_global_server_args()
sampling_kwargs: Dict[str, Any] = {
"request_id": request_id,
"prompt": req.prompt,
"num_frames": 1,
"image_path": [image_path] if image_path else None,
"save_output": True,
"output_file_name": f"{request_id}.{ext}",
"seed": req.seed,
"generator_device": req.generator_device,
}
if req.num_inference_steps is not None:
sampling_kwargs["num_inference_steps"] = req.num_inference_steps
if req.guidance_scale is not None:
sampling_kwargs["guidance_scale"] = req.guidance_scale
if req.negative_prompt is not None:
sampling_kwargs["negative_prompt"] = req.negative_prompt
return SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
**sampling_kwargs,
)
def _mesh_job_from_sampling(
request_id: str, req: MeshGenerationsRequest, sampling: SamplingParams
) -> Dict[str, Any]:
return {
"id": request_id,
"object": "mesh",
"model": req.model or "",
"status": "queued",
"progress": 0,
"created_at": int(time.time()),
"format": _normalize_format(req.output_format),
"file_path": os.path.abspath(sampling.output_file_path()),
}
async def _dispatch_job_async(job_id: str, batch: Req) -> None:
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
try:
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
file_size = None
if os.path.exists(save_file_path):
file_size = os.path.getsize(save_file_path)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
update_fields: Dict[str, Any] = {
"status": "completed",
"progress": 100,
"completed_at": int(time.time()),
"url": cloud_url,
"file_path": save_file_path if not cloud_url else None,
"file_size_bytes": file_size,
}
update_fields = add_common_data_to_response(
update_fields, request_id=job_id, result=result
)
await MESH_STORE.update_fields(job_id, update_fields)
except Exception as e:
logger.error(f"{e}")
await MESH_STORE.update_fields(
job_id, {"status": "failed", "error": {"message": str(e)}}
)
@router.post("", response_model=MeshResponse)
async def create_mesh(
request: Request,
image: Optional[List[UploadFile]] = File(None),
image_array: Optional[List[UploadFile]] = File(None, alias="image[]"),
url: Optional[List[str]] = Form(None),
url_array: Optional[List[str]] = Form(None, alias="url[]"),
prompt: Optional[str] = Form("generate 3d mesh"),
model: Optional[str] = Form(None),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
guidance_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
negative_prompt: Optional[str] = Form(None),
output_format: Optional[str] = Form("glb"),
):
content_type = request.headers.get("content-type", "").lower()
request_id = generate_request_id()
server_args = get_global_server_args()
input_path = None
if "multipart/form-data" in content_type:
images = image or image_array
urls = url or url_array
image_list = merge_image_input_list(images, urls)
if not image_list:
raise HTTPException(
status_code=422,
detail="Field 'image' or 'url' is required for mesh generation",
)
uploads_dir = os.path.join("outputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
img = image_list[0]
filename = img.filename if hasattr(img, "filename") else "input_image"
try:
input_path = await save_image_to_path(
img, os.path.join(uploads_dir, f"{request_id}_{filename}")
)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to process image source: {str(e)}"
)
req = MeshGenerationsRequest(
prompt=prompt or "generate 3d mesh",
model=model,
seed=seed,
generator_device=generator_device,
num_inference_steps=num_inference_steps,
negative_prompt=negative_prompt,
output_format=output_format,
**(
{"guidance_scale": guidance_scale} if guidance_scale is not None else {}
),
)
else:
try:
body = await request.json()
except Exception:
body = {}
try:
payload: Dict[str, Any] = dict(body or {})
if payload.get("input_image"):
img_src = payload.pop("input_image")
uploads_dir = os.path.join("outputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
input_path = await save_image_to_path(
img_src,
os.path.join(uploads_dir, f"{request_id}_input_image"),
)
req = MeshGenerationsRequest(**payload)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
if not input_path:
raise HTTPException(
status_code=422,
detail="An input image is required for mesh generation",
)
sampling_params = _build_sampling_params_from_request(request_id, req, input_path)
job = _mesh_job_from_sampling(request_id, req, sampling_params)
await MESH_STORE.upsert(request_id, job)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
)
asyncio.create_task(_dispatch_job_async(request_id, batch))
return MeshResponse(**job)
@router.get("", response_model=MeshListResponse)
async def list_meshes(
after: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=100),
order: Optional[str] = Query("desc"),
):
order = (order or "desc").lower()
if order not in ("asc", "desc"):
order = "desc"
jobs = await MESH_STORE.list_values()
reverse = order != "asc"
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=reverse)
if after is not None:
try:
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
jobs = jobs[idx + 1 :]
except StopIteration:
jobs = []
if limit is not None:
jobs = jobs[:limit]
items = [MeshResponse(**j) for j in jobs]
return MeshListResponse(data=items)
@router.get("/{mesh_id}", response_model=MeshResponse)
async def retrieve_mesh(mesh_id: str = Path(...)):
job = await MESH_STORE.get(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
return MeshResponse(**job)
@router.delete("/{mesh_id}", response_model=MeshResponse)
async def delete_mesh(mesh_id: str = Path(...)):
job = await MESH_STORE.pop(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
job["status"] = "deleted"
return MeshResponse(**job)
@router.get("/{mesh_id}/content")
async def download_mesh_content(
mesh_id: str = Path(...), variant: Optional[str] = Query(None)
):
job = await MESH_STORE.get(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
if job.get("url"):
raise HTTPException(
status_code=400,
detail=f"Mesh has been uploaded to cloud storage. Please use the cloud URL: {job.get('url')}",
)
file_path = job.get("file_path")
if not file_path or not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Generation is still in-progress")
ext = os.path.splitext(file_path)[1].lower()
media_type = {
".glb": "model/gltf-binary",
".obj": "text/plain",
}.get(ext, "application/octet-stream")
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,227 @@
import time
import uuid
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
# Image API protocol models
class ImageResponseData(BaseModel):
b64_json: Optional[str] = None
url: Optional[str] = None
revised_prompt: Optional[str] = None
file_path: Optional[str] = None
class ImageResponse(BaseModel):
id: str
created: int = Field(default_factory=lambda: int(time.time()))
data: List[ImageResponseData]
peak_memory_mb: Optional[float] = None
inference_time_s: Optional[float] = None
class ImageGenerationsRequest(BaseModel):
model_config = ConfigDict(extra="allow")
prompt: str
model: Optional[str] = None
n: Optional[int] = 1
quality: Optional[str] = "auto"
response_format: Optional[str] = "url" # url | b64_json
size: Optional[str] = "1024x1024" # e.g., 1024x1024
style: Optional[str] = "vivid"
background: Optional[str] = "auto" # transparent | opaque | auto
output_format: Optional[str] = None # png | jpeg | webp
user: Optional[str] = None
# SGLang extensions
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
true_cfg_scale: Optional[float] = (
None # for CFG vs guidance distillation (e.g., QwenImage)
)
seed: Optional[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
negative_prompt: Optional[str] = None
output_quality: Optional[str] = "default"
output_compression: Optional[int] = None
enable_teacache: Optional[bool] = False
max_sequence_length: Optional[int] = None
flow_shift: Optional[float] = None
# Upscaling
enable_upscaling: Optional[bool] = False
upscaling_model_path: Optional[str] = None
upscaling_scale: Optional[int] = 4
diffusers_kwargs: Optional[Dict[str, Any]] = None # kwargs for diffusers backend
# Performance profiling
perf_dump_path: Optional[str] = None
# Progressive resolution generation
progressive_mode: Optional[str] = None
progressive_levels: Optional[int] = None
progressive_delta: Optional[float] = None
# Video API protocol models
class VideoResponse(BaseModel):
id: str
object: str = "video"
model: str = "sora-2"
status: str = "queued"
progress: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
size: str = ""
seconds: str = "4"
quality: str = "standard"
url: Optional[str] = None
remixed_from_video_id: Optional[str] = None
completed_at: Optional[int] = None
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
action: Optional[Dict[str, Any]] = None
class VideoGenerationsRequest(BaseModel):
model_config = ConfigDict(extra="allow")
prompt: str
input_reference: Optional[str] = None
reference_url: Optional[str] = None
video_path: Optional[str] = None
video_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[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
# SGLang extensions
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
guidance_scale_2: Optional[float] = None
true_cfg_scale: Optional[float] = (
None # for CFG vs guidance distillation (e.g., QwenImage)
)
negative_prompt: Optional[str] = None
max_sequence_length: Optional[int] = None
flow_shift: Optional[float] = None
enable_teacache: Optional[bool] = False
# Frame interpolation
enable_frame_interpolation: Optional[bool] = False
frame_interpolation_exp: Optional[int] = 1 # 1=2×, 2=4×
frame_interpolation_scale: Optional[float] = 1.0
frame_interpolation_model_path: Optional[str] = None
# Upscaling
enable_upscaling: Optional[bool] = False
upscaling_model_path: Optional[str] = None
upscaling_scale: Optional[int] = 4
output_quality: Optional[str] = "default"
output_compression: Optional[int] = None
output_path: Optional[str] = None
diffusers_kwargs: Optional[Dict[str, Any]] = None # kwargs for diffusers backend
# Performance profiling
perf_dump_path: Optional[str] = None
class VideoListResponse(BaseModel):
data: List[VideoResponse]
object: str = "list"
class VideoRemixRequest(BaseModel):
prompt: str
class RealtimeVideoGenerationsRequest(VideoGenerationsRequest):
type: Literal["init"]
# WebSocket does not support multipart/form-data image uploads
first_frame: Optional[bytes | str] = None
condition_inputs: Optional[Dict[str, Any]] = None
max_chunks: Optional[int] = Field(default=None, ge=1)
seed: Optional[int] = 42
guidance_scale: Optional[float] = 1.0
size: Optional[str] = "832x480"
profile: Optional[bool] = False
num_profiled_timesteps: Optional[int] = None
profile_all_stages: Optional[bool] = False
realtime_output_format: Optional[Literal["raw", "webp", "jpeg"]] = None
realtime_preview_max_width: Optional[int] = None
realtime_output_pacing: Optional[bool] = False
realtime_causal_sink_size: Optional[int] = None
realtime_causal_kv_cache_num_frames: Optional[int] = None
class RealtimeEvent(BaseModel):
type: Literal["event"]
kind: str
payload: Any = None
event_id: Optional[int] = None
# Mesh API protocol models
class MeshResponse(BaseModel):
id: str
object: str = "mesh"
model: str = ""
status: str = "queued"
progress: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
format: str = "glb"
url: Optional[str] = None
completed_at: Optional[int] = None
expires_at: Optional[int] = None
error: Optional[Dict[str, Any]] = None
file_path: Optional[str] = None
file_size_bytes: Optional[int] = None
peak_memory_mb: Optional[float] = None
inference_time_s: Optional[float] = None
class MeshGenerationsRequest(BaseModel):
prompt: str = "generate 3d mesh"
input_image: Optional[str] = None
model: Optional[str] = None
seed: Optional[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
negative_prompt: Optional[str] = None
output_format: Optional[str] = "glb"
class MeshListResponse(BaseModel):
data: List[MeshResponse]
object: str = "list"
@dataclass
class BaseReq(ABC):
rid: Optional[Union[str, List[str]]] = field(default=None, kw_only=True)
http_worker_ipc: Optional[str] = field(default=None, kw_only=True)
def regenerate_rid(self):
"""Generate a new request ID and return it."""
if isinstance(self.rid, list):
self.rid = [uuid.uuid4().hex for _ in range(len(self.rid))]
else:
self.rid = uuid.uuid4().hex
return self.rid
@dataclass
class VertexGenerateReqInput(BaseReq):
instances: List[dict]
parameters: Optional[dict] = None
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,352 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
RealtimeChunkInputs,
build_realtime_sampling_params,
save_realtime_first_frame,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world.constants import (
LINGBOT_CAMERA_ACTIONS_CONDITION,
LINGBOT_PROMPT_UPDATED_CONDITION,
)
from sglang.multimodal_gen.runtime.realtime.control_signals import (
ControlSignalQueue,
ParsedControlEventPayload,
parse_control_event_payload,
)
from sglang.multimodal_gen.runtime.realtime.states import (
RealtimeCameraControlState,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
LINGBOT_REALTIME_DEFAULT_NUM_INFERENCE_STEPS = 4
LINGBOT_REALTIME_MIN_CONDITION_CHUNKS = 2
COMPOSITE_INPUT_EVENT_KIND = "composite_input"
class LingBotWorldRealtimeState(RealtimeCameraControlState):
def __init__(self):
super().__init__(
min_pulse_items=1,
script_maxlen=512,
max_transitions=512,
)
self.prompt_queue = ControlSignalQueue(max_events={"prompt": 1})
def clear(self) -> None:
super().clear()
self.prompt_queue.clear()
def receive_prompt(self, prompt: str, *, event_id: int | None = None) -> None:
self.prompt_queue.push("prompt", prompt, event_id=event_id)
def parse_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> ParsedControlEventPayload:
return parse_control_event_payload(
payload,
event_id=event_id,
kind="camera_actions",
normalize_state_payload=self._normalize_state_actions,
validate_script_payload=LingBotWorldRealtimeAdapter._validate_camera_actions,
)
def receive_parsed_camera_control_event_payload(
self,
parsed: ParsedControlEventPayload,
*,
event_id: int | None,
) -> str:
if parsed.mode == "state":
transitions = parsed.payload
self.receive_camera_state_transitions(transitions)
return f"kind=camera_actions, mode=state, transitions={len(transitions)}"
camera_actions = parsed.payload
self.receive_camera_action_script(camera_actions, event_id=event_id)
return f"kind=camera_actions, mode=script, frames={len(camera_actions)}"
def receive_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> str:
parsed = self.parse_camera_control_event_payload(payload, event_id=event_id)
return self.receive_parsed_camera_control_event_payload(
parsed, event_id=event_id
)
def sample_prompt(self) -> str:
prompt = self.prompt_queue.pop_latest("prompt")
if not isinstance(prompt, str):
raise ValueError("prompt event payload must be a string")
self.latest_sampled_event_id = self.prompt_queue.last_sampled_seq_id("prompt")
return prompt
def has_prompt(self) -> bool:
return self.prompt_queue.has_events("prompt")
class LingBotWorldRealtimeAdapter(BaseRealtimeModelAdapter):
def create_state(self) -> LingBotWorldRealtimeState:
return LingBotWorldRealtimeState()
def _state(self, session: GenerateSession) -> LingBotWorldRealtimeState:
state = session.adapter_state
if not isinstance(state, LingBotWorldRealtimeState):
raise TypeError("LingBot realtime adapter state is not initialized")
return state
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
condition_inputs = request.condition_inputs or {}
camera_actions = condition_inputs.get(LINGBOT_CAMERA_ACTIONS_CONDITION)
if camera_actions is not None:
state = self._state(session)
state.receive_camera_action_script(
self._validate_camera_actions(camera_actions)
)
await save_realtime_first_frame(session, request)
@staticmethod
def _validate_camera_actions(payload: Any) -> list[list[str]]:
if not isinstance(payload, list):
raise ValueError("camera_actions event payload must be list[list[str]]")
normalized = []
for frame_actions in payload:
if not isinstance(frame_actions, list):
raise ValueError("camera_actions event payload must be list[list[str]]")
normalized.append(list(frame_actions))
return normalized
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
state = self._state(session)
if event.kind == "camera_actions":
return self._ingest_camera_actions(state, event.payload, event.event_id)
elif event.kind == "prompt":
return self._ingest_prompt(state, event.payload, event.event_id)
elif event.kind == COMPOSITE_INPUT_EVENT_KIND:
return self._ingest_composite_input(state, event.payload, event.event_id)
raise ValueError(f"unsupported event kind: {event.kind}")
def _ingest_camera_actions(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
return state.receive_camera_control_event_payload(
payload,
event_id=event_id,
)
def _ingest_prompt(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
prompt = self._validate_prompt_payload(payload)
state.receive_prompt(prompt, event_id=event_id)
return f"kind=prompt, prompt_len={len(prompt)}"
@staticmethod
def _validate_prompt_payload(payload: Any) -> str:
if not isinstance(payload, str) or not payload:
raise ValueError("prompt event payload must be a non-empty string")
return payload
def _ingest_composite_input(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
if not isinstance(payload, dict):
raise ValueError("composite_input event payload must be a map")
input_types = payload.get("input_types")
if not isinstance(input_types, list) or not input_types:
raise ValueError(
"composite_input event payload requires non-empty input_types"
)
parsed_inputs = []
for input_type in input_types:
if not isinstance(input_type, str) or not input_type:
raise ValueError(
"composite_input input_types must contain non-empty strings"
)
if input_type not in payload:
raise ValueError(f"composite_input event payload requires {input_type}")
parsed_inputs.append(
(
input_type,
self._parse_composite_input_item(
state,
input_type,
payload[input_type],
event_id,
),
)
)
input_logs = []
for input_type, parsed_payload in parsed_inputs:
input_logs.append(
self._ingest_parsed_composite_input_item(
state,
input_type,
parsed_payload,
event_id,
)
)
return f"kind=composite_input, inputs={input_logs}"
def _parse_composite_input_item(
self,
state: LingBotWorldRealtimeState,
input_type: str,
payload: Any,
event_id: int | None,
) -> Any:
if input_type == "camera_actions":
return state.parse_camera_control_event_payload(
payload,
event_id=event_id,
)
if input_type == "prompt":
return self._validate_prompt_payload(payload)
raise ValueError(f"unsupported composite_input type: {input_type}")
def _ingest_parsed_composite_input_item(
self,
state: LingBotWorldRealtimeState,
input_type: str,
parsed_payload: Any,
event_id: int | None,
) -> str:
if input_type == "camera_actions":
return state.receive_parsed_camera_control_event_payload(
parsed_payload,
event_id=event_id,
)
if input_type == "prompt":
state.receive_prompt(parsed_payload, event_id=event_id)
return f"kind=prompt, prompt_len={len(parsed_payload)}"
raise ValueError(f"unsupported composite_input type: {input_type}")
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
"""Samples user inputs (conditions) for the current RealtimeChunk from RealtimeStates"""
state = self._state(session)
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
prompt_updated = False
if chunk.index == 0:
prompt = request.prompt
elif state.has_prompt():
prompt = state.sample_prompt()
request.prompt = prompt
prompt_updated = True
else:
prompt = request.prompt
condition_inputs = {}
if prompt_updated:
condition_inputs[LINGBOT_PROMPT_UPDATED_CONDITION] = True
camera_actions = state.sample_camera_actions(chunk_size)
if camera_actions is not None:
condition_inputs[LINGBOT_CAMERA_ACTIONS_CONDITION] = camera_actions
return RealtimeChunkInputs(prompt=prompt, condition_inputs=condition_inputs)
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
num_frames = self._condition_num_frames(
request=request,
server_args=server_args,
chunk_size=chunk_size,
)
return build_realtime_sampling_params(
chunk.request_id,
request=request,
chunk_inputs=chunk_inputs,
num_frames=num_frames,
num_inference_steps=(
request.num_inference_steps
or LINGBOT_REALTIME_DEFAULT_NUM_INFERENCE_STEPS
),
chunk_size=chunk_size,
)
@staticmethod
def _condition_num_frames(
*,
request: RealtimeVideoGenerationsRequest,
server_args: ServerArgs | None,
chunk_size: int,
) -> int:
if server_args is None:
return int(request.num_frames or 0)
# encode one extra blank condition chunk so repeat-last never reuses
# the first-frame image mask on later realtime chunks
temporal_ratio = int(
server_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
required_latent_frames = chunk_size * LINGBOT_REALTIME_MIN_CONDITION_CHUNKS
required_num_frames = (required_latent_frames - 1) * temporal_ratio + 1
return max(int(request.num_frames or 0), required_num_frames)
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
return self._state(session).latest_sampled_event_id
def clear_state(self, session: GenerateSession) -> None:
state = session.adapter_state
if isinstance(state, LingBotWorldRealtimeState):
state.clear()
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
RealtimeChunkInputs,
build_realtime_sampling_params,
save_realtime_first_frame,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.sana_wm.base import (
normalize_sana_wm_camera_actions,
parse_sana_wm_action_string,
snap_sana_wm_num_frames,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.sana_wm.self_forcing import (
SanaWMSelfForcingSampler,
)
from sglang.multimodal_gen.runtime.realtime.states import (
RealtimeCameraControlState,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
SANA_WM_DEFAULT_SIZE = "1280x704"
SANA_WM_DEFAULT_NUM_FRAMES = 1081
SANA_WM_DEFAULT_FPS = 16
SANA_WM_DEFAULT_STEPS = 4
SANA_WM_DEFAULT_GUIDANCE = 1.0
SANA_WM_CONTROL_PULSE_FRAMES = 8
def _normalize_sana_wm_state_actions(actions: list[Any]) -> list[str]:
return [str(action).lower() for action in actions]
class SanaWMRealtimeAdapterState(RealtimeCameraControlState):
def __init__(self):
super().__init__(
min_pulse_items=SANA_WM_CONTROL_PULSE_FRAMES,
script_maxlen=2048,
max_transitions=512,
normalize_state_actions=_normalize_sana_wm_state_actions,
)
self.base_condition_inputs: dict[str, Any] = {}
def clear(self) -> None:
super().clear()
self.base_condition_inputs.clear()
def receive_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> str:
return super().receive_camera_control_event_payload(
payload,
event_id=event_id,
validate_camera_actions=SanaWMRealtimeAdapter._validate_camera_actions,
)
class SanaWMRealtimeAdapter(BaseRealtimeModelAdapter):
def create_state(self) -> SanaWMRealtimeAdapterState:
return SanaWMRealtimeAdapterState()
def _state(self, session: GenerateSession) -> SanaWMRealtimeAdapterState:
state = session.adapter_state
if not isinstance(state, SanaWMRealtimeAdapterState):
raise TypeError("SANA-WM realtime adapter state is not initialized")
return state
@staticmethod
def _validate_camera_actions(payload: Any) -> list[list[str]]:
return normalize_sana_wm_camera_actions(
payload, error_label="camera_actions event payload"
)
@staticmethod
def _raw_frame_count(result: OutputBatch) -> int | None:
if result.raw_frame_batches is None:
return None
return sum(len(frames) for frames in result.raw_frame_batches)
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
request.size = request.size or SANA_WM_DEFAULT_SIZE
if request.num_frames is not None:
request.num_frames = int(request.num_frames)
else:
# Open-ended session: keep num_frames unset so prepare_next_request
# samples uniform action chunks (no front-loaded segmentation), and
# flag the stage explicitly via condition_inputs —
# build_sampling_params strips None fields, so the per-chunk batch
# would otherwise carry the SamplingParams default num_frames.
request.condition_inputs = {
**(request.condition_inputs or {}),
"sana_wm_open_ended": True,
}
request.fps = int(request.fps or SANA_WM_DEFAULT_FPS)
request.num_inference_steps = int(
request.num_inference_steps or SANA_WM_DEFAULT_STEPS
)
request.guidance_scale = float(
request.guidance_scale or SANA_WM_DEFAULT_GUIDANCE
)
if request.negative_prompt is None:
request.negative_prompt = ""
if request.generator_device is None:
request.generator_device = "cuda"
state = self._state(session)
condition_inputs = dict(request.condition_inputs or {})
camera_actions = condition_inputs.pop("camera_actions", None)
action = condition_inputs.pop("action", None)
if camera_actions is not None and action is not None:
raise ValueError("pass only one of camera_actions or action")
if camera_actions is not None:
state.receive_camera_control_event_payload(camera_actions, event_id=None)
if action is not None:
if not isinstance(action, str) or not action:
raise ValueError("action condition input must be a non-empty string")
state.receive_camera_action_script(
parse_sana_wm_action_string(action), event_id=None
)
state.base_condition_inputs = condition_inputs
await save_realtime_first_frame(
session,
request,
required_error="SANA-WM realtime requires first_frame",
cache_remote_urls=True,
)
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
state = self._state(session)
if event.kind == "camera_actions":
return state.receive_camera_control_event_payload(
event.payload,
event_id=event.event_id,
)
if event.kind == "action":
if not isinstance(event.payload, str) or not event.payload:
raise ValueError("action event payload must be a non-empty string")
camera_actions = parse_sana_wm_action_string(event.payload)
state.receive_camera_action_script(camera_actions, event_id=event.event_id)
return f"kind=action, frames={len(camera_actions)}"
raise ValueError(f"unsupported event kind: {event.kind}")
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
action_chunk_size = self._action_chunk_size(
session,
server_args,
chunk,
chunk_size,
)
state = self._state(session)
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
condition_inputs = dict(state.base_condition_inputs) if chunk.index == 0 else {}
camera_actions = state.sample_camera_actions(action_chunk_size)
if camera_actions is not None:
condition_inputs["camera_actions"] = camera_actions
return RealtimeChunkInputs(
prompt=request.prompt,
condition_inputs=condition_inputs,
)
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
return build_realtime_sampling_params(
chunk.request_id,
request=request,
chunk_inputs=chunk_inputs,
num_frames=request.num_frames,
num_inference_steps=request.num_inference_steps,
chunk_size=chunk_size,
)
def _action_chunk_size(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> int:
temporal_compression = int(
server_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
# Match action sampling to the latent span used by the batch path. Chunk
# 0 may carry a front-loaded remainder, so a fixed nfpb*tc action count
# would read static-padded camera poses and drift from batch output.
action_chunk_size = chunk_size * temporal_compression
req_num_frames = (
session.request.num_frames if session.request is not None else None
)
if req_num_frames is not None:
snapped = snap_sana_wm_num_frames(
int(req_num_frames), stride=temporal_compression
)
latent_t = (snapped - 1) // temporal_compression + 1
segments = SanaWMSelfForcingSampler.create_autoregressive_segments(
latent_t, chunk_size
)
idx = int(chunk.index)
if 0 <= idx and idx + 1 < len(segments):
action_chunk_size = (
segments[idx + 1] - segments[idx]
) * temporal_compression
return action_chunk_size
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
return self._state(session).latest_sampled_event_id
def on_chunk_complete(self, session: GenerateSession, result: OutputBatch) -> None:
if session.request is not None and self._raw_frame_count(result) == 0:
session.request.max_chunks = session.generate_chunk_cnt + 1
session.generate_chunk_completed()
def clear_state(self, session: GenerateSession) -> None:
state = session.adapter_state
if isinstance(state, SanaWMRealtimeAdapterState):
state.clear()
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.realtime.session import (
RealtimeSession,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
@dataclass(frozen=True, slots=True)
class RealtimeChunkContext:
session_id: str
index: int
request_id: str
class GenerateSession:
"""A realtime generation session"""
def __init__(self):
self.id = uuid4().hex
self.request: RealtimeVideoGenerationsRequest | None = None
self.input_temp_dir: str | None = None
self.generate_chunk_cnt = 0
self.current_chunk: RealtimeChunkContext | None = None
self.realtime_session = RealtimeSession()
self.adapter: BaseRealtimeModelAdapter | None = None
self.adapter_state: Any = None
self.output_pace_next_send_at: float | None = None
self.output_pace_last_event_id: int | None = None
def set_adapter(self, adapter: BaseRealtimeModelAdapter):
self.adapter = adapter
self.adapter_state = adapter.create_state()
def set_request(self, request: RealtimeVideoGenerationsRequest):
self.request = request
def dispose(self):
if self.adapter is not None:
self.adapter.dispose(self)
self.request = None
self.input_temp_dir = None
self.generate_chunk_cnt = 0
self.current_chunk = None
self.adapter = None
self.adapter_state = None
self.output_pace_next_send_at = None
self.output_pace_last_event_id = None
self.realtime_session.dispose()
def new_chunk(self) -> RealtimeChunkContext:
if self.current_chunk is not None:
raise RuntimeError("previous realtime chunk is still active")
chunk = RealtimeChunkContext(
session_id=self.id,
index=self.generate_chunk_cnt,
request_id=f"{self.id}_{uuid4().hex}",
)
self.current_chunk = chunk
return chunk
def generate_chunk_completed(self):
self.generate_chunk_cnt += 1
self.current_chunk = None
def reached_max_chunks(self) -> bool:
return (
self.request is not None
and self.request.max_chunks is not None
and self.generate_chunk_cnt >= self.request.max_chunks
)
@@ -0,0 +1,268 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import hashlib
import os
import tempfile
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from fastapi import WebSocket
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
RawRGBRealtimeOutputAdapter,
RealtimeFrameSendStats,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
build_sampling_params,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request,
)
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
Req,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
@dataclass(slots=True)
class RealtimeChunkInputs:
"""Sampled from realtime control state, consumed by the Req"""
prompt: str
condition_inputs: dict[str, Any] = field(default_factory=dict)
async def save_realtime_first_frame(
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
*,
required_error: str | None = None,
cache_remote_urls: bool = False,
) -> None:
first_frame = request.first_frame
if first_frame is None:
if required_error is not None:
raise ValueError(required_error)
return
server_args = get_global_server_args()
if server_args.input_save_path is not None:
uploads_dir = server_args.input_save_path
os.makedirs(uploads_dir, exist_ok=True)
else:
if session.input_temp_dir is None:
session.input_temp_dir = tempfile.mkdtemp(prefix="sglang_input_")
uploads_dir = session.input_temp_dir
if (
cache_remote_urls
and isinstance(first_frame, str)
and first_frame.lower().startswith(("http://", "https://"))
):
suffix = os.path.splitext(first_frame.split("?", 1)[0])[1]
digest = hashlib.sha256(first_frame.encode("utf-8")).hexdigest()[:16]
target_path = os.path.join(uploads_dir, f"realtime_ref_{digest}{suffix}")
if os.path.exists(target_path):
request.first_frame = target_path
return
else:
target_path = os.path.join(uploads_dir, f"{session.id}_first_frame")
request.first_frame = await save_image_to_path(first_frame, target_path)
def build_realtime_sampling_params(
request_id: str,
*,
request: RealtimeVideoGenerationsRequest,
chunk_inputs: RealtimeChunkInputs,
num_frames: int | None,
num_inference_steps: int | None,
chunk_size: int,
):
return build_sampling_params(
request_id,
prompt=chunk_inputs.prompt,
size=request.size,
num_frames=num_frames,
fps=request.fps,
image_path=request.first_frame,
output_file_name=request_id,
save_output=False,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=num_inference_steps,
guidance_scale=request.guidance_scale,
guidance_scale_2=request.guidance_scale_2,
negative_prompt=request.negative_prompt,
enable_teacache=request.enable_teacache,
enable_frame_interpolation=request.enable_frame_interpolation,
frame_interpolation_exp=request.frame_interpolation_exp,
frame_interpolation_scale=request.frame_interpolation_scale,
frame_interpolation_model_path=request.frame_interpolation_model_path,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
diffusers_kwargs=request.diffusers_kwargs,
profile=request.profile,
num_profiled_timesteps=request.num_profiled_timesteps,
profile_all_stages=request.profile_all_stages,
perf_dump_path=request.perf_dump_path,
output_path=request.output_path,
output_compression=request.output_compression,
output_quality=request.output_quality,
condition_inputs=chunk_inputs.condition_inputs,
realtime_chunk_size=chunk_size,
)
class BaseRealtimeModelAdapter:
def __init__(self):
self.output_adapter = RawRGBRealtimeOutputAdapter()
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
raise NotImplementedError
def create_state(self) -> Any:
"""create a state for managing runtime states"""
raise NotImplementedError
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
"""
Ingest a realtime endpoint event and install it into the model's realtime control queues
"""
raise NotImplementedError
async def wait_for_next_chunk(self, session: GenerateSession) -> None:
del session
def get_chunk_size(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
) -> int:
del session, chunk
arch_config = server_args.pipeline_config.dit_config.arch_config
return int(getattr(arch_config, "num_frames_per_block", 3))
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
raise NotImplementedError
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
raise NotImplementedError
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
del session
return None
def prepare_next_request(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
) -> Req:
chunk_size = self.get_chunk_size(session, server_args, chunk)
chunk_inputs = self.sample_chunk_inputs(
session,
server_args,
chunk,
chunk_size,
)
sampling_params = self.build_sampling_params(
session,
server_args,
chunk,
chunk_inputs,
chunk_size,
)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
)
self.apply_realtime_request_fields(
batch,
session,
chunk,
event_id=self.get_realtime_event_id(session),
)
return batch
def apply_realtime_request_fields(
self,
batch: Req,
session: GenerateSession,
chunk: RealtimeChunkContext,
*,
event_id: int | None,
) -> None:
batch.realtime_session_id = session.id
batch.return_raw_frames = True
batch.block_idx = chunk.index
batch.realtime_event_id = event_id
if session.request is None:
return
batch.realtime_output_format = session.request.realtime_output_format
batch.realtime_preview_max_width = session.request.realtime_preview_max_width
batch.realtime_output_pacing = bool(session.request.realtime_output_pacing)
batch.realtime_causal_sink_size = session.request.realtime_causal_sink_size
batch.realtime_causal_kv_cache_num_frames = (
session.request.realtime_causal_kv_cache_num_frames
)
async def send_output(
self,
ws: WebSocket,
session: GenerateSession,
result: OutputBatch,
batch: Req,
) -> RealtimeFrameSendStats:
"""send the generate output (usually frames) back via websocket"""
return await self.output_adapter.send(ws, session, result, batch)
def on_chunk_complete(self, session: GenerateSession, result: OutputBatch) -> None:
del result
session.generate_chunk_completed()
def clear_state(self, session: GenerateSession) -> None:
del session
def dispose(self, session: GenerateSession) -> None:
self.clear_state(session)
self.output_adapter.reset()
@@ -0,0 +1,610 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import asyncio
import io
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypedDict
import msgspec.msgpack
from fastapi import WebSocket
from PIL import Image
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.timer import (
RealtimeStageTimer,
)
from sglang.multimodal_gen.runtime.utils.realtime_video import (
JPEG_FRAME_CONTENT_TYPE,
RAW_RGB_CHANNELS,
RAW_RGB_CONTENT_TYPE,
WEBP_FRAME_CONTENT_TYPE,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
Req,
)
class RealtimeFrameBatchHeader(TypedDict, total=False):
type: str
request_id: str
chunk_index: int
content_type: str
num_frames: int
total_size: int
format: str
width: int
height: int
channels: int
bytes_per_frame: int
raw_size: int
encoding: str
delta_reference: str
payload_lengths: list[int]
event_id: int
frame_batch_index: int
num_frame_batches: int
is_final_frame_batch: bool
class RealtimeFrameBatchMessage(RealtimeFrameBatchHeader, total=False):
payload: bytes
class RealtimeFrameSendStats(TypedDict):
header_pack_ms: float
header_write_ms: float
raw_payload_build_ms: float
raw_write_ms: float
ws_write_ms: float
pace_wait_ms: float
raw_bytes: int
ws_payload_bytes: int
num_frames: int
num_batches: int
frame_shape: tuple[int, int, int] | None
content_type: str
def empty_frame_send_stats(content_type: str = "") -> RealtimeFrameSendStats:
return {
"header_pack_ms": 0.0,
"header_write_ms": 0.0,
"raw_payload_build_ms": 0.0,
"raw_write_ms": 0.0,
"ws_write_ms": 0.0,
"pace_wait_ms": 0.0,
"raw_bytes": 0,
"ws_payload_bytes": 0,
"num_frames": 0,
"num_batches": 0,
"frame_shape": None,
"content_type": content_type,
}
def _raw_rgb_frame_metadata(batch: Req) -> dict[str, int | str]:
frame_width = batch.width
frame_height = batch.height
if frame_width is None or frame_height is None:
return {}
frame_width = int(frame_width)
frame_height = int(frame_height)
if batch.enable_upscaling:
upscaling_scale = int(batch.upscaling_scale or 1)
frame_width *= upscaling_scale
frame_height *= upscaling_scale
return {
"format": "rgb24",
"width": frame_width,
"height": frame_height,
"channels": RAW_RGB_CHANNELS,
"bytes_per_frame": frame_width * frame_height * RAW_RGB_CHANNELS,
}
def _frame_shape_from_metadata(
metadata: dict[str, int | str] | None,
) -> tuple[int, int, int] | None:
if not metadata:
return None
return (
int(metadata["height"]),
int(metadata["width"]),
int(metadata["channels"]),
)
RAW_RGB_FRAMES_PER_WS_MESSAGE = 16
ENCODED_PREVIEW_FRAMES_PER_WS_MESSAGE = 6
FRAME_BATCH_PACK_OFFLOAD_BYTES = 64 * 1024
WEBP_DEFAULT_QUALITY = 90
JPEG_DEFAULT_QUALITY = 95
JPEG_SUBSAMPLING = 0
RAW_LOSSLESS_OUTPUT_FORMAT = "raw"
ENCODED_PREVIEW_FORMATS = {"webp", "jpeg"}
@dataclass(frozen=True)
class _TransportPayload:
content_type: str
payload: bytes
metadata: dict[str, int | str | bool | list[int]]
def _split_frame_batch(
frames: list[bytes],
frames_per_message: int = RAW_RGB_FRAMES_PER_WS_MESSAGE,
) -> list[list[bytes]]:
if not frames:
return [frames]
return [
frames[i : i + frames_per_message]
for i in range(0, len(frames), frames_per_message)
]
def _encode_rgb_frame_to_webp(
frame: bytes,
*,
width: int,
height: int,
quality: int,
preview_max_width: int | None,
) -> bytes:
buffer = io.BytesIO()
image = _resize_preview_image(
Image.frombytes("RGB", (width, height), frame),
preview_max_width=preview_max_width,
)
image.save(
buffer,
format="WEBP",
quality=quality,
method=0,
)
return buffer.getvalue()
def _encode_rgb_frame_to_jpeg(
frame: bytes,
*,
width: int,
height: int,
quality: int,
preview_max_width: int | None,
) -> bytes:
buffer = io.BytesIO()
image = _resize_preview_image(
Image.frombytes("RGB", (width, height), frame),
preview_max_width=preview_max_width,
)
image.save(
buffer,
format="JPEG",
quality=quality,
subsampling=JPEG_SUBSAMPLING,
)
return buffer.getvalue()
def _preview_dimensions(
*,
width: int,
height: int,
preview_max_width: int | None,
) -> tuple[int, int]:
if (
preview_max_width is None
or preview_max_width <= 0
or width <= preview_max_width
):
return width, height
preview_width = int(preview_max_width)
preview_height = max(1, round(height * preview_width / width))
return preview_width, preview_height
def _resize_preview_image(
image: Image.Image,
*,
preview_max_width: int | None,
) -> Image.Image:
width, height = image.size
preview_width, preview_height = _preview_dimensions(
width=width,
height=height,
preview_max_width=preview_max_width,
)
if (preview_width, preview_height) == image.size:
return image
return image.resize((preview_width, preview_height), Image.Resampling.BICUBIC)
def _pack_frame_batch_message(
header: RealtimeFrameBatchHeader,
payload: bytes,
) -> bytes:
message: RealtimeFrameBatchMessage = {
**header,
"type": "frame_batch",
"payload": payload,
}
return msgspec.msgpack.encode(message)
def _pack_frame_batch_header(header: RealtimeFrameBatchHeader) -> bytes:
return msgspec.msgpack.encode(header)
def _build_transport_payload(
transport_frames: list[bytes],
*,
content_type: str,
metadata: dict[str, int | str],
output_format: str | None,
transport_quality: int | None,
preview_max_width: int | None,
) -> _TransportPayload:
payload_content_type = content_type
payload_metadata: dict[str, int | str | bool | list[int]] = {}
raw_payload = b""
if (
output_format in ENCODED_PREVIEW_FORMATS
and content_type == RAW_RGB_CONTENT_TYPE
and transport_frames
):
if output_format == "webp":
encoded_frames = [
_encode_rgb_frame_to_webp(
frame,
width=int(metadata["width"]),
height=int(metadata["height"]),
quality=int(transport_quality or WEBP_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
]
payload_content_type = WEBP_FRAME_CONTENT_TYPE
else:
encoded_frames = [
_encode_rgb_frame_to_jpeg(
frame,
width=int(metadata["width"]),
height=int(metadata["height"]),
quality=int(transport_quality or JPEG_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
]
payload_content_type = JPEG_FRAME_CONTENT_TYPE
raw_payload = b"".join(encoded_frames)
preview_width, preview_height = _preview_dimensions(
width=int(metadata["width"]),
height=int(metadata["height"]),
preview_max_width=preview_max_width,
)
payload_metadata = {
"format": output_format,
"encoding": output_format,
"source_width": int(metadata["width"]),
"source_height": int(metadata["height"]),
"preview_width": preview_width,
"preview_height": preview_height,
"width": preview_width,
"height": preview_height,
"payload_lengths": [len(frame) for frame in encoded_frames],
}
elif content_type == RAW_RGB_CONTENT_TYPE and transport_frames:
raw_payload = b"".join(transport_frames)
payload_metadata = {
"raw_size": len(raw_payload),
"encoding": RAW_LOSSLESS_OUTPUT_FORMAT,
}
else:
raw_payload = b"".join(transport_frames)
return _TransportPayload(
content_type=payload_content_type,
payload=raw_payload,
metadata=payload_metadata,
)
def _should_build_payload_off_loop(
*,
content_type: str,
output_format: str | None,
transport_frames: list[bytes],
) -> bool:
if content_type != RAW_RGB_CONTENT_TYPE or not transport_frames:
return False
return output_format in ENCODED_PREVIEW_FORMATS or output_format is None
def _is_encoded_preview_transport(
*,
content_type: str,
output_format: str | None,
) -> bool:
return (
output_format in ENCODED_PREVIEW_FORMATS
and content_type == RAW_RGB_CONTENT_TYPE
)
async def _build_encoded_preview_payloads(
split_batches: list[list[bytes]],
*,
content_type: str,
metadata: dict[str, int | str],
output_format: str,
transport_quality: int | None,
preview_max_width: int | None,
event_id: int | None,
) -> list[_TransportPayload]:
return list(
await asyncio.gather(
*(
_build_encoded_preview_payload(
transport_frames,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
for transport_frames in split_batches
)
)
)
async def _build_encoded_preview_payload(
transport_frames: list[bytes],
*,
metadata: dict[str, int | str],
output_format: str,
transport_quality: int | None,
preview_max_width: int | None,
) -> _TransportPayload:
width = int(metadata["width"])
height = int(metadata["height"])
if output_format == "webp":
encoded_frames = list(
await asyncio.gather(
*(
asyncio.to_thread(
_encode_rgb_frame_to_webp,
frame,
width=width,
height=height,
quality=int(transport_quality or WEBP_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
)
)
)
payload_content_type = WEBP_FRAME_CONTENT_TYPE
else:
encoded_frames = list(
await asyncio.gather(
*(
asyncio.to_thread(
_encode_rgb_frame_to_jpeg,
frame,
width=width,
height=height,
quality=int(transport_quality or JPEG_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
)
)
)
payload_content_type = JPEG_FRAME_CONTENT_TYPE
preview_width, preview_height = _preview_dimensions(
width=width,
height=height,
preview_max_width=preview_max_width,
)
return _TransportPayload(
content_type=payload_content_type,
payload=b"".join(encoded_frames),
metadata={
"format": output_format,
"encoding": output_format,
"source_width": width,
"source_height": height,
"preview_width": preview_width,
"preview_height": preview_height,
"width": preview_width,
"height": preview_height,
"payload_lengths": [len(frame) for frame in encoded_frames],
},
)
class RawRGBRealtimeOutputAdapter:
"""send raw RGB over WebSocket using lossless transport"""
def __init__(self) -> None:
pass
def reset(self) -> None:
pass
async def send(
self,
ws: WebSocket,
session: GenerateSession,
result: OutputBatch,
batch: Req,
) -> RealtimeFrameSendStats:
"""send frames through ws"""
content_type = result.raw_frame_content_type
if result.raw_frame_batches is None:
return empty_frame_send_stats(content_type)
if batch.block_idx == 0:
self.reset()
frame_metadata = (
result.raw_frame_metadata or _raw_rgb_frame_metadata(batch)
if content_type == RAW_RGB_CONTENT_TYPE
else {}
)
output_format = getattr(batch, "realtime_output_format", None)
preview_max_width = getattr(batch, "realtime_preview_max_width", None)
stats = await self._send_frame_batches(
ws,
result.raw_frame_batches,
content_type=content_type,
chunk_index_start=batch.block_idx,
request_id=batch.request_id,
event_id=getattr(batch, "realtime_event_id", None),
frame_metadata=frame_metadata,
output_format=output_format,
transport_quality=getattr(batch, "output_compression", None),
preview_max_width=preview_max_width,
)
stats["frame_shape"] = _frame_shape_from_metadata(frame_metadata)
return stats
async def _send_frame_batches(
self,
ws: WebSocket,
frame_batches: list[list[bytes]],
*,
content_type: str,
chunk_index_start: int,
request_id: str,
event_id: int | None = None,
frame_metadata: dict[str, int | str] | None = None,
output_format: str | None = None,
transport_quality: int | None = None,
preview_max_width: int | None = None,
) -> RealtimeFrameSendStats:
chunk_index = chunk_index_start
metadata = frame_metadata or {}
stats = empty_frame_send_stats(content_type)
for frames in frame_batches:
split_batches = (
_split_frame_batch(frames, ENCODED_PREVIEW_FRAMES_PER_WS_MESSAGE)
if _is_encoded_preview_transport(
content_type=content_type,
output_format=output_format,
)
else (
_split_frame_batch(frames)
if content_type == RAW_RGB_CONTENT_TYPE
else [frames]
)
)
num_frame_batches = len(split_batches)
encoded_preview_payloads: list[_TransportPayload] | None = None
if _is_encoded_preview_transport(
content_type=content_type,
output_format=output_format,
):
timer = RealtimeStageTimer()
encoded_preview_payloads = await _build_encoded_preview_payloads(
split_batches,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
event_id=event_id,
)
stats["raw_payload_build_ms"] += timer.mark_ms()
for frame_batch_index, transport_frames in enumerate(split_batches):
timer = RealtimeStageTimer()
transport_metadata = metadata
if encoded_preview_payloads is not None:
transport_payload = encoded_preview_payloads[frame_batch_index]
else:
if _should_build_payload_off_loop(
content_type=content_type,
output_format=output_format,
transport_frames=transport_frames,
):
transport_payload = await asyncio.to_thread(
_build_transport_payload,
transport_frames,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
else:
transport_payload = _build_transport_payload(
transport_frames,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
stats["raw_payload_build_ms"] += timer.mark_ms()
header: RealtimeFrameBatchHeader = {
"type": "frame_batch_header",
"request_id": request_id,
"chunk_index": chunk_index,
"content_type": transport_payload.content_type,
"num_frames": len(transport_frames),
"total_size": len(transport_payload.payload),
"frame_batch_index": frame_batch_index,
"num_frame_batches": num_frame_batches,
"is_final_frame_batch": frame_batch_index == num_frame_batches - 1,
}
if event_id is not None:
header["event_id"] = event_id
header.update(transport_metadata)
header.update(transport_payload.metadata)
if len(transport_payload.payload) >= FRAME_BATCH_PACK_OFFLOAD_BYTES:
header_payload = _pack_frame_batch_header(header)
stats["header_pack_ms"] += timer.mark_ms()
await ws.send_bytes(header_payload)
stats["header_write_ms"] += timer.mark_ms()
await ws.send_bytes(transport_payload.payload)
stats["raw_write_ms"] += timer.mark_ms()
stats["ws_payload_bytes"] += len(header_payload) + len(
transport_payload.payload
)
else:
message_payload = _pack_frame_batch_message(
header,
transport_payload.payload,
)
stats["header_pack_ms"] += timer.mark_ms()
stats["header_write_ms"] += timer.mark_ms()
await ws.send_bytes(message_payload)
stats["raw_write_ms"] += timer.mark_ms()
stats["ws_payload_bytes"] += len(message_payload)
stats["raw_bytes"] += sum(len(frame) for frame in transport_frames)
stats["num_frames"] += len(transport_frames)
stats["num_batches"] += 1
stats["content_type"] = transport_payload.content_type
chunk_index += 1
stats["ws_write_ms"] = stats["header_write_ms"] + stats["raw_write_ms"]
return stats
@@ -0,0 +1,500 @@
# SPDX-License-Identifier: Apache-2.0
import asyncio
import shutil
import time
from typing import TYPE_CHECKING
import msgspec.msgpack
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
RealtimeFrameSendStats,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
get_realtime_model_adapter,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.timer import (
RealtimeStageTimer,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
process_generation_batch,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ReleaseRealtimeSessionReq,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/realtime_video", tags=["realtime"])
_ACTIVE_SESSION_IDS: set[str] = set()
_ACTIVE_SESSION_WAIT_SECONDS = 1.0
_ACTIVE_SESSION_WAIT_INTERVAL_SECONDS = 0.1
def _transport_ms(value: float) -> int:
return max(0, int(value + 0.5))
async def _wait_for_active_session_slot(
*,
timeout_s: float = _ACTIVE_SESSION_WAIT_SECONDS,
interval_s: float = _ACTIVE_SESSION_WAIT_INTERVAL_SECONDS,
) -> bool:
deadline = time.monotonic() + timeout_s
while _ACTIVE_SESSION_IDS and time.monotonic() < deadline:
await asyncio.sleep(interval_s)
return not _ACTIVE_SESSION_IDS
def _log_realtime_chunk_timing(
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_total_ms: float,
send_stats: RealtimeFrameSendStats,
) -> None:
logger.info(
"realtime chunk timing: session_id=%s request_id=%s "
"chunk_idx=%s event_id=%s condition_kinds=%s "
"request_prepare=%.2fms scheduler_forward=%.2fms "
"output_pace=%.2fms "
"header_pack=%.2fms "
"header_write=%.2fms raw_payload_build=%.2fms raw_write=%.2fms "
"ws_write=%.2fms chunk_total=%.2fms batches=%d frames=%d "
"frame_shape=%s raw_bytes=%d ws_payload_bytes=%d content_type=%s",
session.id,
chunk.request_id,
batch.block_idx,
getattr(batch, "realtime_event_id", None),
sorted(batch.condition_inputs) if batch.condition_inputs else [],
request_prepare_ms,
scheduler_forward_ms,
send_stats["pace_wait_ms"],
send_stats["header_pack_ms"],
send_stats["header_write_ms"],
send_stats["raw_payload_build_ms"],
send_stats["raw_write_ms"],
send_stats["ws_write_ms"],
chunk_total_ms,
send_stats["num_batches"],
send_stats["num_frames"],
send_stats["frame_shape"],
send_stats["raw_bytes"],
send_stats["ws_payload_bytes"],
send_stats["content_type"],
)
async def _send_realtime_chunk_stats(
ws: WebSocket,
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_total_ms: float,
send_stats: RealtimeFrameSendStats,
) -> None:
await ws.send_bytes(
msgspec.msgpack.encode(
{
"type": "chunk_stats",
"session_id": session.id,
"request_id": chunk.request_id,
"chunk_index": batch.block_idx,
"event_id": getattr(batch, "realtime_event_id", None),
"request_prepare_ms": _transport_ms(request_prepare_ms),
"scheduler_forward_ms": _transport_ms(scheduler_forward_ms),
"pace_wait_ms": _transport_ms(send_stats["pace_wait_ms"]),
"header_write_ms": _transport_ms(send_stats["header_write_ms"]),
"raw_payload_build_ms": _transport_ms(
send_stats["raw_payload_build_ms"]
),
"raw_write_ms": _transport_ms(send_stats["raw_write_ms"]),
"ws_write_ms": _transport_ms(send_stats["ws_write_ms"]),
"chunk_total_ms": _transport_ms(chunk_total_ms),
"num_batches": send_stats["num_batches"],
"num_frames": send_stats["num_frames"],
"raw_bytes": send_stats["raw_bytes"],
"ws_payload_bytes": send_stats["ws_payload_bytes"],
"content_type": send_stats["content_type"],
}
)
)
async def _generate_loop(ws: WebSocket, session: GenerateSession):
adapter = session.adapter
if adapter is None:
raise ValueError("realtime adapter is not initialized")
pending_send_task = None
while not session.reached_max_chunks():
try:
if pending_send_task is not None and pending_send_task.done():
await pending_send_task
pending_send_task = None
# send to scheduler and generate video chunk
server_args = get_global_server_args()
await adapter.wait_for_next_chunk(session)
timer = RealtimeStageTimer()
chunk_started = time.perf_counter()
chunk = session.new_chunk()
batch = adapter.prepare_next_request(
session,
server_args,
chunk,
)
if batch.condition_inputs:
logger.debug(
"consume realtime conditions, session_id=%s, block_idx=%s, kinds=%s",
session.id,
batch.block_idx,
sorted(batch.condition_inputs),
)
request_prepare_ms = timer.mark_ms()
_, result = await process_generation_batch(async_scheduler_client, batch)
scheduler_forward_ms = timer.mark_ms()
# finish
adapter.on_chunk_complete(session, result)
if pending_send_task is not None:
await pending_send_task
if getattr(batch, "realtime_output_pacing", False):
await _send_output_and_log(
ws,
session,
chunk,
batch,
result,
request_prepare_ms,
scheduler_forward_ms,
chunk_started,
)
pending_send_task = None
else:
pending_send_task = asyncio.create_task(
_send_output_and_log(
ws,
session,
chunk,
batch,
result,
request_prepare_ms,
scheduler_forward_ms,
chunk_started,
)
)
except asyncio.CancelledError:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
logger.info("generation completed, session_id=%s", session.id)
break
except WebSocketDisconnect:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
logger.info(
"client disconnected during generation, session_id=%s", session.id
)
break
except Exception as e:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
err_msg = str(e).splitlines()[0]
logger.error("error during generate loop: %s", err_msg)
try:
await write_error_msg(f"error during generate loop: {err_msg}", ws)
except Exception as send_error:
logger.error(
"error during sending complete msg: %s",
send_error,
)
break
else:
if pending_send_task is not None:
await pending_send_task
logger.info(
"generation reached max chunks, session_id=%s, max_chunks=%s",
session.id,
session.request.max_chunks if session.request is not None else None,
)
async def _send_output_and_log(
ws: WebSocket,
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
result,
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_started: float,
) -> RealtimeFrameSendStats:
if session.adapter is None:
raise ValueError("realtime adapter is not initialized")
pace_wait_ms = await _wait_for_realtime_output_slot(session, batch, result)
send_stats = await session.adapter.send_output(
ws,
session,
result,
batch,
)
send_stats["pace_wait_ms"] = pace_wait_ms
chunk_total_ms = (time.perf_counter() - chunk_started) * 1000
_log_realtime_chunk_timing(
session,
chunk,
batch,
request_prepare_ms,
scheduler_forward_ms,
chunk_total_ms,
send_stats,
)
await _send_realtime_chunk_stats(
ws,
session,
chunk,
batch,
request_prepare_ms,
scheduler_forward_ms,
chunk_total_ms,
send_stats,
)
return send_stats
def _result_num_frames(result) -> int:
if result.raw_frame_batches is None:
return 0
return sum(len(frames) for frames in result.raw_frame_batches)
def _output_pacing_fps(batch: "Req") -> float:
fps = float(batch.fps or 0)
if batch.enable_frame_interpolation:
fps *= 2 ** int(batch.frame_interpolation_exp or 1)
return fps
async def _wait_for_realtime_output_slot(
session: GenerateSession,
batch: "Req",
result,
) -> float:
if not getattr(batch, "realtime_output_pacing", False):
return 0.0
frame_count = _result_num_frames(result)
output_fps = _output_pacing_fps(batch)
if frame_count <= 0 or output_fps <= 0:
return 0.0
now = time.perf_counter()
next_send_at = session.output_pace_next_send_at
if next_send_at is None:
next_send_at = now
if (
batch.realtime_event_id is not None
and batch.realtime_event_id != session.output_pace_last_event_id
):
next_send_at = min(next_send_at, now)
session.output_pace_last_event_id = batch.realtime_event_id
wait_s = max(0.0, next_send_at - now)
if wait_s > 0:
await asyncio.sleep(wait_s)
send_started_at = time.perf_counter()
session.output_pace_next_send_at = (
max(next_send_at, send_started_at) + frame_count / output_fps
)
return wait_s * 1000
async def _await_realtime_task(task: asyncio.Task | None) -> None:
if task is None:
return
try:
await task
except (asyncio.CancelledError, WebSocketDisconnect):
pass
except Exception as e:
logger.debug("realtime task exited with error: %s", e)
async def _listen_events(ws: WebSocket, session: GenerateSession):
"""listen for user events: usually condition inputs"""
async for message in ws.iter_bytes():
data = None
try:
data = msgspec.msgpack.decode(message)
if not isinstance(data, dict):
raise ValueError("realtime event must be a map")
realtime_event = RealtimeEvent.model_validate(data)
if session.adapter is None:
raise ValueError("realtime adapter is not initialized")
event_log = session.adapter.ingest_event(session, realtime_event)
logger.info(
"receive realtime event, session_id=%s, event_id=%s, %s",
session.id,
realtime_event.event_id,
event_log,
)
except Exception as e:
event_kind = data.get("kind") if isinstance(data, dict) else None
logger.warning("invalid event, kind=%s, error=%s", event_kind, e)
await write_error_msg("invalid event", ws)
continue
async def _listen_generate_request(ws: WebSocket, session: GenerateSession):
while True:
try:
data = msgspec.msgpack.decode(await ws.receive_bytes())
if not isinstance(data, dict):
raise ValueError("generate request must be a map")
realtime_req = RealtimeVideoGenerationsRequest.model_validate(data)
adapter = get_realtime_model_adapter(get_global_server_args())
session.set_adapter(adapter)
await adapter.on_init(session, realtime_req)
# Keep session state update atomic with validated request.
session.set_request(realtime_req)
break
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(
"invalid generate request, session_id=%s, error=%s",
session.id,
e,
)
await write_error_msg("invalid generate request", ws)
continue
async def _cleanup_realtime_session(
session: GenerateSession,
generate_task: asyncio.Task | None,
listen_task: asyncio.Task | None,
) -> None:
logger.info("terminating session, session_id=%s", session.id)
for task in (generate_task, listen_task):
if task and not task.done():
task.cancel()
for task in (generate_task, listen_task):
if task is None:
continue
await _await_realtime_task(task)
try:
await async_scheduler_client.forward(
ReleaseRealtimeSessionReq(session_id=session.id)
)
except Exception as e:
logger.warning(
"failed to release realtime session on scheduler, session_id=%s, error=%s",
session.id,
e,
)
if session.input_temp_dir is not None:
shutil.rmtree(session.input_temp_dir, ignore_errors=True)
session.dispose()
async def _close_realtime_websocket(
websocket: WebSocket,
*,
code: int,
reason: str,
) -> None:
try:
await websocket.close(code=code, reason=reason)
except (RuntimeError, WebSocketDisconnect):
pass
async def _wait_for_server_warmup(websocket: WebSocket) -> None:
warmup_done = getattr(websocket.app.state, "server_warmup_done", None)
if warmup_done is not None and not warmup_done.is_set():
await warmup_done.wait()
@router.websocket("/generate")
async def generate(websocket: WebSocket):
"""endpoint for creating a new realtime session"""
await websocket.accept()
await _wait_for_server_warmup(websocket)
if _ACTIVE_SESSION_IDS and not await _wait_for_active_session_slot():
logger.warning(
"reject realtime session because another session is active: %s",
sorted(_ACTIVE_SESSION_IDS),
)
try:
await write_error_msg(
"another realtime session is already active", websocket
)
finally:
await websocket.close(code=1008)
return
session = GenerateSession()
_ACTIVE_SESSION_IDS.add(session.id)
generate_task = None
listen_task = None
try:
# receive new generate request
await _listen_generate_request(websocket, session)
# continuously generate video chunk
generate_task = asyncio.create_task(_generate_loop(websocket, session))
# continuously listen for user events
listen_task = asyncio.create_task(_listen_events(websocket, session))
wait_tasks = [generate_task, listen_task]
await asyncio.wait(wait_tasks, return_when=asyncio.FIRST_COMPLETED)
if generate_task.done() and session.reached_max_chunks():
await _close_realtime_websocket(
websocket,
code=1000,
reason="generation complete",
)
except WebSocketDisconnect:
logger.info("client disconnected, session_id=%s", session.id)
finally:
try:
await _cleanup_realtime_session(session, generate_task, listen_task)
finally:
_ACTIVE_SESSION_IDS.discard(session.id)
async def write_error_msg(error_msg: str, websocket: WebSocket):
await websocket.send_bytes(
msgspec.msgpack.encode({"type": "error", "content": error_msg})
)
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.server_args import ServerArgs
_REALTIME_ADAPTER_REGISTRY: dict[type, type[BaseRealtimeModelAdapter]] = {}
_BUILTIN_ADAPTERS_REGISTERED = False
def register_realtime_model_adapter(
pipeline_config_cls: type,
adapter_cls: type[BaseRealtimeModelAdapter],
) -> None:
_REALTIME_ADAPTER_REGISTRY[pipeline_config_cls] = adapter_cls
def _register_builtin_realtime_model_adapters() -> None:
global _BUILTIN_ADAPTERS_REGISTERED
if _BUILTIN_ADAPTERS_REGISTERED:
return
from sglang.multimodal_gen.configs.pipeline_configs.lingbot_world import (
LingBotWorldCausalDMDConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.sana_wm import (
SanaWMRealtimeConfig,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.adapters.lingbot_world_realtime_adapter import (
LingBotWorldRealtimeAdapter,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.adapters.sana_wm_realtime_adapter import (
SanaWMRealtimeAdapter,
)
register_realtime_model_adapter(
LingBotWorldCausalDMDConfig,
LingBotWorldRealtimeAdapter,
)
register_realtime_model_adapter(
SanaWMRealtimeConfig,
SanaWMRealtimeAdapter,
)
_BUILTIN_ADAPTERS_REGISTERED = True
def get_realtime_model_adapter(
server_args: ServerArgs,
) -> BaseRealtimeModelAdapter:
_register_builtin_realtime_model_adapters()
pipeline_config = server_args.pipeline_config
for config_cls in type(pipeline_config).__mro__:
adapter_cls = _REALTIME_ADAPTER_REGISTRY.get(config_cls)
if adapter_cls is not None:
return adapter_cls()
raise ValueError(
"Realtime video is not supported for pipeline config "
f"{type(pipeline_config).__name__}; no realtime adapter is registered."
)
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
import time
class RealtimeStageTimer:
__slots__ = ("_last", "_start")
def __init__(self):
now = time.perf_counter()
self._start = now
self._last = now
def mark_ms(self) -> float:
now = time.perf_counter()
elapsed_ms = (now - self._last) * 1000.0
self._last = now
return elapsed_ms
def total_ms(self) -> float:
return (time.perf_counter() - self._start) * 1000.0
@@ -0,0 +1,109 @@
import asyncio
import os
from typing import Optional
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class CloudStorage:
def __init__(self):
self.enabled = os.getenv("SGLANG_CLOUD_STORAGE_TYPE", "").lower() == "s3"
if not self.enabled:
return
try:
import boto3
except ImportError:
logger.error(
"boto3 is not installed. Please install it with `pip install boto3` to use cloud storage."
)
self.enabled = False
return
self.bucket_name = os.getenv("SGLANG_S3_BUCKET_NAME")
if not self.bucket_name:
self.enabled = False
return
endpoint_url = os.getenv("SGLANG_S3_ENDPOINT_URL") or None
region_name = os.getenv("SGLANG_S3_REGION_NAME") or None
self.client = boto3.client(
"s3",
aws_access_key_id=os.getenv("SGLANG_S3_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("SGLANG_S3_SECRET_ACCESS_KEY"),
endpoint_url=endpoint_url,
region_name=region_name,
)
self.endpoint_url = endpoint_url
self.region_name = region_name
def is_enabled(self) -> bool:
return self.enabled
async def upload_file(self, local_path: str, destination_key: str) -> Optional[str]:
if not self.is_enabled():
return None
def _sync_upload():
"""Synchronous part of the upload to run in a thread."""
ext = os.path.splitext(local_path)[1].lower()
content_type = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".mp4": "video/mp4",
".glb": "model/gltf-binary",
".obj": "text/plain",
}.get(ext, "application/octet-stream")
# Use the client created once in __init__
self.client.upload_file(
local_path,
self.bucket_name,
destination_key,
ExtraArgs={"ContentType": content_type},
)
try:
# Offload the blocking I/O call to a thread executor
await asyncio.get_running_loop().run_in_executor(None, _sync_upload)
except Exception as e:
# If upload fails, log the error and return None for fallback
logger.error(f"Upload failed for {destination_key}: {e}")
return None
# Simplified URL generation with a default region
if self.endpoint_url:
url = (
f"{self.endpoint_url.rstrip('/')}/{self.bucket_name}/{destination_key}"
)
else:
region = self.region_name or "us-east-1"
url = f"https://{self.bucket_name}.s3.{region}.amazonaws.com/{destination_key}"
logger.info(f"Uploaded {local_path} to {url}")
return url
async def upload_and_cleanup(self, file_path: str) -> Optional[str]:
"""Helper to upload a file and delete the local copy if successful."""
if not self.is_enabled():
return None
key = os.path.basename(file_path)
url = await self.upload_file(file_path, key)
if url:
try:
# pass if removal fails
os.remove(file_path)
except OSError as e:
logger.warning(f"Failed to remove temporary file {file_path}: {e}")
return url
# Global instance
cloud_storage = CloudStorage()
@@ -0,0 +1,48 @@
import asyncio
from typing import Any, Dict, List, Optional
class AsyncDictStore:
"""A small async-safe in-memory key-value store for dict items.
This encapsulates the usual pattern of a module-level dict guarded by
an asyncio.Lock and provides simple CRUD methods that are safe to call
concurrently from FastAPI request handlers and background tasks.
"""
def __init__(self) -> None:
self._items: Dict[str, Dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def upsert(self, key: str, value: Dict[str, Any]) -> None:
async with self._lock:
self._items[key] = value
async def update_fields(
self, key: str, updates: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
async with self._lock:
item = self._items.get(key)
if item is None:
return None
item.update(updates)
return item
async def get(self, key: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return self._items.get(key)
async def pop(self, key: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return self._items.pop(key, None)
async def list_values(self) -> List[Dict[str, Any]]:
async with self._lock:
return list(self._items.values())
# Global stores shared by OpenAI entrypoints
# [request_id, dict]
VIDEO_STORE = AsyncDictStore()
IMAGE_STORE = AsyncDictStore()
MESH_STORE = AsyncDictStore()
@@ -0,0 +1,452 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import inspect
import json
import os
import shutil
import tempfile
import time
from contextlib import contextmanager
from typing import Any, Generator, List, Optional, Union
import httpx
from fastapi import HTTPException, UploadFile
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
format_lora_message,
save_outputs,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import AsyncSchedulerClient
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.common import parse_size
from sglang.multimodal_gen.runtime.utils.image_io import save_base64_image_to_path
from sglang.multimodal_gen.runtime.utils.logging_utils import (
init_logger,
log_batch_completion,
log_generation_timer,
)
from sglang.multimodal_gen.runtime.utils.trace_wrapper import trace_req
# re-export LoRA protocol types for backward compatibility
__all__ = [
"SetLoraReq",
"MergeLoraWeightsReq",
"UnmergeLoraWeightsReq",
"ListLorasReq",
"ShutdownReq",
"format_lora_message",
]
logger = init_logger(__name__)
OUTPUT_QUALITY_MAPPER = {"maximum": 100, "high": 90, "medium": 55, "low": 35}
DEFAULT_FPS = 24
DEFAULT_VIDEO_SECONDS = 4
def _bad_request(message: str) -> HTTPException:
return HTTPException(status_code=400, detail=message)
def _parse_size_or_raise(size: str) -> tuple[int, int]:
width, height = parse_size(size)
if width is None or height is None or width <= 0 or height <= 0:
raise _bad_request("size must be formatted as positive WIDTHxHEIGHT")
return width, height
def _validate_positive_int(kwargs: dict[str, Any], name: str) -> None:
value = kwargs.get(name)
if value is not None and int(value) <= 0:
raise _bad_request(f"{name} must be positive")
def flatten_extra_params(payload: Any) -> dict[str, Any]:
"""Promote vLLM-Omni-style extra_params into regular request fields."""
if not isinstance(payload, dict):
return {}
extra_params = payload.pop("extra_params", None)
if isinstance(extra_params, str):
try:
extra_params = json.loads(extra_params)
except Exception:
extra_params = None
if not isinstance(extra_params, dict):
if "guardrails" in payload:
payload.setdefault("use_guardrails", payload["guardrails"])
return payload
for key, value in extra_params.items():
payload.setdefault(key, value)
if "guardrails" in extra_params:
payload.setdefault("use_guardrails", extra_params["guardrails"])
return payload
@contextmanager
def temp_dir_if_disabled(
configured_path: str | None,
) -> Generator[str, None, None]:
"""Yield *configured_path* when it is set, otherwise create a temporary
directory that is automatically removed when the context exits."""
if configured_path is not None:
os.makedirs(configured_path, exist_ok=True)
yield configured_path
else:
tmp = tempfile.mkdtemp(prefix="sglang_")
try:
yield tmp
finally:
shutil.rmtree(tmp, ignore_errors=True)
def choose_output_image_ext(
output_format: Optional[str], background: Optional[str]
) -> str:
fmt = (output_format or "").lower()
if fmt in {"png", "webp", "jpeg", "jpg"}:
return "jpg" if fmt == "jpeg" else fmt
if (background or "auto").lower() == "transparent":
return "png"
return "jpg"
def build_sampling_params(request_id: str, **kwargs) -> SamplingParams:
"""Build SamplingParams from request parameters.
Handles size parsing, output_quality resolution, and None filtering before
delegating to SamplingParams.from_user_sampling_params_args. Callers pass
only the parameters they have; None values are stripped automatically so
that SamplingParams defaults apply.
"""
server_args = get_global_server_args()
# pop HTTP-layer params that aren't SamplingParams fields
output_quality = kwargs.pop("output_quality", None)
has_explicit_compression = kwargs.get("output_compression") is not None
# parse "WxH" size string if provided
size = kwargs.pop("size", None)
if size:
w, h = _parse_size_or_raise(size)
# treat None dimensions as unset so parsed size can fill them
if kwargs.get("width") is None:
kwargs["width"] = w
if kwargs.get("height") is None:
kwargs["height"] = h
for name in (
"width",
"height",
"num_frames",
"num_inference_steps",
"num_outputs_per_prompt",
):
_validate_positive_int(kwargs, name)
# filter out None values to let SamplingParams defaults apply
kwargs = {k: v for k, v in kwargs.items() if v is not None}
kwargs.setdefault("save_output", True)
sampling_params = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
request_id=request_id,
**kwargs,
)
# resolve output_quality → output_compression with the correct data_type.
# SamplingParams.__post_init__ may have resolved with the wrong data_type
# (default VIDEO) before _adjust() set the correct one.
if not has_explicit_compression and output_quality is not None:
resolved = adjust_output_quality(output_quality, sampling_params.data_type)
if resolved is not None:
sampling_params.output_compression = resolved
return sampling_params
async def save_image_to_path(
image: Union[UploadFile, bytes, str],
target_path: str,
*,
prefer_remote_source: bool = False,
) -> str:
input_path = await _maybe_url_image(
image, target_path, prefer_remote_source=prefer_remote_source
)
if input_path is None:
input_path = await _save_upload_to_path(image, target_path)
return input_path
# Helpers
async def _save_upload_to_path(
upload: Union[UploadFile, bytes], target_path: str
) -> str:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
if isinstance(upload, bytes):
content = upload
elif isinstance(upload, (bytearray, memoryview)):
content = bytes(upload)
else:
read = getattr(upload, "read", None)
if not callable(read):
raise TypeError(f"Unsupported image upload type: {type(upload).__name__}")
content = read()
if inspect.isawaitable(content):
content = await content
if isinstance(content, (bytearray, memoryview)):
content = bytes(content)
if not isinstance(content, bytes):
raise TypeError(
f"Image upload read() returned {type(content).__name__}, expected bytes"
)
with open(target_path, "wb") as f:
f.write(content)
return target_path
async def _maybe_url_image(
img_url: str,
target_path: str,
*,
prefer_remote_source: bool = False,
) -> str | None:
if not isinstance(img_url, str):
return None
if img_url.lower().startswith(("http://", "https://")):
# Only bypass persistence when the caller explicitly disables input saves.
# Otherwise keep the prefetch outside the measured server stages.
if prefer_remote_source:
return img_url
# download image from URL and persist on disk
input_path = await _save_url_image_to_path(img_url, target_path)
return input_path
elif img_url.startswith("data:image"):
if prefer_remote_source:
return img_url
# encode image base64 url and persist on disk
input_path = save_base64_image_to_path(img_url, target_path)
return input_path
else:
raise ValueError("Unsupported image url format")
async def _save_url_image_to_path(image_url: str, target_path: str) -> str:
"""Download image from URL and save to target path."""
def _is_retryable_download_error(error: Exception) -> bool:
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
# Retry on rate limit and transient server-side failures.
return status_code == 429 or 500 <= status_code < 600
# Retry on transient network/protocol issues.
return isinstance(
error,
(
httpx.TimeoutException,
httpx.NetworkError,
httpx.RemoteProtocolError,
),
)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
max_attempts = 3
backoff_seconds = 0.2
last_error: Exception | None = None
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
for attempt in range(1, max_attempts + 1):
try:
response = await client.get(image_url, timeout=10.0)
response.raise_for_status()
# Determine file extension from content type or URL after downloading
if not os.path.splitext(target_path)[1]:
content_type = response.headers.get("content-type", "").lower()
url_path = image_url.split("?")[0]
_, url_ext = os.path.splitext(url_path)
url_ext = url_ext.lower()
if url_ext in {
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
}:
ext = ".jpg" if url_ext == ".jpeg" else url_ext
elif content_type.startswith("image/"):
if "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
elif "webp" in content_type:
ext = ".webp"
else:
ext = ".jpg" # Default to jpg
elif content_type == "application/octet-stream":
# for octet-stream, if we couldn't get it from URL, default to jpg
ext = ".jpg"
else:
raise ValueError(
f"URL does not point to an image. Content-Type: {content_type}"
)
target_path = f"{target_path}{ext}"
with open(target_path, "wb") as f:
f.write(response.content)
return target_path
except Exception as e:
last_error = e
if attempt == max_attempts or not _is_retryable_download_error(e):
raise
wait_s = backoff_seconds * (2 ** (attempt - 1))
logger.warning(
"Retrying image download (%s/%s) for %s after %.1fs due to: %s",
attempt,
max_attempts,
image_url,
wait_s,
e,
)
await asyncio.sleep(wait_s)
except Exception as e:
final_error = last_error or e
raise Exception(
f"Failed to download image from URL {image_url}: {str(final_error)}"
)
async def process_generation_batch(
scheduler_client: AsyncSchedulerClient,
batch,
) -> tuple[list[str], OutputBatch]:
total_start_time = time.perf_counter()
with trace_req(batch.trace_ctx), log_generation_timer(logger, batch.prompt):
result = await scheduler_client.forward([batch])
if (
result.output is None
and result.output_file_paths is None
and result.raw_frame_batches is None
):
error_msg = result.error or "Unknown error"
raise RuntimeError(
f"Model generation returned no output. Error from scheduler: {error_msg}"
)
save_file_path_list = []
if result.output_file_paths:
save_file_path_list = result.output_file_paths
elif result.output is not None:
num_outputs = 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)),
audio=result.audio,
audio_sample_rate=result.audio_sample_rate,
output_compression=batch.output_compression,
enable_frame_interpolation=batch.enable_frame_interpolation,
frame_interpolation_exp=batch.frame_interpolation_exp,
frame_interpolation_scale=batch.frame_interpolation_scale,
frame_interpolation_model_path=batch.frame_interpolation_model_path,
enable_upscaling=batch.enable_upscaling,
upscaling_model_path=batch.upscaling_model_path,
upscaling_scale=batch.upscaling_scale,
)
total_time = time.perf_counter() - total_start_time
if get_global_server_args().batching_max_size > 1:
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")
return save_file_path_list, result
def merge_image_input_list(*inputs: Union[List, Any, None]) -> List:
"""
Merge multiple image input sources into a single list.
This function handles both single items and lists of items, merging them
into a single flattened list. Useful for processing images, URLs, or other
multimedia inputs that can come as either single items or lists.
Args:
*inputs: Variable number of inputs, each can be None, single item, or list
Returns:
List: Flattened list of all non-None inputs
Example:
>>> merge_image_input_list(["img1", "img2"], "img3", None)
["img1", "img2", "img3"]
"""
result = []
for input_item in inputs:
if input_item is not None:
if isinstance(input_item, list):
result.extend(input_item)
else:
result.append(input_item)
return result
def add_common_data_to_response(
response: dict, request_id: str, result: OutputBatch
) -> dict:
if result.peak_memory_mb and result.peak_memory_mb > 0:
response["peak_memory_mb"] = result.peak_memory_mb
if result.metrics and result.metrics.total_duration_s > 0:
response["inference_time_s"] = result.metrics.total_duration_s
response["id"] = request_id
if result.action_pred is not None:
t = result.action_pred
response["action"] = {
"data": t.tolist(),
"shape": list(t.shape),
"dtype": str(t.dtype).replace("torch.", ""),
"raw_action_dim": result.action_raw_action_dim,
"action_mode": result.action_mode,
"domain_id": result.action_domain_id,
}
return response
def adjust_output_quality(output_quality: str, data_type: DataType = None) -> int:
if output_quality == "default":
return 50 if data_type == DataType.VIDEO else 75
return OUTPUT_QUALITY_MAPPER.get(output_quality, None)
@@ -0,0 +1,741 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import json
import os
import shutil
import tempfile
import time
from typing import Any, Dict, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
VideoGenerationsRequest,
VideoListResponse,
VideoResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import VIDEO_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
DEFAULT_FPS,
DEFAULT_VIDEO_SECONDS,
add_common_data_to_response,
build_sampling_params,
flatten_extra_params,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.observability.trace import extract_trace_headers
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/videos", tags=["videos"])
_VIDEO_EXTENSIONS = {
".avi",
".gif",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".webm",
}
def _extra_value(request: VideoGenerationsRequest, name: str) -> Any:
return (request.model_extra or {}).get(name)
def _request_value(request: VideoGenerationsRequest, name: str) -> Any:
value = getattr(request, name, None)
if value is not None:
return value
return _extra_value(request, name)
def _parse_form_extra_value(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except Exception:
return value
def _is_probably_video_source(source: Any) -> bool:
content_type = (getattr(source, "content_type", "") or "").lower()
if content_type.startswith("video/"):
return True
if isinstance(source, str):
if source.lower().startswith("data:video"):
return True
source_name = source
else:
source_name = getattr(source, "filename", None)
if not source_name:
return False
source_name = str(source_name).split("?", 1)[0].split("#", 1)[0]
return os.path.splitext(source_name)[1].lower() in _VIDEO_EXTENSIONS
def _is_cosmos3_server(server_args) -> bool:
from sglang.multimodal_gen.configs.pipeline_configs.cosmos3 import Cosmos3Config
return isinstance(server_args.pipeline_config, Cosmos3Config)
def _normalize_optional_string(value: Any) -> Any:
if isinstance(value, str) and not value.strip():
return None
return value
def _coerce_optional_int_list(value: Any) -> list[int] | None:
value = _parse_form_extra_value(value)
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
if isinstance(value, (list, tuple)):
return [int(item) for item in value]
return [int(value)]
def _resolve_video_path(req: VideoGenerationsRequest) -> str | None:
video_path = _request_value(req, "video_path") or _request_value(req, "video_url")
if video_path:
return str(video_path)
input_reference = _request_value(req, "input_reference")
if _is_probably_video_source(input_reference):
return str(input_reference)
reference_url = _request_value(req, "reference_url")
if _is_probably_video_source(reference_url):
return str(reference_url)
return None
def _resolve_image_path(
req: VideoGenerationsRequest, video_path: str | None
) -> str | None:
image_path = _request_value(req, "input_reference")
if video_path and image_path == video_path:
return None
if _is_probably_video_source(image_path):
return None
return image_path
def _resolve_sound_duration(
req: VideoGenerationsRequest, *, num_frames: int, fps: int
) -> float | None:
generate_sound = _request_value(req, "generate_sound")
sound_duration = _request_value(req, "sound_duration")
if generate_sound is False:
return 0.0
if sound_duration is not None:
return float(sound_duration)
if generate_sound is True:
return float(num_frames) / float(fps)
return None
def _cosmos3_sampling_param_kwargs(
req: VideoGenerationsRequest, *, num_frames: int, fps: int
) -> Dict[str, Any]:
"""Map HTTP/API aliases to Cosmos3SamplingParams field names."""
kwargs: Dict[str, Any] = {}
sound_duration = _resolve_sound_duration(req, num_frames=num_frames, fps=fps)
if sound_duration is not None:
kwargs["sound_duration"] = sound_duration
condition_frame_indexes = _request_value(req, "condition_frame_indexes")
if condition_frame_indexes is None:
condition_frame_indexes = _request_value(req, "condition_frame_indexes_vision")
condition_frame_indexes = _coerce_optional_int_list(condition_frame_indexes)
if condition_frame_indexes is not None:
kwargs["condition_frame_indexes"] = condition_frame_indexes
for name in (
"condition_video_keep",
"action_mode",
"domain_id",
"domain_name",
"raw_action_dim",
"action_fps",
"action",
"action_view_point",
"action_normalization",
):
value = _parse_form_extra_value(_request_value(req, name))
value = _normalize_optional_string(value)
if value is not None:
kwargs[name] = value
return kwargs
def _build_video_sampling_params(request_id: str, request: VideoGenerationsRequest):
"""Resolve video-specific defaults (fps, seconds → num_frames) then
delegate to the shared build_sampling_params."""
server_args = get_global_server_args()
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
video_path = _resolve_video_path(request)
image_path = _resolve_image_path(request, video_path)
cosmos3_kwargs = {}
if _is_cosmos3_server(server_args):
cosmos3_kwargs = _cosmos3_sampling_param_kwargs(
request, num_frames=num_frames, fps=fps
)
if server_args.pipeline_config.action_stats_path is not None:
cosmos3_kwargs["action_stats_path"] = (
server_args.pipeline_config.action_stats_path
)
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,
num_frames=num_frames,
fps=fps,
image_path=image_path,
video_path=video_path,
output_file_name=request_id,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps,
guidance_scale=request.guidance_scale,
guidance_scale_2=request.guidance_scale_2,
negative_prompt=request.negative_prompt,
max_sequence_length=request.max_sequence_length,
flow_shift=request.flow_shift,
use_duration_template=_extra_value(request, "use_duration_template"),
use_resolution_template=_extra_value(request, "use_resolution_template"),
use_system_prompt=_extra_value(request, "use_system_prompt"),
use_guardrails=_extra_value(request, "use_guardrails"),
enable_teacache=request.enable_teacache,
enable_frame_interpolation=request.enable_frame_interpolation,
frame_interpolation_exp=request.frame_interpolation_exp,
frame_interpolation_scale=request.frame_interpolation_scale,
frame_interpolation_model_path=request.frame_interpolation_model_path,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
output_path=request.output_path,
output_compression=request.output_compression,
output_quality=request.output_quality,
perf_dump_path=request.perf_dump_path,
diffusers_kwargs=request.diffusers_kwargs,
**cosmos3_kwargs,
)
# extract metadata which http_server needs to know
def _video_job_from_sampling(
request_id: str, req: VideoGenerationsRequest, sampling: SamplingParams
) -> Dict[str, Any]:
size_str = f"{sampling.width}x{sampling.height}"
seconds = int(round((sampling.num_frames or 0) / float(sampling.fps or 24)))
return {
"id": request_id,
"object": "video",
"model": req.model or "sora-2",
"status": "queued",
"progress": 0,
"created_at": int(time.time()),
"size": size_str,
"seconds": str(seconds),
"quality": "standard",
"file_path": os.path.abspath(sampling.output_file_path()),
}
async def _save_first_input_image(
image_sources,
request_id: str,
uploads_dir: str,
*,
prefer_remote_source: bool = False,
) -> str | None:
"""Save the first input image from a list of sources and return its path."""
image_list = merge_image_input_list(image_sources)
if not image_list:
return None
image = image_list[0]
os.makedirs(uploads_dir, exist_ok=True)
filename = image.filename if hasattr(image, "filename") else "url_image"
target_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
return await save_image_to_path(
image, target_path, prefer_remote_source=prefer_remote_source
)
async def _dispatch_job_async(
job_id: str,
batch: Req,
*,
temp_dirs: list[str] | None = None,
output_persistent: bool = True,
) -> None:
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
try:
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
persistent_path = (
save_file_path if not cloud_url and output_persistent else None
)
update_fields = {
"status": "completed",
"progress": 100,
"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
)
await VIDEO_STORE.update_fields(job_id, update_fields)
except Exception as e:
logger.error(f"{e}")
await VIDEO_STORE.update_fields(
job_id, {"status": "failed", "error": {"message": str(e)}}
)
finally:
for td in temp_dirs or []:
shutil.rmtree(td, ignore_errors=True)
# TODO: support image to video generation
@router.post("", response_model=VideoResponse)
async def create_video(
request: Request,
# multipart/form-data fields (optional; used only when content-type is multipart)
prompt: Optional[str] = Form(None),
input_reference: Optional[UploadFile] = File(None),
reference_url: Optional[str] = Form(None),
video_reference: Optional[UploadFile] = File(None),
video_url: Optional[str] = Form(None),
video_path: 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),
num_frames: Optional[int] = Form(None),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
negative_prompt: Optional[str] = Form(None),
guidance_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
max_sequence_length: Optional[int] = Form(None),
flow_shift: Optional[float] = Form(None),
enable_teacache: Optional[bool] = Form(None),
enable_frame_interpolation: Optional[bool] = Form(None),
frame_interpolation_exp: Optional[int] = Form(None),
frame_interpolation_scale: Optional[float] = Form(None),
frame_interpolation_model_path: Optional[str] = Form(None),
enable_upscaling: Optional[bool] = Form(None),
upscaling_model_path: Optional[str] = Form(None),
upscaling_scale: Optional[int] = Form(None),
output_quality: Optional[str] = Form(None),
output_compression: Optional[int] = Form(None),
output_path: Optional[str] = Form(None),
extra_params: Optional[str] = Form(None),
extra_body: Optional[str] = Form(None),
):
content_type = request.headers.get("content-type", "").lower()
request_id = generate_request_id()
server_args = get_global_server_args()
task_type = server_args.pipeline_config.task_type
# Resolve input upload directory (may be a temp dir when saving is disabled)
temp_dirs: list[str] = []
if server_args.input_save_path is not None:
uploads_dir = server_args.input_save_path
os.makedirs(uploads_dir, exist_ok=True)
else:
uploads_dir = tempfile.mkdtemp(prefix="sglang_input_")
temp_dirs.append(uploads_dir)
# Resolve output directory
effective_output_path = server_args.output_path
output_persistent = True
if "multipart/form-data" not in content_type:
# JSON body may carry a per-request output_path; checked after parsing below
pass
if "multipart/form-data" in content_type:
if not prompt:
raise HTTPException(status_code=400, detail="prompt is required")
video_input_path = None
image_sources = merge_image_input_list(input_reference, reference_url)
if video_reference is not None:
video_input_path = await _save_first_input_image(
video_reference,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
elif video_path or video_url:
video_input_path = video_path or video_url
elif input_reference is not None and _is_probably_video_source(input_reference):
video_input_path = await _save_first_input_image(
input_reference,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
image_sources = merge_image_input_list(reference_url)
elif reference_url and _is_probably_video_source(reference_url):
video_input_path = reference_url
image_sources = merge_image_input_list(input_reference)
# Validate image input based on model task type
if task_type.requires_image_input() and not image_sources:
raise HTTPException(
status_code=400,
detail="input_reference or reference_url is required for image-to-video generation",
)
input_path = None
if image_sources:
try:
input_path = await _save_first_input_image(
image_sources,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to process image source: {str(e)}"
)
# Parse extra_body JSON (if provided in multipart form) to get fps/num_frames overrides
extra_from_form: Dict[str, Any] = {}
if extra_body:
try:
extra_from_form = flatten_extra_params(json.loads(extra_body))
except Exception:
extra_from_form = {}
if extra_params:
try:
extra_from_form.update(
flatten_extra_params({"extra_params": json.loads(extra_params)})
)
except Exception:
pass
def form_value(name: str, value: Any) -> Any:
selected = value if value is not None else extra_from_form.get(name)
return _parse_form_extra_value(selected)
raw_form = await request.form()
for key in (
"use_duration_template",
"use_resolution_template",
"use_system_prompt",
"use_guardrails",
"guardrails",
"video_path",
"video_url",
"generate_sound",
"sound_duration",
"condition_frame_indexes",
"action_mode",
"domain_id",
"domain_name",
"raw_action_dim",
"action_fps",
"action",
"action_view_point",
"action_normalization",
"condition_frame_indexes_vision",
"condition_video_keep",
):
if key in raw_form and key not in extra_from_form:
extra_from_form[key] = _parse_form_extra_value(raw_form[key])
flatten_extra_params(extra_from_form)
request_field_names = set(VideoGenerationsRequest.model_fields)
extra_request_fields = {
key: value
for key, value in extra_from_form.items()
if key not in request_field_names
}
fps_val = form_value("fps", fps)
num_frames_val = form_value("num_frames", num_frames)
req = VideoGenerationsRequest(
prompt=prompt,
input_reference=input_path,
video_path=form_value("video_path", video_input_path),
video_url=form_value("video_url", video_url),
model=form_value("model", model),
n=form_value("n", n),
num_outputs_per_prompt=form_value(
"num_outputs_per_prompt", num_outputs_per_prompt
),
seconds=form_value("seconds", seconds) or 4,
size=form_value("size", size),
fps=fps_val,
num_frames=num_frames_val,
seed=form_value("seed", seed),
generator_device=form_value("generator_device", generator_device),
negative_prompt=form_value("negative_prompt", negative_prompt),
num_inference_steps=form_value("num_inference_steps", num_inference_steps),
guidance_scale=form_value("guidance_scale", guidance_scale),
max_sequence_length=form_value("max_sequence_length", max_sequence_length),
flow_shift=form_value("flow_shift", flow_shift),
enable_teacache=form_value("enable_teacache", enable_teacache),
enable_frame_interpolation=form_value(
"enable_frame_interpolation", enable_frame_interpolation
),
frame_interpolation_exp=form_value(
"frame_interpolation_exp", frame_interpolation_exp
),
frame_interpolation_scale=form_value(
"frame_interpolation_scale", frame_interpolation_scale
),
frame_interpolation_model_path=form_value(
"frame_interpolation_model_path", frame_interpolation_model_path
),
enable_upscaling=form_value("enable_upscaling", enable_upscaling),
upscaling_model_path=form_value(
"upscaling_model_path", upscaling_model_path
),
upscaling_scale=form_value("upscaling_scale", upscaling_scale),
output_compression=form_value("output_compression", output_compression),
output_quality=form_value("output_quality", output_quality),
output_path=form_value("output_path", output_path),
diffusers_kwargs=form_value("diffusers_kwargs", None),
**extra_request_fields,
)
else:
try:
body = await request.json()
except Exception:
body = {}
try:
# If client uses extra_body, merge it into the top-level payload
payload: Dict[str, Any] = dict(body or {})
extra = payload.pop("extra_body", None)
if isinstance(extra, str):
extra = json.loads(extra)
if isinstance(extra, dict):
payload.update(flatten_extra_params(extra))
# openai may turn extra_body to extra_json
extra_json = payload.pop("extra_json", None)
if isinstance(extra_json, str):
extra_json = json.loads(extra_json)
if isinstance(extra_json, dict):
payload.update(flatten_extra_params(extra_json))
flatten_extra_params(payload)
# Validate image input based on model task type
if payload.get("video_url") and not payload.get("video_path"):
payload["video_path"] = payload["video_url"]
if _is_probably_video_source(payload.get("reference_url")):
payload.setdefault("video_path", payload.get("reference_url"))
if _is_probably_video_source(payload.get("input_reference")):
payload.setdefault("video_path", payload.get("input_reference"))
has_image_input = (
payload.get("reference_url")
and not _is_probably_video_source(payload.get("reference_url"))
) or (
payload.get("input_reference")
and not _is_probably_video_source(payload.get("input_reference"))
)
if task_type.requires_image_input() and not has_image_input:
raise HTTPException(
status_code=400,
detail="input_reference or reference_url is required for image-to-video generation",
)
# for non-multipart/form-data type
if payload.get("reference_url") and not _is_probably_video_source(
payload.get("reference_url")
):
try:
input_path = await _save_first_input_image(
payload.get("reference_url"),
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to process image source: {str(e)}",
)
payload["input_reference"] = input_path
req = VideoGenerationsRequest(**payload)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
# Resolve per-request output_path override
effective_output_path = req.output_path or server_args.output_path
if effective_output_path is None:
output_tmp = tempfile.mkdtemp(prefix="sglang_output_")
temp_dirs.append(output_tmp)
effective_output_path = output_tmp
output_persistent = False
# Inject resolved output_path so _build_video_sampling_params picks it up
req.output_path = effective_output_path
logger.debug(f"Server received from create_video endpoint: req={req}")
try:
sampling_params = _build_video_sampling_params(request_id, req)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=str(e))
job = _video_job_from_sampling(request_id, req, sampling_params)
await VIDEO_STORE.upsert(request_id, job)
# Build Req for scheduler
trace_headers = extract_trace_headers(request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
external_trace_header=trace_headers,
)
# Add diffusers_kwargs if provided
if req.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
if "max_sequence_length" in req.diffusers_kwargs:
batch.max_sequence_length = req.diffusers_kwargs["max_sequence_length"]
if "flow_shift" in req.diffusers_kwargs:
batch.flow_shift = req.diffusers_kwargs["flow_shift"]
# Enqueue the job asynchronously and return immediately
asyncio.create_task(
_dispatch_job_async(
request_id,
batch,
temp_dirs=temp_dirs or None,
output_persistent=output_persistent,
)
)
return VideoResponse(**job)
@router.get("", response_model=VideoListResponse)
async def list_videos(
after: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=100),
order: Optional[str] = Query("desc"),
):
# Normalize order
order = (order or "desc").lower()
if order not in ("asc", "desc"):
order = "desc"
jobs = await VIDEO_STORE.list_values()
reverse = order != "asc"
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=reverse)
if after is not None:
try:
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
jobs = jobs[idx + 1 :]
except StopIteration:
jobs = []
if limit is not None:
jobs = jobs[:limit]
items = [VideoResponse(**j) for j in jobs]
return VideoListResponse(data=items)
@router.get("/{video_id}", response_model=VideoResponse)
async def retrieve_video(video_id: str = Path(...)):
job = await VIDEO_STORE.get(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
return VideoResponse(**job)
# TODO: support aborting a job.
@router.delete("/{video_id}", response_model=VideoResponse)
async def delete_video(video_id: str = Path(...)):
job = await VIDEO_STORE.pop(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
# Mark as deleted in response semantics
job["status"] = "deleted"
return VideoResponse(**job)
@router.get("/{video_id}/content")
async def download_video_content(
video_id: str = Path(...), variant: Optional[str] = Query(None)
):
job = await VIDEO_STORE.get(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
if job.get("url"):
raise HTTPException(
status_code=400,
detail=f"Video has been uploaded to cloud storage. Please use the cloud URL: {job.get('url')}",
)
file_path = job.get("file_path")
if not file_path or not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Generation is still in-progress")
media_type = "video/mp4" # default variant
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,110 @@
"""Request/response data structures for post-training APIs."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
from pydantic import BaseModel
@dataclass
class UpdateWeightFromDiskReqInput:
"""Request to update model weights from disk for diffusion models."""
model_path: str
flush_cache: bool = True
target_modules: list[str] | None = None
@dataclass
class UpdateWeightFromTensorReqInput:
"""Request to update model weights from tensor payloads for diffusion models."""
serialized_named_tensors: list[str | bytes]
load_format: str | None = None
target_modules: list[str] | None = None
@dataclass
class UpdateWeightFromTensorCheckerReqInput:
"""Request to verify live module weights against expected SHA-256 values."""
target_module: str
expected_named_tensors_sha256: dict[str, str]
@dataclass
class GetWeightsChecksumReqInput:
"""Compute SHA-256 checksum of loaded module weights for verification."""
module_names: list[str] | None = None
@dataclass
class ReleaseMemoryOccupationReqInput:
"""Request to release (sleep) GPU memory occupation for the diffusion engine."""
pass
@dataclass
class ResumeMemoryOccupationReqInput:
"""Request to resume (wake) GPU memory occupation for the diffusion engine."""
pass
class RolloutRequest(BaseModel):
prompt: str
negative_prompt: Optional[str] = None
seed: Optional[int] = None
generator_device: str = "cuda"
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
num_outputs_per_prompt: Optional[int] = None
guidance_scale: Optional[float] = None
true_cfg_scale: Optional[float] = None
# video-specific (ignored by image pipelines)
num_frames: Optional[int] = None
fps: Optional[int] = None
rollout: bool = True
rollout_sde_type: str = "sde"
rollout_noise_level: float = 0.7
rollout_log_prob_no_const: bool = False
rollout_debug_mode: bool = True
rollout_return_denoising_env: bool = False
rollout_return_dit_trajectory: bool = False
# 0-indexed denoising-loop step filters. None = all steps.
rollout_sde_step_indices: Optional[list[int]] = None
rollout_return_step_indices: Optional[list[int]] = None
image_path: Optional[list[str]] = None
# suppress verbose per-request logging (also gates peak_memory_mb collection)
suppress_logs: bool = False
extra_sampling_params: Optional[dict[str, Any]] = None
class RolloutResponse(BaseModel):
request_id: str
prompt: str
seed: int
generated_output: Any = None
rollout_log_probs: Optional[dict[str, Any]] = None
rollout_debug_tensors: Optional[dict[str, Any]] = None
denoising_env: Optional[dict[str, Any]] = None
dit_trajectory: Optional[dict[str, Any]] = None
inference_time_s: Optional[float] = None
peak_memory_mb: Optional[float] = None
@@ -0,0 +1,329 @@
"""Rollout HTTP API (``POST /rollout/generate``)."""
from __future__ import annotations
from typing import Any
import torch
from fastapi import APIRouter, HTTPException
from fastapi.responses import ORJSONResponse
from sglang.multimodal_gen.configs.sample.sampling_params import generate_request_id
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import build_sampling_params
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
RolloutRequest,
RolloutResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.utils import (
_maybe_serialize,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.post_training.rl_dataclasses import (
RolloutDebugTensors,
RolloutDenoisingEnv,
RolloutDitTrajectory,
RolloutTrajectoryData,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
router = APIRouter(prefix="/rollout", tags=["rollout"])
def _extract_single_sample_tensor(
obj: Any, sample_idx: int, batch_size: int, *, current_key: str | None = None
) -> Any:
if isinstance(obj, torch.Tensor):
if obj.dim() >= 1 and obj.shape[0] == batch_size:
return obj[sample_idx].contiguous()
return obj
if isinstance(obj, dict):
return {
k: _extract_single_sample_tensor(v, sample_idx, batch_size, current_key=k)
for k, v in obj.items()
}
if isinstance(obj, list):
if current_key == "img_shapes" and len(obj) == batch_size:
return [obj[sample_idx]]
return [
_extract_single_sample_tensor(
v, sample_idx, batch_size, current_key=current_key
)
for v in obj
]
if isinstance(obj, tuple):
return tuple(
_extract_single_sample_tensor(
v, sample_idx, batch_size, current_key=current_key
)
for v in obj
)
return obj
def _slice_rollout_trajectory_for_sample(
rtd: RolloutTrajectoryData | None,
sample_idx: int,
batch_size: int,
) -> RolloutTrajectoryData | None:
if rtd is None:
return None
log_probs = rtd.rollout_log_probs
if (
isinstance(log_probs, torch.Tensor)
and log_probs.dim() >= 1
and log_probs.shape[0] == batch_size
):
log_probs = log_probs[sample_idx].contiguous()
debug_tensors = None
if rtd.rollout_debug_tensors:
rd = rtd.rollout_debug_tensors
debug_tensors = RolloutDebugTensors(
rollout_variance_noises=_extract_single_sample_tensor(
rd.rollout_variance_noises, sample_idx, batch_size
),
rollout_prev_sample_means=_extract_single_sample_tensor(
rd.rollout_prev_sample_means, sample_idx, batch_size
),
rollout_noise_std_devs=_extract_single_sample_tensor(
rd.rollout_noise_std_devs, sample_idx, batch_size
),
rollout_model_outputs=_extract_single_sample_tensor(
rd.rollout_model_outputs, sample_idx, batch_size
),
)
denoising_env = None
if rtd.denoising_env:
env = rtd.denoising_env
denoising_env = RolloutDenoisingEnv(
image_kwargs=(
_extract_single_sample_tensor(env.image_kwargs, sample_idx, batch_size)
if env.image_kwargs
else None
),
pos_cond_kwargs=(
_extract_single_sample_tensor(
env.pos_cond_kwargs, sample_idx, batch_size
)
if env.pos_cond_kwargs
else None
),
neg_cond_kwargs=(
_extract_single_sample_tensor(
env.neg_cond_kwargs, sample_idx, batch_size
)
if env.neg_cond_kwargs
else None
),
guidance=(
_extract_single_sample_tensor(env.guidance, sample_idx, batch_size)
if env.guidance is not None
else None
),
)
dit_trajectory = None
if rtd.dit_trajectory:
dit = rtd.dit_trajectory
dit_trajectory = RolloutDitTrajectory(
latents=_extract_single_sample_tensor(dit.latents, sample_idx, batch_size),
timesteps=dit.timesteps,
)
return RolloutTrajectoryData(
rollout_log_probs=log_probs,
rollout_debug_tensors=debug_tensors,
denoising_env=denoising_env,
dit_trajectory=dit_trajectory,
)
def _serialize_rollout_trajectory(
rtd: RolloutTrajectoryData | None,
*,
serialized_dit_timesteps: dict | None = None,
) -> tuple[dict | None, dict | None, dict | None, dict | None]:
"""Return order: rollout_log_probs, rollout_debug_tensors, denoising_env, dit_trajectory."""
if rtd is None:
return None, None, None, None
serialized_log_probs = _maybe_serialize(rtd.rollout_log_probs)
serialized_debug_tensors = None
if rtd.rollout_debug_tensors:
rd = rtd.rollout_debug_tensors
serialized_debug_tensors = {
"rollout_variance_noises": _maybe_serialize(rd.rollout_variance_noises),
"rollout_prev_sample_means": _maybe_serialize(rd.rollout_prev_sample_means),
"rollout_noise_std_devs": _maybe_serialize(rd.rollout_noise_std_devs),
"rollout_model_outputs": _maybe_serialize(rd.rollout_model_outputs),
}
serialized_denoising_env = None
if rtd.denoising_env:
env = rtd.denoising_env
serialized_denoising_env = {
"image_kwargs": (
_maybe_serialize(env.image_kwargs) if env.image_kwargs else None
),
"pos_cond_kwargs": (
_maybe_serialize(env.pos_cond_kwargs) if env.pos_cond_kwargs else None
),
"neg_cond_kwargs": (
_maybe_serialize(env.neg_cond_kwargs) if env.neg_cond_kwargs else None
),
"guidance": (
_maybe_serialize(env.guidance) if env.guidance is not None else None
),
}
serialized_dit_trajectory = None
if rtd.dit_trajectory:
dit = rtd.dit_trajectory
serialized_dit_trajectory = {
"latents": (
_maybe_serialize(dit.latents) if dit.latents is not None else None
),
"timesteps": serialized_dit_timesteps,
}
return (
serialized_log_probs,
serialized_debug_tensors,
serialized_denoising_env,
serialized_dit_trajectory,
)
def _build_response(
request_id: str, prompt: str, seed: int, rollout: bool, result: OutputBatch
) -> list[RolloutResponse]:
"""
rollout: bool - set to False when evaluating the model
"""
batch_size = result.output.shape[0]
inference_time_s = (
result.metrics.total_duration_s
if result.metrics and result.metrics.total_duration_s > 0
else None
)
peak_memory_mb = result.peak_memory_mb if result.peak_memory_mb > 0 else None
rollout_trajectory_data = result.rollout_trajectory_data
if rollout:
assert (
rollout_trajectory_data is not None
), "rollout_trajectory_data must be present when rollout=True"
serialized_dit_timesteps = None
if rollout and rollout_trajectory_data and rollout_trajectory_data.dit_trajectory:
serialized_dit_timesteps = _maybe_serialize(
rollout_trajectory_data.dit_trajectory.timesteps
)
responses: list[RolloutResponse] = []
for sample_idx in range(batch_size):
out_i = result.output[sample_idx]
if isinstance(out_i, torch.Tensor):
out_i = out_i.contiguous()
serialized_generated_output = _maybe_serialize(out_i)
if not rollout:
responses.append(
RolloutResponse(
request_id=request_id,
prompt=prompt,
seed=seed,
generated_output=serialized_generated_output,
inference_time_s=inference_time_s,
peak_memory_mb=peak_memory_mb,
)
)
continue
per_sample_trajectory = _slice_rollout_trajectory_for_sample(
result.rollout_trajectory_data, sample_idx, batch_size
)
(
serialized_log_probs,
serialized_debug_tensors,
serialized_denoising_env,
serialized_dit_trajectory,
) = _serialize_rollout_trajectory(
per_sample_trajectory,
serialized_dit_timesteps=serialized_dit_timesteps,
)
responses.append(
RolloutResponse(
request_id=request_id,
prompt=prompt,
seed=seed,
generated_output=serialized_generated_output,
rollout_log_probs=serialized_log_probs,
rollout_debug_tensors=serialized_debug_tensors,
denoising_env=serialized_denoising_env,
dit_trajectory=serialized_dit_trajectory,
inference_time_s=inference_time_s,
peak_memory_mb=peak_memory_mb,
)
)
return responses
def _build_sampling_kwargs(request: RolloutRequest) -> dict:
sampling_kwargs: dict = dict(
prompt=request.prompt,
negative_prompt=request.negative_prompt,
seed=request.seed,
generator_device=request.generator_device,
width=request.width,
height=request.height,
num_inference_steps=request.num_inference_steps,
num_outputs_per_prompt=request.num_outputs_per_prompt,
guidance_scale=request.guidance_scale,
true_cfg_scale=request.true_cfg_scale,
num_frames=request.num_frames,
fps=request.fps,
image_path=request.image_path,
rollout=request.rollout,
rollout_sde_type=request.rollout_sde_type,
rollout_noise_level=request.rollout_noise_level,
rollout_log_prob_no_const=request.rollout_log_prob_no_const,
rollout_debug_mode=request.rollout_debug_mode,
rollout_return_denoising_env=request.rollout_return_denoising_env,
rollout_return_dit_trajectory=request.rollout_return_dit_trajectory,
rollout_sde_step_indices=request.rollout_sde_step_indices,
rollout_return_step_indices=request.rollout_return_step_indices,
suppress_logs=request.suppress_logs,
save_output=False,
return_trajectory_latents=False,
return_trajectory_decoded=False,
)
if request.extra_sampling_params:
sampling_kwargs.update(request.extra_sampling_params)
sampling_kwargs["rollout"] = request.rollout
return {k: v for k, v in sampling_kwargs.items() if v is not None}
@router.post("/generate", response_model=list[RolloutResponse])
async def rollout_generate(request: RolloutRequest):
request_id = generate_request_id()
server_args = get_global_server_args()
sampling_kwargs = _build_sampling_kwargs(request)
try:
sampling_params = build_sampling_params(request_id, **sampling_kwargs)
except Exception as exc:
raise HTTPException(
status_code=400, detail=f"Invalid sampling params: {exc}"
) from exc
pipeline_request = prepare_request(
server_args=server_args, sampling_params=sampling_params
)
try:
output_batch: OutputBatch = await async_scheduler_client.forward(
pipeline_request
)
except Exception as exc:
logger.error("Rollout generation failed: %s", exc, exc_info=True)
raise HTTPException(
status_code=500, detail=f"Generation failed: {exc}"
) from exc
if output_batch.error:
raise HTTPException(status_code=500, detail=output_batch.error)
rollout_responses = _build_response(
request_id, request.prompt, request.seed, request.rollout, output_batch
)
return ORJSONResponse(content=[r.model_dump() for r in rollout_responses])
@@ -0,0 +1,48 @@
"""Tensor serialization for post-training / rollout HTTP responses."""
from __future__ import annotations
import base64
from typing import Any
import numpy as np
import torch
from safetensors.torch import load, save
def tensor_to_base64(t: torch.Tensor) -> str:
t = t.detach().contiguous().cpu()
raw = save({"t": t})
return base64.b64encode(raw).decode("ascii")
def base64_to_tensor(s: str) -> torch.Tensor:
raw = base64.b64decode(s)
return load(raw)["t"]
def _maybe_serialize(obj: Any) -> Any:
if isinstance(obj, torch.Tensor):
return {
"__tensor__": True,
"data": tensor_to_base64(obj),
"shape": list(obj.shape),
"dtype": str(obj.dtype),
}
if isinstance(obj, np.ndarray):
return _maybe_serialize(torch.from_numpy(obj))
if isinstance(obj, dict):
return {k: _maybe_serialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_maybe_serialize(v) for v in obj]
return obj
def _maybe_deserialize(obj: Any) -> Any:
if isinstance(obj, dict):
if obj.get("__tensor__"):
return base64_to_tensor(obj["data"])
return {k: _maybe_deserialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_maybe_deserialize(v) for v in obj]
return obj
@@ -0,0 +1,199 @@
"""Weight update API for the diffusion engine."""
from fastapi import APIRouter, Request
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.srt.utils.json_response import orjson_response
router = APIRouter()
@router.post("/update_weights_from_disk")
async def update_weights_from_disk(request: Request):
"""Update model weights from disk inplace without restarting the server."""
body = await request.json()
model_path = body.get("model_path")
if not model_path:
return orjson_response(
{"success": False, "message": "model_path is required"},
status_code=400,
)
req = UpdateWeightFromDiskReqInput(
model_path=model_path,
flush_cache=body.get("flush_cache", True),
target_modules=body.get("target_modules"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
result = response.output
return orjson_response(
result,
status_code=200 if result["success"] else 400,
)
@router.post("/update_weights_from_tensor")
async def update_weights_from_tensor(request: Request):
"""Update model weights from serialized tensor payloads."""
body = await request.json()
serialized_named_tensors = body.get("serialized_named_tensors")
if not serialized_named_tensors:
return orjson_response(
{"success": False, "message": "serialized_named_tensors is required"},
status_code=400,
)
req = UpdateWeightFromTensorReqInput(
serialized_named_tensors=serialized_named_tensors,
load_format=body.get("load_format"),
target_modules=body.get("target_modules"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
result = response.output
return orjson_response(
result,
status_code=200 if result["success"] else 400,
)
@router.post("/update_weights_from_tensor_checker")
async def update_weights_from_tensor_checker(request: Request):
"""Verify live module weights against expected SHA-256 values."""
body = await request.json()
target_module = body.get("target_module")
if not target_module:
return orjson_response(
{"success": False, "message": "target_module is required"},
status_code=400,
)
expected_named_tensors_sha256 = body.get("expected_named_tensors_sha256")
if (
not isinstance(expected_named_tensors_sha256, dict)
or not expected_named_tensors_sha256
):
return orjson_response(
{
"success": False,
"message": "expected_named_tensors_sha256 is required",
},
status_code=400,
)
req = UpdateWeightFromTensorCheckerReqInput(
target_module=target_module,
expected_named_tensors_sha256=expected_named_tensors_sha256,
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
result = response.output
success = result.get("success", False)
message = result.get("message", "Unknown status")
return orjson_response(
{"success": success, "message": message},
status_code=200 if success else 400,
)
@router.post("/get_weights_checksum")
async def get_weights_checksum(request: Request):
"""Return SHA-256 checksum of each requested module's weights."""
body = await request.json()
req = GetWeightsChecksumReqInput(
module_names=body.get("module_names"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response({"error": str(e)}, status_code=500)
return orjson_response(response.output, status_code=200)
@router.post("/release_memory_occupation")
async def release_memory_occupation():
"""Release GPU memory occupation (sleep the engine)."""
try:
response = await async_scheduler_client.forward(
ReleaseMemoryOccupationReqInput()
)
except Exception as e:
return orjson_response({"success": False, "message": str(e)}, status_code=500)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
payload = response.output
success = bool(payload["success"])
return orjson_response(payload, status_code=200 if success else 400)
@router.post("/resume_memory_occupation")
async def resume_memory_occupation():
"""Resume GPU memory occupation (wake the engine)."""
try:
response = await async_scheduler_client.forward(
ResumeMemoryOccupationReqInput()
)
except Exception as e:
return orjson_response({"success": False, "message": str(e)}, status_code=500)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
payload = response.output
success = bool(payload["success"])
return orjson_response(payload, status_code=200 if success else 400)
@@ -0,0 +1,755 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
"""
DiffGenerator module for sglang-diffusion.
This module provides a consolidated interface for generating videos using
diffusion models.
"""
import json
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
import imageio
import numpy as np
import torch
from PIL import Image
try:
import scipy.io.wavfile as scipy_wavfile
except ImportError: # pragma: no cover
scipy_wavfile = None
try:
import imageio_ffmpeg as _imageio_ffmpeg
except ImportError: # pragma: no cover
_imageio_ffmpeg = None
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import CYAN, RESET, init_logger
from sglang.srt.observability.trace import TraceReqContext
logger = init_logger(__name__)
@dataclass
class SetLoraReq:
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0
merge_mode: Optional[str] = None
@dataclass
class MergeLoraWeightsReq:
target: str = "all"
strength: float = 1.0
@dataclass
class UnmergeLoraWeightsReq:
target: str = "all"
@dataclass
class ListLorasReq:
pass
@dataclass
class ShutdownReq:
pass
@dataclass
class ReleaseRealtimeSessionReq:
session_id: str
@dataclass
class GetDisaggStatsReq:
"""Request to get disagg pipeline metrics from the scheduler."""
pass
def format_lora_message(
lora_nickname: Union[str, List[str]],
target: Union[str, List[str]],
strength: Union[float, List[float]],
) -> tuple[str, str, str]:
"""Format success message for single or multiple LoRAs."""
if isinstance(lora_nickname, list):
nickname_str = ", ".join(lora_nickname)
target_str = ", ".join(target) if isinstance(target, list) else target
strength_str = (
", ".join(f"{s:.2f}" for s in strength)
if isinstance(strength, list)
else f"{strength:.2f}"
)
else:
nickname_str = lora_nickname
target_str = target if isinstance(target, str) else ", ".join(target)
strength_str = (
f"{strength:.2f}"
if isinstance(strength, (int, float))
else ", ".join(f"{s:.2f}" for s in strength)
)
return nickname_str, target_str, strength_str
@dataclass
class GenerationResult:
"""Result of a single generation request from DiffGenerator."""
samples: Any = None
frames: Any = None
audio: Any = None
action: Any = None # [T, raw_action_dim] predicted action (policy/inverse_dynamics)
prompt: str | None = None
size: tuple | None = None # (height, width, num_frames)
generation_time: float = 0.0
peak_memory_mb: float = 0.0
metrics: dict = field(default_factory=dict)
trajectory_latents: Any = None
trajectory_timesteps: Any = None
rollout_trajectory_data: Any = None
trajectory_decoded: Any = None
prompt_index: int = 0
output_file_path: str | None = None
@dataclass
class MaterializedOutput:
sample: Any
frames: list[Any]
audio: Any = None
fps: int = 0
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.condition_inputs = dict(req.condition_inputs)
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:
return None
if isinstance(audio, torch.Tensor):
audio_np = audio.detach().float().clamp(-1.0, 1.0).cpu().numpy()
elif isinstance(audio, np.ndarray):
audio_np = audio.astype(np.float32, copy=False)
audio_np = np.clip(audio_np, -1.0, 1.0)
else:
return None
# 1. Squeeze leading singleton dimensions (Batch, etc.)
while audio_np.ndim > 1 and audio_np.shape[0] == 1:
audio_np = audio_np.squeeze(0)
# 2. Handle (C, L) -> (L, C)
if audio_np.ndim == 2 and audio_np.shape[0] < audio_np.shape[1]:
audio_np = audio_np.transpose(1, 0)
# 3. Final safety check: if still 2D and channels (dim 1) is huge, something is wrong
if audio_np.ndim == 2 and audio_np.shape[1] > 256 and audio_np.shape[0] == 1:
audio_np = audio_np.flatten()
return audio_np
def _pick_audio_sample_rate(
*,
audio_np: np.ndarray,
audio_sample_rate: Optional[int],
fps: int,
num_frames: int,
) -> int:
"""Pick a plausible sample rate, falling back to inferring from video duration."""
selected_sr = int(audio_sample_rate) if audio_sample_rate is not None else None
if selected_sr is None or not (8000 <= selected_sr <= 192000):
selected_sr = 24000
try:
duration_s = float(num_frames) / float(fps) if fps else 0.0
if duration_s > 0:
audio_len = (
int(audio_np.shape[0])
if audio_np.ndim == 2
else int(audio_np.shape[-1])
)
inferred_sr = int(round(float(audio_len) / duration_s))
if 8000 <= inferred_sr <= 192000:
selected_sr = inferred_sr
except Exception:
pass
return selected_sr
def _resolve_ffmpeg_exe() -> str:
ffmpeg_exe = "ffmpeg"
ffmpeg_on_path = shutil.which("ffmpeg")
if ffmpeg_on_path:
ffmpeg_exe = ffmpeg_on_path
try:
if _imageio_ffmpeg is not None:
ffmpeg_exe = _imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
ffmpeg_ok = False
if ffmpeg_exe:
if os.path.isabs(ffmpeg_exe):
ffmpeg_ok = os.path.exists(ffmpeg_exe)
else:
ffmpeg_ok = shutil.which(ffmpeg_exe) is not None
if not ffmpeg_ok:
raise RuntimeError("ffmpeg not found")
return ffmpeg_exe
def _mux_audio_np_into_mp4(
*,
save_file_path: str,
audio_np: np.ndarray,
sample_rate: int,
ffmpeg_exe: str,
) -> None:
merged_path = save_file_path.rsplit(".", 1)[0] + ".tmp_mux.mp4"
tmp_wav_path = None
try:
if scipy_wavfile is None:
raise RuntimeError(
"scipy is required to mux audio into mp4 (pip install scipy)"
)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp_wav_path = f.name
scipy_wavfile.write(tmp_wav_path, sample_rate, audio_np)
subprocess.run(
[
ffmpeg_exe,
"-y",
"-i",
save_file_path,
"-i",
tmp_wav_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-strict",
"experimental",
merged_path,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
os.replace(merged_path, save_file_path)
finally:
if tmp_wav_path:
try:
os.remove(tmp_wav_path)
except OSError:
pass
if os.path.exists(merged_path):
try:
os.remove(merged_path)
except OSError:
pass
def _maybe_mux_audio_into_mp4(
*,
save_file_path: str,
audio: Any,
frames: list,
fps: int,
audio_sample_rate: Optional[int],
) -> None:
"""Best-effort mux audio into an already-written mp4 at save_file_path.
Any failure should keep the silent video and only log a warning.
"""
audio_np = _normalize_audio_to_numpy(audio)
if audio_np is None:
return
selected_sr = _pick_audio_sample_rate(
audio_np=audio_np,
audio_sample_rate=audio_sample_rate,
fps=fps,
num_frames=len(frames),
)
try:
ffmpeg_exe = _resolve_ffmpeg_exe()
_mux_audio_np_into_mp4(
save_file_path=save_file_path,
audio_np=audio_np,
sample_rate=selected_sr,
ffmpeg_exe=ffmpeg_exe,
)
except Exception as e:
logger.warning(
"Failed to mux audio into mp4 (saved silent video): %s",
str(e),
)
def prepare_request(
server_args: ServerArgs,
sampling_params: SamplingParams,
external_trace_header: dict[str, str] | None = None,
) -> Req:
"""
Create a Req object with sampling_params as a parameter.
"""
req = Req(
sampling_params=sampling_params,
VSA_sparsity=server_args.attention_backend_config.VSA_sparsity,
)
sampling_params.apply_request_extra(req)
if getattr(sampling_params, "max_sequence_length", None) is not None:
req.max_sequence_length = sampling_params.max_sequence_length
diffusers_kwargs = getattr(sampling_params, "diffusers_kwargs", None)
if diffusers_kwargs and "max_sequence_length" in diffusers_kwargs:
req.max_sequence_length = diffusers_kwargs["max_sequence_length"]
if not isinstance(req.prompt, str):
raise TypeError(f"`prompt` must be a string, but got {type(req.prompt)}")
req_width = getattr(req, "width", None)
req_height = getattr(req, "height", None)
if (req_width is not None and req_width <= 0) or (
req_height is not None and req_height <= 0
):
raise ValueError(
f"Height and width must be positive, got height={req_height}, width={req_width}"
)
if server_args.enable_trace:
trace_ctx = TraceReqContext(
rid=sampling_params.request_id,
module_name="diffusion",
external_trace_header=external_trace_header,
)
trace_ctx.trace_req_start()
req.trace_ctx = trace_ctx
return req
def attach_audio_to_video_sample(
sample: Any,
audio: Any,
output_idx: int,
) -> Any:
"""Attach per-sample audio for video outputs when available."""
audio = select_output_audio(audio, output_idx)
if audio is None:
return sample
if not (isinstance(sample, (tuple, list)) and len(sample) == 2):
return (sample, audio)
return sample
def select_output_audio(audio: Any, output_idx: int) -> Any:
if isinstance(audio, torch.Tensor) and audio.ndim >= 2:
return audio[output_idx] if audio.shape[0] > output_idx else None
if isinstance(audio, np.ndarray) and audio.ndim >= 2:
return audio[output_idx] if audio.shape[0] > output_idx else None
return audio
def _split_sample_audio(sample: Any) -> tuple[Any, Any]:
if isinstance(sample, (tuple, list)) and len(sample) == 2:
return sample[0], sample[1]
return sample, None
def _sample_to_uint8_frames(sample: Any) -> list[Any]:
"""return numpy frames in THCW format"""
if isinstance(sample, torch.Tensor):
# sample is raw tensor
if sample.dim() == 3:
sample = sample.unsqueeze(1)
sample = (sample * 255).clamp(0, 255).to(torch.uint8)
videos = sample.permute(1, 2, 3, 0).contiguous().cpu().numpy()
return list(videos)
if not isinstance(sample, np.ndarray):
raise TypeError(f"Unsupported sample type: {type(sample)}")
# sample is numpy frames
arr = sample
if arr.ndim == 3:
if arr.shape[-1] in (1, 3, 4):
arr = arr[None, ...]
else:
arr = arr[..., None]
if arr.ndim != 4:
raise ValueError(f"Unexpected numpy sample shape: {tuple(arr.shape)}")
if arr.shape[-1] not in (1, 3, 4) and arr.shape[0] in (1, 3, 4):
t = torch.from_numpy(arr)
if t.dim() == 3:
t = t.unsqueeze(1)
t = (t * 255).clamp(0, 255).to(torch.uint8)
videos = t.permute(1, 2, 3, 0).contiguous().cpu().numpy()
return list(videos)
if arr.dtype != np.uint8:
arr = (np.clip(arr, 0.0, 1.0) * 255.0).astype(np.uint8)
return list(arr)
def materialize_output_sample(
sample: Any,
data_type: DataType,
fps: int,
*,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> MaterializedOutput:
"""materialize samples, apply postprocessing if applicable"""
sample_without_audio, audio = _split_sample_audio(sample)
frames = _sample_to_uint8_frames(sample_without_audio)
# frames are uint8 numpy arrays in THWC format at this point
if enable_frame_interpolation and data_type == DataType.VIDEO and len(frames) > 1:
from sglang.multimodal_gen.runtime.postprocess import (
interpolate_video_frames,
)
frames, multiplier = interpolate_video_frames(
frames,
exp=frame_interpolation_exp,
scale=frame_interpolation_scale,
model_path=frame_interpolation_model_path,
)
fps = fps * multiplier
if enable_upscaling and frames:
from sglang.multimodal_gen.runtime.postprocess import upscale_frames
frames = upscale_frames(
frames,
model_path=upscaling_model_path,
scale=upscaling_scale,
)
return MaterializedOutput(sample=sample, frames=frames, audio=audio, fps=fps)
def save_materialized_output(
materialized: MaterializedOutput,
data_type: DataType,
save_file_path: Optional[str],
*,
save_output: bool = True,
audio_sample_rate: Optional[int] = None,
output_compression: Optional[int] = None,
) -> None:
if not save_output:
return
if not save_file_path:
logger.info("No output path provided, output not saved")
return
os.makedirs(os.path.dirname(save_file_path), exist_ok=True)
if data_type == DataType.VIDEO:
quality = output_compression / 10 if output_compression is not None else 5
imageio.mimsave(
save_file_path,
materialized.frames,
fps=materialized.fps,
format=data_type.get_default_extension(),
codec="libx264",
quality=quality,
)
_maybe_mux_audio_into_mp4(
save_file_path=save_file_path,
audio=materialized.audio,
frames=materialized.frames,
fps=materialized.fps,
audio_sample_rate=audio_sample_rate,
)
else:
quality = output_compression if output_compression is not None else 75
if len(materialized.frames) > 1:
for i, image in enumerate(materialized.frames):
parts = save_file_path.rsplit(".", 1)
if len(parts) == 2:
indexed_path = f"{parts[0]}_{i}.{parts[1]}"
else:
indexed_path = f"{save_file_path}_{i}"
_save_image_frame(indexed_path, image, quality, output_compression)
else:
_save_image_frame(
save_file_path, materialized.frames[0], quality, output_compression
)
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
def _save_image_frame(
path: str, frame: np.ndarray, quality: int | None, output_compression: int | None
) -> None:
ext = os.path.splitext(path)[1].lower()
if ext == ".png":
compress_level = 1
if output_compression is not None and output_compression != 75:
compress_level = max(0, min(9, round(output_compression / 100 * 9)))
if frame.ndim == 3 and frame.shape[-1] == 1:
frame = frame[..., 0]
Image.fromarray(frame).save(path, format="PNG", compress_level=compress_level)
else:
imageio.imwrite(path, frame, quality=quality)
def save_outputs(
outputs: Sequence[Any],
data_type: DataType,
fps: int,
save_output: bool,
build_output_path: Callable[[int], str],
*,
audio: Any = None,
audio_sample_rate: Optional[int] = None,
samples_out: Optional[list[Any]] = None,
audios_out: Optional[list[Any]] = None,
frames_out: Optional[list[Any]] = None,
output_compression: Optional[int] = None,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> list[str]:
output_paths: list[str] = []
for idx, sample in enumerate(outputs):
save_file_path = build_output_path(idx)
if data_type == DataType.ACTION:
if samples_out is not None:
samples_out.append(sample)
if audios_out is not None:
audios_out.append(None)
if frames_out is not None:
frames_out.append([])
if save_output and save_file_path:
os.makedirs(os.path.dirname(save_file_path) or ".", exist_ok=True)
with open(save_file_path, "w", encoding="utf-8") as f:
json.dump(sample, f, ensure_ascii=False)
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
output_paths.append(save_file_path)
continue
if data_type == DataType.VIDEO:
sample = attach_audio_to_video_sample(sample, audio, idx)
frames = post_process_sample(
sample,
data_type,
fps,
save_output,
save_file_path,
audio_sample_rate=audio_sample_rate,
output_compression=output_compression,
enable_frame_interpolation=enable_frame_interpolation,
frame_interpolation_exp=frame_interpolation_exp,
frame_interpolation_scale=frame_interpolation_scale,
frame_interpolation_model_path=frame_interpolation_model_path,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
if samples_out is not None:
samples_out.append(sample)
if audios_out is not None:
if data_type == DataType.VIDEO:
audios_out.append(select_output_audio(audio, idx))
else:
audios_out.append(audio)
if frames_out is not None:
frames_out.append(frames)
output_paths.append(save_file_path)
return output_paths
def post_process_sample(
sample: Any,
data_type: DataType,
fps: int,
save_output: bool = True,
save_file_path: Optional[str] = None,
audio_sample_rate: Optional[int] = None,
output_compression: Optional[int] = None,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> list[Any]:
"""materialize frames and save outputs (optional)"""
if data_type == DataType.ACTION:
return []
materialized = materialize_output_sample(
sample,
data_type,
fps,
enable_frame_interpolation=enable_frame_interpolation,
frame_interpolation_exp=frame_interpolation_exp,
frame_interpolation_scale=frame_interpolation_scale,
frame_interpolation_model_path=frame_interpolation_model_path,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
save_materialized_output(
materialized,
data_type,
save_file_path,
save_output=save_output,
audio_sample_rate=audio_sample_rate,
output_compression=output_compression,
)
return materialized.frames
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
action_generation_response,
action_metadata,
action_raw_response,
infer_action,
pack_msgpack,
unpack_msgpack,
)
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
run_action_msgpack_ws,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.srt.utils.json_response import orjson_response
router = APIRouter(prefix="/v1/actions", tags=["actions"])
def _wants_msgpack(request: Request) -> bool:
content_type = request.headers.get("content-type", "").lower()
accept = request.headers.get("accept", "").lower()
return "msgpack" in content_type or "msgpack" in accept
def _response_format(payload: dict) -> str:
runtime = payload.get("runtime") or {}
response_format = str(runtime.get("response_format", "envelope")).lower()
if response_format not in ("envelope", "raw"):
raise ValueError("runtime.response_format must be 'envelope' or 'raw'")
return response_format
def _prefer_numpy_output(payload: dict) -> None:
runtime = payload.setdefault("runtime", {})
runtime.setdefault("output_format", "numpy")
@router.post("/generations")
async def create_action_generation(request: Request):
server_args: ServerArgs = request.app.state.server_args
try:
if "msgpack" in request.headers.get("content-type", "").lower():
payload = unpack_msgpack(await request.body())
else:
payload = await request.json()
wants_msgpack = _wants_msgpack(request)
if wants_msgpack:
_prefer_numpy_output(payload)
output = await infer_action(payload, server_args)
if _response_format(payload) == "raw":
response = action_raw_response(output, preserve_numpy=wants_msgpack)
else:
response = action_generation_response(
output,
server_args,
preserve_numpy=wants_msgpack,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if wants_msgpack:
return Response(
content=pack_msgpack(response), media_type="application/msgpack"
)
return orjson_response(response)
@router.get("/metadata")
async def action_metadata_endpoint(request: Request):
return orjson_response(action_metadata(request.app.state.server_args))
@router.websocket("/realtime")
async def action_realtime_ws(websocket: WebSocket):
server_args: ServerArgs = websocket.app.state.server_args
await run_action_msgpack_ws(
websocket,
server_args,
prepare_payload=_prefer_numpy_output,
build_response=lambda output: action_generation_response(
output,
server_args,
preserve_numpy=True,
),
)
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, WebSocket
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
run_action_msgpack_ws,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
router = APIRouter()
def _prefer_numpy_output(observation: dict[str, Any]) -> None:
observation.setdefault("output_format", "numpy")
@router.websocket("/openpi/policy")
async def openpi_policy_ws(websocket: WebSocket):
server_args: ServerArgs = websocket.app.state.server_args
await run_action_msgpack_ws(
websocket,
server_args,
prepare_payload=_prefer_numpy_output,
build_response=lambda output: output,
)
@@ -0,0 +1,443 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import base64
import dataclasses
import io
import time
import uuid
from functools import lru_cache
from typing import Any
import numpy as np
from PIL import Image
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs
def pack_numpy_payload(obj):
if isinstance(obj, (np.ndarray, np.generic)) and obj.dtype.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {obj.dtype}")
if isinstance(obj, np.ndarray):
return {
b"__ndarray__": True,
b"data": obj.tobytes(),
b"dtype": obj.dtype.str,
b"shape": obj.shape,
}
if isinstance(obj, np.generic):
return {
b"__npgeneric__": True,
b"data": obj.item(),
b"dtype": obj.dtype.str,
}
return obj
def unpack_numpy_payload(obj):
ndarray_marker = obj.get("__ndarray__") or obj.get(b"__ndarray__")
npgeneric_marker = obj.get("__npgeneric__") or obj.get(b"__npgeneric__")
data = obj.get("data", obj.get(b"data"))
dtype = obj.get("dtype", obj.get(b"dtype"))
shape = obj.get("shape", obj.get(b"shape"))
if ndarray_marker:
return np.ndarray(
buffer=data,
dtype=np.dtype(dtype),
shape=shape,
)
if npgeneric_marker:
return np.dtype(dtype).type(data)
return obj
def pack_msgpack(payload: Any) -> bytes:
import msgpack
return msgpack.packb(payload, default=pack_numpy_payload, use_bin_type=True)
def unpack_msgpack(payload: bytes) -> Any:
import msgpack
return msgpack.unpackb(payload, object_hook=unpack_numpy_payload, raw=False)
def _decode_b64_image(payload: dict[str, Any]) -> Image.Image:
data = payload.get("b64_json") or payload.get("base64")
if not data:
raise ValueError("image payload requires b64_json")
if isinstance(data, str) and "," in data and data.startswith("data:"):
data = data.split(",", 1)[1]
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
def _decode_tensor_payload(payload: dict[str, Any]) -> Any:
values = payload.get("values")
if values is None:
values = payload.get("data")
if values is None:
return payload
dtype = payload.get("dtype")
array = np.asarray(values, dtype=np.dtype(dtype) if dtype else None)
shape = payload.get("shape")
if shape is not None:
array = array.reshape(tuple(shape))
return array
def _normalize_image_value(value: Any) -> Any:
if not isinstance(value, dict):
return value
if "b64_json" in value or "base64" in value:
return _decode_b64_image(value)
if "values" in value or "data" in value:
return _decode_tensor_payload(value)
return value
def _normalize_observation(observation: dict[str, Any]) -> dict[str, Any]:
normalized = dict(observation)
images = normalized.get("images")
if isinstance(images, dict):
normalized["images"] = {
name: _normalize_image_value(value) for name, value in images.items()
}
state = normalized.get("state")
if isinstance(state, dict):
normalized["state"] = _decode_tensor_payload(state)
observation_state = normalized.get("observation.state")
if isinstance(observation_state, dict):
normalized["observation.state"] = _decode_tensor_payload(observation_state)
noise = normalized.get("noise")
if isinstance(noise, dict):
normalized["noise"] = _decode_tensor_payload(noise)
observation_noise = normalized.get("observation.noise")
if isinstance(observation_noise, dict):
normalized["observation.noise"] = _decode_tensor_payload(observation_noise)
return normalized
def images_from_observation(
observation: dict[str, Any],
pipeline_config: Any,
) -> dict[str, Any]:
if isinstance(observation.get("images"), dict):
images = dict(observation["images"])
else:
images = {}
for key in pipeline_config.image_keys:
if key in observation:
images[key] = observation[key]
full_key = f"observation.images.{key}"
if full_key in observation:
images[key] = observation[full_key]
return {name: _normalize_image_value(value) for name, value in images.items()}
def action_metadata(server_args: ServerArgs) -> dict[str, Any]:
pipeline_config = server_args.pipeline_config
policy_family = getattr(
pipeline_config,
"policy_family",
type(pipeline_config).__name__.removesuffix("PipelineConfig").lower(),
)
return {
"object": "action.metadata",
"model": server_args.model_id or server_args.model_path,
"model_path": server_args.model_path,
"policy_family": policy_family,
"input": {
"image_keys": list(pipeline_config.image_keys),
"image_size": list(pipeline_config.image_size),
"state_dim": pipeline_config.state_dim,
},
"output": {
"action_type": "continuous",
"action_horizon": pipeline_config.action_horizon,
"action_dim": pipeline_config.output_action_dim,
"padded_action_dim": pipeline_config.action_dim,
"dtype": "float32",
},
"runtime": {
"materialize_dtype": pipeline_config.materialize_dtype,
"enable_autocast": pipeline_config.enable_autocast,
"parallelism": {
"num_gpus": server_args.num_gpus,
"tp_size": server_args.tp_size,
"sp_degree": server_args.sp_degree,
"ulysses_degree": server_args.ulysses_degree,
"ring_degree": server_args.ring_degree,
"prefix_strategy": pipeline_config.prefix_parallel_strategy,
"action_strategy": pipeline_config.action_parallel_strategy,
"layout_version": pipeline_config.parallel_layout_version,
},
},
"defaults": {
"num_inference_steps": pipeline_config.default_num_inference_steps,
"prefix_cache": (
"auto" if pipeline_config.enable_global_prefix_cache else False
),
"cuda_graph": "auto" if pipeline_config.enable_action_cuda_graph else False,
},
"capabilities": {
"exact_prefix_cache": True,
"cuda_graph": pipeline_config.enable_action_cuda_graph,
"realtime_websocket": True,
"openpi_websocket": True,
"batch_inputs": False,
"multiple_candidates": False,
},
}
def _runtime_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, str):
value = value.lower()
if value == "auto":
return default
if value in ("true", "1", "yes"):
return True
if value in ("false", "0", "no"):
return False
return bool(value)
def _action_request_to_observation(payload: dict[str, Any]) -> dict[str, Any]:
if "input" not in payload:
return _normalize_observation(payload)
input_payload = payload.get("input") or {}
observation = dict(input_payload.get("observation") or {})
if "task" in input_payload:
observation["prompt"] = input_payload["task"]
elif "prompt" in input_payload:
observation["prompt"] = input_payload["prompt"]
if "images" in input_payload:
observation["images"] = input_payload["images"]
if "state" in input_payload:
observation["state"] = input_payload["state"]
if "noise" in input_payload:
observation["noise"] = input_payload["noise"]
return _normalize_observation(observation)
@lru_cache(maxsize=32)
def _resolve_action_sampling_params_cls_cached(
model_path: str,
backend: str | None,
model_id: str | None,
pipeline_class_name: str | None,
) -> type[VLASamplingParams]:
if pipeline_class_name:
from sglang.multimodal_gen.registry import get_pipeline_config_classes
config_classes = get_pipeline_config_classes(pipeline_class_name)
if config_classes is not None:
_, sampling_params_cls = config_classes
if issubclass(sampling_params_cls, VLASamplingParams):
return sampling_params_cls
from sglang.multimodal_gen.registry import get_model_info
model_info = get_model_info(
model_path,
backend=backend,
model_id=model_id,
)
sampling_params_cls = model_info.sampling_param_cls
if not issubclass(sampling_params_cls, VLASamplingParams):
raise ValueError(
f"Action endpoint requires VLASamplingParams, got {sampling_params_cls.__name__}"
)
return sampling_params_cls
def _resolve_action_sampling_params_cls(
server_args: ServerArgs,
) -> type[VLASamplingParams]:
return _resolve_action_sampling_params_cls_cached(
server_args.model_path,
getattr(server_args, "backend", None),
getattr(server_args, "model_id", None),
getattr(server_args, "pipeline_class_name", None),
)
@lru_cache(maxsize=32)
def _sampling_params_field_names(
sampling_params_cls: type[VLASamplingParams],
) -> frozenset[str]:
return frozenset(field.name for field in dataclasses.fields(sampling_params_cls))
def build_action_sampling_params(
payload: dict[str, Any],
server_args: ServerArgs,
) -> VLASamplingParams:
pipeline_config = server_args.pipeline_config
observation = _action_request_to_observation(payload)
parameters = dict(payload.get("parameters") or {})
runtime = dict(payload.get("runtime") or {})
if "return_timing" in payload and "return_timing" not in runtime:
runtime["return_timing"] = payload["return_timing"]
images = images_from_observation(observation, pipeline_config)
state = observation.get("state")
if state is None:
state = observation.get("observation.state")
noise = observation.get("noise")
if noise is None:
noise = observation.get("observation.noise")
prompt = observation.get("prompt") or observation.get("task") or ""
prefix_cache = runtime.get("prefix_cache")
if prefix_cache is None:
prefix_cache = observation.get("enable_prefix_cache")
if prefix_cache is None:
prefix_cache = observation.get("enable_pi_prefix_cache")
cuda_graph = runtime.get("cuda_graph")
if cuda_graph is None:
cuda_graph = observation.get("enable_cuda_graph")
if cuda_graph is None:
cuda_graph = observation.get("enable_pi_cuda_graph")
output_format = str(
runtime.get(
"output_format",
parameters.get(
"output_format",
observation.get("output_format", "list"),
),
)
).lower()
if output_format not in ("list", "numpy"):
raise ValueError("output_format must be 'list' or 'numpy'")
sampling_params_cls = _resolve_action_sampling_params_cls(server_args)
sampling_kwargs = {
"request_id": payload.get("request_id") or payload.get("id"),
"prompt": prompt,
"images": images,
"image_masks": observation.get("image_masks"),
"camera_order": observation.get("camera_order"),
"state": state,
"noise": noise,
"observation": observation,
"action_horizon": int(
parameters.get(
"action_horizon",
observation.get("action_horizon", pipeline_config.action_horizon),
)
),
"action_dim": int(
parameters.get(
"action_dim",
observation.get("action_dim", pipeline_config.action_dim),
)
),
"num_inference_steps": int(
parameters.get(
"num_inference_steps",
observation.get(
"num_inference_steps",
pipeline_config.default_num_inference_steps,
),
)
),
"output_format": output_format,
"return_timing": _runtime_bool(runtime.get("return_timing"), True),
"enable_prefix_cache": _runtime_bool(prefix_cache, True),
"enable_cuda_graph": _runtime_bool(cuda_graph, True),
}
supported_fields = _sampling_params_field_names(sampling_params_cls)
sp = sampling_params_cls(
**{
name: value
for name, value in sampling_kwargs.items()
if name in supported_fields
}
)
sp._adjust(server_args)
return sp
async def infer_action(
payload: dict[str, Any],
server_args: ServerArgs,
) -> dict[str, Any]:
sp = build_action_sampling_params(payload, server_args)
req = prepare_request(server_args, sp)
response = await async_scheduler_client.forward(req)
if getattr(response, "error", None):
raise RuntimeError(response.error)
if response.output is None:
raise RuntimeError("action policy returned no output")
return response.output[0]
def action_generation_response(
output: dict[str, Any],
server_args: ServerArgs,
*,
preserve_numpy: bool = False,
) -> dict[str, Any]:
actions = output["actions"]
if isinstance(actions, np.ndarray):
action_shape = list(actions.shape)
action_values = actions if preserve_numpy else actions.tolist()
else:
horizon = len(actions) if isinstance(actions, list) else 0
action_dim = len(actions[0]) if horizon and isinstance(actions[0], list) else 0
action_shape = [horizon, action_dim]
action_values = actions
response = {
"id": output.get("request_id") or f"act_{uuid.uuid4().hex}",
"object": "action.generation",
"created": int(time.time()),
"model": server_args.model_id or server_args.model_path,
"data": [
{
"index": 0,
"input_index": 0,
"candidate_index": 0,
"action": {
"type": "continuous",
"dtype": "float32",
"shape": action_shape,
"values": action_values,
},
}
],
"usage": {
"action_horizon": action_shape[0] if action_shape else 0,
"action_dim": action_shape[1] if len(action_shape) > 1 else 0,
"denoise_steps": output.get("parameters", {}).get(
"num_inference_steps",
server_args.pipeline_config.default_num_inference_steps,
),
"prefix_cache_hit": bool(output.get("cache", {}).get("hit", False)),
},
}
if "timings" in output:
response["timings"] = output["timings"]
if "cache" in output:
response["cache"] = output["cache"]
if "parallel" in output:
response["parallel"] = output["parallel"]
return response
def action_raw_response(
output: dict[str, Any],
*,
preserve_numpy: bool = False,
) -> dict[str, Any]:
response = dict(output)
actions = response.get("actions")
if isinstance(actions, np.ndarray) and not preserve_numpy:
response["actions"] = actions.tolist()
return response
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import time
import traceback
from collections.abc import Callable
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
action_metadata,
infer_action,
pack_msgpack,
unpack_msgpack,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
async def run_action_msgpack_ws(
websocket: WebSocket,
server_args: ServerArgs,
*,
prepare_payload: Callable[[dict[str, Any]], None],
build_response: Callable[[dict[str, Any]], dict[str, Any]],
) -> None:
await websocket.accept()
await websocket.send_bytes(pack_msgpack(action_metadata(server_args)))
prev_total_time = None
while True:
try:
start_time = time.monotonic()
payload = unpack_msgpack(await websocket.receive_bytes())
prepare_payload(payload)
infer_start = time.monotonic()
output = await infer_action(payload, server_args)
response = build_response(output)
response.setdefault("server_timing", {})["infer_ms"] = (
time.monotonic() - infer_start
) * 1000
if prev_total_time is not None:
response["server_timing"]["prev_total_ms"] = prev_total_time * 1000
await websocket.send_bytes(pack_msgpack(response))
prev_total_time = time.monotonic() - start_time
except WebSocketDisconnect:
break
except Exception:
try:
await websocket.send_bytes(
pack_msgpack({"error": traceback.format_exc()})
)
except Exception:
pass
await websocket.close(code=1011, reason="Internal server error")
raise
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: Apache-2.0
"""Helpers for transferring large numpy arrays between local scheduler processes."""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
_MIN_FILE_REF_BYTES = 32 << 20
@dataclass
class NumpyArrayFileRef:
path: str
def materialize(self) -> np.ndarray:
try:
return np.load(self.path, allow_pickle=False)
finally:
try:
os.unlink(self.path)
except FileNotFoundError:
pass
def is_local_endpoint(endpoint: str) -> bool:
return endpoint.startswith(
("tcp://127.0.0.1:", "tcp://localhost:", "ipc://", "inproc://")
)
def spill_large_arrays_to_file_refs(value: Any) -> Any:
directory = _array_ipc_dir()
if directory is None:
return value
return _spill_large_arrays_to_file_refs(value, directory)
def _spill_large_arrays_to_file_refs(value: Any, directory: str) -> Any:
if isinstance(value, np.ndarray) and value.nbytes >= _MIN_FILE_REF_BYTES:
# only spill if the array size is above the threshold. if not, it's not worth it
return _spill_array(value, directory)
if isinstance(value, list):
return [_spill_large_arrays_to_file_refs(item, directory) for item in value]
if isinstance(value, tuple):
return tuple(
_spill_large_arrays_to_file_refs(item, directory) for item in value
)
return value
def materialize_file_refs(value: Any) -> Any:
if isinstance(value, NumpyArrayFileRef):
return value.materialize()
if isinstance(value, list):
return [materialize_file_refs(item) for item in value]
if isinstance(value, tuple):
return tuple(materialize_file_refs(item) for item in value)
return value
def _spill_array(array: np.ndarray, directory: str) -> NumpyArrayFileRef:
if not array.flags.c_contiguous:
array = np.ascontiguousarray(array)
fd, path = tempfile.mkstemp(
prefix="sgldiffusion-array-",
suffix=".npy",
dir=directory,
)
try:
with os.fdopen(fd, "wb") as f:
np.save(f, array, allow_pickle=False)
except Exception:
try:
os.unlink(path)
except FileNotFoundError:
pass
raise
return NumpyArrayFileRef(path=path)
def _array_ipc_dir() -> str | None:
shm_path = Path("/dev/shm")
if shm_path.is_dir() and os.access(shm_path, os.W_OK):
return str(shm_path)
return None
@@ -0,0 +1,788 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import dataclasses
import multiprocessing as mp
import os
import signal
import sys
import threading
import time
import psutil
import uvicorn
from sglang.multimodal_gen.runtime.disaggregation.orchestrator import (
DiffusionServer,
)
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.entrypoints.http_server import create_app
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
from sglang.multimodal_gen.runtime.managers.gpu_worker import run_scheduler_process
from sglang.multimodal_gen.runtime.scheduler_client import SchedulerClient
from sglang.multimodal_gen.runtime.server_args import (
ServerArgs,
prepare_server_args,
set_global_server_args,
)
from sglang.multimodal_gen.runtime.utils.common import is_port_available
from sglang.multimodal_gen.runtime.utils.logging_utils import configure_logger, logger
from sglang.multimodal_gen.runtime.utils.trace_wrapper import init_diffusion_tracing
from sglang.multimodal_gen.utils import kill_itself_when_parent_died
_SCHEDULER_SHUTDOWN_TIMEOUT_MS = 5000
_WORKER_JOIN_TIMEOUT_S = 10
_WORKER_TERMINATE_TIMEOUT_S = 1
_WORKER_KILL_TIMEOUT_S = 1
def _find_available_port(
start: int = 10000, avoid: set[int] | None = None, max_attempts: int = 100
) -> int:
"""Find an available port starting from *start*, skipping ports in *avoid*."""
if avoid is None:
avoid = set()
port = max(1024, min(start, 65535))
for _ in range(max_attempts):
if port not in avoid and is_port_available(port):
return port
port += 1
if port > 65535:
port = 1024
raise RuntimeError(
f"No available port found after {max_attempts} attempts (start={start})"
)
def kill_process_tree(parent_pid, include_parent: bool = True, skip_pid: int = None):
"""Kill the process and all its child processes."""
# Remove sigchld handler to avoid spammy logs.
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
if parent_pid is None:
parent_pid = os.getpid()
include_parent = False
try:
itself = psutil.Process(parent_pid)
except psutil.NoSuchProcess:
return
children = itself.children(recursive=True)
for child in children:
if child.pid == skip_pid:
continue
try:
child.kill()
except psutil.NoSuchProcess:
pass
if include_parent:
try:
if parent_pid == os.getpid():
itself.kill()
sys.exit(0)
itself.kill()
# Sometime processes cannot be killed with SIGKILL (e.g, PID=1 launched by kubernetes),
# so we send an additional signal to kill them.
itself.send_signal(signal.SIGQUIT)
except psutil.NoSuchProcess:
pass
def _process_names(processes) -> str:
return ", ".join(getattr(p, "name", repr(p)) for p in processes)
def _join_processes_with_deadline(processes, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
for process in processes:
remaining_s = max(0.0, deadline - time.monotonic())
process.join(timeout=remaining_s)
def _terminate_alive_processes(processes, timeout_s: float) -> list:
alive = [p for p in processes if p.is_alive()]
if not alive:
return []
logger.warning(
"Worker process(es) did not exit in time; terminating: %s",
_process_names(alive),
)
for process in alive:
process.terminate()
_join_processes_with_deadline(alive, timeout_s)
return [p for p in alive if p.is_alive()]
def _kill_alive_processes(processes, timeout_s: float) -> None:
alive = [p for p in processes if p.is_alive()]
if not alive:
return
logger.warning(
"Worker process(es) did not terminate in time; killing: %s",
_process_names(alive),
)
for process in alive:
process.kill()
_join_processes_with_deadline(alive, timeout_s)
def _run_http_server_process(server_args: ServerArgs) -> None:
kill_itself_when_parent_died()
launch_http_server_only(server_args)
def _request_monolithic_scheduler_shutdown(server_args: ServerArgs) -> None:
if server_args.disagg_role != RoleType.MONOLITHIC:
return
client = SchedulerClient()
try:
client.initialize(server_args)
client.forward(ShutdownReq(), timeout_ms=_SCHEDULER_SHUTDOWN_TIMEOUT_MS)
except Exception as e:
logger.warning("Failed to request graceful scheduler shutdown: %s", e)
finally:
client.close()
def shutdown_scheduler_processes(
server_args: ServerArgs | None,
processes: list,
*,
request_shutdown: bool = True,
) -> None:
if not processes:
return
if request_shutdown and server_args is not None:
_request_monolithic_scheduler_shutdown(server_args)
_join_processes_with_deadline(processes, _WORKER_JOIN_TIMEOUT_S)
alive = _terminate_alive_processes(processes, _WORKER_TERMINATE_TIMEOUT_S)
_kill_alive_processes(alive, _WORKER_KILL_TIMEOUT_S)
def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
"""
Args:
launch_http_server: False for offline local mode
"""
configure_logger(server_args)
# Start a new server with multiple worker processes
logger.info("Starting server...")
num_gpus = server_args.num_gpus
processes = []
# Pipes for master to talk to slaves
task_pipes_to_slaves_w = []
task_pipes_to_slaves_r = []
for _ in range(num_gpus - 1):
r, w = mp.Pipe(duplex=False)
task_pipes_to_slaves_r.append(r)
task_pipes_to_slaves_w.append(w)
# Pipes for slaves to talk to master
result_pipes_from_slaves_w = []
result_pipes_from_slaves_r = []
for _ in range(num_gpus - 1):
r, w = mp.Pipe(duplex=False)
result_pipes_from_slaves_r.append(r)
result_pipes_from_slaves_w.append(w)
# Launch all worker processes
master_port = server_args.master_port
scheduler_pipe_readers = []
scheduler_pipe_writers = []
for i in range(num_gpus):
reader, writer = mp.Pipe(duplex=False)
scheduler_pipe_writers.append(writer)
if i == 0: # Master worker
process = mp.Process(
target=run_scheduler_process,
args=(
i, # local_rank
i, # rank
master_port,
server_args,
writer,
None, # No task pipe to read from master
None, # No result pipe to write to master
task_pipes_to_slaves_w,
result_pipes_from_slaves_r,
),
name=f"sglang-diffusionWorker-{i}",
daemon=True,
)
else: # Slave workers
process = mp.Process(
target=run_scheduler_process,
args=(
i, # local_rank
i, # rank
master_port,
server_args,
writer,
None, # No task pipe to read from master
None, # No result pipe to write to master
task_pipes_to_slaves_r[i - 1],
result_pipes_from_slaves_w[i - 1],
),
name=f"sglang-diffusionWorker-{i}",
daemon=True,
)
scheduler_pipe_readers.append(reader)
process.start()
processes.append(process)
# Wait for all workers to be ready
scheduler_infos = []
for writer in scheduler_pipe_writers:
writer.close()
# Close unused pipe ends in parent process
for p in task_pipes_to_slaves_w:
p.close()
for p in task_pipes_to_slaves_r:
p.close()
for p in result_pipes_from_slaves_w:
p.close()
for p in result_pipes_from_slaves_r:
p.close()
for i, reader in enumerate(scheduler_pipe_readers):
try:
data = reader.recv()
except EOFError:
logger.error(
f"Rank {i} scheduler is dead. Please check if there are relevant logs."
)
processes[i].join()
logger.error(f"Exit code: {processes[i].exitcode}")
raise
if data["status"] != "ready":
raise RuntimeError(
"Initialization failed. Please see the error messages above."
)
scheduler_infos.append(data)
reader.close()
logger.debug("All workers are ready")
if launch_http_server:
if server_args.pipeline_config.task_type.is_action_gen():
logger.info(
"VLA pipeline ready: model=%s; per-request details are "
"debug-only (use --log-level debug).",
server_args.model_id or server_args.model_path,
)
logger.info("Starting FastAPI server.")
if server_args.webui:
logger.info("Launch FastAPI server in another process because of webui.")
http_server_process = mp.Process(
target=_run_http_server_process,
args=(server_args,),
name="sglang-diffusion-webui",
daemon=True,
)
http_server_process.start()
else:
try:
launch_http_server_only(server_args)
finally:
shutdown_scheduler_processes(server_args, processes)
return processes
def launch_pool_disagg_server(
server_args: ServerArgs,
encoder_gpus: list[list[int]],
denoiser_gpus: list[list[int]],
decoder_gpus: list[list[int]],
launch_http_server: bool = True,
):
"""Launch a pool-based disaggregated server with N:M:K independent role instances.
DiffusionServer orchestrates the full pipeline, dispatching at every
role transition (Encoder → Denoiser → Decoder).
Args:
server_args: Base server configuration
encoder_gpus: List of GPU ID lists, one per encoder instance.
e.g., [[0], [2]] for 2 encoder instances on GPUs 0 and 2.
denoiser_gpus: List of GPU ID lists, one per denoiser instance.
e.g., [[1], [3]] for 2 denoiser instances.
decoder_gpus: List of GPU ID lists, one per decoder instance.
e.g., [[0], [2]] for 2 decoder instances (can share with encoder).
launch_http_server: Whether to launch the HTTP server.
Example:
launch_pool_disagg_server(server_args,
encoder_gpus=[[0], [2]],
denoiser_gpus=[[1], [3]],
decoder_gpus=[[0], [2]],
)
"""
configure_logger(server_args)
num_encoders = len(encoder_gpus)
num_denoisers = len(denoiser_gpus)
num_decoders = len(decoder_gpus)
logger.info(
"Starting pool disagg server: %d encoder(s), %d denoiser(s), %d decoder(s)...",
num_encoders,
num_denoisers,
num_decoders,
)
host = server_args.host or "127.0.0.1"
def find_port(start):
return _find_available_port(start)
# Allocate endpoints
port_cursor = server_args.scheduler_port + 3000
# Per-instance work endpoints (instance binds PULL, DS connects PUSH)
encoder_work_endpoints = []
for i in range(num_encoders):
p = find_port(port_cursor)
encoder_work_endpoints.append(f"tcp://{host}:{p}")
port_cursor = p + 1
denoiser_work_endpoints = []
for i in range(num_denoisers):
p = find_port(port_cursor)
denoiser_work_endpoints.append(f"tcp://{host}:{p}")
port_cursor = p + 1
decoder_work_endpoints = []
for i in range(num_decoders):
p = find_port(port_cursor)
decoder_work_endpoints.append(f"tcp://{host}:{p}")
port_cursor = p + 1
# Per-role-type result endpoints (DS binds PULL, instances connect PUSH)
# Use deterministic convention: scheduler_port + {1,2,3}
base_port = server_args.scheduler_port
encoder_result_ep = f"tcp://{host}:{base_port + 1}"
denoiser_result_ep = f"tcp://{host}:{base_port + 2}"
decoder_result_ep = f"tcp://{host}:{base_port + 3}"
logger.info(
"Pool endpoints allocated: %d work + 3 result endpoints",
num_encoders + num_denoisers + num_decoders,
)
# Launch all role instances
all_processes = []
role_configs = [
(RoleType.ENCODER, encoder_gpus, encoder_work_endpoints, encoder_result_ep),
(
RoleType.DENOISER,
denoiser_gpus,
denoiser_work_endpoints,
denoiser_result_ep,
),
(RoleType.DECODER, decoder_gpus, decoder_work_endpoints, decoder_result_ep),
]
for role_type, gpu_lists, work_eps, result_ep in role_configs:
for inst_idx, gpu_ids in enumerate(gpu_lists):
num_role_gpus = len(gpu_ids)
# Per-role parallelism: use explicit overrides if set, else None (auto-derive)
role_par = server_args.get_role_parallelism(role_type)
role_overrides = {
"disagg_role": role_type,
"disagg_mode": True,
"pool_work_endpoint": work_eps[inst_idx],
"pool_result_endpoint": result_ep,
"num_gpus": num_role_gpus,
"warmup": role_type == RoleType.ENCODER,
"server_warmup": False,
"scheduler_port": find_port(port_cursor),
"master_port": find_port(port_cursor + 100),
# Per-role parallelism (None = auto-derive from num_gpus)
"tp_size": role_par["tp_size"],
"sp_degree": role_par["sp_degree"],
"ulysses_degree": role_par["ulysses_degree"],
"ring_degree": role_par["ring_degree"],
}
port_cursor = role_overrides["master_port"] + 100
base_dict = {
f.name: getattr(server_args, f.name)
for f in dataclasses.fields(server_args)
}
base_dict.update(role_overrides)
base_dict.pop("pipeline_config", None)
role_args = ServerArgs.from_kwargs(**base_dict)
pool_ctx = mp.get_context("spawn")
inst_readers = []
# Spawn all ranks first — NCCL init blocks until all ranks connect
for rank_idx in range(num_role_gpus):
reader, writer = pool_ctx.Pipe(duplex=False)
gpu_id = gpu_ids[rank_idx]
process = pool_ctx.Process(
target=_run_disagg_role_process,
args=(gpu_id, rank_idx, rank_idx, role_args, writer, [], []),
name=f"sglang-pool-{role_type.value}-{inst_idx}-r{rank_idx}",
daemon=True,
)
process.start()
all_processes.append(process)
inst_readers.append(reader)
# Wait for all ranks to be ready (after all are spawned)
for rank_idx, reader in enumerate(inst_readers):
try:
data = reader.recv()
except EOFError:
logger.error(
"Pool %s[%d] rank %d is dead.",
role_type.value,
inst_idx,
rank_idx,
)
raise
if data.get("status") != "ready":
raise RuntimeError(
f"Pool {role_type.value}[{inst_idx}] rank {rank_idx} "
"failed to initialize."
)
reader.close()
logger.info(
"Pool %s[%d] ready on GPU(s) %s (work=%s)",
role_type.value.upper(),
inst_idx,
gpu_ids,
work_eps[inst_idx],
)
logger.info("All pool role instances ready")
# Start DiffusionServer
frontend_endpoint = f"tcp://{host}:{server_args.scheduler_port}"
diffusion_server = DiffusionServer(
frontend_endpoint=frontend_endpoint,
encoder_work_endpoints=encoder_work_endpoints,
denoiser_work_endpoints=denoiser_work_endpoints,
decoder_work_endpoints=decoder_work_endpoints,
encoder_result_endpoint=encoder_result_ep,
denoiser_result_endpoint=denoiser_result_ep,
decoder_result_endpoint=decoder_result_ep,
dispatch_policy_name=server_args.disagg_dispatch_policy,
timeout_s=float(server_args.disagg_timeout),
)
diffusion_server.start()
if not diffusion_server.wait_ready(timeout=30.0):
raise RuntimeError("DiffusionServer failed to bind sockets within 30 seconds")
if launch_http_server:
logger.info(
"Starting FastAPI server (connected to DiffusionServer at port %d).",
server_args.scheduler_port,
)
try:
launch_http_server_only(server_args)
finally:
diffusion_server.stop()
shutdown_scheduler_processes(
server_args, all_processes, request_shutdown=False
)
return all_processes
def _run_disagg_role_process(
gpu_id: int,
_local_rank: int,
rank: int,
server_args: ServerArgs,
pipe_writer: mp.connection.Connection,
task_pipes: list,
result_pipes: list,
):
"""Entry point for a disagg role process.
Uses the physical GPU index (gpu_id) as local_rank so that
torch.cuda.set_device(local_rank) selects the correct GPU.
This avoids relying on CUDA_VISIBLE_DEVICES remapping, which
may not work if CUDA was pre-initialized in the parent process.
"""
run_scheduler_process(
local_rank=gpu_id,
rank=rank,
master_port=server_args.master_port,
server_args=server_args,
pipe_writer=pipe_writer,
task_pipe_r=None,
result_pipe_w=None,
task_pipes_to_slaves=task_pipes,
result_pipes_from_slaves=result_pipes,
)
def launch_http_server_only(server_args):
init_diffusion_tracing(server_args, "DiffHTTPServer")
# set for endpoints to access global_server_args
set_global_server_args(server_args)
app = create_app(server_args)
uvicorn.run(
app,
use_colors=True,
log_level=server_args.log_level,
host=server_args.host,
port=server_args.port,
reload=False,
ws_per_message_deflate=False,
)
def parse_url_string(url_str: str) -> list[str]:
"""Parse a semicolon-separated URL string into a list.
Example: "tcp://10.0.0.1:35000;tcp://10.0.0.2:35000" -> ["tcp://...", "tcp://..."]
"""
return [u.strip() for u in url_str.split(";") if u.strip()]
def launch_disagg_server(server_args: ServerArgs):
"""Launch DiffusionServer head node + HTTP server (--disagg-role server).
No GPU workers are spawned. Connects to remote role instances
specified by --encoder-urls, --denoiser-urls, --decoder-urls.
Result endpoints use deterministic convention:
encoder result: scheduler_port + 1
denoiser result: scheduler_port + 2
decoder result: scheduler_port + 3
"""
configure_logger(server_args)
for name, val in [
("--encoder-urls", server_args.encoder_urls),
("--denoiser-urls", server_args.denoiser_urls),
("--decoder-urls", server_args.decoder_urls),
]:
if val is None:
raise ValueError(f"{name} is required for --disagg-role server")
host = server_args.host or "127.0.0.1"
base_port = server_args.scheduler_port
encoder_work_endpoints = parse_url_string(server_args.encoder_urls)
denoiser_work_endpoints = parse_url_string(server_args.denoiser_urls)
decoder_work_endpoints = parse_url_string(server_args.decoder_urls)
encoder_result_ep = f"tcp://{host}:{base_port + 1}"
denoiser_result_ep = f"tcp://{host}:{base_port + 2}"
decoder_result_ep = f"tcp://{host}:{base_port + 3}"
frontend_endpoint = f"tcp://{host}:{base_port}"
logger.info(
"Starting DiffusionServer: %d encoder(s), %d denoiser(s), %d decoder(s)",
len(encoder_work_endpoints),
len(denoiser_work_endpoints),
len(decoder_work_endpoints),
)
logger.info(" Frontend: %s", frontend_endpoint)
logger.info(" Encoder work endpoints: %s", encoder_work_endpoints)
logger.info(" Denoiser work endpoints: %s", denoiser_work_endpoints)
logger.info(" Decoder work endpoints: %s", decoder_work_endpoints)
logger.info(
" Result endpoints: encoder=%s, denoiser=%s, decoder=%s",
encoder_result_ep,
denoiser_result_ep,
decoder_result_ep,
)
diffusion_server = DiffusionServer(
frontend_endpoint=frontend_endpoint,
encoder_work_endpoints=encoder_work_endpoints,
denoiser_work_endpoints=denoiser_work_endpoints,
decoder_work_endpoints=decoder_work_endpoints,
encoder_result_endpoint=encoder_result_ep,
denoiser_result_endpoint=denoiser_result_ep,
decoder_result_endpoint=decoder_result_ep,
dispatch_policy_name=server_args.disagg_dispatch_policy,
timeout_s=float(server_args.disagg_timeout),
)
diffusion_server.start()
if not diffusion_server.wait_ready(timeout=30.0):
raise RuntimeError("DiffusionServer failed to bind sockets within 30 seconds")
logger.info(
"Starting HTTP server (connected to DiffusionServer at port %d).",
base_port,
)
try:
launch_http_server_only(server_args)
finally:
diffusion_server.stop()
def launch_disagg_role(server_args: ServerArgs):
"""Launch a standalone disaggregated role instance (--disagg-role encoder/denoising/decoder).
The instance:
1. Binds its work PULL socket on tcp://0.0.0.0:{scheduler_port}
2. Connects its result PUSH socket to the DiffusionServer head node
(derived from --disagg-server-addr + role offset)
3. Spawns GPU worker processes for the assigned role.
"""
configure_logger(server_args)
role_type = server_args.disagg_role
if server_args.disagg_server_addr is None:
raise ValueError(
"--disagg-server-addr is required for --disagg-role " f"{role_type.value}"
)
# Derive endpoints
work_endpoint = server_args.derive_pool_work_endpoint()
result_endpoint = server_args.derive_pool_result_endpoint()
logger.info(
"Starting disagg role: %s, num_gpus=%d",
role_type.value,
server_args.num_gpus,
)
logger.info(" Work endpoint (bind): %s", work_endpoint)
logger.info(" Result endpoint (connect): %s", result_endpoint)
logger.info(
" P2P: hostname=%s, ib_device=%s, pool_size=%d",
server_args.disagg_p2p_hostname,
server_args.disagg_ib_device,
server_args.disagg_transfer_pool_size,
)
# Build role-specific ServerArgs
# Use a different port for the scheduler's internal ROUTER socket to avoid
# conflicting with the pool work PULL socket (both bind on scheduler_port).
internal_scheduler_port = _find_available_port(
start=server_args.scheduler_port + 100, avoid={server_args.scheduler_port}
)
role_par = server_args.get_role_parallelism(role_type)
role_overrides = {
"disagg_role": role_type,
"disagg_mode": True,
"pool_work_endpoint": work_endpoint,
"pool_result_endpoint": result_endpoint,
"warmup": role_type == RoleType.ENCODER,
"server_warmup": False,
"scheduler_port": internal_scheduler_port,
# Per-role parallelism (None = auto-derive from num_gpus)
"tp_size": role_par["tp_size"],
"sp_degree": role_par["sp_degree"],
"ulysses_degree": role_par["ulysses_degree"],
"ring_degree": role_par["ring_degree"],
}
base_dict = {
f.name: getattr(server_args, f.name) for f in dataclasses.fields(server_args)
}
base_dict.update(role_overrides)
base_dict.pop("pipeline_config", None)
role_args = ServerArgs.from_kwargs(**base_dict)
# Spawn GPU worker processes
# NOTE: All ranks must be spawned before waiting for ready signals,
# because NCCL init_process_group blocks until all ranks connect.
num_gpus = server_args.num_gpus
base_gpu_id = server_args.base_gpu_id
pool_ctx = mp.get_context("spawn")
processes = []
readers = []
for rank_idx in range(num_gpus):
reader, writer = pool_ctx.Pipe(duplex=False)
gpu_id = base_gpu_id + rank_idx
process = pool_ctx.Process(
target=_run_disagg_role_process,
args=(gpu_id, rank_idx, rank_idx, role_args, writer, [], []),
name=f"sglang-{role_type.value}-r{rank_idx}",
daemon=True,
)
process.start()
processes.append(process)
readers.append(reader)
# Wait for all ranks to be ready (after all are spawned)
for rank_idx, reader in enumerate(readers):
try:
data = reader.recv()
except EOFError:
logger.error(
"Role %s rank %d is dead.",
role_type.value,
rank_idx,
)
raise
if data.get("status") != "ready":
raise RuntimeError(
f"Role {role_type.value} rank {rank_idx} failed to initialize."
)
reader.close()
logger.info(
"Role %s ready (%d GPU(s), work=%s)",
role_type.value.upper(),
num_gpus,
work_endpoint,
)
# Block until interrupted
try:
for p in processes:
p.join()
except KeyboardInterrupt:
logger.info("Role %s shutting down.", role_type.value)
finally:
shutdown_scheduler_processes(role_args, processes, request_shutdown=False)
def dispatch_launch(server_args: ServerArgs):
"""Route to the correct launch function based on --disagg-role."""
role = server_args.disagg_role
if role == RoleType.MONOLITHIC:
launch_server(server_args)
elif role == RoleType.SERVER:
launch_disagg_server(server_args)
elif role in (RoleType.ENCODER, RoleType.DENOISER, RoleType.DECODER):
launch_disagg_role(server_args)
else:
raise ValueError(f"Unknown disagg_role: {role}")
if __name__ == "__main__":
server_args = prepare_server_args(sys.argv[1:])
try:
dispatch_launch(server_args)
finally:
kill_process_tree(os.getpid(), include_parent=False)
@@ -0,0 +1 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
@@ -0,0 +1,178 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# Adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/model_executor/layers/activation.py
"""Custom activation functions."""
import math
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from sglang.multimodal_gen.runtime.platforms import current_platform
_is_cuda = current_platform.is_cuda()
_is_hip = current_platform.is_hip()
_is_npu = current_platform.is_npu()
_is_xpu = current_platform.is_xpu()
if _is_cuda:
from sglang.jit_kernel.activation import silu_and_mul
elif _is_hip or _is_xpu:
from sgl_kernel import silu_and_mul
if _is_npu:
import torch_npu
# TODO (will): remove this dependency
from sglang.multimodal_gen.runtime.layers.custom_op import CustomOp
@CustomOp.register("silu_and_mul")
class SiluAndMul(CustomOp):
"""An activation function for SwiGLU.
The function computes x -> silu(x[:d]) * x[d:] where d = x.shape[-1] // 2.
Shapes:
x: (num_tokens, 2 * d) or (batch_size, seq_len, 2 * d)
return: (num_tokens, d) or (batch_size, seq_len, d)
"""
def __init__(self) -> None:
super().__init__()
def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
d = x.shape[-1] // 2
output_shape = x.shape[:-1] + (d,)
out = torch.empty(output_shape, dtype=x.dtype, device=x.device)
silu_and_mul(x, out)
return out
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
"""PyTorch-native implementation equivalent to forward()."""
d = x.shape[-1] // 2
return F.silu(x[..., :d]) * x[..., d:]
def forward_npu(self, x: torch.Tensor) -> torch.Tensor:
out = torch_npu.npu_swiglu(x)
return out
def forward_musa(self, x: torch.Tensor) -> torch.Tensor:
return nn.SwishGLU()(x)
def forward_xpu(self, x: torch.Tensor) -> torch.Tensor:
d = x.shape[-1] // 2
output_shape = x.shape[:-1] + (d,)
out = torch.empty(output_shape, dtype=x.dtype, device=x.device)
silu_and_mul(x, out)
return out
@CustomOp.register("gelu_and_mul")
class GeluAndMul(CustomOp):
"""An activation function for GeGLU.
The function computes x -> GELU(x[:d]) * x[d:] where d = x.shape[-1] // 2.
Shapes:
x: (batch_size, seq_len, 2 * d) or (num_tokens, 2 * d)
return: (batch_size, seq_len, d) or (num_tokens, d)
"""
def __init__(self, approximate: str = "none"):
super().__init__()
self.approximate = approximate
if approximate not in ("none", "tanh"):
raise ValueError(f"Unknown approximate mode: {approximate}")
def forward_cuda(self, *args, **kwargs) -> Any:
return self.forward_native(*args, **kwargs)
def forward_npu(self, x: torch.Tensor) -> torch.Tensor:
y_npu, _ = torch_npu.npu_geglu(
x,
dim=-1,
approximate=1 if self.approximate == "tanh" else 0,
activate_left=True,
)
return y_npu
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
"""PyTorch-native implementation equivalent to forward()."""
d = x.shape[-1] // 2
return F.gelu(x[..., :d], approximate=self.approximate) * x[..., d:]
def extra_repr(self) -> str:
return f"approximate={repr(self.approximate)}"
@CustomOp.register("gelu_new")
class NewGELU(CustomOp):
def __init__(self):
super().__init__()
def forward_cuda(self, *args, **kwargs) -> Any:
return self.forward_native(*args, **kwargs)
def forward_xpu(self, *args, **kwargs) -> Any:
return self.forward_native(*args, **kwargs)
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
"""PyTorch-native implementation equivalent to forward()."""
c = math.sqrt(2.0 / math.pi)
return 0.5 * x * (1.0 + torch.tanh(c * (x + 0.044715 * torch.pow(x, 3.0))))
@CustomOp.register("quick_gelu")
class QuickGELU(CustomOp):
# https://github.com/huggingface/transformers/blob/main/src/transformers/activations.py#L90
def __init__(self):
super().__init__()
def forward_cuda(self, *args, **kwargs) -> Any:
return self.forward_native(*args, **kwargs)
def forward_xpu(self, *args, **kwargs) -> Any:
return self.forward_native(*args, **kwargs)
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
"""PyTorch-native implementation equivalent to forward()."""
return x * torch.sigmoid(1.702 * x)
_ACTIVATION_REGISTRY = {
"gelu": nn.GELU,
"gelu_new": NewGELU,
"gelu_pytorch_tanh": lambda: nn.GELU(approximate="tanh"),
"relu": nn.ReLU,
"silu": nn.SiLU,
"quick_gelu": QuickGELU,
}
def get_act_fn(act_fn_name: str) -> nn.Module:
"""Get an activation function by name."""
act_fn_name = act_fn_name.lower()
if act_fn_name not in _ACTIVATION_REGISTRY:
raise ValueError(f"Activation function {act_fn_name!r} is not supported.")
return _ACTIVATION_REGISTRY[act_fn_name]()
_ACTIVATION_AND_MUL_REGISTRY = {
"gelu": GeluAndMul,
"silu": SiluAndMul,
}
def get_act_and_mul_fn(act_fn_name: str) -> nn.Module:
"""Get an activation-and-mul (i.e. SiluAndMul) function by name."""
act_fn_name = act_fn_name.lower()
if act_fn_name not in _ACTIVATION_AND_MUL_REGISTRY:
raise ValueError(f"Activation function {act_fn_name!r} is not supported.")
return _ACTIVATION_AND_MUL_REGISTRY[act_fn_name]()
@@ -0,0 +1,414 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import json
import os
from collections import defaultdict
from typing import Any
import numpy as np
from sglang.multimodal_gen.utils import dict_to_3d_list
def configure_sta(
mode: str = "STA_searching",
layer_num: int = 40,
time_step_num: int = 50,
head_num: int = 40,
**kwargs,
) -> list[list[list[Any]]]:
"""
Configure Sliding Tile Attention (STA) parameters based on the specified mode.
Parameters:
----------
mode : str
The STA mode to use. Options are:
- 'STA_searching': Generate a set of mask candidates for initial search
- 'STA_tuning': Select best mask strategy based on previously saved results
- 'STA_inference': Load and use a previously tuned mask strategy
layer_num: int, number of layers
time_step_num: int, number of timesteps
head_num: int, number of heads
**kwargs : dict
Mode-specific parameters:
For 'STA_searching':
- mask_candidates: list of str, optional, mask candidates to use
- mask_selected: list of int, optional, indices of selected masks
For 'STA_tuning':
- mask_search_files_path: str, required, path to mask search results
- mask_candidates: list of str, optional, mask candidates to use
- mask_selected: list of int, optional, indices of selected masks
- skip_time_steps: int, optional, number of time steps to use full attention (default 12)
- save_dir: str, optional, directory to save mask strategy (default "mask_candidates")
For 'STA_inference':
- load_path: str, optional, path to load mask strategy (default "mask_candidates/mask_strategy.json")
"""
valid_modes = ["STA_searching", "STA_tuning", "STA_inference", "STA_tuning_cfg"]
if mode not in valid_modes:
raise ValueError(f"Mode must be one of {valid_modes}, got {mode}")
if mode == "STA_searching":
# Get parameters with defaults
mask_candidates: list[str] | None = kwargs.get("mask_candidates")
if mask_candidates is None:
raise ValueError("mask_candidates is required for STA_searching mode")
mask_selected: list[int] = kwargs.get(
"mask_selected", list(range(len(mask_candidates)))
)
# Parse selected masks
selected_masks: list[list[int]] = []
for index in mask_selected:
mask = mask_candidates[index]
masks_list = [int(x) for x in mask.split(",")]
selected_masks.append(masks_list)
# Create 3D mask structure with fixed dimensions (t=50, l=60)
masks_3d: list[list[list[list[int]]]] = []
for i in range(time_step_num): # Fixed t dimension = 50
row = []
for j in range(layer_num): # Fixed l dimension = 60
row.append(selected_masks) # Add all masks at each position
masks_3d.append(row)
return masks_3d
elif mode == "STA_tuning":
# Get required parameters
mask_search_files_path: str | None = kwargs.get("mask_search_files_path")
if not mask_search_files_path:
raise ValueError("mask_search_files_path is required for STA_tuning mode")
# Get optional parameters with defaults
mask_candidates_tuning: list[str] | None = kwargs.get("mask_candidates")
if mask_candidates_tuning is None:
raise ValueError("mask_candidates is required for STA_tuning mode")
mask_selected_tuning: list[int] = kwargs.get(
"mask_selected", list(range(len(mask_candidates_tuning)))
)
skip_time_steps_tuning: int | None = kwargs.get("skip_time_steps")
save_dir_tuning: str | None = kwargs.get("save_dir", "mask_candidates")
# Parse selected masks
selected_masks_tuning: list[list[int]] = []
for index in mask_selected_tuning:
mask = mask_candidates_tuning[index]
masks_list = [int(x) for x in mask.split(",")]
selected_masks_tuning.append(masks_list)
# Read JSON results
results = read_specific_json_files(mask_search_files_path)
averaged_results = average_head_losses(results, selected_masks_tuning)
# Add full attention mask for specific cases
full_attention_mask_tuning: list[int] | None = kwargs.get("full_attention_mask")
if full_attention_mask_tuning is not None:
selected_masks_tuning.append(full_attention_mask_tuning)
# Select best mask strategy
timesteps_tuning: int = kwargs.get("timesteps", time_step_num)
if skip_time_steps_tuning is None:
skip_time_steps_tuning = 12
mask_strategy, sparsity, strategy_counts = select_best_mask_strategy(
averaged_results,
selected_masks_tuning,
skip_time_steps_tuning,
timesteps_tuning,
head_num,
)
# Save mask strategy
if save_dir_tuning is not None:
os.makedirs(save_dir_tuning, exist_ok=True)
file_path = os.path.join(
save_dir_tuning, f"mask_strategy_s{skip_time_steps_tuning}.json"
)
with open(file_path, "w") as f:
json.dump(mask_strategy, f, indent=4)
print(f"Successfully saved mask_strategy to {file_path}")
# Print sparsity and strategy counts for information
print(f"Overall sparsity: {sparsity:.4f}")
print("\nStrategy usage counts:")
total_heads = time_step_num * layer_num * head_num # Fixed dimensions
for strategy, count in strategy_counts.items():
print(f"Strategy {strategy}: {count} heads ({count/total_heads*100:.2f}%)")
# Convert dictionary to 3D list with fixed dimensions
mask_strategy_3d = dict_to_3d_list(
mask_strategy, t_max=time_step_num, l_max=layer_num, h_max=head_num
)
return mask_strategy_3d
elif mode == "STA_tuning_cfg":
# Get required parameters for both positive and negative paths
mask_search_files_path_pos: str | None = kwargs.get(
"mask_search_files_path_pos"
)
mask_search_files_path_neg: str | None = kwargs.get(
"mask_search_files_path_neg"
)
save_dir_cfg: str | None = kwargs.get("save_dir")
if (
not mask_search_files_path_pos
or not mask_search_files_path_neg
or not save_dir_cfg
):
raise ValueError(
"mask_search_files_path_pos, mask_search_files_path_neg, and save_dir are required for STA_tuning_cfg mode"
)
# Get optional parameters with defaults
mask_candidates_cfg: list[str] | None = kwargs.get("mask_candidates")
if mask_candidates_cfg is None:
raise ValueError("mask_candidates is required for STA_tuning_cfg mode")
mask_selected_cfg: list[int] = kwargs.get(
"mask_selected", list(range(len(mask_candidates_cfg)))
)
skip_time_steps_cfg: int | None = kwargs.get("skip_time_steps")
# Parse selected masks
selected_masks_cfg: list[list[int]] = []
for index in mask_selected_cfg:
mask = mask_candidates_cfg[index]
masks_list = [int(x) for x in mask.split(",")]
selected_masks_cfg.append(masks_list)
# Read JSON results for both positive and negative paths
pos_results = read_specific_json_files(mask_search_files_path_pos)
neg_results = read_specific_json_files(mask_search_files_path_neg)
# Combine positive and negative results into one list
combined_results = pos_results + neg_results
# Average the combined results
averaged_results = average_head_losses(combined_results, selected_masks_cfg)
# Add full attention mask for specific cases
full_attention_mask_cfg: list[int] | None = kwargs.get("full_attention_mask")
if full_attention_mask_cfg is not None:
selected_masks_cfg.append(full_attention_mask_cfg)
timesteps_cfg: int = kwargs.get("timesteps", time_step_num)
if skip_time_steps_cfg is None:
skip_time_steps_cfg = 12
# Select best mask strategy using combined results
mask_strategy, sparsity, strategy_counts = select_best_mask_strategy(
averaged_results,
selected_masks_cfg,
skip_time_steps_cfg,
timesteps_cfg,
head_num,
)
# Save mask strategy
os.makedirs(save_dir_cfg, exist_ok=True)
file_path = os.path.join(
save_dir_cfg, f"mask_strategy_s{skip_time_steps_cfg}.json"
)
with open(file_path, "w") as f:
json.dump(mask_strategy, f, indent=4)
print(f"Successfully saved mask_strategy to {file_path}")
# Print sparsity and strategy counts for information
print(f"Overall sparsity: {sparsity:.4f}")
print("\nStrategy usage counts:")
total_heads = time_step_num * layer_num * head_num # Fixed dimensions
for strategy, count in strategy_counts.items():
print(f"Strategy {strategy}: {count} heads ({count/total_heads*100:.2f}%)")
# Convert dictionary to 3D list with fixed dimensions
mask_strategy_3d = dict_to_3d_list(
mask_strategy, t_max=time_step_num, l_max=layer_num, h_max=head_num
)
return mask_strategy_3d
else: # STA_inference
# Get parameters with defaults
load_path: str | None = kwargs.get(
"load_path", "mask_candidates/mask_strategy.json"
)
if load_path is None:
raise ValueError("load_path is required for STA_inference mode")
# Load previously saved mask strategy
with open(load_path) as f:
mask_strategy = json.load(f)
# Convert dictionary to 3D list with fixed dimensions
mask_strategy_3d = dict_to_3d_list(
mask_strategy, t_max=time_step_num, l_max=layer_num, h_max=head_num
)
return mask_strategy_3d
# Helper functions
def read_specific_json_files(folder_path: str) -> list[dict[str, Any]]:
"""Read and parse JSON files containing mask search results."""
json_contents: list[dict[str, Any]] = []
# List files only in the current directory (no walk)
files = os.listdir(folder_path)
# Filter files
matching_files = [f for f in files if "mask" in f and f.endswith(".json")]
print(f"Found {len(matching_files)} matching files: {matching_files}")
for file_name in matching_files:
file_path = os.path.join(folder_path, file_name)
with open(file_path) as file:
data = json.load(file)
json_contents.append(data)
return json_contents
def average_head_losses(
results: list[dict[str, Any]], selected_masks: list[list[int]]
) -> dict[str, dict[str, np.ndarray]]:
"""Average losses across all prompts for each mask strategy."""
# Initialize a dictionary to store the averaged results
averaged_losses: dict[str, dict[str, np.ndarray]] = {}
loss_type = "L2_loss"
# Get all loss types (e.g., 'L2_loss')
averaged_losses[loss_type] = {}
for mask in selected_masks:
mask_str = str(mask)
data_shape = np.array(results[0][loss_type][mask_str]).shape
accumulated_data = np.zeros(data_shape)
# Sum across all prompts
for prompt_result in results:
accumulated_data += np.array(prompt_result[loss_type][mask_str])
# Average by dividing by number of prompts
averaged_data = accumulated_data / len(results)
averaged_losses[loss_type][mask_str] = averaged_data
return averaged_losses
def select_best_mask_strategy(
averaged_results: dict[str, dict[str, np.ndarray]],
selected_masks: list[list[int]],
skip_time_steps: int = 12,
timesteps: int = 50,
head_num: int = 40,
) -> tuple[dict[str, list[int]], float, dict[str, int]]:
"""Select the best mask strategy for each head based on loss minimization."""
best_mask_strategy: dict[str, list[int]] = {}
loss_type = "L2_loss"
# Get the shape of time steps and layers
layers = len(averaged_results[loss_type][str(selected_masks[0])][0])
# Counter for sparsity calculation
total_tokens = 0 # total number of masked tokens
total_length = 0 # total sequence length
strategy_counts: dict[str, int] = {str(strategy): 0 for strategy in selected_masks}
full_attn_strategy = selected_masks[-1] # Last strategy is full attention
print(f"Strategy {full_attn_strategy}, skip first {skip_time_steps} steps ")
for t in range(timesteps):
for layer_idx in range(layers):
for h in range(head_num):
if t < skip_time_steps: # First steps use full attention
strategy = full_attn_strategy
else:
# Get losses for this head across all strategies
head_losses = []
for strategy in selected_masks[:-1]: # Exclude full attention
head_losses.append(
averaged_results[loss_type][str(strategy)][t][layer_idx][h]
)
# Find which strategy gives minimum loss
best_strategy_idx = np.argmin(head_losses)
strategy = selected_masks[best_strategy_idx]
best_mask_strategy[f"{t}_{layer_idx}_{h}"] = strategy
# Calculate sparsity
nums = strategy # strategy is already a list of numbers
total_tokens += (
nums[0] * nums[1] * nums[2]
) # masked tokens for chosen strategy
total_length += (
full_attn_strategy[0]
* full_attn_strategy[1]
* full_attn_strategy[2]
)
# Count strategy usage
strategy_counts[str(strategy)] += 1
overall_sparsity = 1 - total_tokens / total_length
return best_mask_strategy, overall_sparsity, strategy_counts
def save_mask_search_results(
mask_search_final_result: list[dict[str, list[float]]],
prompt: str,
mask_strategies: list[str],
output_dir: str = "output/mask_search_result/",
) -> str | None:
if not mask_search_final_result:
print("No mask search results to save")
return None
# Create result dictionary with defaultdict for nested lists
mask_search_dict: dict[str, dict[str, list[list[float]]]] = {
"L2_loss": defaultdict(list),
"L1_loss": defaultdict(list),
}
mask_selected = list(range(len(mask_strategies)))
selected_masks: list[list[int]] = []
for index in mask_selected:
mask = mask_strategies[index]
masks_list = [int(x) for x in mask.split(",")]
selected_masks.append(masks_list)
# Process each mask strategy
for i, mask_strategy in enumerate(selected_masks):
mask_strategy_str = str(mask_strategy)
# Process L2 loss
step_results: list[list[float]] = []
for step_data in mask_search_final_result:
if isinstance(step_data, dict) and "L2_loss" in step_data:
layer_losses = [float(loss) for loss in step_data["L2_loss"]]
step_results.append(layer_losses)
mask_search_dict["L2_loss"][mask_strategy_str] = step_results
step_results = []
for step_data in mask_search_final_result:
if isinstance(step_data, dict) and "L1_loss" in step_data:
layer_losses = [float(loss) for loss in step_data["L1_loss"]]
step_results.append(layer_losses)
mask_search_dict["L1_loss"][mask_strategy_str] = step_results
# Create the output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
# Create a filename based on the first 20 characters of the prompt
filename = prompt[:50].replace(" ", "_")
filepath = os.path.join(output_dir, f"mask_search_{filename}.json")
# Save the results to a JSON file
with open(filepath, "w") as f:
json.dump(mask_search_dict, f, indent=4)
print(f"Successfully saved mask research results to {filepath}")
return filepath
@@ -0,0 +1,38 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.layers.attention.layer import (
DynamicVarlenMaskMeta,
LocalAttention,
UlyssesAttention,
UlyssesAttention_VSA,
USPAttention,
build_varlen_mask_meta,
build_varlen_mask_meta_from_lengths,
build_varlen_mask_meta_from_ranges,
)
from sglang.multimodal_gen.runtime.layers.attention.selector import get_attn_backend
from sglang.multimodal_gen.runtime.layers.attention.turbo_layer import MinimalA2AAttnOp
__all__ = [
"USPAttention",
"LocalAttention",
"DynamicVarlenMaskMeta",
"UlyssesAttention",
"UlyssesAttention_VSA",
"MinimalA2AAttnOp",
"AttentionBackend",
"AttentionMetadata",
"AttentionMetadataBuilder",
# "AttentionState",
"get_attn_backend",
"build_varlen_mask_meta",
"build_varlen_mask_meta_from_lengths",
"build_varlen_mask_meta_from_ranges",
]
@@ -0,0 +1 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
@@ -0,0 +1,207 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import logging
import os
import aiter
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.platforms.aiter import USE_AITER_GFX95
logger = logging.getLogger(__name__)
_use_fp8_attn = os.environ.get("SGLANG_DIFFUSION_AITER_FP8_ATTN", "0") == "1"
_fp8_dtype = torch.float8_e4m3fn
# fmha_fwd_hd128_fp8_gfx950 ASM kernel. Support full MHA with q/k/v head_dim == 128 -- e.g., Wan 2.2 self- and cross-attention.
_FMHA_FP8_HEAD_DIM = 128
if _use_fp8_attn:
logger.info("DiT FP8 attention enabled via SGLANG_DIFFUSION_AITER_FP8_ATTN=1")
def _can_use_fmha_fp8_prefill(
q_head_dim: int,
k_head_dim: int,
v_head_dim: int,
num_heads: int,
num_kv_heads: int,
) -> bool:
"""True if MHA q/k/v head_dim==128 on a gfx950-class arch."""
if not USE_AITER_GFX95:
return False
if num_kv_heads != num_heads:
return False
return q_head_dim == k_head_dim == v_head_dim == _FMHA_FP8_HEAD_DIM
def _fmha_fp8_prefill_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
softmax_scale: float,
is_causal: bool,
q_scale: torch.Tensor,
k_scale: torch.Tensor,
v_scale: torch.Tensor,
) -> torch.Tensor:
"""
FP8 FMHA prefill via aiter.flash_attn_fp8_pertensor_func.
Expects q, k, v as (batch, seqlen, nheads, 128) FP8, contiguous.
"""
def _ensure_fp8_descale(scale: torch.Tensor) -> torch.Tensor:
"""Per-tensor descale as shape (1,) float32 for flash_attn_fp8_pertensor_func."""
return scale.to(dtype=torch.float32).reshape(1).contiguous()
q = q.contiguous()
k = k.contiguous()
v = v.contiguous()
q_descale = _ensure_fp8_descale(q_scale)
k_descale = _ensure_fp8_descale(k_scale)
v_descale = _ensure_fp8_descale(v_scale)
return aiter.flash_attn_fp8_pertensor_func(
q,
k,
v,
q_descale,
k_descale,
v_descale,
causal=is_causal,
softmax_scale=softmax_scale,
window_size=(-1, -1, 0),
)
class AITerBackend(AttentionBackend):
"""
Backend for AITemplate attention implementation.
"""
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.AITER
@staticmethod
def get_impl_cls() -> type["AITerImpl"]:
return AITerImpl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
# AITer backend does not require special metadata.
return AttentionMetadata
@staticmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
raise NotImplementedError("AITer backend does not have a metadata builder.")
class AITerImpl(AttentionImpl):
"""
Implementation of attention using AITemplate.
"""
def __init__(
self,
num_heads: int,
head_size: int,
softmax_scale: float,
causal: bool = False,
num_kv_heads: int | None = None,
prefix: str = "",
dropout_p: float = 0.0,
**extra_impl_args,
) -> None:
if num_kv_heads is not None and num_kv_heads != num_heads:
raise NotImplementedError(
"AITer backend does not support Grouped Query Attention yet."
)
self.causal = causal
self.dropout_p = dropout_p
self.softmax_scale = softmax_scale
@torch.compiler.disable
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
"""
Performs attention using one of:
- _fmha_fp8_prefill_attention (FP8, SGLANG_DIFFUSION_AITER_FP8_ATTN=1 when eligible)
- flash_attn_func (BF16, default or FP8 fallback for unsupported shapes)
Args:
query: Query tensor of shape [batch_size, seq_len, num_heads, head_dim]
key: Key tensor of shape [batch_size, seq_len, num_heads, head_dim]
value: Value tensor of shape [batch_size, seq_len, num_heads, head_dim]
attn_metadata: Metadata for the attention operation (unused).
Returns:
Output tensor of shape [batch_size, seq_len, num_heads, head_dim]
"""
if _use_fp8_attn:
if query.dtype != _fp8_dtype:
q_fp8, q_scale = aiter.per_tensor_quant(query, quant_dtype=_fp8_dtype)
k_fp8, k_scale = aiter.per_tensor_quant(key, quant_dtype=_fp8_dtype)
v_fp8, v_scale = aiter.per_tensor_quant(value, quant_dtype=_fp8_dtype)
else:
q_fp8, k_fp8, v_fp8 = query, key, value
one = torch.tensor(1.0, dtype=torch.float32, device=query.device)
q_scale = k_scale = v_scale = one
d_q = q_fp8.shape[-1]
d_k = k_fp8.shape[-1]
d_v = v_fp8.shape[-1]
h_q = q_fp8.shape[2]
h_kv = k_fp8.shape[2]
if _can_use_fmha_fp8_prefill(d_q, d_k, d_v, h_q, h_kv):
return _fmha_fp8_prefill_attention(
q_fp8,
k_fp8,
v_fp8,
softmax_scale=self.softmax_scale,
is_causal=self.causal,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
)
logger.warning_once(
"FP8 FMHA prefill unsupported for this shape (need gfx950-class AITER, "
"full MHA, q/k/v head_dim=%d; got q=%d, k=%d, v=%d, num_heads=%d, "
"num_kv_heads=%d). Falling back to BF16.",
_FMHA_FP8_HEAD_DIM,
d_q,
d_k,
d_v,
h_q,
h_kv,
)
# BF16 path
output, _ = aiter.flash_attn_func(
query,
key,
value,
dropout_p=self.dropout_p,
causal=self.causal,
return_attn_probs=False,
return_lse=True,
)
return output
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
class AITERSageBackend(AttentionBackend):
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.AITER_SAGE
@staticmethod
def get_impl_cls() -> type["AITERSageImpl"]:
return AITERSageImpl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
# AITER Sage backend does not require special metadata.
return AttentionMetadata
@staticmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
raise NotImplementedError(
"AITER Sage backend does not have a metadata builder."
)
class AITERSageImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
softmax_scale: float,
causal: bool = False,
num_kv_heads: int | None = None,
prefix: str = "",
dropout_p: float = 0.0,
**extra_impl_args,
) -> None:
try:
from aiter.ops.triton.attention.fav3_sage import fav3_sage_wrapper_func
self.aiter_sage_attn_fn = fav3_sage_wrapper_func
except ImportError:
raise ImportError(
"AITER Sage attention is not available, please update AITER version."
)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
"""
Performs attention using aiter sage backend.
Args:
query: Query tensor of shape [batch_size, seq_len, head_num, head_dim]
key: Key tensor of shape [batch_size, seq_len, head_num, head_dim]
value: Value tensor of shape [batch_size, seq_len, head_num, head_dim]
attn_metadata: Metadata for the attention operation (unused).
Returns:
Output tensor of shape [batch_size, seq_len, head_num, head_dim]
"""
output = self.aiter_sage_attn_fn(query, key, value)
return output
@@ -0,0 +1,104 @@
from dataclasses import dataclass
from typing import Any
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
@dataclass
class AscendFAMetadata:
pass
class AscendFAMetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build(
self,
**kwargs: dict[str, Any],
) -> AttentionMetadata:
return AscendFAMetadata()
class AscendFABackend(AttentionBackend):
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.FA
@staticmethod
def get_impl_cls() -> type["AscendFAImpl"]:
return AscendFAImpl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
raise NotImplementedError
@staticmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
return AscendFAMetadataBuilder
class AscendFAImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
return_softmax_lse: bool = False,
) -> torch.Tensor:
mask = None
num_heads, num_key_value_heads = query.shape[2], key.shape[2]
if self.causal:
seq_len = query.shape[1]
mask = torch.triu(
torch.ones(seq_len, seq_len, device=query.device), diagonal=1
).bool()
# transpose to bs, heads, seq_len, head_dim
query = query.transpose(1, 2)
key = key.transpose(1, 2)
value = value.transpose(1, 2)
output, lse = torch.ops.npu.npu_fused_infer_attention_score(
query,
key,
value,
num_heads=num_heads,
num_key_value_heads=num_key_value_heads,
scale=self.softmax_scale,
input_layout="BNSD",
softmax_lse_flag=return_softmax_lse,
atten_mask=mask,
)
output = output.transpose(1, 2)
if return_softmax_lse:
return output, lse
return output
@@ -0,0 +1,179 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# Adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/attention/backends/abstract.py
from abc import ABC, abstractmethod
from dataclasses import dataclass, fields
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar
if TYPE_CHECKING:
pass
import torch
from sglang.kernel_api_logging import wrap_method_with_debug_kernel_once
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
class AttentionBackend(ABC):
"""Abstract class for attention backends."""
# For some attention backends, we allocate an output tensor before
# calling the custom op. When piecewise cudagraph is enabled, this
# makes sure the output tensor is allocated inside the cudagraph.
accept_output_buffer: bool = False
@staticmethod
@abstractmethod
def get_enum() -> AttentionBackendEnum:
raise NotImplementedError
@staticmethod
@abstractmethod
def get_impl_cls() -> type["AttentionImpl"]:
raise NotImplementedError
@staticmethod
@abstractmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
raise NotImplementedError
# @staticmethod
# @abstractmethod
# def get_state_cls() -> Type["AttentionState"]:
# raise NotImplementedError
# @classmethod
# def make_metadata(cls, *args, **kwargs) -> "AttentionMetadata":
# return cls.get_metadata_cls()(*args, **kwargs)
@staticmethod
@abstractmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
return None
@dataclass
class AttentionMetadata:
"""Attention metadata for prefill and decode batched together."""
# Current step of diffusion process
current_timestep: int
def asdict_zerocopy(self, skip_fields: set[str] | None = None) -> dict[str, Any]:
"""Similar to dataclasses.asdict, but avoids deepcopying."""
if skip_fields is None:
skip_fields = set()
# Note that if we add dataclasses as fields, they will need
# similar handling.
return {
field.name: getattr(self, field.name)
for field in fields(self)
if field.name not in skip_fields
}
T = TypeVar("T", bound=AttentionMetadata)
class AttentionMetadataBuilder(ABC, Generic[T]):
"""Abstract class for attention metadata builders."""
@abstractmethod
def __init__(self) -> None:
"""Create the builder, remember some configuration and parameters."""
raise NotImplementedError
@abstractmethod
def prepare(self) -> None:
"""Prepare for one batch."""
raise NotImplementedError
@abstractmethod
def build(
self,
**kwargs: dict[str, Any],
) -> AttentionMetadata:
"""Build attention metadata with on-device tensors."""
raise NotImplementedError
class AttentionLayer(Protocol):
_k_scale: torch.Tensor
_v_scale: torch.Tensor
_k_scale_float: float
_v_scale_float: float
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
kv_cache: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor: ...
class AttentionImpl(ABC, Generic[T]):
@abstractmethod
def __init__(
self,
num_heads: int,
head_size: int,
softmax_scale: float,
causal: bool = False,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
raise NotImplementedError
def preprocess_qkv(self, qkv: torch.Tensor, attn_metadata: T) -> torch.Tensor:
"""Preprocess QKV tensor before performing attention operation.
Default implementation returns the tensor unchanged.
Subclasses can override this to implement custom preprocessing
like reshaping, tiling, scaling, or other transformations.
Called AFTER all_to_all for distributed attention
"""
return qkv
def postprocess_output(
self,
output: torch.Tensor,
attn_metadata: T,
) -> torch.Tensor:
"""Postprocess the output tensor after the attention operation.
Default implementation returns the tensor unchanged.
Subclasses can override this to implement custom postprocessing
like untiling, scaling, or other transformations.
Called BEFORE all_to_all for distributed attention
"""
return output
@abstractmethod
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: T,
) -> torch.Tensor:
raise NotImplementedError
def wrap_attention_impl_forward(attn_impl: AttentionImpl) -> AttentionImpl:
return wrap_method_with_debug_kernel_once(
attn_impl,
"forward",
op_name=f"diffusion.attn_impl.{attn_impl.__class__.__name__}.forward",
)
@@ -0,0 +1,279 @@
from dataclasses import dataclass
from typing import Any
import attentions # noqa: F401
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.laser_attn import (
LaserAttentionBackend,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
BSA_BLOCK_SIZE = 128
class BlockSparseAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.BLOCK_SPARSE_ATTN
@staticmethod
def get_impl_cls() -> type["BlockSparseAttentionImpl"]:
return BlockSparseAttentionImpl
@staticmethod
def get_metadata_cls() -> type["BlockSparseAttentionMetadata"]:
return BlockSparseAttentionMetadata
@staticmethod
def get_builder_cls() -> type["BlockSparseAttentionMetadataBuilder"]:
return BlockSparseAttentionMetadataBuilder
@dataclass
class BlockSparseAttentionMetadata(AttentionMetadata):
current_timestep: int
skip_first_steps: int
sparsity: float
block_frame_stride: int
class BlockSparseAttentionMetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build(
self,
current_timestep: int,
skip_first_steps: int,
sparsity: float,
raw_latent_shape: list[int],
patch_size: tuple[int, int, int],
**kwargs: dict[str, Any],
) -> BlockSparseAttentionMetadata:
"""
Builds BlockSparseAttention metadata.
Args:
current_timestep: The current diffusion timestep.
skip_first_steps: Number of initial timesteps to skip before applying
sparsity. Must be nonnegative.
sparsity: Fraction of tokens to drop (blockwise) in the block sparse
attention mechanism. Must be in the range [0.0, 1.0).
raw_latent_shape: Shape of the latent tensor before patching.
patch_size: Patch size as (T, height, width). Only the height
and width components are used to divide the latent dimensions.
**kwargs: Additional keyword arguments (ignored, but accepted for
compatibility with base class or calling conventions).
Returns:
BlockSparseAttentionMetadata
Note:
The `block_frame_stride` is needed to set the first blocks to be nonsparse.
"""
if not (skip_first_steps >= 0 and 0.0 <= sparsity < 1.0):
raise ValueError(
(
"Invalid attention metadata values."
f"Sparsity should be in [0, 1), skip_first_steps should be non-negative."
f"Got sparsity={sparsity}, skip_first_steps={skip_first_steps}"
)
)
if sparsity == 0.0:
logger.warning(
(
"Sparsity is set to 0.0, which means no tokens will be dropped."
"For better performance use Laser Attention or increase sparsity."
)
)
if len(raw_latent_shape) >= 5:
latent_height, latent_width = raw_latent_shape[3:5]
else:
latent_height, latent_width = raw_latent_shape[-2:]
latent_height //= patch_size[1]
latent_width //= patch_size[2]
frame_stride = latent_height * latent_width
block_frame_stride = (frame_stride + BSA_BLOCK_SIZE - 1) // BSA_BLOCK_SIZE
return BlockSparseAttentionMetadata(
current_timestep=current_timestep,
skip_first_steps=skip_first_steps,
sparsity=sparsity,
block_frame_stride=block_frame_stride,
)
class BlockSparseAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads or num_heads
self.block_size = BSA_BLOCK_SIZE
self.stride = 8
self.default_tokens = 214748647
self.laser_attn_impl = LaserAttentionBackend.get_impl_cls()(
num_heads,
head_size,
causal,
softmax_scale,
num_kv_heads,
prefix,
**extra_impl_args,
)
def _get_estimate_mask(
self,
query: torch.Tensor,
key: torch.Tensor,
sparsity: float,
) -> tuple[torch.Tensor, torch.Tensor]:
return torch.ops.attentions.sparse_block_estimate(
query=query,
key=key,
actual_seq_lengths=None,
actual_seq_lengths_kv=None,
input_layout="BNSD",
stride=self.stride,
sparse_size=self.block_size,
num_heads=query.shape[1],
num_key_value_heads=key.shape[1],
scale_value=self.softmax_scale / self.stride,
threshold=1.0,
causal=self.causal,
keep_sink=True,
keep_recent=True,
row_sparse=1.0 - sparsity,
)
def _block_sparse_attention(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
smask: torch.Tensor,
sct: torch.Tensor,
) -> torch.Tensor:
return torch.ops.attentions.block_sparse_attention(
query=query,
key=key,
value=value,
sparse_mask=smask,
sparse_count_table=sct,
input_layout="BNSD",
sparse_size=self.block_size,
num_heads=query.shape[1],
num_key_value_heads=key.shape[1],
scale_value=self.softmax_scale,
causal=self.causal,
inner_precise=1,
pre_tokens=self.default_tokens,
next_tokens=self.default_tokens,
actual_seq_lengths=None,
actual_seq_lengths_kv=None,
)
def _get_smask(
self,
query: torch.Tensor,
key: torch.Tensor,
block_frame_stride: int,
sparsity: float,
) -> tuple[torch.Tensor, torch.Tensor]:
smask, sct = self._get_estimate_mask(
query,
key,
sparsity,
)
seq_len = smask.shape[2]
# Set the first blocks to be non-sparse to ensure the quality of the first few steps
smask[:, :, :block_frame_stride, :seq_len] = 1
smask[:, :, :seq_len, :block_frame_stride] = 1
smask = smask.to(torch.int8)
sct = smask.sum(dim=-1, dtype=torch.int32)
return smask, sct
def _adaptive_block_sparse_attention(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
block_frame_stride: int,
sparsity: float,
) -> torch.Tensor:
# TODO Currently implementation for BSND input layout has quality issues
# When the implementation is improved, transposes can be removed
q = query.permute(0, 2, 1, 3).contiguous()
k = key.permute(0, 2, 1, 3).contiguous()
v = value.permute(0, 2, 1, 3).contiguous()
smask, sct = self._get_smask(
q,
k,
block_frame_stride,
sparsity,
)
output = self._block_sparse_attention(q, k, v, smask, sct)
output = output.permute(0, 2, 1, 3).contiguous()
return output
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
if attn_metadata.current_timestep < attn_metadata.skip_first_steps:
output = self.laser_attn_impl.forward(
query,
key,
value,
attn_metadata,
)
else:
output = self._adaptive_block_sparse_attention(
query,
key,
value,
attn_metadata.block_frame_stride,
attn_metadata.sparsity,
)
return output
@@ -0,0 +1,445 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
import torch
from sglang.jit_kernel.flash_attention import flash_attn_varlen_func
from sglang.multimodal_gen.runtime.layers.utils import register_custom_op
from sglang.multimodal_gen.runtime.platforms import (
AttentionBackendEnum,
)
def maybe_contiguous(x: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
return x.contiguous() if x is not None and x.stride(-1) != 1 else x
# -----------------------------
# Fake implementations for schema / tracing
# custom op schema requires FIXED return structure.
# We provide TWO ops:
# 1) out-only op: always returns Tensor
# 2) out+lse op: always returns Tuple[Tensor, Tensor]
# -----------------------------
def flash_attn_varlen_func_fake_out(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: Optional[torch.Tensor] = None,
cu_seqlens_k: Optional[torch.Tensor] = None,
max_seqlen_q: Optional[int] = None,
max_seqlen_k: Optional[int] = None,
seqused_q: Optional[torch.Tensor] = None,
seqused_k: Optional[torch.Tensor] = None,
page_table: Optional[torch.Tensor] = None,
softmax_scale: Optional[float] = None,
causal: bool = False,
qv: Optional[torch.Tensor] = None,
q_descale: Optional[torch.Tensor] = None,
k_descale: Optional[torch.Tensor] = None,
v_descale: Optional[torch.Tensor] = None,
window_size: Optional[List[int]] = None,
attention_chunk: int = 0,
softcap: float = 0.0,
num_splits: int = 1,
pack_gqa: Optional[bool] = None,
sm_margin: int = 0,
return_softmax_lse: bool = False,
sinks: Optional[torch.Tensor] = None,
ver: int = 4,
) -> torch.Tensor:
assert ver == 4, "only support flash attention v4"
q, k, v = [maybe_contiguous(t) for t in (q, k, v)]
num_head, head_dim = q.shape[-2:]
if cu_seqlens_q is None:
batch_size, seqlen_q = q.shape[:2]
else:
batch_size = cu_seqlens_q.shape[0] - 1
seqlen_q = None
head_dim_v = v.shape[-1]
if cu_seqlens_q is not None:
assert cu_seqlens_q.shape == (
batch_size + 1,
), "cu_seqlens_q must have shape (batch_size + 1,)"
assert cu_seqlens_q.dtype == torch.int32, "cu_seqlens_q must be int32"
assert cu_seqlens_q.stride(0) == 1, "cu_seqlens_q must be contiguous"
assert q.dtype in [
torch.float16,
torch.bfloat16,
], "inputs must be float16 or bfloat16"
assert q.dtype == k.dtype == v.dtype, "inputs must have the same dtype"
assert head_dim <= 256, "head_dim must be less than or equal to 256"
alignment = 16 // q.element_size()
assert head_dim_v % alignment == 0, f"head_dim_v must be divisible by {alignment}"
q_batch_seqlen_shape = (
(batch_size, seqlen_q) if cu_seqlens_q is None else (q.shape[0],)
)
out = q.new_empty(*q_batch_seqlen_shape, num_head, head_dim_v)
return out
def flash_attn_varlen_func_fake_out_lse(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: Optional[torch.Tensor] = None,
cu_seqlens_k: Optional[torch.Tensor] = None,
max_seqlen_q: Optional[int] = None,
max_seqlen_k: Optional[int] = None,
seqused_q: Optional[torch.Tensor] = None,
seqused_k: Optional[torch.Tensor] = None,
page_table: Optional[torch.Tensor] = None,
softmax_scale: Optional[float] = None,
causal: bool = False,
qv: Optional[torch.Tensor] = None,
q_descale: Optional[torch.Tensor] = None,
k_descale: Optional[torch.Tensor] = None,
v_descale: Optional[torch.Tensor] = None,
window_size: Optional[List[int]] = None,
attention_chunk: int = 0,
softcap: float = 0.0,
num_splits: int = 1,
pack_gqa: Optional[bool] = None,
sm_margin: int = 0,
return_softmax_lse: bool = True,
sinks: Optional[torch.Tensor] = None,
ver: int = 4,
) -> Tuple[torch.Tensor, torch.Tensor]:
assert ver == 4, "only support flash attention v4"
q, k, v = [maybe_contiguous(t) for t in (q, k, v)]
num_head, head_dim = q.shape[-2:]
if cu_seqlens_q is None:
batch_size, seqlen_q = q.shape[:2]
total_q = batch_size * seqlen_q
else:
batch_size = cu_seqlens_q.shape[0] - 1
seqlen_q = None
total_q = q.shape[0]
head_dim_v = v.shape[-1]
if cu_seqlens_q is not None:
assert cu_seqlens_q.shape == (
batch_size + 1,
), "cu_seqlens_q must have shape (batch_size + 1,)"
assert cu_seqlens_q.dtype == torch.int32, "cu_seqlens_q must be int32"
assert cu_seqlens_q.stride(0) == 1, "cu_seqlens_q must be contiguous"
assert q.dtype in [
torch.float16,
torch.bfloat16,
], "inputs must be float16 or bfloat16"
assert q.dtype == k.dtype == v.dtype, "inputs must have the same dtype"
assert head_dim <= 256, "head_dim must be less than or equal to 256"
alignment = 16 // q.element_size()
assert head_dim_v % alignment == 0, f"head_dim_v must be divisible by {alignment}"
q_batch_seqlen_shape = (
(batch_size, seqlen_q) if cu_seqlens_q is None else (total_q,)
)
lse_shape = (
(batch_size, num_head, seqlen_q)
if cu_seqlens_q is None
else (num_head, total_q)
)
out = q.new_empty(*q_batch_seqlen_shape, num_head, head_dim_v)
lse = q.new_empty(lse_shape, dtype=torch.float32)
return out, lse
# -----------------------------
# Registered custom ops
# NOTE: fixed return schemas to avoid:
# "Object of type 'Tensor' is not an instance of 'sequence'"
# -----------------------------
@register_custom_op(fake_impl=flash_attn_varlen_func_fake_out)
def flash_attn_varlen_func_op(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: Optional[torch.Tensor] = None,
cu_seqlens_k: Optional[torch.Tensor] = None,
max_seqlen_q: Optional[int] = None,
max_seqlen_k: Optional[int] = None,
seqused_q: Optional[torch.Tensor] = None,
seqused_k: Optional[torch.Tensor] = None,
page_table: Optional[torch.Tensor] = None,
softmax_scale: Optional[float] = None,
causal: bool = False,
qv: Optional[torch.Tensor] = None,
q_descale: Optional[torch.Tensor] = None,
k_descale: Optional[torch.Tensor] = None,
v_descale: Optional[torch.Tensor] = None,
window_size: Optional[List[int]] = None,
attention_chunk: int = 0,
softcap: float = 0.0,
num_splits: int = 1,
pack_gqa: Optional[bool] = None,
sm_margin: int = 0,
return_softmax_lse: bool = False,
sinks: Optional[torch.Tensor] = None,
ver: int = 4,
) -> torch.Tensor:
if window_size is None:
window_size = [-1, -1]
if return_softmax_lse:
raise ValueError(
"flash_attn_varlen_func_op is out-only op; return_softmax_lse must be False. "
"Use flash_attn_varlen_func_op_lse for (out, lse)."
)
return flash_attn_varlen_func(
q,
k,
v,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
seqused_q=seqused_q,
seqused_k=seqused_k,
page_table=page_table,
softmax_scale=softmax_scale,
causal=causal,
qv=qv,
q_descale=q_descale,
k_descale=k_descale,
v_descale=v_descale,
window_size=tuple(window_size),
attention_chunk=attention_chunk,
softcap=softcap,
num_splits=num_splits,
pack_gqa=pack_gqa,
sm_margin=sm_margin,
return_softmax_lse=False,
sinks=sinks,
ver=ver,
)
@register_custom_op(fake_impl=flash_attn_varlen_func_fake_out_lse)
def flash_attn_varlen_func_op_lse(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
cu_seqlens_q: Optional[torch.Tensor] = None,
cu_seqlens_k: Optional[torch.Tensor] = None,
max_seqlen_q: Optional[int] = None,
max_seqlen_k: Optional[int] = None,
seqused_q: Optional[torch.Tensor] = None,
seqused_k: Optional[torch.Tensor] = None,
page_table: Optional[torch.Tensor] = None,
softmax_scale: Optional[float] = None,
causal: bool = False,
qv: Optional[torch.Tensor] = None,
q_descale: Optional[torch.Tensor] = None,
k_descale: Optional[torch.Tensor] = None,
v_descale: Optional[torch.Tensor] = None,
window_size: Optional[List[int]] = None,
attention_chunk: int = 0,
softcap: float = 0.0,
num_splits: int = 1,
pack_gqa: Optional[bool] = None,
sm_margin: int = 0,
return_softmax_lse: bool = True,
sinks: Optional[torch.Tensor] = None,
ver: int = 4,
) -> Tuple[torch.Tensor, torch.Tensor]:
if window_size is None:
window_size = [-1, -1]
if not return_softmax_lse:
raise ValueError(
"flash_attn_varlen_func_op_lse is out+lse op; return_softmax_lse must be True. "
"Use flash_attn_varlen_func_op for out-only."
)
return flash_attn_varlen_func(
q,
k,
v,
cu_seqlens_q=cu_seqlens_q,
cu_seqlens_k=cu_seqlens_k,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
seqused_q=seqused_q,
seqused_k=seqused_k,
page_table=page_table,
softmax_scale=softmax_scale,
causal=causal,
qv=qv,
q_descale=q_descale,
k_descale=k_descale,
v_descale=v_descale,
window_size=tuple(window_size),
attention_chunk=attention_chunk,
softcap=softcap,
num_splits=num_splits,
pack_gqa=pack_gqa,
sm_margin=sm_margin,
return_softmax_lse=True,
sinks=sinks,
ver=ver,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
fa_ver = 3
def set_fa_ver(ver: int) -> None:
global fa_ver
fa_ver = ver
@dataclass
class FlashAttentionMetadata:
# Sequence lengths for the forward batch
# Maximum sequence length for query
max_seqlen_q: int = 1
# Maximum sequence length for key
max_seqlen_k: int = 0
# Cumulative sequence lengths for query
cu_seqlens_q: torch.Tensor = None
# Cumulative sequence lengths for key
cu_seqlens_k: torch.Tensor = None
class FlashAttentionMetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build( # type: ignore
self,
raw_latent_shape=list,
**kwargs: dict[str, Any],
) -> FlashAttentionMetadata:
# TODO: put empty values here to be set at first-run, since the q_len calculation can be complicated
return FlashAttentionMetadata(max_seqlen_q=None, max_seqlen_k=None)
class FlashAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128, 160, 192, 224, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.FA
@staticmethod
def get_impl_cls() -> type["FlashAttentionImpl"]:
return FlashAttentionImpl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
raise NotImplementedError
@staticmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
return FlashAttentionMetadataBuilder
class FlashAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads
self.head_size = head_size
self.causal = causal
self.softmax_scale = softmax_scale
self.attention_metadata = FlashAttentionMetadata()
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata = None,
*,
return_softmax_lse: bool = False,
):
if attn_metadata is not None:
if attn_metadata.max_seqlen_q is None:
attn_metadata.max_seqlen_q = query.shape[1]
if attn_metadata.max_seqlen_k is None:
attn_metadata.max_seqlen_k = key.shape[1]
max_seqlen_q = attn_metadata.max_seqlen_q
max_seqlen_k = attn_metadata.max_seqlen_k
else:
max_seqlen_q = query.shape[1]
max_seqlen_k = key.shape[1]
# FA version selection:
# - fa_ver == 3: call python function (can return Tensor or (Tensor, Tensor) depending on flag)
# - fa_ver == 4: call custom ops with FIXED return schema
if fa_ver == 3:
flash_attn_op = flash_attn_varlen_func
output = flash_attn_op(
q=query,
k=key,
v=value,
cu_seqlens_q=None,
cu_seqlens_k=None,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
softmax_scale=self.softmax_scale,
causal=self.causal,
return_softmax_lse=return_softmax_lse,
ver=fa_ver,
)
return output
if fa_ver == 4:
if return_softmax_lse:
out_tensor, softmax_lse = flash_attn_varlen_func_op_lse(
q=query,
k=key,
v=value,
cu_seqlens_q=None,
cu_seqlens_k=None,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
softmax_scale=self.softmax_scale,
causal=self.causal,
return_softmax_lse=True,
ver=fa_ver,
)
return out_tensor, softmax_lse
out_tensor = flash_attn_varlen_func_op(
q=query,
k=key,
v=value,
cu_seqlens_q=None,
cu_seqlens_k=None,
max_seqlen_q=max_seqlen_q,
max_seqlen_k=max_seqlen_k,
softmax_scale=self.softmax_scale,
causal=self.causal,
return_softmax_lse=False,
ver=fa_ver,
)
return out_tensor
raise ValueError(f"flash attention version {fa_ver} is not supported.")
@@ -0,0 +1,79 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.flash_attn import (
flash_attn_func,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class FlashAttention2Backend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128, 160, 192, 224, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.FA2
@staticmethod
def get_impl_cls() -> type["FlashAttention2Impl"]:
return FlashAttention2Impl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
raise NotImplementedError
@staticmethod
def get_builder_cls() -> type["AttentionMetadataBuilder"]:
raise NotImplementedError
class FlashAttention2Impl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
):
output = flash_attn_func(
q=query, # type: ignore[no-untyped-call]
k=key,
v=value,
cu_seqlens_q=None,
cu_seqlens_k=None,
max_seqlen_q=None,
max_seqlen_k=None,
softmax_scale=self.softmax_scale,
causal=self.causal,
)
return output
@@ -0,0 +1,191 @@
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.sdpa import SDPABackend
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
# Import to use torch.ops.attentions, install package with sgl_kernel_npu
try:
import attentions # noqa: F401
except ImportError as e:
raise ImportError(
(
"The required 'attentions' package is not installed."
"The package can be installed with sgl_kernel_npu"
)
) from e
logger = init_logger(__name__)
class LaserAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.LASER_ATTN
@staticmethod
def get_impl_cls() -> type["LaserAttentionImpl"]:
return LaserAttentionImpl
class LaserAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.softmax_scale = softmax_scale
# After preprocess input layout should be BNSD.
self.seqlen_base = 256
self.seqlen_index = 2
self.dim_index = 3
self.dim_base = 128
self.max_token = 2**31 - 1
self.seq_len_pad_base = 256
# the laser attention operator has issues with small seq_len
self.min_seqlen = 2048
self.sdpa_impl = SDPABackend.get_impl_cls()(
num_heads,
head_size,
causal,
softmax_scale,
num_kv_heads,
prefix,
**extra_impl_args,
)
def _pad(self, input_tensor: torch.Tensor) -> torch.Tensor:
"""
Pad the input tensor along the sequence length and head dimension.
to multiples of base values. self.seqlen_index and self.dim_index should be positive integers.
"""
seq_len = input_tensor.size(self.seqlen_index)
head_dim = input_tensor.size(self.dim_index)
pad_seq = 0
if seq_len % self.seqlen_base != 0:
pad_seq = ((seq_len // self.seqlen_base) + 1) * self.seqlen_base - seq_len
pad_dim = 0
if head_dim % self.dim_base != 0:
pad_dim = ((head_dim // self.dim_base) + 1) * self.dim_base - head_dim
if pad_seq == 0 and pad_dim == 0:
return input_tensor
pad_list = [0] * (2 * input_tensor.ndim)
pad_list[len(pad_list) - 2 * self.seqlen_index - 1] = pad_seq
pad_list[len(pad_list) - 2 * self.dim_index - 1] = pad_dim
return torch.nn.functional.pad(input_tensor, pad_list)
def _la_preprocess_input(
self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
# Currently BSND input layout is not supported
q = query.transpose(1, 2)
k = key.transpose(1, 2)
v = value.transpose(1, 2)
if q.dtype != torch.float16:
q = q.to(torch.float16)
k = k.to(torch.float16)
v = v.to(torch.float16)
q = self._pad(q)
k = self._pad(k)
v = self._pad(v)
return q, k, v
def _la_postprocess_output(
self,
attention_out: torch.Tensor,
dtype: torch.dtype,
qseqlen: int,
head_dim: int,
) -> torch.Tensor:
if dtype != attention_out.dtype:
attention_out = attention_out.to(dtype)
attention_out = attention_out[:, :, :qseqlen, :head_dim]
attention_out = attention_out.transpose(1, 2).contiguous()
return attention_out
def _laser_attention(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
head_num: int,
pre_tokens: int,
) -> tuple[torch.Tensor, torch.Tensor]:
return torch.ops.attentions.la(
query=query,
key=key,
value=value,
atten_mask=None,
alibi_mask=None,
drop_mask=None,
scale_value=self.softmax_scale,
head_num=head_num,
input_layout="BNSD",
keep_prob=1.0,
pre_tokens=pre_tokens,
next_tokens=1,
is_highPrecision=True,
)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
q_seqlen, head_dim = query.shape[1], query.shape[3]
kv_seqlen = key.shape[1]
if q_seqlen < self.min_seqlen or kv_seqlen != q_seqlen:
output = self.sdpa_impl.forward(
query,
key,
value,
attn_metadata,
)
else:
pre_tokens = self.max_token
if kv_seqlen % self.seq_len_pad_base != 0:
pre_tokens = (
kv_seqlen // self.seq_len_pad_base + 1
) * self.seq_len_pad_base - kv_seqlen
q, k, v = self._la_preprocess_input(query, key, value)
_, la_output = self._laser_attention(q, k, v, q.shape[1], pre_tokens)
output = self._la_postprocess_output(
la_output, query.dtype, q_seqlen, head_dim
)
return output
@@ -0,0 +1,414 @@
import math
from dataclasses import dataclass
from typing import Any, List, Optional
import attentions # noqa: F401
import torch
from einops import rearrange
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.laser_attn import (
LaserAttentionBackend,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class RainFusionAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.RAIN_FUSION_ATTN
@staticmethod
def get_impl_cls() -> type["RainFusionAttentionImpl"]:
return RainFusionAttentionImpl
@staticmethod
def get_metadata_cls() -> type["RainFusionAttentionMetadata"]:
return RainFusionAttentionMetadata
@staticmethod
def get_builder_cls() -> type["RainFusionAttentionMetadataBuilder"]:
return RainFusionAttentionMetadataBuilder
@dataclass
class RainFusionAttentionMetadata(AttentionMetadata):
current_timestep: int
skip_first_steps: int
sparsity: float
latent_shape: list[int]
class RainFusionAttentionMetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build(
self,
current_timestep: int,
skip_first_steps: int,
sparsity: float,
raw_latent_shape: list[int],
patch_size: tuple[int, int, int],
**kwargs: dict[str, Any],
) -> RainFusionAttentionMetadata:
if not (skip_first_steps >= 0 and 0.0 <= sparsity < 1.0):
raise ValueError(
(
"Invalid attention metadata values."
f"Sparsity should be in [0, 1), skip_first_steps should be non-negative."
f"Got sparsity={sparsity}, skip_first_steps={skip_first_steps}"
)
)
if sparsity == 0.0:
logger.warning(
(
"Sparsity is set to 0.0, which means no tokens will be dropped."
"For better performance use Laser Attention or increase sparsity."
)
)
latent_shape = raw_latent_shape[-3:]
latent_shape = [latent_shape[i] // patch_size[i] for i in range(3)]
return RainFusionAttentionMetadata(
current_timestep=current_timestep,
skip_first_steps=skip_first_steps,
sparsity=sparsity,
latent_shape=latent_shape,
)
class RainFusionAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.block_size = 128
self.inner_precise = 0
self.laser_attn_impl = LaserAttentionBackend.get_impl_cls()(
num_heads,
head_size,
causal,
softmax_scale,
num_kv_heads,
prefix,
**extra_impl_args,
)
def _avgpool(
self, input_tensor: torch.Tensor, pool_size: int = 128
) -> torch.Tensor:
batch, seqlen, heads, dim = input_tensor.shape
x = input_tensor.permute(0, 2, 3, 1).reshape(batch * heads, dim, seqlen)
pooled = torch.nn.functional.avg_pool1d(
x, kernel_size=pool_size, stride=pool_size, ceil_mode=True
)
out = pooled.reshape(batch, heads, dim, -1).permute(0, 3, 1, 2).contiguous()
return out
def _get_mask_index(self, mask: torch.Tensor) -> torch.Tensor:
batch_size, num_heads, seq_len, _ = mask.shape
mask_reshaped = mask.reshape(-1, seq_len)
row_indices = torch.arange(
seq_len, device=mask.device, dtype=torch.float32
).unsqueeze(0)
sorted_vals = torch.where(mask_reshaped, row_indices, seq_len)
sorted_vals, _ = torch.sort(sorted_vals, dim=-1)
valid_count = mask_reshaped.sum(dim=-1, keepdim=True)
keep_mask = row_indices < valid_count
result = torch.where(keep_mask, sorted_vals, -1)
pos_matrix = result.reshape(batch_size, num_heads, seq_len, seq_len).to(
torch.int64
)
return pos_matrix
def _get_blockwise_mask(
self,
qkv_pool: torch.Tensor,
sparsity: float,
scale: float,
pool_size: int,
latent_shape: tuple,
) -> tuple[torch.Tensor, torch.Tensor]:
first_frame_len = latent_shape[1] * latent_shape[2]
query_pool, key_pool, value_pool = torch.chunk(qkv_pool, 3, dim=0)
attn_scores = (
query_pool.permute(0, 2, 1, 3) @ key_pool.permute(0, 2, 3, 1) * scale
)
keep_len = math.ceil(attn_scores.shape[-1] * (1 - sparsity))
topk_values, _ = torch.topk(attn_scores, k=keep_len, dim=-1)
mask = attn_scores >= topk_values[..., -1:]
firstframe_block_num = (first_frame_len + pool_size - 1) // pool_size
if firstframe_block_num > 0:
mask[:, :, :firstframe_block_num, :] = True
mask[:, :, :, :firstframe_block_num] = True
select_idx = self._get_mask_index(mask)
select_idx = select_idx[0].transpose(0, 1)
select_num_idx = mask[0].transpose(0, 1).sum(dim=-1)
return select_idx, select_num_idx
def _rearrange_with_remaining(
self, tensor: torch.Tensor, latent_shape: tuple[int, int, int]
) -> torch.Tensor:
"""
b (f hn hb wn wb) n d -> b (f hn wn hb wb) n d
or
b n (f hn hb wn wb) d -> b n (f hn wn hb wb) d
"""
tq, hq, wq = latent_shape
first_frame_len, frame_num = hq * wq, tq
b, s, n, d = tensor.shape
if (hq % 8 != 0) or (wq % 8 != 0):
tensor_first = tensor[:, :first_frame_len, :, :]
tensor = tensor[:, first_frame_len:, :, :]
tensor_hwt = rearrange(
tensor, "b (f h w) n d -> b f h w n d", f=frame_num - 1, h=hq, w=wq
)
if hq % 8 != 0:
tensor_hwt, tensor_h_r = torch.split(tensor_hwt, hq - (hq % 8), dim=2)
tensor_h_r = tensor_h_r.reshape(b, frame_num - 1, -1, n, d)
if wq % 8 != 0:
tensor_hwt, tensor_w_r = torch.split(tensor_hwt, wq - (wq % 8), dim=3)
tensor_w_r = tensor_w_r.reshape(b, frame_num - 1, -1, n, d)
tensor_hwt = rearrange(
tensor_hwt,
"b f (hn hb) (wn wb) n d -> b f (hn wn hb wb) n d",
f=frame_num - 1,
hb=8,
wb=8,
hn=hq // 8,
wn=wq // 8,
)
if hq % 8 != 0:
tensor_hwt = torch.cat((tensor_hwt, tensor_h_r), dim=2)
if wq % 8 != 0:
tensor_hwt = torch.cat((tensor_hwt, tensor_w_r), dim=2)
tensor_hwt = tensor_hwt.reshape(b, -1, n, d)
tensor_hwt = torch.cat([tensor_first, tensor_hwt], dim=1)
else:
tensor_hwt = rearrange(
tensor,
"b (f hn hb wn wb) n d -> b (f hn wn hb wb) n d",
f=frame_num,
hb=8,
wb=8,
hn=hq // 8,
wn=wq // 8,
)
return tensor_hwt
def _inv_rearrange_with_remaining(
self, tensor: torch.Tensor, latent_shape: tuple[int, int, int]
) -> torch.Tensor:
tq, hq, wq = latent_shape
first_frame_len, frame_num = hq * wq, tq
b, s, n, d = tensor.shape
if (hq % 8 != 0) or (wq % 8 != 0):
tensor_first = tensor[:, :first_frame_len, :, :]
tensor = tensor[:, first_frame_len:, :, :]
tensor_hwt = rearrange(
tensor, "b (f h w) n d -> b f h w n d", f=frame_num - 1, h=hq, w=wq
)
if hq % 8 != 0:
tensor_hwt, tensor_h_r = torch.split(tensor_hwt, hq - (hq % 8), dim=2)
if wq % 8 != 0:
tensor_hwt, tensor_w_r = torch.split(tensor_hwt, wq - (wq % 8), dim=3)
tensor_hwt = tensor_hwt.reshape(b, frame_num - 1, -1, n, d)
tensor_hwt = rearrange(
tensor_hwt,
"b f (hn wn hb wb) n d -> b f (hn hb) (wn wb) n d",
f=frame_num - 1,
hb=8,
wb=8,
hn=hq // 8,
wn=wq // 8,
)
if wq % 8 != 0:
tensor_hwt = torch.cat((tensor_hwt, tensor_w_r), dim=3)
if hq % 8 != 0:
tensor_hwt = torch.cat((tensor_hwt, tensor_h_r), dim=2)
tensor_hwt = tensor_hwt.reshape(b, -1, n, d)
tensor_hwt = torch.cat([tensor_first, tensor_hwt], dim=1)
else:
tensor_hwt = rearrange(
tensor,
"b (f hn wn hb wb) n h -> b (f hn hb wn wb) n h",
f=frame_num,
hb=8,
wb=8,
hn=hq // 8,
wn=wq // 8,
)
return tensor_hwt
def _do_tensor_rearrange_pooling(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
pool_size: int,
latent_shape: tuple[int, int, int],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Tensor block rearrangement + pooling operation
"""
tensor = torch.cat((query, key, value), dim=0)
tensor = self._rearrange_with_remaining(tensor, latent_shape)
tensor_pool = self._avgpool(tensor, pool_size)
query_, key_, value_ = torch.chunk(tensor, 3, dim=0)
return query_, key_, value_, tensor_pool
def _rain_fusion_attention(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
select_idx: torch.Tensor,
select_num_idx: torch.Tensor,
blockshape: List[int],
scale: float = 1.0,
head_num: int = 1,
input_layout: str = "TND",
actual_seq_lengths=Optional[torch.Tensor],
actual_seq_lengths_kv=Optional[torch.Tensor],
) -> tuple[torch.Tensor, torch.Tensor]:
return torch.ops.attentions.rainfusionattention(
query=query,
key=key,
value=value,
select_idx=select_idx,
select_num_idx=select_num_idx,
blockshape=blockshape,
attn_mask=None,
actual_seq_qlen=actual_seq_lengths,
actual_seq_kvlen=actual_seq_lengths_kv,
block_table=None,
q_input_layout=input_layout,
kv_input_layout=input_layout,
head_num=head_num,
mask_type=0,
scale=scale,
inner_precise=self.inner_precise,
block_size=0,
)
def _rain_fusion_sparse_attention(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
latent_shape: tuple[int, int, int],
sparsity: float,
):
q, k, v, qkv_pool = self._do_tensor_rearrange_pooling(
query, key, value, self.block_size, latent_shape
)
select_idx, select_num_idx = self._get_blockwise_mask(
qkv_pool,
sparsity,
self.softmax_scale,
self.block_size,
latent_shape,
)
batch_size, seqlen_q, head_num, head_dim = q.shape
seqlen_kv = k.shape[1]
layout = "TND"
q = q.reshape(-1, head_num, head_dim)
k = k.reshape(-1, head_num, head_dim)
v = v.reshape(-1, head_num, head_dim)
actual_seq_lengths = [seqlen_q] * batch_size
actual_seq_lengths_kv = [seqlen_kv] * batch_size
out, _ = self._rain_fusion_attention(
q,
k,
v,
scale=self.softmax_scale,
head_num=head_num,
input_layout=layout,
select_idx=select_idx,
select_num_idx=select_num_idx,
blockshape=[self.block_size, self.block_size],
actual_seq_lengths=actual_seq_lengths,
actual_seq_lengths_kv=actual_seq_lengths_kv,
)
out = out.reshape(batch_size, seqlen_q, head_num, head_dim)
out = self._inv_rearrange_with_remaining(out, latent_shape)
return out
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
if attn_metadata.current_timestep < attn_metadata.skip_first_steps:
output = self.laser_attn_impl.forward(
query,
key,
value,
attn_metadata,
)
else:
output = self._rain_fusion_sparse_attention(
query,
key,
value,
attn_metadata.latent_shape,
attn_metadata.sparsity,
)
return output
@@ -0,0 +1,74 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import torch
from sageattention import sageattn
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import ( # FlashAttentionMetadata,
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class SageAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128, 160, 192, 224, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SAGE_ATTN
@staticmethod
def get_impl_cls() -> type["SageAttentionImpl"]:
return SageAttentionImpl
class SageAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.dropout = extra_impl_args.get("dropout_p", 0.0)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
*,
return_softmax_lse: bool = False,
) -> torch.Tensor:
output = sageattn(
query,
key,
value,
# since input is (batch_size, seq_len, head_num, head_dim)
tensor_layout="NHD",
is_causal=self.causal,
sm_scale=self.softmax_scale,
return_lse=return_softmax_lse,
)
if return_softmax_lse:
output, softmax_lse = output
return output, softmax_lse
return output
@@ -0,0 +1,92 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import torch
import torch.nn.functional as F
from sageattn3 import sageattn3_blackwell
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class SageAttention3Backend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 128, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SAGE_ATTN_3
@staticmethod
def get_impl_cls() -> type["SageAttention3Impl"]:
return SageAttention3Impl
@staticmethod
def get_metadata_cls() -> type["AttentionMetadata"]:
raise NotImplementedError
class SageAttention3Impl(AttentionImpl):
_warned_gqa_fallback_global: bool = False
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.dropout = extra_impl_args.get("dropout_p", 0.0)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
query = query.transpose(1, 2)
key = key.transpose(1, 2)
value = value.transpose(1, 2)
# SageAttention3's Blackwell kernel assumes MHA (Hq == Hkv). For GQA/MQA
# (Hq != Hkv), fall back to torch SDPA which supports GQA.
if key.shape[1] != query.shape[1]:
if query.shape[1] % key.shape[1] != 0:
raise ValueError(
"GQA/MQA requires query heads to be a multiple of KV heads, "
f"got q_heads={query.shape[1]} and kv_heads={key.shape[1]}"
)
if not type(self)._warned_gqa_fallback_global:
logger.warning(
"SageAttention3 does not support GQA/MQA (Hq != Hkv); falling back to torch SDPA."
)
type(self)._warned_gqa_fallback_global = True
output = F.scaled_dot_product_attention(
query,
key,
value,
is_causal=self.causal,
dropout_p=self.dropout,
scale=self.softmax_scale,
enable_gqa=True,
)
else:
output = sageattn3_blackwell(query, key, value, is_causal=self.causal)
output = output.transpose(1, 2)
return output
@@ -0,0 +1,95 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
from contextlib import nullcontext
import torch
from torch.nn.attention import SDPBackend, sdpa_kernel
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import ( # FlashAttentionMetadata,
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
_PYTORCH_DEFAULT_CUDA_SDP_BACKENDS = [
SDPBackend.CUDNN_ATTENTION,
SDPBackend.FLASH_ATTENTION,
SDPBackend.EFFICIENT_ATTENTION,
SDPBackend.MATH,
]
class SDPABackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [32, 64, 96, 128, 160, 192, 224, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.TORCH_SDPA
@staticmethod
def get_impl_cls() -> type["SDPAImpl"]:
return SDPAImpl
# @staticmethod
# def get_metadata_cls() -> Type["AttentionMetadata"]:
# return FlashAttentionMetadata
class SDPAImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.causal = causal
self.softmax_scale = softmax_scale
self.dropout = extra_impl_args.get("dropout_p", 0.0)
self.allow_cudnn_sdp = bool(extra_impl_args.get("allow_cudnn_sdp", False))
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
# transpose to bs, heads, seq_len, head_dim
query = query.transpose(1, 2)
key = key.transpose(1, 2)
value = value.transpose(1, 2)
attn_kwargs = {
"attn_mask": None,
"dropout_p": self.dropout,
"is_causal": self.causal,
"scale": self.softmax_scale,
}
if query.shape[1] != key.shape[1]:
attn_kwargs["enable_gqa"] = True
sdpa_context = (
sdpa_kernel(_PYTORCH_DEFAULT_CUDA_SDP_BACKENDS)
if self.allow_cudnn_sdp and query.device.type == "cuda"
else nullcontext()
)
with sdpa_context:
output = torch.nn.functional.scaled_dot_product_attention(
query, key, value, **attn_kwargs
)
output = output.transpose(1, 2)
return output
@@ -0,0 +1,316 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import json
from dataclasses import dataclass
from typing import Any
import torch
from einops import rearrange
from sglang.multimodal_gen.runtime.distributed import get_sp_group
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.managers.forward_context import (
ForwardContext,
get_forward_context,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.utils import dict_to_3d_list
try:
from st_attn import sliding_tile_attention
st_attn_backend_available = True
except Exception:
st_attn_backend_available = False
logger = init_logger(__name__)
class RangeDict(dict):
def __getitem__(self, item: int) -> str:
for key in self.keys():
if isinstance(key, tuple):
low, high = key
if low <= item <= high:
return str(super().__getitem__(key))
elif key == item:
return str(super().__getitem__(key))
raise KeyError(f"seq_len {item} not supported for STA")
class SlidingTileAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
# TODO(will-refactor): check this
return [32, 64, 96, 128, 160, 192, 224, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SLIDING_TILE_ATTN
@staticmethod
def get_impl_cls() -> type["SlidingTileAttentionImpl"]:
return SlidingTileAttentionImpl
@staticmethod
def get_metadata_cls() -> type["SlidingTileAttentionMetadata"]:
return SlidingTileAttentionMetadata
@staticmethod
def get_builder_cls() -> type["SlidingTileAttentionMetadataBuilder"]:
return SlidingTileAttentionMetadataBuilder
@dataclass
class SlidingTileAttentionMetadata(AttentionMetadata):
current_timestep: int
STA_param: list[
list[Any]
] # each timestep with one metadata, shape [num_layers, num_heads]
class SlidingTileAttentionMetadataBuilder(AttentionMetadataBuilder):
def __init__(self):
pass
def prepare(self):
pass
def build( # type: ignore
self,
STA_param: list[list[Any]],
current_timestep: int,
**kwargs: dict[str, Any],
) -> SlidingTileAttentionMetadata:
param = STA_param
if param is None:
return SlidingTileAttentionMetadata(
current_timestep=current_timestep, STA_param=[]
)
return SlidingTileAttentionMetadata(
current_timestep=current_timestep, STA_param=param[current_timestep]
)
class SlidingTileAttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
if not st_attn_backend_available:
raise ValueError("st attn not supported")
# TODO(will-refactor): for now this is the mask strategy, but maybe we should
# have a more general config for STA?
mask_strategy_file_path = (
get_global_server_args().attention_backend_config.mask_strategy_file_path
)
if mask_strategy_file_path is None:
raise ValueError("SGLANG_DIFFUSION_ATTENTION_CONFIG is not set")
# TODO(kevin): get mask strategy for different STA modes
with open(mask_strategy_file_path) as f:
mask_strategy = json.load(f)
self.mask_strategy = dict_to_3d_list(mask_strategy)
self.prefix = prefix
sp_group = get_sp_group()
self.sp_size = sp_group.world_size
# STA config
self.STA_base_tile_size = [6, 8, 8]
self.dit_seq_shape_mapping = RangeDict(
{
(115200, 115456): "30x48x80",
82944: "36x48x48",
69120: "18x48x80",
}
)
self.full_window_mapping = {
"30x48x80": [5, 6, 10],
"36x48x48": [6, 6, 6],
"18x48x80": [3, 6, 10],
}
def tile(self, x: torch.Tensor) -> torch.Tensor:
return rearrange(
x,
"b (n_t ts_t n_h ts_h n_w ts_w) h d -> b (n_t n_h n_w ts_t ts_h ts_w) h d",
n_t=self.full_window_size[0],
n_h=self.full_window_size[1],
n_w=self.full_window_size[2],
ts_t=self.STA_base_tile_size[0],
ts_h=self.STA_base_tile_size[1],
ts_w=self.STA_base_tile_size[2],
)
def untile(self, x: torch.Tensor) -> torch.Tensor:
x = rearrange(
x,
"b (n_t n_h n_w ts_t ts_h ts_w) h d -> b (n_t ts_t n_h ts_h n_w ts_w) h d",
n_t=self.full_window_size[0],
n_h=self.full_window_size[1],
n_w=self.full_window_size[2],
ts_t=self.STA_base_tile_size[0],
ts_h=self.STA_base_tile_size[1],
ts_w=self.STA_base_tile_size[2],
)
return x
def preprocess_qkv(
self,
qkv: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
img_sequence_length = qkv.shape[1]
self.dit_seq_shape_str = self.dit_seq_shape_mapping[img_sequence_length]
self.full_window_size = self.full_window_mapping[self.dit_seq_shape_str]
self.dit_seq_shape_int = list(map(int, self.dit_seq_shape_str.split("x")))
self.img_seq_length = (
self.dit_seq_shape_int[0]
* self.dit_seq_shape_int[1]
* self.dit_seq_shape_int[2]
)
return self.tile(qkv)
def postprocess_output(
self,
output: torch.Tensor,
attn_metadata: SlidingTileAttentionMetadata,
) -> torch.Tensor:
return self.untile(output)
def forward(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
attn_metadata: SlidingTileAttentionMetadata,
) -> torch.Tensor:
if self.mask_strategy is None:
raise ValueError("mask_strategy cannot be None for SlidingTileAttention")
if self.mask_strategy[0] is None:
raise ValueError("mask_strategy[0] cannot be None for SlidingTileAttention")
timestep = attn_metadata.current_timestep
forward_context: ForwardContext = get_forward_context()
forward_batch = forward_context.forward_batch
if forward_batch is None:
raise ValueError("forward_batch cannot be None")
# pattern:'.double_blocks.0.attn.impl' or '.single_blocks.0.attn.impl'
layer_idx = int(self.prefix.split(".")[-3])
if attn_metadata.STA_param is None or len(attn_metadata.STA_param) <= layer_idx:
raise ValueError("Invalid STA_param")
STA_param = attn_metadata.STA_param[layer_idx]
text_length = q.shape[1] - self.img_seq_length
has_text = text_length > 0
query = q.transpose(1, 2).contiguous()
key = k.transpose(1, 2).contiguous()
value = v.transpose(1, 2).contiguous()
head_num = query.size(1)
sp_group = get_sp_group()
current_rank = sp_group.rank_in_group
start_head = current_rank * head_num
# searching or tuning mode
if len(STA_param) < head_num * sp_group.world_size:
sparse_attn_hidden_states_all = []
full_mask_window = STA_param[-1]
for window_size in STA_param[:-1]:
sparse_hidden_states = sliding_tile_attention(
query,
key,
value,
[window_size] * head_num,
text_length,
has_text,
self.dit_seq_shape_str,
).transpose(1, 2)
sparse_attn_hidden_states_all.append(sparse_hidden_states)
hidden_states = sliding_tile_attention(
query,
key,
value,
[full_mask_window] * head_num,
text_length,
has_text,
self.dit_seq_shape_str,
).transpose(1, 2)
attn_L2_loss = []
attn_L1_loss = []
# average loss across all heads
for sparse_attn_hidden_states in sparse_attn_hidden_states_all:
# L2 loss
attn_L2_loss_ = (
torch.mean(
(sparse_attn_hidden_states.float() - hidden_states.float())
** 2,
dim=[0, 1, 3],
)
.cpu()
.numpy()
)
attn_L2_loss_ = [round(float(x), 6) for x in attn_L2_loss_]
attn_L2_loss.append(attn_L2_loss_)
# L1 loss
attn_L1_loss_ = (
torch.mean(
torch.abs(
sparse_attn_hidden_states.float() - hidden_states.float()
),
dim=[0, 1, 3],
)
.cpu()
.numpy()
)
attn_L1_loss_ = [round(float(x), 6) for x in attn_L1_loss_]
attn_L1_loss.append(attn_L1_loss_)
layer_loss_save = {"L2_loss": attn_L2_loss, "L1_loss": attn_L1_loss}
if forward_batch.is_cfg_negative:
if forward_batch.mask_search_final_result_neg is not None:
forward_batch.mask_search_final_result_neg[timestep].append(
layer_loss_save
)
else:
if forward_batch.mask_search_final_result_pos is not None:
forward_batch.mask_search_final_result_pos[timestep].append(
layer_loss_save
)
else:
windows = [STA_param[head_idx + start_head] for head_idx in range(head_num)]
hidden_states = sliding_tile_attention(
query,
key,
value,
windows,
text_length,
has_text,
self.dit_seq_shape_str,
).transpose(1, 2)
return hidden_states
@@ -0,0 +1,695 @@
"""
Copyright (c) 2025 by SLA team.
Licensed under the Apache License, Version 2.0 (the "License");
This implementation is adapted from: from https://github.com/thu-ml/TurboDiffusion/blob/main/turbodiffusion/SLA/core.py and https://github.com/thu-ml/SLA/blob/main/SageSLA/core.py
Citation (please cite if you use this code):
@article{zhang2025sla,
title={SLA: Beyond Sparsity in Diffusion Transformers via Fine-Tunable Sparse-Linear Attention},
author={Jintao Zhang and Haoxu Wang and Kai Jiang and Shuo Yang and Kaiwen Zheng and Haocheng Xi and Ziteng Wang and Hongzhou Zhu and Min Zhao and Ion Stoica and Joseph E. Gonzalez and Jun Zhu and Jianfei Chen},
journal={arXiv preprint arXiv:2509.24006},
year={2025}
}
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
import triton
import triton.language as tl
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
# ==================================SLA Functions===================================
def get_block_map(q, k, topk_ratio, BLKQ=64, BLKK=64):
arg_k = k - torch.mean(
k, dim=-2, keepdim=True
) # smooth-k technique in SageAttention
pooled_qblocks = mean_pool(q, BLKQ)
pooled_kblocks = mean_pool(arg_k, BLKK)
pooled_score = pooled_qblocks @ pooled_kblocks.transpose(-1, -2)
K = pooled_score.shape[-1]
topk = min(K, int(topk_ratio * K))
lut = torch.topk(pooled_score, topk, dim=-1, sorted=False).indices
sparse_map = torch.zeros_like(pooled_score, dtype=torch.int8)
sparse_map.scatter_(-1, lut, 1)
return sparse_map, lut, topk
def mean_pool(x, BLK):
assert x.is_contiguous()
B, H, L, D = x.shape
L_BLOCKS = (L + BLK - 1) // BLK
x_mean = torch.empty((B, H, L_BLOCKS, D), device=x.device, dtype=x.dtype)
grid = (L_BLOCKS, B * H)
compress_kernel[grid](x, x_mean, L, D, BLK)
return x_mean
@triton.jit
def compress_kernel(
X,
XM,
L: tl.constexpr,
D: tl.constexpr,
BLOCK_L: tl.constexpr,
):
idx_l = tl.program_id(0)
idx_bh = tl.program_id(1)
offs_l = idx_l * BLOCK_L + tl.arange(0, BLOCK_L)
offs_d = tl.arange(0, D)
x_offset = idx_bh * L * D
xm_offset = idx_bh * ((L + BLOCK_L - 1) // BLOCK_L) * D
x = tl.load(
X + x_offset + offs_l[:, None] * D + offs_d[None, :], mask=offs_l[:, None] < L
)
nx = min(BLOCK_L, L - idx_l * BLOCK_L)
x_mean = tl.sum(x, axis=0, dtype=tl.float32) / nx
tl.store(XM + xm_offset + idx_l * D + offs_d, x_mean.to(XM.dtype.element_ty))
@triton.jit
def _attn_fwd(
Q,
K,
V,
qk_scale: tl.constexpr,
topk: tl.constexpr,
LUT,
LSE,
OS,
L: tl.constexpr,
M_BLOCKS: tl.constexpr,
D: tl.constexpr,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
):
idx_m = tl.program_id(0).to(tl.int64)
idx_bh = tl.program_id(1).to(tl.int64)
qkv_offset = idx_bh * L * D
lut_offset = (idx_bh * M_BLOCKS + idx_m) * topk
lse_offset = idx_bh * L
offs_m = idx_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = tl.arange(0, BLOCK_N)
offs_d = tl.arange(0, D)
Q_ptrs = Q + qkv_offset + offs_m[:, None] * D + offs_d[None, :]
K_ptrs = K + qkv_offset + offs_n[None, :] * D + offs_d[:, None]
V_ptrs = V + qkv_offset + offs_n[:, None] * D + offs_d[None, :]
OS_ptrs = OS + qkv_offset + offs_m[:, None] * D + offs_d[None, :]
LUT_ptr = LUT + lut_offset
LSE_ptrs = LSE + lse_offset + offs_m
m_i = tl.full([BLOCK_M], -float("inf"), dtype=tl.float32)
l_i = tl.zeros([BLOCK_M], dtype=tl.float32)
o_s = tl.zeros([BLOCK_M, D], dtype=tl.float32)
q = tl.load(Q_ptrs, mask=offs_m[:, None] < L)
for block_idx in tl.range(topk):
idx_n = tl.load(LUT_ptr + block_idx)
n_mask = offs_n < L - idx_n * BLOCK_N
k = tl.load(K_ptrs + idx_n * BLOCK_N * D, mask=n_mask[None, :])
qk = tl.dot(q, k) * (qk_scale * 1.4426950408889634) # = 1 / ln(2)
if L - idx_n * BLOCK_N < BLOCK_N:
qk = tl.where(n_mask[None, :], qk, float("-inf"))
v = tl.load(V_ptrs + idx_n * BLOCK_N * D, mask=n_mask[:, None])
local_m = tl.max(qk, 1)
new_m = tl.maximum(m_i, local_m)
qk = qk - new_m[:, None]
p = tl.math.exp2(qk)
l_ij = tl.sum(p, 1)
alpha = tl.math.exp2(m_i - new_m)
o_s = o_s * alpha[:, None]
o_s += tl.dot(p.to(v.dtype), v)
l_i = l_i * alpha + l_ij
m_i = new_m
o_s = o_s / l_i[:, None]
tl.store(OS_ptrs, o_s.to(OS.type.element_ty), mask=offs_m[:, None] < L)
m_i += tl.math.log2(l_i)
tl.store(LSE_ptrs, m_i, mask=offs_m < L)
def _get_cuda_arch(device_index: int) -> str:
"""Get CUDA architecture string for the given device."""
major, minor = torch.cuda.get_device_capability(device_index)
return f"sm{major}{minor}"
# ==================================SLA Class===================================
class SparseLinearAttentionBackend(AttentionBackend):
"""Sparse Linear Attention Backend for efficient attention computation."""
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SLA_ATTN
@staticmethod
def get_impl_cls() -> type["SparseLinearAttentionImpl"]:
return SparseLinearAttentionImpl
@staticmethod
def get_metadata_cls() -> type["SparseLinearAttentionMetadata"]:
return SparseLinearAttentionMetadata
@staticmethod
def get_builder_cls() -> type["SparseLinearAttentionMetadataBuilder"]:
return SparseLinearAttentionMetadataBuilder
@dataclass
class SparseLinearAttentionMetadata(AttentionMetadata):
"""Metadata for Sparse Linear Attention computation."""
# Basic attention parameters
current_timestep: int
# Sparse attention configuration
topk_ratio: float = 0.1
class SparseLinearAttentionMetadataBuilder(AttentionMetadataBuilder):
"""Builder for SparseLinearAttentionMetadata."""
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build(
self,
current_timestep: int,
topk_ratio: float = 0.1,
**kwargs: dict[str, Any],
) -> SparseLinearAttentionMetadata:
return SparseLinearAttentionMetadata(
current_timestep=current_timestep,
topk_ratio=topk_ratio,
)
class SparseLinearAttentionImpl(AttentionImpl, nn.Module):
"""Implementation of sparse linear attention for the backend."""
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool = False,
softmax_scale: float | None = None,
num_kv_heads: int | None = None,
prefix: str = "",
# SLA-specific parameters - matched to TurboDiffusion defaults
topk_ratio: float = 0.1, # TurboDiffusion uses topk=0.1
feature_map: str = "softmax",
BLKQ: int = 128, # TurboDiffusion uses BLKQ=128
BLKK: int = 64, # TurboDiffusion uses BLKK=64
use_bf16: bool = True,
**extra_impl_args,
) -> None:
nn.Module.__init__(self)
# SLA-specific config
self.topk_ratio = topk_ratio
self.BLKQ = BLKQ
self.BLKK = BLKK
self.dtype = torch.bfloat16 if use_bf16 else torch.float16
# Learnable linear projection for combining sparse + linear attention
self.proj_l = nn.Linear(head_size, head_size, dtype=torch.float32)
# Feature map for linear attention
# Type annotation for callables
self.feature_map_q: Callable[[torch.Tensor], torch.Tensor]
self.feature_map_k: Callable[[torch.Tensor], torch.Tensor]
if feature_map == "elu":
self.feature_map_q = lambda x: F.elu(x) + 1
self.feature_map_k = lambda x: F.elu(x) + 1
elif feature_map == "relu":
self.feature_map_q = F.relu
self.feature_map_k = F.relu
elif feature_map == "softmax":
self.feature_map_q = lambda x: F.softmax(x, dim=-1)
self.feature_map_k = lambda x: F.softmax(x, dim=-1)
else:
raise ValueError(f"Unknown feature map: {feature_map}")
self._init_weights()
def _init_weights(self) -> None:
"""Initialize projection weights to zero for residual-like behavior."""
with torch.no_grad():
nn.init.zeros_(self.proj_l.weight)
nn.init.zeros_(self.proj_l.bias) # type: ignore[arg-type]
def _calc_linear_attention_with_torch(self, q, k, v):
kv = torch.matmul(k.transpose(-1, -2), v)
k_sum = torch.sum(k, dim=-2, keepdim=True)
return torch.matmul(q, kv) / (1e-5 + torch.matmul(q, k_sum.transpose(-1, -2)))
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: SparseLinearAttentionMetadata = None,
) -> torch.Tensor:
"""Forward pass for sparse linear attention.
Args:
query: query tensor of shape (B, H, L, D)
key: key tensor of shape (B, H, L, D)
value: value tensor of shape (B, H, L, D)
attn_metadata: attention metadata containing configuration
Returns:
output tensor of shape (B, H, L, D)
"""
dtype = query.dtype
# Transpose for computation
query = query.transpose(1, 2).contiguous()
key = key.transpose(1, 2).contiguous()
value = value.transpose(1, 2).contiguous()
# Get sparse attention map
sparse_map, lut, real_topk = get_block_map(
query, key, topk_ratio=self.topk_ratio, BLKQ=self.BLKQ, BLKK=self.BLKK
)
# Convert to computation dtype
query = query.to(self.dtype)
key = key.to(self.dtype)
value = value.to(self.dtype)
# Sparse attention computation
o_s = _attention.apply(
query, key, value, sparse_map, lut, real_topk, self.BLKQ, self.BLKK
)
# Apply feature maps
query = self.feature_map_q(query).to(self.dtype) # c_q
key = self.feature_map_k(key).to(self.dtype) # c_k
# Linear attention computation
o_l = self._calc_linear_attention_with_torch(query, key, value)
# Apply projection and combine results
with torch.amp.autocast("cuda", dtype=self.dtype):
o_l = self.proj_l(o_l)
# Combine sparse and linear attention
output = (o_s + o_l).to(dtype).transpose(1, 2)
return output
class _attention(torch.autograd.Function):
@staticmethod
def forward(ctx, q, k, v, k_block_id, lut, topk, BLOCK_M, BLOCK_N, qk_scale=None):
assert q.is_contiguous() and k.is_contiguous() and v.is_contiguous()
assert k_block_id.is_contiguous() and lut.is_contiguous()
# We recommend the following two settings
assert BLOCK_M == 64 or BLOCK_M == 128
assert BLOCK_N == 64
B, H, L, D = q.shape
if qk_scale is None:
qk_scale = D**-0.5
M_BLOCKS = triton.cdiv(L, BLOCK_M)
o_s = torch.empty_like(v)
lse = torch.empty(q.shape[:-1], device=q.device, dtype=torch.float32)
grid = (M_BLOCKS, B * H)
_attn_fwd[grid](
q,
k,
v,
qk_scale,
topk,
lut,
lse,
o_s,
L,
M_BLOCKS,
D,
BLOCK_M,
BLOCK_N,
num_warps=4 if q.shape[-1] == 64 else 8,
num_stages=3,
)
ctx.save_for_backward(q, k, v, k_block_id, lut, lse, o_s)
ctx.qk_scale = qk_scale
ctx.topk = topk
ctx.BLOCK_M = BLOCK_M
ctx.BLOCK_N = BLOCK_N
return o_s
# ==================================SageSLA Class===================================
SAGESLA_ENABLED = True
try:
import spas_sage_attn._fused as fused
import spas_sage_attn._qattn as qattn
from spas_sage_attn.utils import block_map_lut_triton, get_vanilla_qk_quant
except ImportError:
SAGESLA_ENABLED = False
SAGE2PP_ENABLED = True
try:
from spas_sage_attn._qattn import (
qk_int8_sv_f8_accum_f16_block_sparse_attn_inst_buf_fuse_v_scale_with_pv_threshold,
)
except ImportError:
SAGE2PP_ENABLED = False
class SageSparseLinearAttentionBackend(AttentionBackend):
"""Quantized Sparse-Linear Attention backend using SageAttention kernels."""
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SAGE_SLA_ATTN
@staticmethod
def get_impl_cls() -> type["SageSparseLinearAttentionImpl"]:
return SageSparseLinearAttentionImpl
@staticmethod
def get_metadata_cls() -> type["SageSparseLinearAttentionMetadata"]:
return SageSparseLinearAttentionMetadata
@staticmethod
def get_builder_cls() -> type["SageSparseLinearAttentionMetadataBuilder"]:
return SageSparseLinearAttentionMetadataBuilder
@dataclass
class SageSparseLinearAttentionMetadata(AttentionMetadata):
"""Metadata for Sage Sparse Linear Attention computation."""
# Basic attention parameters
current_timestep: int
# Sparse attention configuration
topk_ratio: float = 0.1
class SageSparseLinearAttentionMetadataBuilder(AttentionMetadataBuilder):
"""Builder for SageSparseLinearAttentionMetadata."""
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build(
self,
current_timestep: int,
topk_ratio: float = 0.1,
**kwargs: dict[str, Any],
) -> SageSparseLinearAttentionMetadata:
return SageSparseLinearAttentionMetadata(
current_timestep=current_timestep,
topk_ratio=topk_ratio,
)
class SageSparseLinearAttentionImpl(AttentionImpl, nn.Module):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool = False,
softmax_scale: float | None = None,
num_kv_heads: int | None = None,
prefix: str = "",
topk_ratio: float = 0.5,
feature_map: str = "softmax",
use_bf16: bool = True,
**extra_impl_args,
) -> None:
nn.Module.__init__(self)
assert (
SAGESLA_ENABLED
), "Install spas_sage_attn(pip install git+https://github.com/thu-ml/SpargeAttn.git --no-build-isolation) first to enable SageSLA."
self.num_heads = num_heads
self.head_size = head_size
self.softmax_scale = softmax_scale if softmax_scale else head_size**-0.5
self.causal = causal
self.prefix = prefix
self.topk_ratio = topk_ratio
self.dtype = torch.bfloat16 if use_bf16 else torch.float16
# Learnable linear projection for combining sparse + linear attention
self.proj_l = nn.Linear(head_size, head_size, dtype=torch.float32)
# Feature map for linear attention
# Type annotation for callables
self.feature_map_q: Callable[[torch.Tensor], torch.Tensor]
self.feature_map_k: Callable[[torch.Tensor], torch.Tensor]
if feature_map == "elu":
self.feature_map_q = lambda x: F.elu(x) + 1
self.feature_map_k = lambda x: F.elu(x) + 1
elif feature_map == "relu":
self.feature_map_q = F.relu
self.feature_map_k = F.relu
elif feature_map == "softmax":
self.feature_map_q = lambda x: F.softmax(x, dim=-1)
self.feature_map_k = lambda x: F.softmax(x, dim=-1)
else:
raise ValueError(f"Unknown feature map: {feature_map}")
self._init_weights()
def _init_weights(self) -> None:
"""Initialize projection weights to zero for residual-like behavior."""
with torch.no_grad():
nn.init.zeros_(self.proj_l.weight)
nn.init.zeros_(self.proj_l.bias) # type: ignore[arg-type]
def _calc_linear_attention_with_torch(
self,
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
):
kv = torch.matmul(k.transpose(-1, -2), v)
k_sum = torch.sum(k, dim=-2, keepdim=True)
return torch.matmul(q, kv) / (1e-5 + torch.matmul(q, k_sum.transpose(-1, -2)))
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
"""Forward pass for Sage Sparse Linear attention with quantized kernels.
Args:
query: query tensor of shape (B, L, H, D)
key: key tensor of shape (B, L, H, D)
value: value tensor of shape (B, L, H, D)
attn_metadata: attention metadata containing configuration
Returns:
output tensor of shape (B, L, H, D)
"""
dtype = query.dtype
# Transpose from (B, L, H, D) to SLA format (B, H, L, D)
q = query.transpose(1, 2).contiguous()
k = key.transpose(1, 2).contiguous()
v = value.transpose(1, 2).contiguous()
# Determine block sizes based on GPU architecture
arch = _get_cuda_arch(q.device.index)
if arch == "sm90":
BLKQ = 64
BLKK = 128
else:
BLKQ = 128
BLKK = 64
# Compute block-sparse attention pattern
sparse_map, lut, real_topk = get_block_map(
q, k, topk_ratio=self.topk_ratio, BLKQ=BLKQ, BLKK=BLKK
)
# Convert to compute dtype
q = q.to(self.dtype)
k = k.to(self.dtype)
v = v.to(self.dtype)
########## SPARGE BEGIN ##########
km = k.mean(dim=-2, keepdim=True)
headdim = q.size(-1)
assert headdim in [
64,
128,
], "headdim should be in [64, 128]. For other headdim, you can use padding and specify the softmax scale."
# Quantize Q, K to INT8
q_int8, q_scale, k_int8, k_scale = get_vanilla_qk_quant(q, k, km, BLKQ, BLKK)
lut, valid_block_num = block_map_lut_triton(sparse_map)
scale = 1.0 / (headdim**0.5)
o_s = torch.empty_like(q)
if arch in ("sm80", "sm86", "sm87"):
pvthreshold = torch.full(
(q.shape[-3],), 1e6, dtype=torch.float32, device=q.device
)
v_fp16 = v.to(torch.float16)
qattn.qk_int8_sv_f16_accum_f16_block_sparse_attn_inst_buf_with_pv_threshold(
q_int8,
k_int8,
v_fp16,
o_s,
lut,
valid_block_num,
pvthreshold,
q_scale,
k_scale,
1,
False,
1,
scale,
0,
)
else:
b, h_kv, kv_len, head_dim = v.shape
padded_len = (kv_len + 127) // 128 * 128
v_transposed_permutted = torch.empty(
(b, h_kv, head_dim, padded_len), dtype=v.dtype, device=v.device
)
fused.transpose_pad_permute_cuda(v, v_transposed_permutted, 1)
v_fp8 = torch.empty(
v_transposed_permutted.shape, dtype=torch.float8_e4m3fn, device=v.device
)
v_scale = torch.empty(
(b, h_kv, head_dim), dtype=torch.float32, device=v.device
)
fused.scale_fuse_quant_cuda(
v_transposed_permutted, v_fp8, v_scale, kv_len, 2.25, 1
)
if arch == "sm90":
qattn.qk_int8_sv_f8_accum_f32_block_sparse_attn_inst_buf_fuse_v_scale_sm90(
q_int8,
k_int8,
v_fp8,
o_s,
lut,
valid_block_num,
q_scale,
k_scale,
v_scale,
1,
False,
1,
scale,
)
else:
pvthreshold = torch.full(
(q.shape[-3],), 1e6, dtype=torch.float32, device=q.device
)
if SAGE2PP_ENABLED:
qk_int8_sv_f8_accum_f16_block_sparse_attn_inst_buf_fuse_v_scale_with_pv_threshold(
q_int8,
k_int8,
v_fp8,
o_s,
lut,
valid_block_num,
pvthreshold,
q_scale,
k_scale,
v_scale,
1,
False,
1,
scale,
0,
)
else:
qattn.qk_int8_sv_f8_accum_f32_block_sparse_attn_inst_buf_fuse_v_scale_with_pv_threshold(
q_int8,
k_int8,
v_fp8,
o_s,
lut,
valid_block_num,
pvthreshold,
q_scale,
k_scale,
v_scale,
1,
False,
1,
scale,
0,
)
########## SPARGE END ##########
# Linear attention with feature maps
q_linear = self.feature_map_q(q).to(self.dtype)
k_linear = self.feature_map_k(k).to(self.dtype)
o_l = self._calc_linear_attention_with_torch(q_linear, k_linear, v)
# Project linear attention output and combine
with torch.amp.autocast("cuda", dtype=self.dtype):
o_l = self.proj_l(o_l)
# Combine sparse and linear outputs
output = (o_s + o_l).to(dtype).transpose(1, 2)
return output
@@ -0,0 +1,562 @@
"""
Sparse Video Gen 2 (SAP) attention backend.
This is a baseline integration that wires the backend into the
attention framework.
Adapted from https://github.com/svg-project/Sparse-VideoGen/blob/main/svg/models/wan/attention.py
"""
from dataclasses import dataclass, field
from typing import Any
import torch
import torch.nn.functional as F
from torch.nn.attention import SDPBackend, sdpa_kernel
try:
from svg.kernels.triton.permute import (
apply_inverse_permutation_triton,
permute_tensor_by_labels_triton,
)
from svg.kmeans_utils import (
batch_kmeans_Euclid,
dynamic_block_sparse_fwd_flashinfer,
identify_dynamic_map,
)
svg2_available = True
except ImportError:
svg2_available = False
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class SparseVideoGen2AttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 128, 256]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.SPARSE_VIDEO_GEN_2_ATTN
@staticmethod
def get_impl_cls() -> type["SparseVideoGen2AttentionImpl"]:
return SparseVideoGen2AttentionImpl
@staticmethod
def get_metadata_cls() -> type["SparseVideoGen2AttentionMetadata"]:
return SparseVideoGen2AttentionMetadata
@staticmethod
def get_builder_cls() -> type["SparseVideoGen2AttentionMetadataBuilder"]:
return SparseVideoGen2AttentionMetadataBuilder
@dataclass
class Svg2LayerCache:
# centroids for kmeans clustering
q_centroids: torch.Tensor | None = None
k_centroids: torch.Tensor | None = None
centroids_initialized: bool = False
@dataclass
class Svg2Cache:
layers: dict[int, Svg2LayerCache] = field(default_factory=dict)
def get_layer(self, layer_idx: int) -> Svg2LayerCache:
layer_cache = self.layers.get(layer_idx)
if layer_cache is None:
layer_cache = Svg2LayerCache()
self.layers[layer_idx] = layer_cache
return layer_cache
@dataclass
class SparseVideoGen2AttentionMetadata(AttentionMetadata):
current_timestep: int
num_q_centroids: int
num_k_centroids: int
top_p_kmeans: float
min_kc_ratio: float
kmeans_iter_init: int
kmeans_iter_step: int
zero_step_kmeans_init: bool
first_layers_fp: float
first_times_fp: float
context_length: int
num_frame: int
frame_size: int
cache: Svg2Cache
prompt_length: int | None = None
max_seqlen_q: int | None = None
max_seqlen_k: int | None = None
def _require_kwarg(kwargs: dict[str, Any], name: str) -> Any:
if name not in kwargs:
raise ValueError(
f"Missing required argument for SparseVideoGen2Attention: {name}"
)
return kwargs[name]
class SparseVideoGen2AttentionMetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
pass
def prepare(self) -> None:
pass
def build( # type: ignore[override]
self,
current_timestep: int,
raw_latent_shape: tuple[int, ...],
patch_size: tuple[int, int, int],
cache: Svg2Cache,
num_q_centroids: int,
num_k_centroids: int,
top_p_kmeans: float,
min_kc_ratio: float,
kmeans_iter_init: int,
kmeans_iter_step: int,
zero_step_kmeans_init: bool,
first_layers_fp: float,
first_times_fp: float,
context_length: int = 0,
prompt_length: int | None = None,
**kwargs: dict[str, Any],
) -> SparseVideoGen2AttentionMetadata:
raw_shape = tuple(raw_latent_shape)
if len(raw_shape) == 5:
t, h, w = raw_shape[2:5]
elif len(raw_shape) == 3:
t, h, w = raw_shape
else:
raise ValueError(
"raw_latent_shape must be (T, H, W) or (B, C, T, H, W) for SAP attention"
)
pt, ph, pw = patch_size
if t % pt != 0 or h % ph != 0 or w % pw != 0:
raise ValueError(
"raw_latent_shape must be divisible by patch_size for SAP attention"
)
num_frame = t // pt
frame_size = (h // ph) * (w // pw)
return SparseVideoGen2AttentionMetadata(
current_timestep=current_timestep,
num_q_centroids=num_q_centroids,
num_k_centroids=num_k_centroids,
top_p_kmeans=top_p_kmeans,
min_kc_ratio=min_kc_ratio,
kmeans_iter_init=kmeans_iter_init,
kmeans_iter_step=kmeans_iter_step,
zero_step_kmeans_init=zero_step_kmeans_init,
first_layers_fp=first_layers_fp,
first_times_fp=first_times_fp,
context_length=context_length,
prompt_length=prompt_length,
num_frame=num_frame,
frame_size=frame_size,
cache=cache,
)
class SparseVideoGen2AttentionImpl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
if causal:
raise ValueError(
"Sparse Video Gen 2 attention does not support causal attention"
)
if not svg2_available:
raise ImportError(
"Sparse Video Gen 2 attention backend requires svg package to be installed"
"Please install it by following the instructions at "
"https://github.com/svg-project/Sparse-VideoGen"
)
self.prefix = prefix
self.layer_idx = self._get_layer_idx(prefix)
def _get_layer_idx(self, prefix: str) -> int:
parts = prefix.split(".")
if len(parts) < 3:
raise ValueError(
f"Invalid prefix for SparseVideoGen2AttentionImpl: {prefix}"
)
return int(parts[-3])
def kmeans_init(
self,
query: torch.Tensor,
key: torch.Tensor,
attn_metadata: SparseVideoGen2AttentionMetadata,
):
cfg, num_heads, seq_len, dim = query.size()
qlabels, qcentroids, qcluster_sizes, qiter = batch_kmeans_Euclid(
query.reshape(cfg * num_heads, seq_len, dim),
n_clusters=attn_metadata.num_q_centroids,
max_iters=attn_metadata.kmeans_iter_init,
)
klabels, kcentroids, kcluster_sizes, kiter = batch_kmeans_Euclid(
key.reshape(cfg * num_heads, seq_len, dim),
n_clusters=attn_metadata.num_k_centroids,
max_iters=attn_metadata.kmeans_iter_init,
)
layer_cache = attn_metadata.cache.get_layer(self.layer_idx)
layer_cache.q_centroids = qcentroids
layer_cache.k_centroids = kcentroids
return (
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
)
def kmeans_step(
self,
query: torch.Tensor,
key: torch.Tensor,
attn_metadata: SparseVideoGen2AttentionMetadata,
):
cfg, num_heads, seq_len, dim = query.size()
layer_cache = attn_metadata.cache.get_layer(self.layer_idx)
qlabels, qcentroids, qcluster_sizes, qiter = batch_kmeans_Euclid(
query.reshape(cfg * num_heads, seq_len, dim),
n_clusters=attn_metadata.num_q_centroids,
max_iters=attn_metadata.kmeans_iter_step,
init_centroids=layer_cache.q_centroids,
)
klabels, kcentroids, kcluster_sizes, kiter = batch_kmeans_Euclid(
key.reshape(cfg * num_heads, seq_len, dim),
n_clusters=attn_metadata.num_k_centroids,
max_iters=attn_metadata.kmeans_iter_step,
init_centroids=layer_cache.k_centroids,
)
layer_cache.q_centroids = qcentroids
layer_cache.k_centroids = kcentroids
return (
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
)
def kmeans_clustering(
self,
query: torch.Tensor,
key: torch.Tensor,
attn_metadata: SparseVideoGen2AttentionMetadata,
):
layer_cache = attn_metadata.cache.get_layer(self.layer_idx)
if not layer_cache.centroids_initialized:
(
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
) = self.kmeans_init(query, key, attn_metadata)
layer_cache.centroids_initialized = True
logger.debug(
"Centroids initialized at layer %s (init iters: %s).",
self.layer_idx,
attn_metadata.kmeans_iter_init,
)
else:
(
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
) = self.kmeans_step(query, key, attn_metadata)
return (
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
)
def semantic_aware_permutation(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: SparseVideoGen2AttentionMetadata,
):
cfg, num_heads, seq_len, dim = query.size()
# 1. Kmeans clustering
(
qlabels,
qcentroids,
qcluster_sizes,
qiter,
klabels,
kcentroids,
kcluster_sizes,
kiter,
) = self.kmeans_clustering(query, key, attn_metadata)
# 2. Identify dynamic map
q_cluster_sizes = qcluster_sizes.view(
cfg, num_heads, attn_metadata.num_q_centroids
)
k_cluster_sizes = kcluster_sizes.view(
cfg, num_heads, attn_metadata.num_k_centroids
)
dynamic_map = identify_dynamic_map(
qcentroids.view(cfg, num_heads, attn_metadata.num_q_centroids, dim),
kcentroids.view(cfg, num_heads, attn_metadata.num_k_centroids, dim),
q_cluster_sizes,
k_cluster_sizes,
attn_metadata.top_p_kmeans,
attn_metadata.min_kc_ratio,
)
# 3. Permute the query, key, value
q_permuted, q_sorted_indices = permute_tensor_by_labels_triton(
query, qlabels, dim=2
)
k_permuted, k_sorted_indices = permute_tensor_by_labels_triton(
key, klabels, dim=2
)
v_permuted, v_sorted_indices = permute_tensor_by_labels_triton(
value, klabels, dim=2, sorted_indices=k_sorted_indices
)
return (
q_permuted,
k_permuted,
v_permuted,
dynamic_map,
q_cluster_sizes,
k_cluster_sizes,
q_sorted_indices,
)
def _hunyuan_dynamic_map_post_processing(
self,
q_perm: torch.Tensor,
k_perm: torch.Tensor,
v_perm: torch.Tensor,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
dyn_map: torch.Tensor,
qc_sz_s: torch.Tensor,
kc_sz_s: torch.Tensor,
q_sorted_indices: torch.Tensor,
video_length: int,
context_length: int,
prompt_length: int,
unprompt_length: int,
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
]:
# Place the permuted video tokens back and keep text tokens at the tail.
query[:, :, :-context_length, :] = q_perm
key[:, :, :-context_length, :] = k_perm
value[:, :, :-context_length, :] = v_perm
# Add prompt/unprompt clusters to the dynamic map.
dyn_map = F.pad(dyn_map, (0, 2, 0, 2), value=0)
dyn_map[:, :, -2, :-1] = True
dyn_map[:, :, :-1, -2] = True
dyn_map[:, :, -1, -1] = True
qc_sz_s = F.pad(qc_sz_s, (0, 2), value=0)
qc_sz_s[:, :, -2] = prompt_length
qc_sz_s[:, :, -1] = unprompt_length
kc_sz_s = F.pad(kc_sz_s, (0, 2), value=0)
kc_sz_s[:, :, -2] = prompt_length
kc_sz_s[:, :, -1] = unprompt_length
q_sorted_indices = F.pad(q_sorted_indices, (0, context_length), value=0)
q_sorted_indices[:, video_length:] = torch.arange(
video_length,
video_length + context_length,
device=q_sorted_indices.device,
)
return query, key, value, dyn_map, qc_sz_s, kc_sz_s, q_sorted_indices
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: SparseVideoGen2AttentionMetadata,
) -> torch.Tensor:
torch.backends.cuda.preferred_linalg_library(backend="magma")
res = None
# bshd -> bhsd
query = query.transpose(1, 2).contiguous()
key = key.transpose(1, 2).contiguous()
value = value.transpose(1, 2).contiguous()
batch_size, num_heads, seq_len, dim = query.size()
context_length, num_frame, frame_size = (
attn_metadata.context_length,
attn_metadata.num_frame,
attn_metadata.frame_size,
)
prompt_length = attn_metadata.prompt_length
if prompt_length is None:
prompt_length = context_length
assert (
seq_len == context_length + num_frame * frame_size
), f"Query Shape: {seq_len} is not equivalent to {context_length} + {num_frame} * {frame_size}"
# Determine if we use Full Attention to calculate
full_attention_flag = False
if self.layer_idx < attn_metadata.first_layers_fp:
full_attention_flag = True
if attn_metadata.current_timestep > attn_metadata.first_times_fp:
full_attention_flag = True
if full_attention_flag:
if attn_metadata.zero_step_kmeans_init:
video_length = attn_metadata.num_frame * attn_metadata.frame_size
query_video = query[:, :, :video_length, :].contiguous()
key_video = key[:, :, :video_length, :].contiguous()
self.kmeans_clustering(query_video, key_video, attn_metadata)
with sdpa_kernel(
SDPBackend.CUDNN_ATTENTION
): # not sure why we need to force cudnn here, but it's faster than flash attention
output_hidden_states = torch.nn.functional.scaled_dot_product_attention(
query, key, value, dropout_p=0.0, is_causal=False
)
res = output_hidden_states.reshape(
batch_size, num_heads, seq_len, dim
).transpose(1, 2)
else:
if context_length > 0:
video_length = num_frame * frame_size
unprompt_length = max(context_length - prompt_length, 0)
query_video = query[:, :, :video_length, :].contiguous()
key_video = key[:, :, :video_length, :].contiguous()
value_video = value[:, :, :video_length, :].contiguous()
(
q_perm,
k_perm,
v_perm,
dyn_map,
qc_sz_s,
kc_sz_s,
q_sorted_indices,
) = self.semantic_aware_permutation(
query_video, key_video, value_video, attn_metadata
)
(
q_perm,
k_perm,
v_perm,
dyn_map,
qc_sz_s,
kc_sz_s,
q_sorted_indices,
) = self._hunyuan_dynamic_map_post_processing(
q_perm,
k_perm,
v_perm,
query,
key,
value,
dyn_map,
qc_sz_s,
kc_sz_s,
q_sorted_indices,
video_length,
context_length,
prompt_length,
unprompt_length,
)
else:
(
q_perm,
k_perm,
v_perm,
dyn_map,
qc_sz_s,
kc_sz_s,
q_sorted_indices,
) = self.semantic_aware_permutation(query, key, value, attn_metadata)
output_permuted = dynamic_block_sparse_fwd_flashinfer(
q_perm, k_perm, v_perm, dyn_map, qc_sz_s, kc_sz_s, is_cpu=False
)
attn_output = apply_inverse_permutation_triton(
output_permuted, q_sorted_indices, dim=2
)
res = attn_output.reshape(batch_size, num_heads, seq_len, dim).transpose(
1, 2
)
torch.backends.cuda.preferred_linalg_library(
backend="default"
) # reset to default
return res.contiguous()

Some files were not shown because too many files have changed in this diff Show More