[diffusion] model: support lingot-world (#26954)
This commit is contained in:
@@ -82,7 +82,7 @@ dependencies = [
|
||||
"watchfiles",
|
||||
"xgrammar==0.2.1",
|
||||
"smg-grpc-servicer>=0.5.0",
|
||||
"kernels<0.15",
|
||||
"kernels>=0.14.1,<0.15",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
|
||||
@@ -4,6 +4,9 @@ from sglang.multimodal_gen.configs.models.dits.cosmos3video import Cosmos3VideoC
|
||||
from sglang.multimodal_gen.configs.models.dits.helios import HeliosConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.hunyuan3d import Hunyuan3DDiTConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.hunyuanvideo import HunyuanVideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.lingbot_world import (
|
||||
LingBotWorldVideoConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_audio import MOVAAudioConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_video import MOVAVideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.stablediffusion3 import (
|
||||
@@ -15,6 +18,7 @@ __all__ = [
|
||||
"Cosmos3VideoConfig",
|
||||
"HeliosConfig",
|
||||
"HunyuanVideoConfig",
|
||||
"LingBotWorldVideoConfig",
|
||||
"WanVideoConfig",
|
||||
"Hunyuan3DDiTConfig",
|
||||
"MOVAAudioConfig",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
def is_blocks(n: str, m) -> bool:
|
||||
return "blocks" in n and str.isdigit(n.split(".")[-1])
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_blocks])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^patch_embedding\.(.*)$": r"patch_embedding.proj.\1",
|
||||
r"^patch_embedding_wancamctrl\.(.*)$": r"patch_embedding_wancamctrl.proj.\1",
|
||||
r"^c2ws_hidden_states_layer1\.(.*)$": r"c2ws_mlp.fc_in.\1",
|
||||
r"^c2ws_hidden_states_layer2\.(.*)$": r"c2ws_mlp.fc_out.\1",
|
||||
r"^text_embedding\.0\.(.*)$": r"condition_embedder.text_embedder.fc_in.\1",
|
||||
r"^text_embedding\.2\.(.*)$": r"condition_embedder.text_embedder.fc_out.\1",
|
||||
r"^time_embedding\.0\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_in.\1",
|
||||
r"^time_embedding\.2\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_out.\1",
|
||||
r"^time_projection\.1\.(.*)$": r"condition_embedder.time_modulation.linear.\1",
|
||||
r"^blocks\.(\d+)\.modulation$": r"blocks.\1.scale_shift_table",
|
||||
r"^blocks\.(\d+)\.self_attn\.q\.(.*)$": r"blocks.\1.to_q.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.k\.(.*)$": r"blocks.\1.to_k.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.v\.(.*)$": r"blocks.\1.to_v.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.o\.(.*)$": r"blocks.\1.to_out.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.norm_q\.(.*)$": r"blocks.\1.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.norm_k\.(.*)$": r"blocks.\1.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.norm3\.(.*)$": r"blocks.\1.self_attn_residual_norm.norm.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.q\.(.*)$": r"blocks.\1.attn2.to_q.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.k\.(.*)$": r"blocks.\1.attn2.to_k.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.v\.(.*)$": r"blocks.\1.attn2.to_v.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.o\.(.*)$": r"blocks.\1.attn2.to_out.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.norm_q\.(.*)$": r"blocks.\1.attn2.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.norm_k\.(.*)$": r"blocks.\1.attn2.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.cam_injector_layer1\.(.*)$": r"blocks.\1.cam_conditioner.cam_injector.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.cam_injector_layer2\.(.*)$": r"blocks.\1.cam_conditioner.cam_injector.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.cam_scale_layer\.(.*)$": r"blocks.\1.cam_conditioner.cam_scale_layer.\2",
|
||||
r"^blocks\.(\d+)\.cam_shift_layer\.(.*)$": r"blocks.\1.cam_conditioner.cam_shift_layer.\2",
|
||||
r"^head\.modulation$": r"scale_shift_table",
|
||||
r"^head\.head\.(.*)$": r"proj_out.\1",
|
||||
}
|
||||
)
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
lora_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_len: int = 512
|
||||
num_attention_heads: int = 40
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 36
|
||||
out_channels: int = 16
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
ffn_dim: int = 13824
|
||||
num_layers: int = 40
|
||||
cross_attn_norm: bool = True
|
||||
qk_norm: str = "rms_norm_across_heads"
|
||||
eps: float = 1e-6
|
||||
image_dim: int | None = None
|
||||
added_kv_proj_dim: int | None = None
|
||||
rope_max_seq_len: int = 1024
|
||||
pos_embed_seq_len: int | None = None
|
||||
exclude_lora_layers: list[str] = field(default_factory=lambda: ["embedder"])
|
||||
boundary_ratio: float | None = None
|
||||
local_attn_size: int = -1
|
||||
sink_size: int = 3
|
||||
num_frames_per_block: int = 3
|
||||
sliding_window_num_frames: int = 45
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldVideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=LingBotWorldArchConfig)
|
||||
|
||||
prefix: str = "Wan"
|
||||
@@ -28,6 +28,9 @@ from sglang.multimodal_gen.configs.pipeline_configs.hunyuan import (
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.hunyuan3d import (
|
||||
Hunyuan3D2PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.lingbot_world import (
|
||||
LingBotWorldCausalDMDConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import LTX2PipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.mova import MOVAPipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.sana import SanaPipelineConfig
|
||||
@@ -68,4 +71,5 @@ __all__ = [
|
||||
"SelfForcingWanT2V480PConfig",
|
||||
"ZImagePipelineConfig",
|
||||
"LTX2PipelineConfig",
|
||||
"LingBotWorldCausalDMDConfig",
|
||||
]
|
||||
|
||||
@@ -311,6 +311,9 @@ class PipelineConfig:
|
||||
(target_width, target_height), PIL.Image.Resampling.LANCZOS
|
||||
), (target_width, target_height)
|
||||
|
||||
def preprocess_realtime_condition_image(self, batch, _vae_image_processor) -> bool:
|
||||
return False
|
||||
|
||||
def prepare_calculated_size(self, image):
|
||||
return self.calculate_condition_image_size(image, image.width, image.height)
|
||||
|
||||
@@ -686,6 +689,9 @@ class PipelineConfig:
|
||||
def prepare_neg_cond_kwargs(self, batch, device, rotary_emb, dtype):
|
||||
return {}
|
||||
|
||||
def prepare_world_condition(self, batch, device, dtype):
|
||||
return None
|
||||
|
||||
def _unpad_and_unpack_latents(self, latents, audio_latents, batch, vae, audio_vae):
|
||||
raise NotImplementedError("not yet implemented")
|
||||
|
||||
@@ -806,6 +812,18 @@ class PipelineConfig:
|
||||
default=PipelineConfig.dmd_denoising_steps,
|
||||
help="Comma-separated list of denoising steps (e.g., '1000,757,522')",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix_with_dot}realtime-causal-sink-size",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override the number of sink frames kept by realtime causal DiT pipelines that support it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix_with_dot}realtime-causal-kv-cache-num-frames",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override the total frame capacity of realtime causal DiT KV cache for pipelines that support it.",
|
||||
)
|
||||
|
||||
# Add VAE configuration arguments
|
||||
from sglang.multimodal_gen.configs.models.vaes.base import VAEConfig
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import html
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models import DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.dits import LingBotWorldVideoConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.wan import Wan2_2_I2V_A14B_Config
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.camera_geometry import (
|
||||
camera_poses_to_plucker,
|
||||
compute_relative_poses,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def lingbot_prompt_clean(text: str) -> str:
|
||||
try:
|
||||
import ftfy
|
||||
|
||||
text = ftfy.fix_text(text)
|
||||
except ImportError:
|
||||
pass
|
||||
text = html.unescape(html.unescape(text))
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
class _LingBotWorldCameraState(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.action_history: list[list[str]] = []
|
||||
self.last_actions: list[str] = []
|
||||
|
||||
def reset_camera_actions(self):
|
||||
self.action_history.clear()
|
||||
self.last_actions = []
|
||||
|
||||
def append_camera_actions(self, camera_actions: list[list[str]]) -> None:
|
||||
for actions in camera_actions:
|
||||
normalized = list(actions)
|
||||
self.action_history.append(normalized)
|
||||
self.last_actions = normalized
|
||||
|
||||
def dispose(self):
|
||||
super().dispose()
|
||||
self.reset_camera_actions()
|
||||
|
||||
|
||||
def _validate_actions(actions: Any) -> list[list[str]]:
|
||||
if not isinstance(actions, list):
|
||||
raise TypeError("actions must be a list[list[str]]")
|
||||
result: list[list[str]] = []
|
||||
for frame_actions in actions:
|
||||
if not isinstance(frame_actions, list):
|
||||
raise TypeError("actions must be a list[list[str]]")
|
||||
result.append(list(frame_actions))
|
||||
return result
|
||||
|
||||
|
||||
def _pad_actions_to_chunk(
|
||||
action_history: list[list[str]], chunk_size: int
|
||||
) -> list[list[str]]:
|
||||
if len(action_history) >= chunk_size:
|
||||
return action_history
|
||||
fill_item = action_history[-1] if action_history else []
|
||||
return action_history + [
|
||||
list(fill_item) for _ in range(chunk_size - len(action_history))
|
||||
]
|
||||
|
||||
|
||||
def _get_rotation_matrix(axis: str, angle_rad: float) -> np.ndarray:
|
||||
def calculate_c_s():
|
||||
return np.cos(angle_rad), np.sin(angle_rad)
|
||||
|
||||
if axis == "x":
|
||||
c, s = calculate_c_s()
|
||||
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
|
||||
if axis == "y":
|
||||
c, s = calculate_c_s()
|
||||
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
|
||||
if axis == "z":
|
||||
c, s = calculate_c_s()
|
||||
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
|
||||
return np.eye(3)
|
||||
|
||||
|
||||
def _actions_to_c2ws(action_history: list[list[str]]) -> list[np.ndarray]:
|
||||
move_speed = 0.05
|
||||
rotate_speed_rad_ik = np.deg2rad(4.0)
|
||||
rotate_speed_rad_jl = np.deg2rad(6.0)
|
||||
|
||||
current_c2w = np.eye(4)
|
||||
current_pitch = 0.0
|
||||
pitch_limit = np.deg2rad(85)
|
||||
all_matrices = [current_c2w]
|
||||
|
||||
for frame_keys in action_history:
|
||||
R = current_c2w[:3, :3]
|
||||
T = current_c2w[:3, 3]
|
||||
|
||||
pitch_delta = 0.0
|
||||
if "i" in frame_keys:
|
||||
pitch_delta += rotate_speed_rad_ik
|
||||
if "k" in frame_keys:
|
||||
pitch_delta -= rotate_speed_rad_ik
|
||||
|
||||
new_pitch = current_pitch + pitch_delta
|
||||
if -pitch_limit <= new_pitch <= pitch_limit:
|
||||
current_pitch = new_pitch
|
||||
else:
|
||||
pitch_delta = 0.0
|
||||
|
||||
yaw_delta = 0.0
|
||||
if "j" in frame_keys:
|
||||
yaw_delta -= rotate_speed_rad_jl
|
||||
if "l" in frame_keys:
|
||||
yaw_delta += rotate_speed_rad_jl
|
||||
|
||||
R_pitch = _get_rotation_matrix("x", pitch_delta)
|
||||
R_yaw = _get_rotation_matrix("y", yaw_delta)
|
||||
R_new = R_yaw @ R @ R_pitch
|
||||
|
||||
vec_right = R_new[:, 0]
|
||||
vec_forward = R_new[:, 2]
|
||||
forward_flat = np.array([vec_forward[0], 0, vec_forward[2]])
|
||||
right_flat = np.array([vec_right[0], 0, vec_right[2]])
|
||||
|
||||
f_norm = np.linalg.norm(forward_flat)
|
||||
r_norm = np.linalg.norm(right_flat)
|
||||
if f_norm > 0:
|
||||
forward_flat = forward_flat / (f_norm + 1e-6)
|
||||
if r_norm > 0:
|
||||
right_flat = right_flat / (r_norm + 1e-6)
|
||||
|
||||
move_vec = np.zeros(3)
|
||||
if "w" in frame_keys:
|
||||
move_vec += forward_flat * move_speed
|
||||
if "s" in frame_keys:
|
||||
move_vec -= forward_flat * move_speed
|
||||
if "d" in frame_keys:
|
||||
move_vec += right_flat * move_speed
|
||||
if "a" in frame_keys:
|
||||
move_vec -= right_flat * move_speed
|
||||
|
||||
T_new = T + move_vec
|
||||
current_c2w = np.eye(4)
|
||||
current_c2w[:3, :3] = R_new
|
||||
current_c2w[:3, 3] = T_new
|
||||
all_matrices.append(current_c2w)
|
||||
|
||||
return all_matrices
|
||||
|
||||
|
||||
def _get_camera_control(
|
||||
action_history: list[list[str]],
|
||||
*,
|
||||
chunk_size: int,
|
||||
width: int,
|
||||
height: int,
|
||||
device: torch.device | str,
|
||||
dtype: torch.dtype,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
c2ws_list = _actions_to_c2ws(action_history)
|
||||
c2ws_np = np.stack(c2ws_list[1:])
|
||||
c2ws = torch.from_numpy(c2ws_np).to(device=device, dtype=dtype)
|
||||
Ks = torch.tensor(
|
||||
[[500.0, 500.0, width / 2, height / 2]],
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
).repeat(chunk_size, 1)
|
||||
logger.debug("prefix c2ws shape: %s, Ks shape: %s", c2ws.shape, Ks.shape)
|
||||
return c2ws, Ks
|
||||
|
||||
|
||||
def _build_camera_condition(
|
||||
*,
|
||||
action_history: list[list[str]],
|
||||
width: int,
|
||||
height: int,
|
||||
spatial_scale: int,
|
||||
device: torch.device | str,
|
||||
dtype: torch.dtype,
|
||||
tail_chunk_size: int,
|
||||
) -> torch.Tensor:
|
||||
action_history = _pad_actions_to_chunk(action_history, tail_chunk_size)
|
||||
c2ws_prefix, Ks = _get_camera_control(
|
||||
action_history,
|
||||
chunk_size=tail_chunk_size,
|
||||
width=width,
|
||||
height=height,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
c2ws_prefix = compute_relative_poses(c2ws_prefix, framewise=True)
|
||||
c2ws_prefix = c2ws_prefix[-tail_chunk_size:]
|
||||
|
||||
return camera_poses_to_plucker(
|
||||
c2ws=c2ws_prefix,
|
||||
Ks=Ks,
|
||||
height=height,
|
||||
width=width,
|
||||
spatial_scale=spatial_scale,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_lingbot_world_condition(
|
||||
*,
|
||||
batch,
|
||||
pipeline_config,
|
||||
device: torch.device | str,
|
||||
dtype: torch.dtype,
|
||||
) -> torch.Tensor | None:
|
||||
if batch.c2ws_plucker_emb is not None:
|
||||
return batch.c2ws_plucker_emb.to(device=device, dtype=dtype)
|
||||
|
||||
actions = batch.condition_inputs.get("camera_actions")
|
||||
if actions is None:
|
||||
return None
|
||||
|
||||
spatial_scale = pipeline_config.vae_config.arch_config.spatial_compression_ratio
|
||||
chunk_size = batch.realtime_chunk_size or max(
|
||||
1,
|
||||
int(pipeline_config.dit_config.arch_config.num_frames_per_block),
|
||||
)
|
||||
|
||||
normalized_actions = _validate_actions(actions)
|
||||
if len(normalized_actions) == 0:
|
||||
return None
|
||||
|
||||
if batch.session is None:
|
||||
action_history = normalized_actions
|
||||
else:
|
||||
state = batch.session.get_or_create_state(_LingBotWorldCameraState)
|
||||
if batch.block_idx == 0:
|
||||
state.reset_camera_actions()
|
||||
state.append_camera_actions(normalized_actions)
|
||||
action_history = state.action_history
|
||||
|
||||
if len(action_history) == 0:
|
||||
return None
|
||||
|
||||
c2ws_plucker_emb = _build_camera_condition(
|
||||
action_history=action_history,
|
||||
width=int(batch.width),
|
||||
height=int(batch.height),
|
||||
spatial_scale=spatial_scale,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
tail_chunk_size=chunk_size,
|
||||
)
|
||||
logger.debug(
|
||||
"LingBot action condition prepared: session_id=%s, block_idx=%s, new_action_count=%s, total_history=%s",
|
||||
batch.realtime_session_id,
|
||||
batch.block_idx,
|
||||
len(normalized_actions),
|
||||
len(action_history),
|
||||
)
|
||||
return c2ws_plucker_emb
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldI2VConfig(Wan2_2_I2V_A14B_Config):
|
||||
dit_config: DiTConfig = field(default_factory=LingBotWorldVideoConfig)
|
||||
flow_shift: float | None = 10.0
|
||||
boundary_ratio: float | None = 0.947
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16",))
|
||||
preprocess_text_funcs: tuple[Callable[[str], str] | None, ...] = field(
|
||||
default_factory=lambda: (lingbot_prompt_clean,)
|
||||
)
|
||||
|
||||
def prepare_pos_cond_kwargs(self, batch, device, rotary_emb, dtype):
|
||||
kwargs = super().prepare_pos_cond_kwargs(batch, device, rotary_emb, dtype)
|
||||
if batch.c2ws_plucker_emb is not None:
|
||||
kwargs["c2ws_plucker_emb"] = batch.c2ws_plucker_emb.to(
|
||||
device=device, dtype=dtype
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def preprocess_realtime_condition_image(self, batch, _vae_image_processor) -> bool:
|
||||
if batch.condition_image is None:
|
||||
return False
|
||||
if isinstance(batch.condition_image, list):
|
||||
batch.condition_image = batch.condition_image[0]
|
||||
|
||||
width = int(batch.width or 832)
|
||||
height = int(batch.height or 480)
|
||||
batch.condition_image = batch.condition_image.resize((width, height))
|
||||
batch.width = width
|
||||
batch.height = height
|
||||
return True
|
||||
|
||||
def prepare_world_condition(self, batch, device, dtype):
|
||||
c2ws_plucker_emb = _prepare_lingbot_world_condition(
|
||||
batch=batch,
|
||||
pipeline_config=self,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
)
|
||||
if c2ws_plucker_emb is None:
|
||||
return None
|
||||
return {"c2ws_plucker_emb": c2ws_plucker_emb}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldCausalDMDConfig(LingBotWorldI2VConfig):
|
||||
is_causal: bool = True
|
||||
dmd_denoising_steps: list[int] | None = field(
|
||||
default_factory=lambda: [1000, 821, 642, 321]
|
||||
)
|
||||
warp_denoising_step: bool = True
|
||||
realtime_causal_sink_size: int | None = None
|
||||
realtime_causal_kv_cache_num_frames: int | None = None
|
||||
|
||||
def postprocess_image_latent(self, latent_condition, batch):
|
||||
"""Build condition tensor aligned to chunk_size (num_frames_per_block).
|
||||
|
||||
Matches lingbot_fast_server's _prepare_latents_causal:
|
||||
condition = [mask(temporal_ratio ch), latent(z_dim ch)] -> 20ch total,
|
||||
with temporal dim aligned to chunk_size.
|
||||
"""
|
||||
vae_arch = self.vae_config.arch_config
|
||||
temporal_ratio = vae_arch.temporal_compression_ratio
|
||||
spatial_ratio = vae_arch.spatial_compression_ratio
|
||||
chunk_size = self.dit_config.arch_config.num_frames_per_block
|
||||
|
||||
latent_height = batch.height // spatial_ratio
|
||||
latent_width = batch.width // spatial_ratio
|
||||
|
||||
# Align num_latent_frames to chunk_size
|
||||
num_latent_frames = latent_condition.shape[2]
|
||||
num_latent_frames = num_latent_frames - (num_latent_frames % chunk_size)
|
||||
latent_condition = latent_condition[:, :, :num_latent_frames, :, :]
|
||||
|
||||
# Number of initial frames that have actual image content
|
||||
# (latent_condition from VAE encode of [image, zeros...])
|
||||
# First frame is real, rest are zero-padded
|
||||
initial_latent_frames = 1 # single image -> 1 latent frame
|
||||
|
||||
# Build mask: [B, temporal_ratio, num_latent_frames, H, W]
|
||||
mask = torch.ones(
|
||||
1,
|
||||
temporal_ratio,
|
||||
num_latent_frames,
|
||||
latent_height,
|
||||
latent_width,
|
||||
dtype=latent_condition.dtype,
|
||||
device=latent_condition.device,
|
||||
)
|
||||
# Zero out mask for frames beyond the initial image
|
||||
if initial_latent_frames < num_latent_frames:
|
||||
mask[:, :, initial_latent_frames:] = 0
|
||||
|
||||
return torch.cat([mask, latent_condition], dim=1)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.wan import Wan2_2_I2V_A14B_SamplingParam
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldSamplingParams(Wan2_2_I2V_A14B_SamplingParam):
|
||||
negative_prompt: str | None = None
|
||||
actions: list[list[str]] | None = None
|
||||
chunk_size: int | None = None
|
||||
guidance_scale: float = 5.0
|
||||
guidance_scale_2: float = 5.0
|
||||
num_inference_steps: int = 70
|
||||
num_frames: int = 117
|
||||
fps: int = 16
|
||||
|
||||
def _adjust(self, server_args):
|
||||
enable_sequence_shard = self.enable_sequence_shard
|
||||
if enable_sequence_shard is None or enable_sequence_shard:
|
||||
self.adjust_frames = False
|
||||
super()._adjust(server_args)
|
||||
if enable_sequence_shard is None or enable_sequence_shard:
|
||||
self.enable_sequence_shard = True
|
||||
self.adjust_frames = False
|
||||
if self.chunk_size is None:
|
||||
self.chunk_size = max(
|
||||
1,
|
||||
int(
|
||||
server_args.pipeline_config.dit_config.arch_config.num_frames_per_block
|
||||
),
|
||||
)
|
||||
if self.actions is not None:
|
||||
self.condition_inputs["camera_actions"] = self.actions
|
||||
if self.chunk_size is not None:
|
||||
self.realtime_chunk_size = self.chunk_size
|
||||
@@ -216,6 +216,7 @@ class SamplingParams:
|
||||
# if True, suppress verbose logging for this request
|
||||
suppress_logs: bool = field(default=False, metadata={"batch_sig_exclude": True})
|
||||
|
||||
# return output file paths directly to client
|
||||
return_file_paths_only: bool = True
|
||||
enable_sequence_shard: bool | None = None
|
||||
diffusers_kwargs: dict | None = None
|
||||
@@ -227,6 +228,8 @@ class SamplingParams:
|
||||
use_resolution_template: bool | None = None
|
||||
use_system_prompt: bool | None = None
|
||||
use_guardrails: bool | None = None
|
||||
condition_inputs: dict[str, Any] = field(default_factory=dict)
|
||||
realtime_chunk_size: int | None = None
|
||||
|
||||
# Prompt enhancement (ErnieImage)
|
||||
use_pe: bool | None = None
|
||||
@@ -309,6 +312,10 @@ class SamplingParams:
|
||||
def apply_request_extra(self, req: Any) -> None:
|
||||
"""Merge request extras (model specific, e.g., LTX2.3) into an already-created pipeline request."""
|
||||
req.extra.update(self.build_request_extra())
|
||||
if self.condition_inputs:
|
||||
req.condition_inputs.update(self.condition_inputs)
|
||||
if self.realtime_chunk_size is not None:
|
||||
req.realtime_chunk_size = self.realtime_chunk_size
|
||||
|
||||
def _adjust_output_quality(self, output_quality: str, data_type: DataType) -> int:
|
||||
"""Convert output_quality string to compression level."""
|
||||
|
||||
@@ -36,6 +36,7 @@ from sglang.multimodal_gen.configs.pipeline_configs import (
|
||||
HeliosMidConfig,
|
||||
HeliosT2VConfig,
|
||||
HunyuanConfig,
|
||||
LingBotWorldCausalDMDConfig,
|
||||
WanI2V480PConfig,
|
||||
WanI2V720PConfig,
|
||||
WanT2V480PConfig,
|
||||
@@ -104,7 +105,12 @@ from sglang.multimodal_gen.configs.sample.hunyuan import (
|
||||
HunyuanSamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.hunyuan3d import Hunyuan3DSamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.joy_image import JoyImageEditSamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.joy_image import (
|
||||
JoyImageEditSamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.lingbot_world import (
|
||||
LingBotWorldSamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.ltx_2 import (
|
||||
LTX2SamplingParams,
|
||||
LTX23HQSamplingParams,
|
||||
@@ -751,6 +757,14 @@ def _register_configs():
|
||||
pipeline_config_cls=Wan2_2_I2V_A14B_Config,
|
||||
hf_model_paths=["Wan-AI/Wan2.2-I2V-A14B-Diffusers"],
|
||||
)
|
||||
register_configs(
|
||||
sampling_param_cls=LingBotWorldSamplingParams,
|
||||
pipeline_config_cls=LingBotWorldCausalDMDConfig,
|
||||
hf_model_paths=[
|
||||
"IPostYellow/lingbot-world-fast-diffusers",
|
||||
"robbyant/lingbot-world-fast-diffusers",
|
||||
],
|
||||
)
|
||||
register_configs(
|
||||
sampling_param_cls=FastWanT2V480PConfig,
|
||||
pipeline_config_cls=FastWan2_1_T2V_480P_Config,
|
||||
|
||||
@@ -13,10 +13,16 @@ import torch
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
|
||||
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 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,
|
||||
@@ -401,6 +407,7 @@ def create_app(server_args: ServerArgs):
|
||||
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)
|
||||
app.include_router(mesh_api.router)
|
||||
app.include_router(weights_api.router)
|
||||
app.include_router(rollout_api.router)
|
||||
|
||||
@@ -2,7 +2,7 @@ import time
|
||||
import uuid
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -138,6 +138,30 @@ 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_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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections import deque
|
||||
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_adapter import (
|
||||
RealtimeChunkInputs,
|
||||
RealtimeModelAdapter,
|
||||
)
|
||||
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.realtime.condition_events import (
|
||||
ConditionEvent,
|
||||
ConditionEventQueue,
|
||||
ControlSignal,
|
||||
ControlStateSamplingQueue,
|
||||
ControlStateTransition,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
LINGBOT_REALTIME_DEFAULT_NUM_INFERENCE_STEPS = 4
|
||||
LINGBOT_REALTIME_MIN_CONDITION_CHUNKS = 2
|
||||
|
||||
|
||||
class LingBotWorldRealtimeState:
|
||||
def __init__(self):
|
||||
self.events = ConditionEventQueue(max_events={"prompt": 1})
|
||||
self.camera_state = ControlStateSamplingQueue(
|
||||
default_item=[],
|
||||
min_pulse_items=1,
|
||||
max_transitions=512,
|
||||
)
|
||||
self.camera_script_queue: deque[ControlSignal] = deque(maxlen=512)
|
||||
self.latest_sampled_event_id: int | None = None
|
||||
|
||||
def clear(self) -> None:
|
||||
self.events.clear()
|
||||
self.camera_state.clear()
|
||||
self.camera_script_queue.clear()
|
||||
self.latest_sampled_event_id = None
|
||||
|
||||
def receive_prompt(self, prompt: str, *, event_id: int | None = None) -> None:
|
||||
self.events.push(
|
||||
ConditionEvent(
|
||||
kind="prompt",
|
||||
payload=ControlSignal(
|
||||
kind="prompt",
|
||||
payload=prompt,
|
||||
seq_id=event_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def receive_camera_script(
|
||||
self,
|
||||
camera_actions: list[list[str]],
|
||||
*,
|
||||
event_id: int | None = None,
|
||||
) -> None:
|
||||
self.camera_script_queue.clear()
|
||||
self.camera_state.clear()
|
||||
for actions in camera_actions:
|
||||
self.camera_script_queue.append(
|
||||
ControlSignal(
|
||||
kind="camera_actions",
|
||||
payload=list(actions),
|
||||
seq_id=event_id,
|
||||
)
|
||||
)
|
||||
|
||||
def receive_camera_state_transitions(
|
||||
self,
|
||||
transitions: list[ControlStateTransition],
|
||||
) -> None:
|
||||
self.camera_script_queue.clear()
|
||||
self.camera_state.push_many(transitions)
|
||||
|
||||
def receive_camera_actions(
|
||||
self,
|
||||
camera_actions: list[list[str]],
|
||||
*,
|
||||
event_id: int | None = None,
|
||||
) -> None:
|
||||
self.receive_camera_script(camera_actions, event_id=event_id)
|
||||
|
||||
def receive_camera_state(
|
||||
self,
|
||||
actions: list[str],
|
||||
*,
|
||||
event_id: int | None = None,
|
||||
timestamp_ms: int | None = None,
|
||||
) -> None:
|
||||
self.receive_camera_state_transitions(
|
||||
[
|
||||
ControlStateTransition(
|
||||
payload=list(actions),
|
||||
seq_id=event_id,
|
||||
timestamp_ms=timestamp_ms,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def _sample_camera_script(self, chunk_size: int) -> list[list[str]]:
|
||||
chunk: list[list[str]] = []
|
||||
latest_event_id = self.latest_sampled_event_id
|
||||
while self.camera_script_queue and len(chunk) < chunk_size:
|
||||
signal = self.camera_script_queue.popleft()
|
||||
chunk.append(list(signal.payload))
|
||||
latest_event_id = signal.seq_id
|
||||
while len(chunk) < chunk_size:
|
||||
chunk.append([])
|
||||
self.latest_sampled_event_id = latest_event_id
|
||||
return chunk
|
||||
|
||||
def _camera_state_transition(
|
||||
self,
|
||||
actions: list[str],
|
||||
*,
|
||||
event_id: int | None,
|
||||
timestamp_ms: int | None,
|
||||
) -> ControlStateTransition:
|
||||
return ControlStateTransition(
|
||||
payload=list(actions),
|
||||
seq_id=event_id,
|
||||
timestamp_ms=timestamp_ms,
|
||||
)
|
||||
|
||||
def _camera_transitions_from_event_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
event_id: int | None,
|
||||
) -> list[ControlStateTransition]:
|
||||
transitions = payload.get("transitions")
|
||||
if not isinstance(transitions, list):
|
||||
raise ValueError("camera_actions state payload requires transitions")
|
||||
result = []
|
||||
for transition in transitions:
|
||||
if not isinstance(transition, dict):
|
||||
raise ValueError("camera_actions transition must be a map")
|
||||
actions = transition.get("actions")
|
||||
if not isinstance(actions, list):
|
||||
raise ValueError("camera_actions transition actions must be a list")
|
||||
timestamp_ms = transition.get("client_ts_ms")
|
||||
if timestamp_ms is not None:
|
||||
timestamp_ms = int(timestamp_ms)
|
||||
result.append(
|
||||
self._camera_state_transition(
|
||||
list(actions),
|
||||
event_id=event_id,
|
||||
timestamp_ms=timestamp_ms,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def receive_camera_event_payload(
|
||||
self,
|
||||
payload: Any,
|
||||
*,
|
||||
event_id: int | None,
|
||||
) -> str:
|
||||
if isinstance(payload, dict) and payload.get("mode") == "state":
|
||||
transitions = self._camera_transitions_from_event_payload(
|
||||
payload,
|
||||
event_id=event_id,
|
||||
)
|
||||
self.receive_camera_state_transitions(transitions)
|
||||
return f"kind=camera_actions, mode=state, transitions={len(transitions)}"
|
||||
|
||||
camera_actions = LingBotWorldRealtimeAdapter._validate_camera_actions(payload)
|
||||
self.receive_camera_script(camera_actions, event_id=event_id)
|
||||
return f"kind=camera_actions, mode=script, frames={len(camera_actions)}"
|
||||
|
||||
def sample_prompt(self) -> str:
|
||||
prompt = self.events.pop_latest("prompt")
|
||||
if not isinstance(prompt, str):
|
||||
raise ValueError("prompt event payload must be a string")
|
||||
self.latest_sampled_event_id = self.events.last_sampled_seq_id("prompt")
|
||||
return prompt
|
||||
|
||||
def sample_camera_actions(self, chunk_size: int) -> list[list[str]] | None:
|
||||
"""samples a sequence of camera actions for the chunk with chunk_size frames
|
||||
|
||||
Args:
|
||||
chunk_size: number of frames
|
||||
"""
|
||||
if self.camera_script_queue:
|
||||
return self._sample_camera_script(chunk_size)
|
||||
action_list = self.camera_state.sample_chunk(chunk_size)
|
||||
if action_list is None:
|
||||
return None
|
||||
self.latest_sampled_event_id = self.camera_state.latest_sampled_seq_id()
|
||||
return [list(actions) for actions in action_list]
|
||||
|
||||
def has_prompt(self) -> bool:
|
||||
return self.events.has_events("prompt")
|
||||
|
||||
|
||||
class LingBotWorldRealtimeAdapter(RealtimeModelAdapter):
|
||||
name = "lingbot_world"
|
||||
|
||||
def __init__(self):
|
||||
self.output_adapter = RawRGBRealtimeOutputAdapter()
|
||||
|
||||
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("camera_actions")
|
||||
if camera_actions is not None:
|
||||
state = self._state(session)
|
||||
state.receive_camera_script(self._validate_camera_actions(camera_actions))
|
||||
|
||||
if request.first_frame is None:
|
||||
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
|
||||
|
||||
target_path = os.path.join(uploads_dir, f"{session.id}_first_frame")
|
||||
image_path = await save_image_to_path(request.first_frame, target_path)
|
||||
request.first_frame = image_path
|
||||
|
||||
async def wait_for_next_chunk(self, session: GenerateSession) -> None:
|
||||
del session
|
||||
|
||||
@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 state.receive_camera_event_payload(
|
||||
event.payload,
|
||||
event_id=event.event_id,
|
||||
)
|
||||
elif event.kind == "prompt":
|
||||
if not isinstance(event.payload, str) or not event.payload:
|
||||
raise ValueError("prompt event payload must be a non-empty string")
|
||||
state.receive_prompt(event.payload, event_id=event.event_id)
|
||||
return f"kind=prompt, prompt_len={len(event.payload)}"
|
||||
raise ValueError(f"unsupported event kind: {event.kind}")
|
||||
|
||||
def _sample_chunk_inputs(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
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")
|
||||
|
||||
if chunk.index == 0:
|
||||
prompt = request.prompt
|
||||
elif state.has_prompt():
|
||||
prompt = state.sample_prompt()
|
||||
request.prompt = prompt
|
||||
else:
|
||||
prompt = request.prompt
|
||||
|
||||
condition_inputs = {}
|
||||
camera_actions = state.sample_camera_actions(chunk_size)
|
||||
if camera_actions is not None:
|
||||
condition_inputs["camera_actions"] = camera_actions
|
||||
return RealtimeChunkInputs(prompt=prompt, condition_inputs=condition_inputs)
|
||||
|
||||
def _build_sampling_params(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
chunk: RealtimeChunkContext,
|
||||
chunk_inputs: RealtimeChunkInputs,
|
||||
chunk_size: int,
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
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_sampling_params(
|
||||
chunk.request_id,
|
||||
prompt=chunk_inputs.prompt,
|
||||
size=request.size,
|
||||
num_frames=num_frames,
|
||||
fps=request.fps,
|
||||
image_path=request.first_frame,
|
||||
output_file_name=chunk.request_id,
|
||||
save_output=False,
|
||||
seed=request.seed,
|
||||
generator_device=request.generator_device,
|
||||
num_inference_steps=(
|
||||
request.num_inference_steps
|
||||
or LINGBOT_REALTIME_DEFAULT_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,
|
||||
)
|
||||
|
||||
@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 prepare_next_request(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
server_args: ServerArgs,
|
||||
chunk: RealtimeChunkContext,
|
||||
) -> Req:
|
||||
"""build a new request for the next chunk"""
|
||||
pipeline_config = server_args.pipeline_config
|
||||
chunk_size = int(pipeline_config.dit_config.arch_config.num_frames_per_block)
|
||||
chunk_inputs = self._sample_chunk_inputs(session, chunk, chunk_size)
|
||||
sampling_params = self._build_sampling_params(
|
||||
session,
|
||||
chunk,
|
||||
chunk_inputs,
|
||||
chunk_size,
|
||||
server_args,
|
||||
)
|
||||
batch = prepare_request(
|
||||
server_args=server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
batch.session = session.realtime_session
|
||||
batch.realtime_session_id = session.id
|
||||
batch.return_raw_frames = True
|
||||
batch.block_idx = chunk.index
|
||||
batch.realtime_event_id = self._state(session).latest_sampled_event_id
|
||||
if session.request is not None:
|
||||
batch.realtime_output_format = session.request.realtime_output_format
|
||||
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
|
||||
)
|
||||
return batch
|
||||
|
||||
async def send_output(
|
||||
self,
|
||||
ws: WebSocket,
|
||||
session: GenerateSession,
|
||||
result: OutputBatch,
|
||||
batch: Req,
|
||||
) -> RealtimeFrameSendStats:
|
||||
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 dispose(self, session: GenerateSession) -> None:
|
||||
state = session.adapter_state
|
||||
if isinstance(state, LingBotWorldRealtimeState):
|
||||
state.clear()
|
||||
self.output_adapter.reset()
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 (
|
||||
RealtimeModelAdapter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealtimeChunkContext:
|
||||
session_id: str
|
||||
index: int
|
||||
request_id: str
|
||||
|
||||
|
||||
class GenerateSession:
|
||||
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: RealtimeModelAdapter | None = None
|
||||
self.adapter_state: Any = None
|
||||
|
||||
def set_adapter(self, adapter: RealtimeModelAdapter):
|
||||
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.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,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
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 (
|
||||
RealtimeFrameSendStats,
|
||||
)
|
||||
|
||||
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:
|
||||
prompt: str
|
||||
condition_inputs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RealtimeModelAdapter(Protocol):
|
||||
name: str
|
||||
|
||||
def create_state(self) -> Any: ...
|
||||
|
||||
async def on_init(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
request: RealtimeVideoGenerationsRequest,
|
||||
) -> None: ...
|
||||
|
||||
async def wait_for_next_chunk(self, session: GenerateSession) -> None: ...
|
||||
|
||||
def ingest_event(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
event: RealtimeEvent,
|
||||
) -> str: ...
|
||||
|
||||
def prepare_next_request(
|
||||
self,
|
||||
session: GenerateSession,
|
||||
server_args: ServerArgs,
|
||||
chunk: RealtimeChunkContext,
|
||||
) -> Req: ...
|
||||
|
||||
async def send_output(
|
||||
self,
|
||||
ws: WebSocket,
|
||||
session: GenerateSession,
|
||||
result: OutputBatch,
|
||||
batch: Req,
|
||||
) -> RealtimeFrameSendStats: ...
|
||||
|
||||
def on_chunk_complete(
|
||||
self, session: GenerateSession, result: OutputBatch
|
||||
) -> None: ...
|
||||
|
||||
def dispose(self, session: GenerateSession) -> None: ...
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
# 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.timing import (
|
||||
RealtimeStageTimer,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
JPEG_FRAME_CONTENT_TYPE,
|
||||
RAW_RGB_CHANNELS,
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
WEBP_FRAME_CONTENT_TYPE,
|
||||
build_delta_gzip_raw_rgb_payload,
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
"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
|
||||
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]
|
||||
last_raw_rgb_frame: bytes | None = None
|
||||
last_event_id: int | None = None
|
||||
|
||||
|
||||
def _split_frame_batch(frames: list[bytes]) -> list[list[bytes]]:
|
||||
if not frames:
|
||||
return [frames]
|
||||
return [
|
||||
frames[i : i + RAW_RGB_FRAMES_PER_WS_MESSAGE]
|
||||
for i in range(0, len(frames), RAW_RGB_FRAMES_PER_WS_MESSAGE)
|
||||
]
|
||||
|
||||
|
||||
def _encode_rgb_frame_to_webp(
|
||||
frame: bytes,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.frombytes("RGB", (width, height), frame).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,
|
||||
) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.frombytes("RGB", (width, height), frame).save(
|
||||
buffer,
|
||||
format="JPEG",
|
||||
quality=quality,
|
||||
subsampling=JPEG_SUBSAMPLING,
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _pack_frame_batch_message(
|
||||
header: RealtimeFrameBatchHeader,
|
||||
payload: bytes,
|
||||
) -> bytes:
|
||||
message: RealtimeFrameBatchMessage = {
|
||||
**header,
|
||||
"type": "frame_batch",
|
||||
"payload": payload,
|
||||
}
|
||||
return msgspec.msgpack.encode(message)
|
||||
|
||||
|
||||
def _build_transport_payload(
|
||||
transport_frames: list[bytes],
|
||||
*,
|
||||
content_type: str,
|
||||
metadata: dict[str, int | str],
|
||||
output_format: str | None,
|
||||
transport_quality: int | None,
|
||||
reference_frame: bytes | None,
|
||||
event_id: int | None,
|
||||
) -> _TransportPayload:
|
||||
payload_content_type = content_type
|
||||
payload_metadata: dict[str, int | str | bool] = {}
|
||||
raw_payload = b""
|
||||
|
||||
if (
|
||||
output_format in ENCODED_PREVIEW_FORMATS
|
||||
and content_type == RAW_RGB_CONTENT_TYPE
|
||||
and transport_frames
|
||||
):
|
||||
if output_format == "webp":
|
||||
raw_payload = _encode_rgb_frame_to_webp(
|
||||
transport_frames[0],
|
||||
width=int(metadata["width"]),
|
||||
height=int(metadata["height"]),
|
||||
quality=int(transport_quality or WEBP_DEFAULT_QUALITY),
|
||||
)
|
||||
payload_content_type = WEBP_FRAME_CONTENT_TYPE
|
||||
else:
|
||||
raw_payload = _encode_rgb_frame_to_jpeg(
|
||||
transport_frames[0],
|
||||
width=int(metadata["width"]),
|
||||
height=int(metadata["height"]),
|
||||
quality=int(transport_quality or JPEG_DEFAULT_QUALITY),
|
||||
)
|
||||
payload_content_type = JPEG_FRAME_CONTENT_TYPE
|
||||
payload_metadata = {
|
||||
"format": output_format,
|
||||
"encoding": output_format,
|
||||
}
|
||||
elif (
|
||||
output_format == RAW_LOSSLESS_OUTPUT_FORMAT
|
||||
and 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,
|
||||
}
|
||||
elif content_type == RAW_RGB_CONTENT_TYPE and transport_frames:
|
||||
raw_payload = build_delta_gzip_raw_rgb_payload(
|
||||
transport_frames,
|
||||
reference_frame=reference_frame,
|
||||
)
|
||||
payload_content_type = RAW_RGB_DELTA_GZIP_CONTENT_TYPE
|
||||
payload_metadata = {
|
||||
"raw_size": sum(len(frame) for frame in transport_frames),
|
||||
"encoding": "delta-gzip",
|
||||
}
|
||||
if reference_frame is not None:
|
||||
payload_metadata["delta_reference"] = "previous-frame"
|
||||
return _TransportPayload(
|
||||
content_type=payload_content_type,
|
||||
payload=raw_payload,
|
||||
metadata=payload_metadata,
|
||||
last_raw_rgb_frame=transport_frames[-1],
|
||||
last_event_id=event_id,
|
||||
)
|
||||
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,
|
||||
event_id: int | None,
|
||||
) -> list[_TransportPayload]:
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*(
|
||||
asyncio.to_thread(
|
||||
_build_transport_payload,
|
||||
transport_frames,
|
||||
content_type=content_type,
|
||||
metadata=metadata,
|
||||
output_format=output_format,
|
||||
transport_quality=transport_quality,
|
||||
reference_frame=None,
|
||||
event_id=event_id,
|
||||
)
|
||||
for transport_frames in split_batches
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class RawRGBRealtimeOutputAdapter:
|
||||
"""send raw RGB over WebSocket using lossless transport compression"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_raw_rgb_frame: bytes | None = None
|
||||
self._last_event_id: int | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last_raw_rgb_frame = None
|
||||
self._last_event_id = None
|
||||
|
||||
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)
|
||||
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),
|
||||
)
|
||||
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,
|
||||
) -> RealtimeFrameSendStats:
|
||||
chunk_index = chunk_index_start
|
||||
metadata = frame_metadata or {}
|
||||
stats = empty_frame_send_stats(content_type)
|
||||
for frames in frame_batches:
|
||||
split_batches = (
|
||||
[[frame] for frame in frames]
|
||||
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,
|
||||
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:
|
||||
reference_frame = self._last_raw_rgb_frame
|
||||
if event_id != self._last_event_id:
|
||||
reference_frame = None
|
||||
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,
|
||||
reference_frame=reference_frame,
|
||||
event_id=event_id,
|
||||
)
|
||||
else:
|
||||
transport_payload = _build_transport_payload(
|
||||
transport_frames,
|
||||
content_type=content_type,
|
||||
metadata=metadata,
|
||||
output_format=output_format,
|
||||
transport_quality=transport_quality,
|
||||
reference_frame=reference_frame,
|
||||
event_id=event_id,
|
||||
)
|
||||
if transport_payload.last_raw_rgb_frame is not None:
|
||||
self._last_raw_rgb_frame = transport_payload.last_raw_rgb_frame
|
||||
self._last_event_id = transport_payload.last_event_id
|
||||
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:
|
||||
message_payload = await asyncio.to_thread(
|
||||
_pack_frame_batch_message,
|
||||
header,
|
||||
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["raw_bytes"] += sum(len(frame) for frame in transport_frames)
|
||||
stats["ws_payload_bytes"] += len(message_payload)
|
||||
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
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
# 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.timing 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 = 15.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 "
|
||||
"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["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),
|
||||
"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
|
||||
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")
|
||||
send_stats = await session.adapter.send_output(
|
||||
ws,
|
||||
session,
|
||||
result,
|
||||
batch,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.websocket("/generate")
|
||||
async def generate(websocket: WebSocket):
|
||||
"""endpoint for creating a new realtime session"""
|
||||
await websocket.accept()
|
||||
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,59 @@
|
||||
# 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 (
|
||||
RealtimeModelAdapter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
_REALTIME_ADAPTER_REGISTRY: dict[type, type[RealtimeModelAdapter]] = {}
|
||||
_BUILTIN_ADAPTERS_REGISTERED = False
|
||||
|
||||
|
||||
def register_realtime_model_adapter(
|
||||
pipeline_config_cls: type,
|
||||
adapter_cls: type[RealtimeModelAdapter],
|
||||
) -> 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.runtime.entrypoints.openai.realtime.adapters.lingbot_world_realtime_adapter import (
|
||||
LingBotWorldRealtimeAdapter,
|
||||
)
|
||||
|
||||
register_realtime_model_adapter(
|
||||
LingBotWorldCausalDMDConfig,
|
||||
LingBotWorldRealtimeAdapter,
|
||||
)
|
||||
_BUILTIN_ADAPTERS_REGISTERED = True
|
||||
|
||||
|
||||
def get_realtime_model_adapter(
|
||||
server_args: ServerArgs,
|
||||
) -> RealtimeModelAdapter:
|
||||
_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
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
import asyncio
|
||||
import base64
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -165,7 +166,7 @@ def build_sampling_params(request_id: str, **kwargs) -> SamplingParams:
|
||||
|
||||
|
||||
async def save_image_to_path(
|
||||
image: Union[UploadFile, str],
|
||||
image: Union[UploadFile, bytes, str],
|
||||
target_path: str,
|
||||
*,
|
||||
prefer_remote_source: bool = False,
|
||||
@@ -179,9 +180,27 @@ async def save_image_to_path(
|
||||
|
||||
|
||||
# Helpers
|
||||
async def _save_upload_to_path(upload: UploadFile, target_path: str) -> str:
|
||||
async def _save_upload_to_path(
|
||||
upload: Union[UploadFile, bytes], target_path: str
|
||||
) -> str:
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
content = await upload.read()
|
||||
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
|
||||
@@ -353,15 +372,20 @@ async def process_generation_batch(
|
||||
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:
|
||||
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
|
||||
else:
|
||||
elif result.output is not None:
|
||||
num_outputs = len(result.output)
|
||||
save_file_path_list = save_outputs(
|
||||
result.output,
|
||||
|
||||
@@ -73,6 +73,11 @@ class ShutdownReq:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseRealtimeSessionReq:
|
||||
session_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetDisaggStatsReq:
|
||||
"""Request to get disagg pipeline metrics from the scheduler."""
|
||||
@@ -125,6 +130,14 @@ class GenerationResult:
|
||||
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],
|
||||
*,
|
||||
@@ -187,6 +200,7 @@ def _copy_req_for_output(
|
||||
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
|
||||
|
||||
@@ -455,20 +469,173 @@ def attach_audio_to_video_sample(
|
||||
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 isinstance(audio, torch.Tensor) and audio.ndim >= 2:
|
||||
audio = audio[output_idx] if audio.shape[0] > output_idx else None
|
||||
elif isinstance(audio, np.ndarray) and audio.ndim >= 2:
|
||||
audio = audio[output_idx] if audio.shape[0] > output_idx else None
|
||||
|
||||
if audio is not None and not (
|
||||
isinstance(sample, (tuple, list)) and len(sample) == 2
|
||||
):
|
||||
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(f"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,
|
||||
@@ -490,11 +657,9 @@ def save_outputs(
|
||||
upscaling_model_path: Optional[str] = None,
|
||||
upscaling_scale: int = 4,
|
||||
) -> list[str]:
|
||||
"""Save outputs to files and return the list of file paths."""
|
||||
output_paths: list[str] = []
|
||||
for idx, output in enumerate(outputs):
|
||||
for idx, sample in enumerate(outputs):
|
||||
save_file_path = build_output_path(idx)
|
||||
sample = output
|
||||
if data_type == DataType.VIDEO:
|
||||
sample = attach_audio_to_video_sample(sample, audio, idx)
|
||||
|
||||
@@ -519,12 +684,7 @@ def save_outputs(
|
||||
samples_out.append(sample)
|
||||
if audios_out is not None:
|
||||
if data_type == DataType.VIDEO:
|
||||
audio_item = audio
|
||||
if isinstance(audio, torch.Tensor) and audio.ndim >= 2:
|
||||
audio_item = audio[idx] if audio.shape[0] > idx else None
|
||||
elif isinstance(audio, np.ndarray) and audio.ndim >= 2:
|
||||
audio_item = audio[idx] if audio.shape[0] > idx else None
|
||||
audios_out.append(audio_item)
|
||||
audios_out.append(select_output_audio(audio, idx))
|
||||
else:
|
||||
audios_out.append(audio)
|
||||
if frames_out is not None:
|
||||
@@ -548,129 +708,26 @@ def post_process_sample(
|
||||
enable_upscaling: bool = False,
|
||||
upscaling_model_path: Optional[str] = None,
|
||||
upscaling_scale: int = 4,
|
||||
):
|
||||
"""
|
||||
Process sample output, optionally interpolate video frames, and save.
|
||||
"""
|
||||
audio = None
|
||||
if isinstance(sample, (tuple, list)) and len(sample) == 2:
|
||||
sample, audio = sample
|
||||
|
||||
# 1. Convert tensor / array to list of uint8 HWC frames
|
||||
frames = None
|
||||
if isinstance(sample, torch.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).cpu().numpy()
|
||||
frames = list(videos)
|
||||
else:
|
||||
if not isinstance(sample, np.ndarray):
|
||||
raise TypeError(f"Unsupported sample type: {type(sample)}")
|
||||
|
||||
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).cpu().numpy()
|
||||
frames = list(videos)
|
||||
else:
|
||||
if arr.dtype != np.uint8:
|
||||
arr = (np.clip(arr, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
frames = list(arr)
|
||||
|
||||
# 2. Frame interpolation (video only)
|
||||
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
|
||||
|
||||
# 3. Upscaling (images and videos)
|
||||
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,
|
||||
)
|
||||
|
||||
# 4. Save outputs if requested
|
||||
if save_output:
|
||||
if save_file_path:
|
||||
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,
|
||||
frames,
|
||||
fps=fps,
|
||||
format=data_type.get_default_extension(),
|
||||
codec="libx264",
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
_maybe_mux_audio_into_mp4(
|
||||
save_file_path=save_file_path,
|
||||
audio=audio,
|
||||
frames=frames,
|
||||
fps=fps,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
)
|
||||
|
||||
else:
|
||||
quality = output_compression if output_compression is not None else 75
|
||||
if len(frames) > 1:
|
||||
for i, image in enumerate(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, frames[0], quality, output_compression
|
||||
)
|
||||
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
|
||||
else:
|
||||
logger.info(f"No output path provided, output not saved")
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
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)
|
||||
) -> list[Any]:
|
||||
"""materialize frames and save outputs (optional)"""
|
||||
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
|
||||
|
||||
@@ -457,6 +457,7 @@ def launch_http_server_only(server_args):
|
||||
host=server_args.host,
|
||||
port=server_args.port,
|
||||
reload=False,
|
||||
ws_per_message_deflate=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.kvcache.causal_attention_cache import (
|
||||
CausalAttentionKVView,
|
||||
CausalSelfAttentionKVCache,
|
||||
CrossAttentionKVCache,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CausalAttentionKVView",
|
||||
"CausalSelfAttentionKVCache",
|
||||
"CrossAttentionKVCache",
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CausalAttentionKVView:
|
||||
k: torch.Tensor
|
||||
v: torch.Tensor
|
||||
local_start_index: int
|
||||
local_end_index: int
|
||||
visible_local_end: int
|
||||
visible_global_end: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CausalSelfAttentionKVCache:
|
||||
"""one transformer block's causal self-attn K/V cache and write cursors"""
|
||||
|
||||
k: torch.Tensor
|
||||
v: torch.Tensor
|
||||
# the right bound of the valid global token range
|
||||
# e.g., 12000 means [0, 12000) has been generated and cached
|
||||
global_end_index: torch.Tensor
|
||||
# the right bound of the valid local token range within the buffer (when cache is unfilled)
|
||||
local_end_index: torch.Tensor
|
||||
global_end_index_int: int | None = None
|
||||
local_end_index_int: int | None = None
|
||||
cache_size: int = 0
|
||||
sink_tokens: int = 0
|
||||
attention_window_size: int = 0
|
||||
allow_growth: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.cache_size == 0:
|
||||
self.cache_size = self.k.shape[1]
|
||||
if self.attention_window_size == 0:
|
||||
self.attention_window_size = self.cache_size
|
||||
|
||||
def reset_indices(self) -> None:
|
||||
self.global_end_index.zero_()
|
||||
self.local_end_index.zero_()
|
||||
if self.global_end_index_int is not None:
|
||||
self.global_end_index_int = 0
|
||||
if self.local_end_index_int is not None:
|
||||
self.local_end_index_int = 0
|
||||
|
||||
def _read_indices(self) -> tuple[int, int]:
|
||||
global_end_index = self.global_end_index_int
|
||||
local_end_index = self.local_end_index_int
|
||||
if global_end_index is None or local_end_index is None:
|
||||
global_end_index = int(self.global_end_index.item())
|
||||
local_end_index = int(self.local_end_index.item())
|
||||
self.global_end_index_int = global_end_index
|
||||
self.local_end_index_int = local_end_index
|
||||
return global_end_index, local_end_index
|
||||
|
||||
def _write_indices(self, *, global_end_index: int, local_end_index: int) -> None:
|
||||
if (
|
||||
self.global_end_index_int == global_end_index
|
||||
and self.local_end_index_int == local_end_index
|
||||
):
|
||||
return
|
||||
if self.global_end_index_int is not None:
|
||||
self.global_end_index_int = global_end_index
|
||||
if self.local_end_index_int is not None:
|
||||
self.local_end_index_int = local_end_index
|
||||
self.global_end_index.fill_(global_end_index)
|
||||
self.local_end_index.fill_(local_end_index)
|
||||
|
||||
def _grow_to_fit(self, required_tokens: int) -> None:
|
||||
if required_tokens <= self.cache_size:
|
||||
return
|
||||
old_cache_size = self.cache_size
|
||||
new_cache_size = max(required_tokens, old_cache_size * 2)
|
||||
|
||||
new_k = self.k.new_zeros(
|
||||
self.k.shape[0],
|
||||
new_cache_size,
|
||||
self.k.shape[2],
|
||||
self.k.shape[3],
|
||||
)
|
||||
new_v = self.v.new_zeros(
|
||||
self.v.shape[0],
|
||||
new_cache_size,
|
||||
self.v.shape[2],
|
||||
self.v.shape[3],
|
||||
)
|
||||
new_k[:, :old_cache_size] = self.k
|
||||
new_v[:, :old_cache_size] = self.v
|
||||
self.k = new_k
|
||||
self.v = new_v
|
||||
self.cache_size = new_cache_size
|
||||
if self.attention_window_size == old_cache_size:
|
||||
self.attention_window_size = new_cache_size
|
||||
|
||||
def update_and_get_attention_kv(
|
||||
self,
|
||||
*,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
current_chunk_start: int,
|
||||
debug_name: str = "causal KV cache",
|
||||
) -> CausalAttentionKVView:
|
||||
"""write kv into the cache, returns the part visible to the current chunk
|
||||
|
||||
Args:
|
||||
current_chunk_start: the global position of the start of the chunk
|
||||
|
||||
"""
|
||||
num_new_tokens = key.shape[1]
|
||||
current_chunk_end = current_chunk_start + num_new_tokens
|
||||
kv_cache_size = self.cache_size
|
||||
sink_tokens = self.sink_tokens
|
||||
global_end_index, local_end_index_prev = self._read_indices()
|
||||
|
||||
# local_end_index: the local position of the end of current chunk
|
||||
# updated_local_end: the updated local end
|
||||
# updated_global_end: the updated global end
|
||||
|
||||
# the global position of the start of the buffer
|
||||
window_start = global_end_index - local_end_index_prev
|
||||
|
||||
if current_chunk_end <= global_end_index:
|
||||
# the window stays as previous
|
||||
# cache layout:
|
||||
# [sink tokens, recent window tokens, current chunk tokens, uninitialized tokens (optional)]
|
||||
local_start_index = current_chunk_start - window_start
|
||||
local_end_index = local_start_index + num_new_tokens
|
||||
|
||||
# the local end and global end remains unchanged (since the chunk hasn't proceed)
|
||||
updated_local_end = local_end_index_prev
|
||||
updated_global_end = global_end_index
|
||||
else:
|
||||
# the chunk window has proceed, append new tokens, and evict earliest (if have to)
|
||||
appended_tokens = current_chunk_end - global_end_index
|
||||
if self.allow_growth:
|
||||
self._grow_to_fit(local_end_index_prev + appended_tokens)
|
||||
kv_cache_size = self.cache_size
|
||||
if local_end_index_prev + appended_tokens > kv_cache_size:
|
||||
# the new tokens can't fit in the remaining space (after local_end_index_prev), start evicting:
|
||||
# before:
|
||||
# [sink tokens, evicted tokens, rolled tokens, remaining space]
|
||||
# ^ end of previous chunk
|
||||
# after:
|
||||
# [sink tokens, rolled tokens, remaining space ]
|
||||
|
||||
# 1. keep sink tokens ([0: sink_tokens]) untouched
|
||||
# 2. evict obsolete tokens in: [sink_tokens:sink_tokens + num_evicted_tokens]
|
||||
num_evicted_tokens = (
|
||||
local_end_index_prev + appended_tokens - kv_cache_size
|
||||
)
|
||||
|
||||
# number of tokens to move
|
||||
num_rolled_tokens = max(
|
||||
0,
|
||||
local_end_index_prev - num_evicted_tokens - sink_tokens,
|
||||
)
|
||||
if num_rolled_tokens > 0:
|
||||
self.k[:, sink_tokens : sink_tokens + num_rolled_tokens] = self.k[
|
||||
:,
|
||||
sink_tokens
|
||||
+ num_evicted_tokens : sink_tokens
|
||||
+ num_evicted_tokens
|
||||
+ num_rolled_tokens,
|
||||
].clone()
|
||||
self.v[:, sink_tokens : sink_tokens + num_rolled_tokens] = self.v[
|
||||
:,
|
||||
sink_tokens
|
||||
+ num_evicted_tokens : sink_tokens
|
||||
+ num_evicted_tokens
|
||||
+ num_rolled_tokens,
|
||||
].clone()
|
||||
|
||||
# if we move the minimum number of tokens, the right bound of the append token would be aligned with end of the buffer
|
||||
local_end_index = kv_cache_size
|
||||
else:
|
||||
# enough space, directly append new tokens after end of previous chunk
|
||||
local_end_index = local_end_index_prev + appended_tokens
|
||||
local_start_index = local_end_index - num_new_tokens
|
||||
updated_local_end = local_end_index
|
||||
# after filling in the proceeded new chunk, the global end aligns with the global end of the current chunk
|
||||
updated_global_end = current_chunk_end
|
||||
|
||||
if (
|
||||
local_start_index < 0
|
||||
or local_end_index > kv_cache_size
|
||||
or local_end_index - local_start_index != num_new_tokens
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Invalid {debug_name} write range: "
|
||||
f"local=[{local_start_index}, {local_end_index}), "
|
||||
f"global_end={global_end_index}, "
|
||||
f"prev_local_end={local_end_index_prev}, "
|
||||
f"kv_cache_size={kv_cache_size}, "
|
||||
f"num_new_tokens={num_new_tokens}, "
|
||||
f"current_start={current_chunk_start}, current_end={current_chunk_end}"
|
||||
)
|
||||
|
||||
if self.k.requires_grad:
|
||||
self.k = self.k.detach()
|
||||
if self.v.requires_grad:
|
||||
self.v = self.v.detach()
|
||||
self.k[:, local_start_index:local_end_index] = key
|
||||
self.v[:, local_start_index:local_end_index] = value
|
||||
|
||||
attn_start_index = max(0, updated_local_end - self.attention_window_size)
|
||||
self._write_indices(
|
||||
global_end_index=updated_global_end,
|
||||
local_end_index=updated_local_end,
|
||||
)
|
||||
return CausalAttentionKVView(
|
||||
k=self.k[:, attn_start_index:updated_local_end],
|
||||
v=self.v[:, attn_start_index:updated_local_end],
|
||||
local_start_index=local_start_index,
|
||||
local_end_index=local_end_index,
|
||||
visible_local_end=updated_local_end,
|
||||
visible_global_end=updated_global_end,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CrossAttentionKVCache:
|
||||
"""one transformer block's cross-attn condition K/V cache"""
|
||||
|
||||
k: torch.Tensor
|
||||
v: torch.Tensor
|
||||
is_init: bool = False
|
||||
|
||||
def store(self, k: torch.Tensor, v: torch.Tensor) -> None:
|
||||
self.k = k.detach()
|
||||
self.v = v.detach()
|
||||
self.is_init = True
|
||||
|
||||
def reset(self) -> None:
|
||||
self.is_init = False
|
||||
@@ -4,11 +4,13 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.distributed._functional_collectives as ft_c
|
||||
from torch.distributed.tensor.experimental._attention import _cp_options
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_sp_group,
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.srt.utils.common import torch_release
|
||||
@@ -45,6 +47,25 @@ def _usp_all_to_all_single(x: torch.Tensor) -> torch.Tensor:
|
||||
return output.reshape(x_shape)
|
||||
|
||||
|
||||
def _usp_all_to_all_single_varlen(
|
||||
x: torch.Tensor,
|
||||
output_split_sizes: list[int],
|
||||
input_split_sizes: list[int],
|
||||
) -> torch.Tensor:
|
||||
ulysses_pg = get_sp_group().ulysses_group
|
||||
assert ulysses_pg is not None, "Ulysses process group is not initialized."
|
||||
x = x.flatten().contiguous()
|
||||
output = torch.empty(sum(output_split_sizes), dtype=x.dtype, device=x.device)
|
||||
dist.all_to_all_single(
|
||||
output,
|
||||
x,
|
||||
output_split_sizes=output_split_sizes,
|
||||
input_split_sizes=input_split_sizes,
|
||||
group=ulysses_pg,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _usp_input_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
"""
|
||||
Perform Ulysses-style input all-to-all over the head dimension.
|
||||
@@ -101,6 +122,83 @@ def _usp_input_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
return x
|
||||
|
||||
|
||||
def _usp_input_all_to_all_varlen(
|
||||
x: torch.Tensor, seq_lens: list[int], head_dim: int = 1
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Perform Ulysses-style input all-to-all over the head dimension with variable
|
||||
local sequence lengths.
|
||||
|
||||
Default layout expects heads at dim=1 and sequence at dim=2:
|
||||
[b, h, s_local, d] -> [b, h_local, s_global, d]
|
||||
|
||||
If heads are at dim=2 (input is [b, s_local, h, d]), set head_dim=2, and the
|
||||
function returns [b, s_global, h_local, d], preserving the original
|
||||
head/sequence dim ordering.
|
||||
|
||||
Args:
|
||||
x: A 4D tensor with layout [b, *, *, d] where '*' are sequence and heads
|
||||
seq_lens: Local sequence lengths for each rank in the Ulysses group
|
||||
head_dim: Which dimension index corresponds to heads (1 or 2)
|
||||
|
||||
Returns:
|
||||
Tensor with the same dim order as input, with heads sharded and sequence gathered.
|
||||
"""
|
||||
world_size = get_ulysses_parallel_world_size()
|
||||
if world_size <= 1:
|
||||
return x
|
||||
|
||||
assert x.ndim == 4, f"x must have 4 dimensions, got {x.ndim}"
|
||||
assert head_dim in (1, 2), f"head_dim must be 1 or 2, got {head_dim}"
|
||||
assert (
|
||||
len(seq_lens) == world_size
|
||||
), f"seq_lens must have length {world_size}, got {len(seq_lens)}"
|
||||
|
||||
rank = get_ulysses_parallel_rank()
|
||||
|
||||
# Move the dimension to be split (h_global) to dim 0 for all_to_all_single
|
||||
if head_dim == 1:
|
||||
b, h_global, s_local, d = x.shape
|
||||
# Shape transition: [b, h_global, s_local, d] -> [h_global, b, s_local, d]
|
||||
permute_order = (1, 0, 2, 3)
|
||||
else: # head_dim == 2
|
||||
b, s_local, h_global, d = x.shape
|
||||
# Shape transition: [b, s_local, h_global, d] -> [h_global, b, s_local, d]
|
||||
permute_order = (2, 0, 1, 3)
|
||||
|
||||
assert (
|
||||
s_local == seq_lens[rank]
|
||||
), f"s_local ({s_local}) must equal seq_lens[{rank}] ({seq_lens[rank]})"
|
||||
assert (
|
||||
h_global % world_size == 0
|
||||
), f"h_global ({h_global}) must be divisible by world_size ({world_size})"
|
||||
|
||||
h_local = h_global // world_size
|
||||
|
||||
x = x.permute(permute_order).contiguous()
|
||||
x = x.reshape(world_size, h_local, b, s_local, d)
|
||||
input_split_sizes = [h_local * b * s_local * d] * world_size
|
||||
output_split_sizes = [h_local * b * seq_len * d for seq_len in seq_lens]
|
||||
x = _usp_all_to_all_single_varlen(x, output_split_sizes, input_split_sizes)
|
||||
|
||||
chunks = []
|
||||
offset = 0
|
||||
for seq_len, split_size in zip(seq_lens, output_split_sizes):
|
||||
chunk = x[offset : offset + split_size].reshape(h_local, b, seq_len, d)
|
||||
chunks.append(chunk)
|
||||
offset += split_size
|
||||
x = torch.cat(chunks, dim=2)
|
||||
|
||||
if head_dim == 1:
|
||||
# Shape transition: [h_local, b, s_global, d] -> [b, h_local, s_global, d]
|
||||
x = x.permute(1, 0, 2, 3).contiguous()
|
||||
else: # head_dim == 2
|
||||
# Shape transition: [h_local, b, s_global, d] -> [b, s_global, h_local, d]
|
||||
x = x.permute(1, 2, 0, 3).contiguous()
|
||||
|
||||
return x
|
||||
|
||||
|
||||
def _usp_output_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
"""
|
||||
Perform Ulysses-style output all-to-all over the head dimension (inverse of input).
|
||||
@@ -157,6 +255,86 @@ def _usp_output_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
return x
|
||||
|
||||
|
||||
def _usp_output_all_to_all_varlen(
|
||||
x: torch.Tensor, seq_lens: list[int], head_dim: int = 1
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Perform Ulysses-style output all-to-all over the head dimension (inverse of input)
|
||||
with variable local sequence lengths.
|
||||
|
||||
Default layout expects heads at dim=1 and sequence at dim=2:
|
||||
[b, h_local, s, d] -> [b, h, s_local, d]
|
||||
|
||||
If heads are at dim=2 (input is [b, s_global, h // world_size, d]), set head_dim=2,
|
||||
and the function returns [b, s_local, h, d], preserving the original head/sequence
|
||||
dim ordering.
|
||||
|
||||
Args:
|
||||
x: A 4D tensor with layout [b, *, *, d] where '*' are sequence and heads
|
||||
seq_lens: Local sequence lengths for each rank in the Ulysses group
|
||||
head_dim: Which dimension index corresponds to heads (1 or 2)
|
||||
|
||||
Returns:
|
||||
Tensor with the same dim order as input, with heads gathered and sequence sharded.
|
||||
"""
|
||||
world_size = get_ulysses_parallel_world_size()
|
||||
if world_size <= 1:
|
||||
return x
|
||||
|
||||
assert x.ndim == 4, f"x must have 4 dimensions, got {x.ndim}"
|
||||
assert head_dim in (1, 2), f"head_dim must be 1 or 2, got {head_dim}"
|
||||
assert (
|
||||
len(seq_lens) == world_size
|
||||
), f"seq_lens must have length {world_size}, got {len(seq_lens)}"
|
||||
|
||||
rank = get_ulysses_parallel_rank()
|
||||
|
||||
# Move the sequence dimension to dim 2 for splitting across seq_lens
|
||||
if head_dim == 1:
|
||||
b, h_local, s_global, d = x.shape
|
||||
# Shape transition: [b, h_local, s_global, d] -> [h_local, b, s_global, d]
|
||||
permute_order = (1, 0, 2, 3)
|
||||
else: # head_dim == 2
|
||||
b, s_global, h_local, d = x.shape
|
||||
# Shape transition: [b, s_global, h_local, d] -> [h_local, b, s_global, d]
|
||||
permute_order = (2, 0, 1, 3)
|
||||
|
||||
assert s_global == sum(
|
||||
seq_lens
|
||||
), f"s_global ({s_global}) must equal sum(seq_lens) ({sum(seq_lens)})"
|
||||
|
||||
s_local = seq_lens[rank]
|
||||
|
||||
x = x.permute(permute_order).contiguous()
|
||||
input_chunks = []
|
||||
start = 0
|
||||
for seq_len in seq_lens:
|
||||
end = start + seq_len
|
||||
input_chunks.append(x[:, :, start:end, :].contiguous().reshape(-1))
|
||||
start = end
|
||||
x = torch.cat(input_chunks, dim=0)
|
||||
input_split_sizes = [h_local * b * seq_len * d for seq_len in seq_lens]
|
||||
output_split_sizes = [h_local * b * s_local * d] * world_size
|
||||
x = _usp_all_to_all_single_varlen(x, output_split_sizes, input_split_sizes)
|
||||
|
||||
chunks = []
|
||||
offset = 0
|
||||
for split_size in output_split_sizes:
|
||||
chunk = x[offset : offset + split_size].reshape(h_local, b, s_local, d)
|
||||
chunks.append(chunk)
|
||||
offset += split_size
|
||||
x = torch.cat(chunks, dim=0)
|
||||
|
||||
if head_dim == 1:
|
||||
# Shape transition: [h_global, b, s_local, d] -> [b, h_global, s_local, d]
|
||||
x = x.permute(1, 0, 2, 3).contiguous()
|
||||
else: # head_dim == 2
|
||||
# Shape transition: [h_global, b, s_local, d] -> [b, s_local, h_global, d]
|
||||
x = x.permute(1, 2, 0, 3).contiguous()
|
||||
|
||||
return x
|
||||
|
||||
|
||||
def ring_attn(
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
|
||||
@@ -111,6 +111,61 @@ class PatchEmbed(nn.Module):
|
||||
return x
|
||||
|
||||
|
||||
class WanCamControlPatchEmbedding(nn.Module):
|
||||
"""Patch embedding used by LingBotWorld camera/plucker controls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patch_size=(1, 2, 2),
|
||||
in_chans=384,
|
||||
embed_dim=2048,
|
||||
bias=True,
|
||||
dtype=None,
|
||||
prefix: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
del prefix
|
||||
if isinstance(patch_size, list | tuple):
|
||||
if len(patch_size) != 3:
|
||||
raise ValueError(
|
||||
f"patch_size must have length 3, got {len(patch_size)}"
|
||||
)
|
||||
patch_size = tuple(patch_size)
|
||||
else:
|
||||
raise ValueError(f"Unsupported patch_size type: {type(patch_size)}")
|
||||
|
||||
self.patch_size = patch_size
|
||||
pt, ph, pw = self.patch_size
|
||||
self.in_features = in_chans * pt * ph * pw
|
||||
self.proj = nn.Linear(self.in_features, embed_dim, bias=bias, dtype=dtype)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if x.dim() != 5:
|
||||
raise ValueError(
|
||||
f"Expected camera embedding shape [B, C, F, H, W], got {tuple(x.shape)}"
|
||||
)
|
||||
|
||||
bsz, channels, frames, height, width = x.shape
|
||||
pt, ph, pw = self.patch_size
|
||||
if (frames % pt) != 0 or (height % ph) != 0 or (width % pw) != 0:
|
||||
raise ValueError(
|
||||
f"Input shape {tuple(x.shape)} must be divisible by patch_size {self.patch_size}"
|
||||
)
|
||||
|
||||
x = x.view(
|
||||
bsz,
|
||||
channels,
|
||||
frames // pt,
|
||||
pt,
|
||||
height // ph,
|
||||
ph,
|
||||
width // pw,
|
||||
pw,
|
||||
)
|
||||
x = x.permute(0, 2, 4, 6, 1, 3, 5, 7).reshape(bsz, -1, self.in_features)
|
||||
return self.proj(x)
|
||||
|
||||
|
||||
class Timesteps(_Timesteps):
|
||||
def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
|
||||
if _is_cuda:
|
||||
|
||||
@@ -33,6 +33,7 @@ from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
materialize_output_sample,
|
||||
post_process_sample,
|
||||
save_outputs,
|
||||
)
|
||||
@@ -53,6 +54,9 @@ from sglang.multimodal_gen.runtime.pipelines_core import (
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
RealtimeSessionCache,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import PortArgs, ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.common import set_cuda_arch, set_musa_arch
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
||||
@@ -64,6 +68,10 @@ from sglang.multimodal_gen.runtime.utils.perf_logger import (
|
||||
PerformanceLogger,
|
||||
capture_memory_snapshot,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
build_raw_rgb_frame_batches,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.trace_wrapper import DiffStage, trace_slice
|
||||
from sglang.srt.observability.trace import process_tracing_init, trace_set_thread_info
|
||||
from sglang.srt.utils.network import NetworkAddress
|
||||
@@ -118,6 +126,24 @@ class GPUWorker:
|
||||
|
||||
self.cfg_group = get_cfg_group()
|
||||
self.cfg_cpu_group = self.cfg_group.cpu_group
|
||||
self._realtime_sessions = RealtimeSessionCache(max_sessions=1)
|
||||
|
||||
def release_realtime_session(self, session_id: str) -> OutputBatch:
|
||||
"""release the session of a realtime connection"""
|
||||
if not session_id:
|
||||
return OutputBatch(
|
||||
output={
|
||||
"released": False,
|
||||
"session_id": session_id,
|
||||
"reason": "empty_session_id",
|
||||
}
|
||||
)
|
||||
|
||||
released = self._realtime_sessions.release(session_id)
|
||||
if released:
|
||||
if torch.cuda.is_initialized():
|
||||
torch.cuda.empty_cache()
|
||||
return OutputBatch(output={"released": released, "session_id": session_id})
|
||||
|
||||
def init_device_and_model(self) -> None:
|
||||
"""Initialize the device and load the model."""
|
||||
@@ -273,7 +299,7 @@ class GPUWorker:
|
||||
error_context=f"request {req.request_id}",
|
||||
)
|
||||
|
||||
def _execute_forward_batch(self, batch: list[Req]) -> OutputBatch:
|
||||
def _execute_forward_batch(self, batch: list[Req]) -> OutputBatch | Req:
|
||||
"""Execute expanded multi-output requests as one grouped forward."""
|
||||
# TODO: support early return or mix-stage execution for reqs in a group
|
||||
assert self.pipeline is not None
|
||||
@@ -309,6 +335,7 @@ class GPUWorker:
|
||||
torch.get_device_module().reset_peak_memory_stats()
|
||||
|
||||
start_time = time.monotonic()
|
||||
self._realtime_sessions.attach(req)
|
||||
|
||||
# capture memory baseline for each req in grouped forward on rank-0
|
||||
request_metrics = [
|
||||
@@ -354,21 +381,13 @@ class GPUWorker:
|
||||
for metrics in output_metrics:
|
||||
metrics.total_duration_ms = duration_ms
|
||||
|
||||
# file-path-only responses avoid serializing generated tensors between
|
||||
# scheduler_client and gpu_worker.
|
||||
if req.save_output and req.return_file_paths_only:
|
||||
save_output_paths(output_batch)
|
||||
output_batch.output = None
|
||||
output_batch.audio = None
|
||||
output_batch.audio_sample_rate = None
|
||||
self._materialize_output_transport(output_batch, req, save_output_paths)
|
||||
|
||||
if torch.cuda.is_initialized():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Keep return_frames payloads off the scheduler's tensor ZMQ path.
|
||||
self._materialize_frame_outputs_for_return(output_batch, req)
|
||||
|
||||
if torch.cuda.is_initialized() and output_batch.output is None:
|
||||
if (
|
||||
torch.cuda.is_initialized()
|
||||
and output_batch.output is None
|
||||
and not req.return_raw_frames
|
||||
):
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
if req.perf_dump_path is not None or envs.SGLANG_DIFFUSION_STAGE_LOGGING:
|
||||
@@ -403,9 +422,54 @@ class GPUWorker:
|
||||
torch.cuda.empty_cache()
|
||||
return output_batch
|
||||
|
||||
def _materialize_output_transport(
|
||||
self,
|
||||
output_batch: OutputBatch,
|
||||
req: Req,
|
||||
save_output_paths: Callable[[OutputBatch], None],
|
||||
) -> None:
|
||||
if req.return_raw_frames:
|
||||
self._materialize_raw_frame_transport(output_batch, req)
|
||||
elif req.save_output and req.return_file_paths_only:
|
||||
self._materialize_file_path_transport(output_batch, save_output_paths)
|
||||
elif req.return_frames:
|
||||
self._materialize_frame_outputs_for_return(output_batch, req)
|
||||
|
||||
def _materialize_raw_frame_transport(
|
||||
self, output_batch: OutputBatch, req: Req
|
||||
) -> None:
|
||||
if self.rank != 0:
|
||||
return
|
||||
if output_batch.output is not None:
|
||||
output_batch.raw_frame_content_type = RAW_RGB_CONTENT_TYPE
|
||||
(
|
||||
output_batch.raw_frame_batches,
|
||||
output_batch.raw_frame_metadata,
|
||||
) = build_raw_rgb_frame_batches(
|
||||
output_batch.output,
|
||||
req,
|
||||
output_batch,
|
||||
post_process_sample,
|
||||
)
|
||||
output_batch.output = None
|
||||
output_batch.audio = None
|
||||
output_batch.audio_sample_rate = None
|
||||
|
||||
def _materialize_file_path_transport(
|
||||
self,
|
||||
output_batch: OutputBatch,
|
||||
save_output_paths: Callable[[OutputBatch], None],
|
||||
) -> None:
|
||||
if self.rank == 0:
|
||||
save_output_paths(output_batch)
|
||||
output_batch.output = None
|
||||
output_batch.audio = None
|
||||
output_batch.audio_sample_rate = None
|
||||
|
||||
def _materialize_frame_outputs_for_return(
|
||||
self, output_batch: OutputBatch, req: Req
|
||||
) -> None:
|
||||
"""materialize the output from tensor to numpy frames for faster serialization"""
|
||||
if self.rank != 0 or output_batch.output is None or not req.return_frames:
|
||||
return
|
||||
|
||||
@@ -452,13 +516,10 @@ class GPUWorker:
|
||||
):
|
||||
return output
|
||||
|
||||
frames = post_process_sample(
|
||||
materialized = materialize_output_sample(
|
||||
output,
|
||||
req.data_type,
|
||||
req.fps,
|
||||
save_output=False,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=req.output_compression,
|
||||
enable_frame_interpolation=req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=req.frame_interpolation_scale,
|
||||
@@ -467,7 +528,7 @@ class GPUWorker:
|
||||
upscaling_model_path=req.upscaling_model_path,
|
||||
upscaling_scale=req.upscaling_scale,
|
||||
)
|
||||
return np.asarray(frames)
|
||||
return np.asarray(materialized.frames)
|
||||
|
||||
def _record_output_peak_memory(self, output_batch: OutputBatch) -> None:
|
||||
if self.rank != 0 or current_platform.is_cpu():
|
||||
@@ -482,6 +543,7 @@ class GPUWorker:
|
||||
return self._merge_expanded_output_batches(output_batches)
|
||||
|
||||
def _save_output_paths(self, req: Req, output_batch: OutputBatch) -> None:
|
||||
"""save outputs to files"""
|
||||
if self.rank != 0 or output_batch.output is None:
|
||||
return
|
||||
|
||||
@@ -500,10 +562,15 @@ class GPUWorker:
|
||||
dynamic_output_paths = None
|
||||
|
||||
if dynamic_output_paths is not None:
|
||||
build_output_path = lambda idx: dynamic_output_paths[idx]
|
||||
|
||||
def build_output_path(idx: int) -> str:
|
||||
return dynamic_output_paths[idx]
|
||||
|
||||
else:
|
||||
num_outputs = len(output_batch.output)
|
||||
build_output_path = lambda idx: req.output_file_path(num_outputs, idx)
|
||||
|
||||
def build_output_path(idx: int) -> str:
|
||||
return req.output_file_path(num_outputs, idx)
|
||||
|
||||
output_batch.output_file_paths = save_outputs(
|
||||
output_batch.output,
|
||||
|
||||
@@ -25,6 +25,7 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
GetDisaggStatsReq,
|
||||
ListLorasReq,
|
||||
MergeLoraWeightsReq,
|
||||
ReleaseRealtimeSessionReq,
|
||||
SetLoraReq,
|
||||
ShutdownReq,
|
||||
UnmergeLoraWeightsReq,
|
||||
@@ -128,6 +129,7 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
Req: self._handle_generation,
|
||||
ListLorasReq: self._handle_list_loras,
|
||||
ShutdownReq: self._handle_shutdown,
|
||||
ReleaseRealtimeSessionReq: self._handle_release_realtime_session,
|
||||
GetDisaggStatsReq: self._handle_get_disagg_stats,
|
||||
UpdateWeightFromDiskReqInput: self._handle_update_weights_from_disk,
|
||||
GetWeightsChecksumReqInput: self._handle_get_weights_checksum,
|
||||
@@ -205,6 +207,10 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
self._running = False
|
||||
return OutputBatch()
|
||||
|
||||
def _handle_release_realtime_session(self, reqs: List[Any]) -> OutputBatch:
|
||||
req = reqs[0]
|
||||
return self.worker.release_realtime_session(req.session_id)
|
||||
|
||||
def _handle_update_weights_from_disk(self, reqs: List[Any]) -> OutputBatch:
|
||||
"""Handle update_weights_from_disk request for RL workflows."""
|
||||
req = reqs[0]
|
||||
@@ -487,6 +493,10 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
|
||||
if base_req.is_warmup or candidate_req.is_warmup:
|
||||
return "warmup"
|
||||
if self._has_realtime_session(base_req) or self._has_realtime_session(
|
||||
candidate_req
|
||||
):
|
||||
return "realtime_session"
|
||||
if not isinstance(base_req.prompt, str) or not isinstance(
|
||||
candidate_req.prompt, str
|
||||
):
|
||||
@@ -506,11 +516,20 @@ class Scheduler(SchedulerDisaggMixin):
|
||||
or "signature_mismatch"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_realtime_session(req: Req) -> bool:
|
||||
return bool(req.realtime_session_id) or req.session is not None
|
||||
|
||||
def _can_dynamic_batch(self, base_req: Req, candidate_req: Req) -> bool:
|
||||
"""Return whether `candidate_req` can be merged into a batch with `base_req`."""
|
||||
if base_req.is_warmup or candidate_req.is_warmup:
|
||||
return False
|
||||
|
||||
if self._has_realtime_session(base_req) or self._has_realtime_session(
|
||||
candidate_req
|
||||
):
|
||||
return False
|
||||
|
||||
if not isinstance(base_req.prompt, str) or not isinstance(
|
||||
candidate_req.prompt, str
|
||||
):
|
||||
|
||||
@@ -29,6 +29,10 @@ from sglang.multimodal_gen.configs.models.dits import WanVideoConfig
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import get_sp_world_size
|
||||
from sglang.multimodal_gen.runtime.layers.attention import LocalAttention
|
||||
from sglang.multimodal_gen.runtime.layers.elementwise import MulAdd
|
||||
from sglang.multimodal_gen.runtime.layers.kvcache.causal_attention_cache import (
|
||||
CausalSelfAttentionKVCache,
|
||||
CrossAttentionKVCache,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.layernorm import (
|
||||
FP32LayerNorm,
|
||||
LayerNormScaleShift,
|
||||
@@ -80,9 +84,6 @@ class CausalWanSelfAttention(nn.Module):
|
||||
self.qk_norm = qk_norm
|
||||
self.eps = eps
|
||||
self.parallel_attention = parallel_attention
|
||||
self.max_attention_size = (
|
||||
32760 if local_attn_size == -1 else local_attn_size * 1560
|
||||
)
|
||||
|
||||
# Scaled dot product attention
|
||||
self.attn = LocalAttention(
|
||||
@@ -105,7 +106,7 @@ class CausalWanSelfAttention(nn.Module):
|
||||
v: torch.Tensor,
|
||||
freqs_cis: tuple[torch.Tensor, torch.Tensor],
|
||||
block_mask: BlockMask,
|
||||
kv_cache: dict | None = None,
|
||||
kv_cache: CausalSelfAttentionKVCache | None = None,
|
||||
current_start: int = 0,
|
||||
cache_start: int | None = None,
|
||||
):
|
||||
@@ -116,9 +117,6 @@ class CausalWanSelfAttention(nn.Module):
|
||||
grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W)
|
||||
freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2]
|
||||
"""
|
||||
if cache_start is None:
|
||||
cache_start = current_start
|
||||
|
||||
cos, sin = freqs_cis
|
||||
roped_query = _apply_rotary_emb(q, cos, sin, is_neox_style=False).type_as(v)
|
||||
roped_key = _apply_rotary_emb(k, cos, sin, is_neox_style=False).type_as(v)
|
||||
@@ -169,84 +167,17 @@ class CausalWanSelfAttention(nn.Module):
|
||||
block_mask=block_mask,
|
||||
)[:, :, :-padded_length].transpose(2, 1)
|
||||
else:
|
||||
frame_seqlen = q.shape[1]
|
||||
current_end = current_start + roped_query.shape[1]
|
||||
sink_tokens = self.sink_size * frame_seqlen
|
||||
# If we are using local attention and the current KV cache size is larger than the local attention size, we need to truncate the KV cache
|
||||
kv_cache_size = kv_cache["k"].shape[1]
|
||||
num_new_tokens = roped_query.shape[1]
|
||||
if (
|
||||
self.local_attn_size != -1
|
||||
and (current_end > kv_cache["global_end_index"].item())
|
||||
and (
|
||||
num_new_tokens + kv_cache["local_end_index"].item() > kv_cache_size
|
||||
)
|
||||
):
|
||||
# Calculate the number of new tokens added in this step
|
||||
# Shift existing cache content left to discard oldest tokens
|
||||
# Clone the source slice to avoid overlapping memory error
|
||||
num_evicted_tokens = (
|
||||
num_new_tokens + kv_cache["local_end_index"].item() - kv_cache_size
|
||||
)
|
||||
num_rolled_tokens = (
|
||||
kv_cache["local_end_index"].item()
|
||||
- num_evicted_tokens
|
||||
- sink_tokens
|
||||
)
|
||||
kv_cache["k"][
|
||||
:, sink_tokens : sink_tokens + num_rolled_tokens
|
||||
] = kv_cache["k"][
|
||||
:,
|
||||
sink_tokens
|
||||
+ num_evicted_tokens : sink_tokens
|
||||
+ num_evicted_tokens
|
||||
+ num_rolled_tokens,
|
||||
].clone()
|
||||
kv_cache["v"][
|
||||
:, sink_tokens : sink_tokens + num_rolled_tokens
|
||||
] = kv_cache["v"][
|
||||
:,
|
||||
sink_tokens
|
||||
+ num_evicted_tokens : sink_tokens
|
||||
+ num_evicted_tokens
|
||||
+ num_rolled_tokens,
|
||||
].clone()
|
||||
# Insert the new keys/values at the end
|
||||
local_end_index = (
|
||||
kv_cache["local_end_index"].item()
|
||||
+ current_end
|
||||
- kv_cache["global_end_index"].item()
|
||||
- num_evicted_tokens
|
||||
)
|
||||
local_start_index = local_end_index - num_new_tokens
|
||||
kv_cache["k"][:, local_start_index:local_end_index] = roped_key
|
||||
kv_cache["v"][:, local_start_index:local_end_index] = v
|
||||
else:
|
||||
# Assign new keys/values directly up to current_end
|
||||
local_end_index = (
|
||||
kv_cache["local_end_index"].item()
|
||||
+ current_end
|
||||
- kv_cache["global_end_index"].item()
|
||||
)
|
||||
local_start_index = local_end_index - num_new_tokens
|
||||
kv_cache["k"] = kv_cache["k"].detach()
|
||||
kv_cache["v"] = kv_cache["v"].detach()
|
||||
# logger.info("kv_cache['k'] is in comp graph: %s", kv_cache["k"].requires_grad or kv_cache["k"].grad_fn is not None)
|
||||
kv_cache["k"][:, local_start_index:local_end_index] = roped_key
|
||||
kv_cache["v"][:, local_start_index:local_end_index] = v
|
||||
cache_view = kv_cache.update_and_get_attention_kv(
|
||||
key=roped_key,
|
||||
value=v,
|
||||
current_chunk_start=current_start,
|
||||
debug_name="CausalWan KV cache",
|
||||
)
|
||||
x = self.attn(
|
||||
roped_query,
|
||||
kv_cache["k"][
|
||||
:,
|
||||
max(0, local_end_index - self.max_attention_size) : local_end_index,
|
||||
],
|
||||
kv_cache["v"][
|
||||
:,
|
||||
max(0, local_end_index - self.max_attention_size) : local_end_index,
|
||||
],
|
||||
cache_view.k,
|
||||
cache_view.v,
|
||||
)
|
||||
kv_cache["global_end_index"].fill_(current_end)
|
||||
kv_cache["local_end_index"].fill_(local_end_index)
|
||||
|
||||
return x
|
||||
|
||||
@@ -335,8 +266,8 @@ class CausalWanTransformerBlock(nn.Module):
|
||||
temb: torch.Tensor,
|
||||
freqs_cis: tuple[torch.Tensor, torch.Tensor],
|
||||
block_mask: BlockMask,
|
||||
kv_cache: dict | None = None,
|
||||
crossattn_cache: dict | None = None,
|
||||
kv_cache: CausalSelfAttentionKVCache | None = None,
|
||||
crossattn_cache: CrossAttentionKVCache | None = None,
|
||||
current_start: int = 0,
|
||||
cache_start: int | None = None,
|
||||
) -> torch.Tensor:
|
||||
@@ -594,18 +525,17 @@ class CausalWanTransformer3DModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
|
||||
return block_mask
|
||||
|
||||
def _forward_inference(
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
|
||||
timestep: torch.LongTensor,
|
||||
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
|
||||
kv_cache: dict = None,
|
||||
crossattn_cache: dict = None,
|
||||
kv_cache: list[CausalSelfAttentionKVCache] | None = None,
|
||||
crossattn_cache: list[CrossAttentionKVCache] | None = None,
|
||||
current_start: int = 0,
|
||||
cache_start: int = 0,
|
||||
start_frame: int = 0,
|
||||
**kwargs,
|
||||
) -> torch.Tensor:
|
||||
r"""
|
||||
Run the diffusion model with kv caching.
|
||||
@@ -739,144 +669,5 @@ class CausalWanTransformer3DModel(BaseDiT, LayerwiseOffloadableModuleMixin):
|
||||
|
||||
return output
|
||||
|
||||
def _forward_train(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
|
||||
timestep: torch.LongTensor,
|
||||
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
|
||||
start_frame: int = 0,
|
||||
**kwargs,
|
||||
) -> torch.Tensor:
|
||||
|
||||
orig_dtype = hidden_states.dtype
|
||||
if not isinstance(encoder_hidden_states, torch.Tensor):
|
||||
encoder_hidden_states = encoder_hidden_states[0]
|
||||
if (
|
||||
isinstance(encoder_hidden_states_image, list)
|
||||
and len(encoder_hidden_states_image) > 0
|
||||
):
|
||||
encoder_hidden_states_image = encoder_hidden_states_image[0]
|
||||
else:
|
||||
encoder_hidden_states_image = None
|
||||
|
||||
batch_size, num_channels, num_frames, height, width = hidden_states.shape
|
||||
p_t, p_h, p_w = self.patch_size
|
||||
post_patch_num_frames = num_frames // p_t
|
||||
post_patch_height = height // p_h
|
||||
post_patch_width = width // p_w
|
||||
|
||||
# Get rotary embeddings
|
||||
d = self.hidden_size // self.num_attention_heads
|
||||
rope_dim_list = [d - 4 * (d // 6), 2 * (d // 6), 2 * (d // 6)]
|
||||
freqs_cos, freqs_sin = get_rotary_pos_embed(
|
||||
(
|
||||
post_patch_num_frames * get_sp_world_size(),
|
||||
post_patch_height,
|
||||
post_patch_width,
|
||||
),
|
||||
self.hidden_size,
|
||||
self.num_attention_heads,
|
||||
rope_dim_list,
|
||||
dtype=(
|
||||
torch.float64
|
||||
if current_platform.is_float64_supported()
|
||||
else torch.float32
|
||||
),
|
||||
rope_theta=10000,
|
||||
start_frame=start_frame,
|
||||
)
|
||||
freqs_cos = freqs_cos.to(hidden_states.device)
|
||||
freqs_sin = freqs_sin.to(hidden_states.device)
|
||||
freqs_cis = (
|
||||
(freqs_cos.float(), freqs_sin.float()) if freqs_cos is not None else None
|
||||
)
|
||||
|
||||
# Construct blockwise causal attn mask
|
||||
if self.block_mask is None:
|
||||
self.block_mask = self._prepare_blockwise_causal_attn_mask(
|
||||
device=hidden_states.device,
|
||||
num_frames=num_frames,
|
||||
frame_seqlen=post_patch_height * post_patch_width,
|
||||
num_frame_per_block=self.num_frame_per_block,
|
||||
local_attn_size=self.local_attn_size,
|
||||
)
|
||||
|
||||
hidden_states = self.patch_embedding(hidden_states)
|
||||
hidden_states = hidden_states.flatten(2).transpose(1, 2)
|
||||
|
||||
(
|
||||
temb,
|
||||
timestep_proj,
|
||||
encoder_hidden_states,
|
||||
encoder_hidden_states_image,
|
||||
) = self.condition_embedder(
|
||||
timestep.flatten(), encoder_hidden_states, encoder_hidden_states_image
|
||||
)
|
||||
timestep_proj = timestep_proj.unflatten(1, (6, self.hidden_size)).unflatten(
|
||||
dim=0, sizes=timestep.shape
|
||||
)
|
||||
|
||||
if encoder_hidden_states_image is not None:
|
||||
encoder_hidden_states = torch.concat(
|
||||
[encoder_hidden_states_image, encoder_hidden_states], dim=1
|
||||
)
|
||||
|
||||
encoder_hidden_states = (
|
||||
encoder_hidden_states.to(orig_dtype)
|
||||
if current_platform.is_mps()
|
||||
else encoder_hidden_states
|
||||
) # cast to orig_dtype for MPS
|
||||
|
||||
assert encoder_hidden_states.dtype == orig_dtype
|
||||
|
||||
# 4. Transformer blocks
|
||||
if torch.is_grad_enabled() and self.gradient_checkpointing:
|
||||
for block in self.blocks:
|
||||
hidden_states = self._gradient_checkpointing_func(
|
||||
block,
|
||||
hidden_states,
|
||||
encoder_hidden_states,
|
||||
timestep_proj,
|
||||
freqs_cis,
|
||||
block_mask=self.block_mask,
|
||||
)
|
||||
else:
|
||||
for block in self.blocks:
|
||||
hidden_states = block(
|
||||
hidden_states,
|
||||
encoder_hidden_states,
|
||||
timestep_proj,
|
||||
freqs_cis,
|
||||
block_mask=self.block_mask,
|
||||
)
|
||||
|
||||
# 5. Output norm, projection & unpatchify
|
||||
temb = temb.unflatten(dim=0, sizes=timestep.shape).unsqueeze(2)
|
||||
shift, scale = (self.scale_shift_table.unsqueeze(1) + temb).chunk(2, dim=2)
|
||||
hidden_states = self.norm_out(hidden_states, shift, scale)
|
||||
hidden_states = self.proj_out(hidden_states)
|
||||
|
||||
hidden_states = hidden_states.reshape(
|
||||
batch_size,
|
||||
post_patch_num_frames,
|
||||
post_patch_height,
|
||||
post_patch_width,
|
||||
p_t,
|
||||
p_h,
|
||||
p_w,
|
||||
-1,
|
||||
)
|
||||
hidden_states = hidden_states.permute(0, 7, 1, 4, 2, 5, 3, 6)
|
||||
output = hidden_states.flatten(6, 7).flatten(4, 5).flatten(2, 3)
|
||||
|
||||
return output
|
||||
|
||||
def forward(self, *args, **kwargs):
|
||||
if kwargs.get("kv_cache") is not None:
|
||||
return self._forward_inference(*args, **kwargs)
|
||||
else:
|
||||
return self._forward_train(*args, **kwargs)
|
||||
|
||||
|
||||
EntryClass = CausalWanTransformer3DModel
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -863,6 +863,7 @@ class AutoencoderKLWan(ParallelTiledVAE):
|
||||
)
|
||||
|
||||
self.use_feature_cache = config.use_feature_cache
|
||||
self._causal_decode_initialized = False
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
|
||||
@@ -883,6 +884,41 @@ class AutoencoderKLWan(ParallelTiledVAE):
|
||||
self._enc_conv_idx = 0
|
||||
self._enc_feat_map = [None] * self._enc_conv_num
|
||||
|
||||
def reset_causal_decode_state(self) -> None:
|
||||
"""Reset decoder feature cache before a new causal video session."""
|
||||
self._causal_decode_initialized = False
|
||||
if self.use_feature_cache:
|
||||
self.clear_cache()
|
||||
|
||||
def causal_decode(self, z: torch.Tensor) -> torch.Tensor:
|
||||
"""Decode latents while preserving decoder feature cache across chunks."""
|
||||
if not self.use_feature_cache:
|
||||
return self.decode(z)
|
||||
|
||||
is_first_chunk = not self._causal_decode_initialized
|
||||
if is_first_chunk:
|
||||
self.clear_cache()
|
||||
|
||||
iter_ = z.shape[2]
|
||||
x = self.post_quant_conv(z)
|
||||
outs = []
|
||||
with forward_context(
|
||||
feat_cache_arg=self._feat_map, feat_idx_arg=self._conv_idx
|
||||
):
|
||||
for i in range(iter_):
|
||||
feat_idx.set(0)
|
||||
first_chunk.set(is_first_chunk and i == 0)
|
||||
outs.append(self.decoder(x[:, :, i : i + 1, :, :]))
|
||||
out = torch.cat(outs, 2)
|
||||
|
||||
if self.config.patch_size is not None:
|
||||
out = unpatchify(out, patch_size=self.config.patch_size)
|
||||
|
||||
out = out.float()
|
||||
out = torch.clamp(out, min=-1.0, max=1.0)
|
||||
self._causal_decode_initialized = True
|
||||
return out
|
||||
|
||||
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if self.use_feature_cache:
|
||||
self.clear_cache()
|
||||
@@ -946,6 +982,7 @@ class AutoencoderKLWan(ParallelTiledVAE):
|
||||
self.clear_cache()
|
||||
iter_ = z.shape[2]
|
||||
x = self.post_quant_conv(z)
|
||||
outs = []
|
||||
with forward_context(
|
||||
feat_cache_arg=self._feat_map, feat_idx_arg=self._conv_idx
|
||||
):
|
||||
@@ -953,11 +990,10 @@ class AutoencoderKLWan(ParallelTiledVAE):
|
||||
feat_idx.set(0)
|
||||
if i == 0:
|
||||
first_chunk.set(True)
|
||||
out = self.decoder(x[:, :, i : i + 1, :, :])
|
||||
else:
|
||||
first_chunk.set(False)
|
||||
out_ = self.decoder(x[:, :, i : i + 1, :, :])
|
||||
out = torch.cat([out, out_], 2)
|
||||
outs.append(self.decoder(x[:, :, i : i + 1, :, :]))
|
||||
out = torch.cat(outs, 2)
|
||||
|
||||
if self.config.patch_size is not None:
|
||||
out = unpatchify(out, patch_size=self.config.patch_size)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
LingBot-World realtime causal DMD pipeline.
|
||||
"""
|
||||
|
||||
from sglang.multimodal_gen.runtime.models.schedulers.scheduling_self_forcing_flow_match import (
|
||||
SelfForcingFlowMatchScheduler,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
||||
ComposedPipelineBase,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import (
|
||||
AuxiliaryConditionEncodingStage,
|
||||
CausalVaeDecodingStage,
|
||||
DMDTimestepPreparationStage,
|
||||
ImageEncodingStage,
|
||||
RealtimeChunkLatentPreparationStage,
|
||||
RealtimeImageVAEEncodingStage,
|
||||
RealtimeInputValidationStage,
|
||||
RealtimeTextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world import (
|
||||
LingBotWorldCausalDMDDenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class LingBotWorldCausalDMDPipeline(LoRAPipeline, ComposedPipelineBase):
|
||||
pipeline_name = "LingBotWorldCausalDMDPipeline"
|
||||
|
||||
_required_config_modules = [
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"vae",
|
||||
"transformer",
|
||||
"scheduler",
|
||||
"image_encoder",
|
||||
"image_processor",
|
||||
]
|
||||
|
||||
def initialize_pipeline(self, server_args: ServerArgs):
|
||||
self.modules["scheduler"] = SelfForcingFlowMatchScheduler(
|
||||
num_inference_steps=1000,
|
||||
shift=server_args.pipeline_config.flow_shift,
|
||||
sigma_min=0.0,
|
||||
extra_one_step=True,
|
||||
)
|
||||
|
||||
def create_pipeline_stages(self, server_args) -> None:
|
||||
self.add_stage(RealtimeInputValidationStage())
|
||||
self.add_stage(
|
||||
RealtimeTextEncodingStage(
|
||||
text_encoders=[self.get_module("text_encoder")],
|
||||
tokenizers=[self.get_module("tokenizer")],
|
||||
)
|
||||
)
|
||||
|
||||
image_encoder = self.get_module("image_encoder", None)
|
||||
image_processor = self.get_module("image_processor", None)
|
||||
self.add_stage_if(
|
||||
image_encoder is not None and image_processor is not None,
|
||||
ImageEncodingStage(
|
||||
image_encoder=image_encoder,
|
||||
image_processor=image_processor,
|
||||
),
|
||||
)
|
||||
|
||||
self.add_stage(AuxiliaryConditionEncodingStage())
|
||||
self.add_stage(
|
||||
RealtimeImageVAEEncodingStage(
|
||||
vae=self.get_module("vae"),
|
||||
)
|
||||
)
|
||||
self.add_stage(DMDTimestepPreparationStage(self.get_module("scheduler")))
|
||||
self.add_stage(
|
||||
RealtimeChunkLatentPreparationStage(
|
||||
scheduler=self.get_module("scheduler"),
|
||||
transformer=self.get_module("transformer"),
|
||||
)
|
||||
)
|
||||
self.add_stage(
|
||||
LingBotWorldCausalDMDDenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
CausalVaeDecodingStage(
|
||||
vae=self.get_module("vae"),
|
||||
pipeline=self,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
EntryClass = LingBotWorldCausalDMDPipeline
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from dataclasses import MISSING, asdict, dataclass, field, fields
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, Optional, Sequence, Union
|
||||
|
||||
import PIL.Image
|
||||
import torch
|
||||
@@ -26,6 +26,9 @@ from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.post_training.rl_dataclasses import (
|
||||
RolloutTrajectoryData,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
RealtimeSession,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
||||
_sanitize_for_logging,
|
||||
@@ -141,6 +144,7 @@ class Req:
|
||||
image_latent: torch.Tensor | list[torch.Tensor] | None = None
|
||||
condition_image_latent_ids: torch.Tensor | list[torch.Tensor] | None = None
|
||||
vae_image_sizes: list[tuple[int, int]] | None = None
|
||||
c2ws_plucker_emb: torch.Tensor | None = None
|
||||
|
||||
# Latent dimensions
|
||||
height_latents: list[int] | int | None = None
|
||||
@@ -179,6 +183,8 @@ class Req:
|
||||
# Extra parameters that might be needed by specific pipeline implementations (e.g., LTX2.3 DenoisingAVStage)
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
condition_inputs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
is_warmup: bool = False
|
||||
|
||||
# STA parameters
|
||||
@@ -198,6 +204,18 @@ class Req:
|
||||
default_factory=TraceNullContext
|
||||
)
|
||||
|
||||
# realtime
|
||||
realtime_session_id: str | None = None
|
||||
session: RealtimeSession | None = None
|
||||
block_idx: int = 0
|
||||
realtime_chunk_size: int | None = None
|
||||
realtime_event_id: int | None = None
|
||||
realtime_output_format: str | None = None
|
||||
realtime_causal_sink_size: int | None = None
|
||||
realtime_causal_kv_cache_num_frames: int | None = None
|
||||
# return websocket-friendly raw RGB frame bytes instead of rwa tensors
|
||||
return_raw_frames: bool = False
|
||||
|
||||
# results
|
||||
output: torch.Tensor | None = None
|
||||
audio: torch.Tensor | None = None
|
||||
@@ -386,7 +404,11 @@ class OutputBatch:
|
||||
Final output (after pipeline completion)
|
||||
"""
|
||||
|
||||
output: Any | None = None
|
||||
# tensors or numpy frames
|
||||
output: Sequence[Any] | None = None
|
||||
raw_frame_batches: list[list[bytes]] | None = None
|
||||
raw_frame_content_type: str = "application/x-raw-rgb"
|
||||
raw_frame_metadata: dict[str, Any] | None = None
|
||||
audio: torch.Tensor | None = None
|
||||
audio_sample_rate: int | None = None
|
||||
trajectory_timesteps: torch.Tensor | None = None
|
||||
|
||||
@@ -15,6 +15,10 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.causal_denoising import
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.comfyui_latent_preparation import (
|
||||
ComfyUILatentPreparationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.condition_encoding import (
|
||||
AuxiliaryConditionEncodingStage,
|
||||
ConditionEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding import DecodingStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding_av import (
|
||||
LTX2AVDecodingStage,
|
||||
@@ -53,6 +57,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation import (
|
||||
LatentPreparationStage,
|
||||
RealtimeChunkLatentPreparationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation_av import (
|
||||
LTX2AVLatentPreparationStage,
|
||||
@@ -60,6 +65,16 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation_av i
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.ltx_2_denoising import (
|
||||
LTX2DenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime_input_validation import (
|
||||
RealtimeInputValidationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime_text_encoding import (
|
||||
RealtimeTextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime_vae import (
|
||||
CausalVaeDecodingStage,
|
||||
RealtimeImageVAEEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.text_connector import (
|
||||
LTX2TextConnectorStage,
|
||||
)
|
||||
@@ -67,6 +82,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
|
||||
TextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.timestep_preparation import (
|
||||
DMDTimestepPreparationStage,
|
||||
TimestepPreparationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.upsampling import (
|
||||
@@ -78,8 +94,11 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.upsampling import (
|
||||
__all__ = [
|
||||
"PipelineStage",
|
||||
"InputValidationStage",
|
||||
"RealtimeInputValidationStage",
|
||||
"TimestepPreparationStage",
|
||||
"DMDTimestepPreparationStage",
|
||||
"LatentPreparationStage",
|
||||
"RealtimeChunkLatentPreparationStage",
|
||||
"ComfyUILatentPreparationStage",
|
||||
"LTX2AVLatentPreparationStage",
|
||||
"DenoisingStage",
|
||||
@@ -88,12 +107,17 @@ __all__ = [
|
||||
"LTX2AVDenoisingStage",
|
||||
"CausalDMDDenoisingStage",
|
||||
"EncodingStage",
|
||||
"ConditionEncodingStage",
|
||||
"AuxiliaryConditionEncodingStage",
|
||||
"DecodingStage",
|
||||
"CausalVaeDecodingStage",
|
||||
"LTX2AVDecodingStage",
|
||||
"ImageEncodingStage",
|
||||
"ImageVAEEncodingStage",
|
||||
"RealtimeImageVAEEncodingStage",
|
||||
"LTX2ImageEncodingStage",
|
||||
"TextEncodingStage",
|
||||
"RealtimeTextEncodingStage",
|
||||
"LTX2TextConnectorStage",
|
||||
# Hunyuan3D shape stages
|
||||
"Hunyuan3DShapeBeforeDenoisingStage",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class ConditionEncodingStage(PipelineStage):
|
||||
"""Base class for stages that materialize denoiser conditions on the batch."""
|
||||
|
||||
|
||||
class AuxiliaryConditionEncodingStage(ConditionEncodingStage):
|
||||
"""Apply pipeline-config prepared auxiliary conditioning tensors to the request."""
|
||||
|
||||
def __init__(self, dtype: torch.dtype = torch.bfloat16):
|
||||
super().__init__()
|
||||
self.dtype = dtype
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
condition = server_args.pipeline_config.prepare_world_condition(
|
||||
batch=batch,
|
||||
device=self.device,
|
||||
dtype=self.dtype,
|
||||
)
|
||||
if condition is None:
|
||||
return batch
|
||||
if not isinstance(condition, Mapping):
|
||||
raise TypeError("prepare_world_condition must return a mapping or None")
|
||||
for field_name, value in condition.items():
|
||||
setattr(batch, field_name, value)
|
||||
return batch
|
||||
@@ -38,6 +38,17 @@ class LatentPreparationFingerprint:
|
||||
generator_device: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LatentPreparationSpec:
|
||||
""" "dataclass for controlling the LatentPreparationStage runtime semantics"""
|
||||
|
||||
shape: tuple[int, ...]
|
||||
dtype: torch.dtype
|
||||
device: torch.device | str
|
||||
prepare_latent_ids: bool = True
|
||||
pack_latents: bool = True
|
||||
|
||||
|
||||
class LatentPreparationStage(PipelineStage):
|
||||
"""
|
||||
Stage for preparing initial latent variables for the diffusion process.
|
||||
@@ -60,6 +71,37 @@ class LatentPreparationStage(PipelineStage):
|
||||
batch.prompt_embeds[0].dtype
|
||||
)
|
||||
|
||||
def get_forward_latent_num_frames(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> int:
|
||||
"""get the number of frames to generate for the current batch"""
|
||||
return self.adjust_video_length(batch, server_args)
|
||||
|
||||
def get_latent_preparation_spec(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
batch_size: int,
|
||||
num_frames: int,
|
||||
device: torch.device | str,
|
||||
) -> LatentPreparationSpec:
|
||||
shape = server_args.pipeline_config.prepare_latent_shape(
|
||||
batch, batch_size, num_frames
|
||||
)
|
||||
return LatentPreparationSpec(
|
||||
shape=shape,
|
||||
dtype=self._get_latent_dtype(batch, server_args),
|
||||
device=device,
|
||||
)
|
||||
|
||||
def should_scale_initial_noise(self, batch: Req, server_args: ServerArgs) -> bool:
|
||||
return True
|
||||
|
||||
def requires_batch_height_width(self, batch: Req, server_args: ServerArgs) -> bool:
|
||||
return True
|
||||
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
@@ -75,23 +117,21 @@ class LatentPreparationStage(PipelineStage):
|
||||
"""
|
||||
|
||||
# Adjust video length based on VAE version if needed
|
||||
latent_num_frames = self.adjust_video_length(batch, server_args)
|
||||
latent_num_frames = self.get_forward_latent_num_frames(batch, server_args)
|
||||
|
||||
batch_size = batch.batch_size
|
||||
|
||||
# Get required parameters
|
||||
dtype = self._get_latent_dtype(batch, server_args)
|
||||
device = get_local_torch_device()
|
||||
generator = batch.generator
|
||||
latents = batch.latents
|
||||
num_frames = (
|
||||
latent_num_frames if latent_num_frames is not None else batch.num_frames
|
||||
)
|
||||
height = batch.height
|
||||
width = batch.width
|
||||
|
||||
# TODO(will): remove this once we add input/output validation for stages
|
||||
if height is None or width is None:
|
||||
if self.requires_batch_height_width(batch, server_args) and (
|
||||
height is None or width is None
|
||||
):
|
||||
raise ValueError("Height and width must be provided")
|
||||
|
||||
# Validate generator if it's a list
|
||||
@@ -103,26 +143,36 @@ class LatentPreparationStage(PipelineStage):
|
||||
|
||||
# Generate or use provided latents
|
||||
if latents is None:
|
||||
shape = server_args.pipeline_config.prepare_latent_shape(
|
||||
batch, batch_size, num_frames
|
||||
spec = self.get_latent_preparation_spec(
|
||||
batch, server_args, batch_size, latent_num_frames, device
|
||||
)
|
||||
latents = randn_tensor(
|
||||
shape, generator=generator, device=device, dtype=dtype
|
||||
spec.shape,
|
||||
generator=generator,
|
||||
device=spec.device,
|
||||
dtype=spec.dtype,
|
||||
)
|
||||
|
||||
latent_ids = server_args.pipeline_config.maybe_prepare_latent_ids(latents)
|
||||
latent_ids = (
|
||||
server_args.pipeline_config.maybe_prepare_latent_ids(latents)
|
||||
if spec.prepare_latent_ids
|
||||
else None
|
||||
)
|
||||
|
||||
if latent_ids is not None:
|
||||
batch.latent_ids = latent_ids.to(device=device)
|
||||
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, batch
|
||||
)
|
||||
if spec.pack_latents:
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, batch
|
||||
)
|
||||
else:
|
||||
latents = latents.to(device)
|
||||
|
||||
# Scale the initial noise if needed
|
||||
if hasattr(self.scheduler, "init_noise_sigma"):
|
||||
if self.should_scale_initial_noise(batch, server_args) and hasattr(
|
||||
self.scheduler, "init_noise_sigma"
|
||||
):
|
||||
latents = latents * self.scheduler.init_noise_sigma
|
||||
# Update batch with prepared latents
|
||||
batch.latents = latents
|
||||
@@ -175,7 +225,7 @@ class LatentPreparationStage(PipelineStage):
|
||||
if isinstance(batch.prompt_embeds, list) and batch.prompt_embeds
|
||||
else None
|
||||
)
|
||||
latent_num_frames = self.adjust_video_length(batch, server_args)
|
||||
latent_num_frames = self.get_forward_latent_num_frames(batch, server_args)
|
||||
return LatentPreparationFingerprint(
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
@@ -206,51 +256,58 @@ class LatentPreparationStage(PipelineStage):
|
||||
deterministic packing/scaling work.
|
||||
"""
|
||||
first_batch = batches[0]
|
||||
latent_num_frames = self.adjust_video_length(first_batch, server_args)
|
||||
latent_num_frames = self.get_forward_latent_num_frames(first_batch, server_args)
|
||||
batch_size = len(batches)
|
||||
|
||||
dtype = self._get_latent_dtype(first_batch, server_args)
|
||||
device = get_local_torch_device()
|
||||
num_frames = (
|
||||
latent_num_frames
|
||||
if latent_num_frames is not None
|
||||
else first_batch.num_frames
|
||||
first_spec = self.get_latent_preparation_spec(
|
||||
first_batch,
|
||||
server_args,
|
||||
batch_size,
|
||||
latent_num_frames,
|
||||
device,
|
||||
)
|
||||
height = first_batch.height
|
||||
width = first_batch.width
|
||||
|
||||
if height is None or width is None:
|
||||
raise ValueError("Height and width must be provided")
|
||||
|
||||
raw_latents = []
|
||||
for batch in batches:
|
||||
shape = server_args.pipeline_config.prepare_latent_shape(
|
||||
batch, 1, num_frames
|
||||
spec = self.get_latent_preparation_spec(
|
||||
batch,
|
||||
server_args,
|
||||
1,
|
||||
latent_num_frames,
|
||||
device,
|
||||
)
|
||||
raw_latents.append(
|
||||
randn_tensor(
|
||||
shape,
|
||||
spec.shape,
|
||||
generator=self._single_generator(batch),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
device=spec.device,
|
||||
dtype=spec.dtype,
|
||||
)
|
||||
)
|
||||
|
||||
latents = torch.cat(raw_latents, dim=0)
|
||||
latent_ids = server_args.pipeline_config.maybe_prepare_latent_ids(latents)
|
||||
latent_ids = (
|
||||
server_args.pipeline_config.maybe_prepare_latent_ids(latents)
|
||||
if first_spec.prepare_latent_ids
|
||||
else None
|
||||
)
|
||||
if latent_ids is not None:
|
||||
first_batch.latent_ids = latent_ids.to(device=device)
|
||||
|
||||
original_num_outputs = first_batch.num_outputs_per_prompt
|
||||
try:
|
||||
first_batch.num_outputs_per_prompt = batch_size
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, first_batch
|
||||
)
|
||||
finally:
|
||||
first_batch.num_outputs_per_prompt = original_num_outputs
|
||||
if first_spec.pack_latents:
|
||||
original_num_outputs = first_batch.num_outputs_per_prompt
|
||||
try:
|
||||
first_batch.num_outputs_per_prompt = batch_size
|
||||
latents = server_args.pipeline_config.maybe_pack_latents(
|
||||
latents, batch_size, first_batch
|
||||
)
|
||||
finally:
|
||||
first_batch.num_outputs_per_prompt = original_num_outputs
|
||||
|
||||
if hasattr(self.scheduler, "init_noise_sigma"):
|
||||
if self.should_scale_initial_noise(first_batch, server_args) and hasattr(
|
||||
self.scheduler, "init_noise_sigma"
|
||||
):
|
||||
latents = latents * self.scheduler.init_noise_sigma
|
||||
|
||||
first_batch.latents = latents
|
||||
@@ -320,3 +377,59 @@ class LatentPreparationStage(PipelineStage):
|
||||
# result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
|
||||
result.add_check("raw_latent_shape", batch.raw_latent_shape, V.is_tuple)
|
||||
return result
|
||||
|
||||
|
||||
class RealtimeChunkLatentPreparationStage(LatentPreparationStage):
|
||||
"""Prepare one realtime causal DiT chunk from the encoded condition shape."""
|
||||
|
||||
def get_forward_latent_num_frames(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> int:
|
||||
return int(
|
||||
batch.realtime_chunk_size
|
||||
or self.transformer.config.arch_config.num_frames_per_block
|
||||
)
|
||||
|
||||
def get_latent_preparation_spec(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
batch_size: int,
|
||||
num_frames: int,
|
||||
device: torch.device | str,
|
||||
) -> LatentPreparationSpec:
|
||||
condition_latent = batch.image_latent
|
||||
assert condition_latent is not None, (
|
||||
"Realtime chunk latent preparation requires image_latent. "
|
||||
"Ensure the condition VAE encoding stage runs before this stage."
|
||||
)
|
||||
return LatentPreparationSpec(
|
||||
shape=(
|
||||
condition_latent.shape[0],
|
||||
self.transformer.config.arch_config.out_channels,
|
||||
num_frames,
|
||||
condition_latent.shape[3],
|
||||
condition_latent.shape[4],
|
||||
),
|
||||
dtype=condition_latent.dtype,
|
||||
device=device,
|
||||
prepare_latent_ids=False,
|
||||
pack_latents=False,
|
||||
)
|
||||
|
||||
def should_scale_initial_noise(self, batch: Req, server_args: ServerArgs) -> bool:
|
||||
return False
|
||||
|
||||
def requires_batch_height_width(self, batch: Req, server_args: ServerArgs) -> bool:
|
||||
return False
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check(
|
||||
"image_latent", batch.image_latent, [V.is_tensor, V.with_dims(5)]
|
||||
)
|
||||
result.add_check("generator", batch.generator, V.generator_or_list_generators)
|
||||
result.add_check("latents", batch.latents, V.none_or_tensor)
|
||||
return result
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""LingBot-World-specific pipeline stages."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world.lingbot_world_causal_denoising import (
|
||||
LingBotWorldCausalDMDDenoisingStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LingBotWorldCausalDMDDenoisingStage",
|
||||
]
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
"""LingBot-World causal DMD denoising stage."""
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ring_parallel_world_size,
|
||||
get_ulysses_parallel_world_size,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.causal_denoising import (
|
||||
CausalDMDCachePolicy,
|
||||
CausalDMDDenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class LingBotWorldCausalDMDDenoisingStage(CausalDMDDenoisingStage):
|
||||
"""Causal DMD denoising with I2V condition concatenation for LingBot-World.
|
||||
|
||||
The LingBot-World transformer has ``in_channels = 36`` and expects
|
||||
``[noise(16ch), condition(20ch)]`` concatenated along channel dim.
|
||||
Each call processes one chunk (num_frames_per_block frames).
|
||||
"""
|
||||
|
||||
def _get_causal_kv_cache_size(
|
||||
self,
|
||||
*,
|
||||
sequence_shard_enabled: bool = False,
|
||||
) -> int:
|
||||
if self.local_attn_size != -1:
|
||||
return self.local_attn_size * self.num_token_per_frame
|
||||
|
||||
return self.sliding_window_num_frames * self.num_token_per_frame
|
||||
|
||||
def _causal_sequence_shard_enabled(self, batch: Req) -> bool:
|
||||
return bool(
|
||||
getattr(batch, "enable_sequence_shard", False)
|
||||
and get_ulysses_parallel_world_size() > 1
|
||||
)
|
||||
|
||||
def _num_causal_cache_attention_heads(
|
||||
self,
|
||||
*,
|
||||
sequence_shard_enabled: bool,
|
||||
) -> int:
|
||||
num_attention_heads = self.transformer.num_attention_heads
|
||||
if not sequence_shard_enabled:
|
||||
return num_attention_heads
|
||||
|
||||
ulysses_world_size = get_ulysses_parallel_world_size()
|
||||
if get_ring_parallel_world_size() > 1:
|
||||
raise NotImplementedError(
|
||||
"LingBot causal sequence sharding currently supports ulysses_degree > 1 with ring_degree = 1 only."
|
||||
)
|
||||
if ulysses_world_size <= 1:
|
||||
raise ValueError(
|
||||
"LingBot causal sequence sharding requires ulysses_degree > 1."
|
||||
)
|
||||
if num_attention_heads % ulysses_world_size != 0:
|
||||
raise ValueError(
|
||||
f"num_attention_heads ({num_attention_heads}) must be divisible by ulysses_degree ({ulysses_world_size})."
|
||||
)
|
||||
return num_attention_heads // ulysses_world_size
|
||||
|
||||
def _causal_kv_cache_kwargs(
|
||||
self,
|
||||
policy: CausalDMDCachePolicy,
|
||||
) -> dict[str, bool]:
|
||||
return {"sequence_shard_enabled": policy.sequence_shard_enabled}
|
||||
|
||||
def _use_causal_cache_int_indices(
|
||||
self,
|
||||
*,
|
||||
sequence_shard_enabled: bool,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check(
|
||||
"image_latent", batch.image_latent, [V.is_tensor, V.with_dims(5)]
|
||||
)
|
||||
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
|
||||
result.add_check("timesteps", batch.timesteps, [V.is_tensor, V.with_dims(1)])
|
||||
result.add_check("scheduler", batch.scheduler, V.not_none)
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_not_empty)
|
||||
return result
|
||||
|
||||
def _get_causal_dmd_latents(self, batch: Req) -> torch.Tensor:
|
||||
latents = batch.latents
|
||||
assert latents is not None, (
|
||||
"LingBot-World causal DMD requires prepared chunk latents. "
|
||||
"Ensure RealtimeChunkLatentPreparationStage runs before this stage."
|
||||
)
|
||||
return latents
|
||||
|
||||
def _get_causal_dmd_scheduler(self, batch: Req, server_args: ServerArgs):
|
||||
scheduler = batch.scheduler
|
||||
assert scheduler is not None, (
|
||||
"LingBot-World causal DMD requires prepared DMD timesteps. "
|
||||
"Ensure DMDTimestepPreparationStage runs before this stage."
|
||||
)
|
||||
return scheduler
|
||||
|
||||
def _prepare_causal_dmd_timesteps(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
scheduler,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
timesteps = batch.timesteps
|
||||
assert timesteps is not None
|
||||
return timesteps.to(device)
|
||||
|
||||
def _prepare_causal_dmd_image_kwargs(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
target_dtype: torch.dtype,
|
||||
) -> dict:
|
||||
image_embeds = getattr(batch, "image_embeds", [])
|
||||
if len(image_embeds) > 0:
|
||||
image_embeds = [ie.to(target_dtype) for ie in image_embeds]
|
||||
return {
|
||||
"encoder_hidden_states_image": image_embeds,
|
||||
}
|
||||
|
||||
def _prepare_causal_dmd_pos_cond_kwargs(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
target_dtype: torch.dtype,
|
||||
) -> dict:
|
||||
# lingbot transformer forward uses varargs, so inspect filtering drops valid kwargs
|
||||
return server_args.pipeline_config.prepare_pos_cond_kwargs(
|
||||
batch,
|
||||
self.device,
|
||||
getattr(self.transformer, "rotary_emb", None),
|
||||
dtype=target_dtype,
|
||||
)
|
||||
|
||||
def _prepare_causal_dmd_prompt_embeds(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
target_dtype: torch.dtype,
|
||||
):
|
||||
return server_args.pipeline_config.get_pos_prompt_embeds(batch)
|
||||
|
||||
def _update_causal_context_cache(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
context_input: torch.Tensor,
|
||||
prompt_embeds,
|
||||
kv_cache,
|
||||
crossattn_cache,
|
||||
current_start_tokens: int,
|
||||
start_frame: int,
|
||||
image_kwargs: dict,
|
||||
pos_cond_kwargs: dict,
|
||||
attn_metadata,
|
||||
target_dtype: torch.dtype,
|
||||
autocast_enabled: bool,
|
||||
) -> None:
|
||||
context_noise = getattr(server_args.pipeline_config, "context_noise", 0)
|
||||
timestep = torch.full(
|
||||
(context_input.shape[0], 1),
|
||||
int(context_noise),
|
||||
device=context_input.device,
|
||||
dtype=torch.long,
|
||||
)
|
||||
with (
|
||||
torch.autocast(
|
||||
device_type=current_platform.device_type,
|
||||
dtype=target_dtype,
|
||||
enabled=autocast_enabled,
|
||||
),
|
||||
set_forward_context(
|
||||
current_timestep=0,
|
||||
attn_metadata=attn_metadata,
|
||||
forward_batch=batch,
|
||||
),
|
||||
):
|
||||
self.transformer(
|
||||
context_input.to(target_dtype),
|
||||
prompt_embeds,
|
||||
timestep,
|
||||
kv_cache=kv_cache,
|
||||
crossattn_cache=crossattn_cache,
|
||||
current_start=current_start_tokens,
|
||||
start_frame=start_frame,
|
||||
skip_final_projection=True,
|
||||
**image_kwargs,
|
||||
**pos_cond_kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _select_i2v_condition_chunk(
|
||||
condition_full: torch.Tensor,
|
||||
chunk_idx: int,
|
||||
chunk_size: int,
|
||||
) -> torch.Tensor:
|
||||
condition_chunks = condition_full.split(chunk_size, dim=2)
|
||||
condition = condition_chunks[min(chunk_idx, len(condition_chunks) - 1)]
|
||||
|
||||
if condition.shape[2] == chunk_size:
|
||||
return condition
|
||||
pad_frames = chunk_size - condition.shape[2]
|
||||
return torch.cat(
|
||||
[
|
||||
condition,
|
||||
condition.new_zeros(
|
||||
condition.shape[0],
|
||||
condition.shape[1],
|
||||
pad_frames,
|
||||
condition.shape[3],
|
||||
condition.shape[4],
|
||||
),
|
||||
],
|
||||
dim=2,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_i2v_model_input_writer(
|
||||
*,
|
||||
latents: torch.Tensor,
|
||||
condition: torch.Tensor,
|
||||
target_dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
):
|
||||
b, latent_channels, t, h, w = latents.shape
|
||||
condition = condition.to(device=device, dtype=target_dtype)
|
||||
model_input = torch.empty(
|
||||
(
|
||||
b,
|
||||
latent_channels + condition.shape[1],
|
||||
t,
|
||||
h,
|
||||
w,
|
||||
),
|
||||
dtype=target_dtype,
|
||||
device=device,
|
||||
)
|
||||
model_input[:, latent_channels:].copy_(condition)
|
||||
|
||||
def write(current_latents: torch.Tensor) -> torch.Tensor:
|
||||
model_input[:, :latent_channels].copy_(current_latents)
|
||||
return model_input
|
||||
|
||||
return write
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
# --- Condition: take current chunk's slice ---
|
||||
condition_full = batch.image_latent
|
||||
assert condition_full is not None, (
|
||||
"LingBot-World causal DMD requires image_latent as condition. "
|
||||
"Ensure ImageVAEEncodingStage runs before this stage."
|
||||
)
|
||||
ctx = self._prepare_causal_dmd_forward_context(batch, server_args)
|
||||
latents = ctx.latents
|
||||
cache_ctx = self._prepare_realtime_causal_caches(batch, server_args, ctx)
|
||||
|
||||
# Keep cross-attention K/V cache across realtime chunks; LingBot text/image
|
||||
# conditions are session-static and are invalidated by cache reset.
|
||||
|
||||
# Slice condition to current chunk
|
||||
condition = self._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
cache_ctx.chunk_idx,
|
||||
ctx.num_frames,
|
||||
)
|
||||
|
||||
# --- Denoising loop (single chunk) ---
|
||||
current_latents = latents
|
||||
prepare_model_input = self._build_i2v_model_input_writer(
|
||||
latents=current_latents,
|
||||
condition=condition,
|
||||
target_dtype=ctx.target_dtype,
|
||||
device=ctx.device,
|
||||
)
|
||||
|
||||
current_latents = self._denoise_realtime_causal_chunk(
|
||||
batch,
|
||||
server_args,
|
||||
ctx=ctx,
|
||||
cache_ctx=cache_ctx,
|
||||
chunk_latents=current_latents,
|
||||
prepare_model_input=prepare_model_input,
|
||||
prepare_context_input=prepare_model_input,
|
||||
)
|
||||
|
||||
# Advance cumulative frame position
|
||||
self._advance_realtime_causal_cache(cache_ctx, num_frames=ctx.num_frames)
|
||||
|
||||
# Output denoised latents for decoder
|
||||
batch.latents = current_latents
|
||||
batch.raw_latent_shape = current_latents.shape
|
||||
if not cache_ctx.persist_state:
|
||||
cache_ctx.cache_state.dispose()
|
||||
return batch
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import (
|
||||
InputValidationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class RealtimeInputValidationState(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.image_path = None
|
||||
self.condition_image = None
|
||||
self.original_condition_image_size = None
|
||||
self.height = None
|
||||
self.width = None
|
||||
self.generator = None
|
||||
self.seeds = None
|
||||
self.generator_seed = None
|
||||
self.generator_device = None
|
||||
self.num_outputs_per_prompt = None
|
||||
|
||||
def dispose(self):
|
||||
super().dispose()
|
||||
self.image_path = None
|
||||
self.condition_image = None
|
||||
self.original_condition_image_size = None
|
||||
self.height = None
|
||||
self.width = None
|
||||
self.generator = None
|
||||
self.seeds = None
|
||||
self.generator_seed = None
|
||||
self.generator_device = None
|
||||
self.num_outputs_per_prompt = None
|
||||
|
||||
|
||||
class RealtimeInputValidationStage(InputValidationStage):
|
||||
"""Reuse validated image and generator inputs across chunks in a realtime session."""
|
||||
|
||||
def preprocess_condition_image(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
condition_image_width,
|
||||
condition_image_height,
|
||||
):
|
||||
if server_args.pipeline_config.preprocess_realtime_condition_image(
|
||||
batch,
|
||||
self.vae_image_processor,
|
||||
):
|
||||
return
|
||||
return super().preprocess_condition_image(
|
||||
batch,
|
||||
server_args,
|
||||
condition_image_width,
|
||||
condition_image_height,
|
||||
)
|
||||
|
||||
def _cache_batch(self, batch: Req, state: RealtimeInputValidationState) -> None:
|
||||
state.image_path = batch.image_path
|
||||
state.condition_image = batch.condition_image
|
||||
state.original_condition_image_size = batch.original_condition_image_size
|
||||
state.height = batch.height
|
||||
state.width = batch.width
|
||||
|
||||
def _can_reuse_cached_image(
|
||||
self, batch: Req, state: RealtimeInputValidationState
|
||||
) -> bool:
|
||||
if batch.block_idx == 0 or state.condition_image is None:
|
||||
return False
|
||||
return (
|
||||
batch.image_path in (None, state.image_path)
|
||||
and batch.height in (None, state.height)
|
||||
and batch.width in (None, state.width)
|
||||
)
|
||||
|
||||
def _cache_generator(self, batch: Req, state: RealtimeInputValidationState) -> None:
|
||||
state.generator = batch.generator
|
||||
state.seeds = batch.seeds
|
||||
state.generator_seed = batch.seed
|
||||
state.generator_device = batch.generator_device
|
||||
state.num_outputs_per_prompt = batch.num_outputs_per_prompt
|
||||
|
||||
def _can_reuse_generator(
|
||||
self, batch: Req, state: RealtimeInputValidationState
|
||||
) -> bool:
|
||||
if batch.block_idx == 0 or state.generator is None:
|
||||
return False
|
||||
return (
|
||||
state.generator_seed == batch.seed
|
||||
and state.generator_device == batch.generator_device
|
||||
and state.num_outputs_per_prompt == batch.num_outputs_per_prompt
|
||||
)
|
||||
|
||||
def _reuse_or_cache_generator(
|
||||
self, batch: Req, state: RealtimeInputValidationState
|
||||
) -> None:
|
||||
if self._can_reuse_generator(batch, state):
|
||||
batch.generator = state.generator
|
||||
batch.seeds = state.seeds
|
||||
return
|
||||
|
||||
self._cache_generator(batch, state)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> Req:
|
||||
if batch.session is None:
|
||||
return super().forward(batch, server_args)
|
||||
|
||||
state = batch.session.get_or_create_state(RealtimeInputValidationState)
|
||||
if self._can_reuse_cached_image(batch, state):
|
||||
original_image_path = batch.image_path
|
||||
batch.image_path = None
|
||||
batch.condition_image = state.condition_image
|
||||
batch.original_condition_image_size = state.original_condition_image_size
|
||||
if batch.height is None:
|
||||
batch.height = state.height
|
||||
if batch.width is None:
|
||||
batch.width = state.width
|
||||
try:
|
||||
batch = super().forward(batch, server_args)
|
||||
self._reuse_or_cache_generator(batch, state)
|
||||
return batch
|
||||
finally:
|
||||
batch.image_path = original_image_path
|
||||
|
||||
batch = super().forward(batch, server_args)
|
||||
self._cache_batch(batch, state)
|
||||
self._reuse_or_cache_generator(batch, state)
|
||||
return batch
|
||||
@@ -0,0 +1,152 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
"""
|
||||
LingBot-World realtime text stages.
|
||||
|
||||
The reference lingbot_fast_server initializes prompt embeddings once per session.
|
||||
Cache text encoder outputs across realtime chunks so condition sampling stays
|
||||
closer to the actual denoising step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
|
||||
TextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
def _normalize_prompt_value(
|
||||
value: str | list[str] | None,
|
||||
) -> str | tuple[str, ...] | None:
|
||||
if isinstance(value, list):
|
||||
return tuple(value)
|
||||
return value
|
||||
|
||||
|
||||
def _copy_tensor_list(
|
||||
value: list[torch.Tensor] | None,
|
||||
) -> list[torch.Tensor] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return list(value)
|
||||
|
||||
|
||||
def _copy_seq_lens(
|
||||
value: list[list[int]] | None,
|
||||
) -> list[list[int]] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return [list(seq_lens) for seq_lens in value]
|
||||
|
||||
|
||||
class RealtimeTextState(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.cache_key: tuple[Any, ...] | None = None
|
||||
self.prompt_embeds: list[torch.Tensor] | None = None
|
||||
self.pooled_embeds: list[torch.Tensor] | None = None
|
||||
self.prompt_attention_mask: list[torch.Tensor] | None = None
|
||||
self.prompt_embeds_mask: list[torch.Tensor] | None = None
|
||||
self.prompt_seq_lens: list[list[int]] | None = None
|
||||
self.negative_prompt_embeds: list[torch.Tensor] | None = None
|
||||
self.neg_pooled_embeds: list[torch.Tensor] | None = None
|
||||
self.negative_attention_mask: list[torch.Tensor] | None = None
|
||||
self.negative_prompt_embeds_mask: list[torch.Tensor] | None = None
|
||||
self.negative_prompt_seq_lens: list[list[int]] | None = None
|
||||
|
||||
def clear_text_cache(self):
|
||||
self.cache_key = None
|
||||
self.prompt_embeds = None
|
||||
self.pooled_embeds = None
|
||||
self.prompt_attention_mask = None
|
||||
self.prompt_embeds_mask = None
|
||||
self.prompt_seq_lens = None
|
||||
self.negative_prompt_embeds = None
|
||||
self.neg_pooled_embeds = None
|
||||
self.negative_attention_mask = None
|
||||
self.negative_prompt_embeds_mask = None
|
||||
self.negative_prompt_seq_lens = None
|
||||
|
||||
def dispose(self):
|
||||
super().dispose()
|
||||
self.clear_text_cache()
|
||||
|
||||
|
||||
class RealtimeTextEncodingStage(TextEncodingStage):
|
||||
"""Cache text encoder outputs across realtime chunks by prompt identity."""
|
||||
|
||||
def _make_cache_key(self, batch: Req) -> tuple[Any, ...]:
|
||||
return (
|
||||
_normalize_prompt_value(batch.prompt),
|
||||
bool(batch.do_classifier_free_guidance),
|
||||
(
|
||||
_normalize_prompt_value(batch.negative_prompt)
|
||||
if batch.do_classifier_free_guidance
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def _restore_cached_outputs(self, batch: Req, state: RealtimeTextState) -> Req:
|
||||
batch.prompt_embeds = _copy_tensor_list(state.prompt_embeds) or []
|
||||
batch.pooled_embeds = _copy_tensor_list(state.pooled_embeds) or []
|
||||
batch.prompt_attention_mask = _copy_tensor_list(state.prompt_attention_mask)
|
||||
batch.prompt_embeds_mask = _copy_tensor_list(state.prompt_embeds_mask)
|
||||
batch.prompt_seq_lens = _copy_seq_lens(state.prompt_seq_lens)
|
||||
batch.negative_prompt_embeds = _copy_tensor_list(state.negative_prompt_embeds)
|
||||
batch.neg_pooled_embeds = _copy_tensor_list(state.neg_pooled_embeds) or []
|
||||
batch.negative_attention_mask = _copy_tensor_list(state.negative_attention_mask)
|
||||
batch.negative_prompt_embeds_mask = _copy_tensor_list(
|
||||
state.negative_prompt_embeds_mask
|
||||
)
|
||||
batch.negative_prompt_seq_lens = _copy_seq_lens(state.negative_prompt_seq_lens)
|
||||
return batch
|
||||
|
||||
def _store_outputs(self, batch: Req, state: RealtimeTextState) -> None:
|
||||
state.prompt_embeds = _copy_tensor_list(batch.prompt_embeds)
|
||||
state.pooled_embeds = _copy_tensor_list(batch.pooled_embeds)
|
||||
state.prompt_attention_mask = _copy_tensor_list(batch.prompt_attention_mask)
|
||||
state.prompt_embeds_mask = _copy_tensor_list(batch.prompt_embeds_mask)
|
||||
state.prompt_seq_lens = _copy_seq_lens(batch.prompt_seq_lens)
|
||||
state.negative_prompt_embeds = _copy_tensor_list(batch.negative_prompt_embeds)
|
||||
state.neg_pooled_embeds = _copy_tensor_list(batch.neg_pooled_embeds)
|
||||
state.negative_attention_mask = _copy_tensor_list(batch.negative_attention_mask)
|
||||
state.negative_prompt_embeds_mask = _copy_tensor_list(
|
||||
batch.negative_prompt_embeds_mask
|
||||
)
|
||||
state.negative_prompt_seq_lens = _copy_seq_lens(batch.negative_prompt_seq_lens)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> Req:
|
||||
if batch.session is None:
|
||||
return super().forward(batch, server_args)
|
||||
|
||||
state = batch.session.get_or_create_state(RealtimeTextState)
|
||||
assert isinstance(state, RealtimeTextState)
|
||||
|
||||
# cache the encoder results into BaseRealtimeState, restore when encoder inputs hits the cache
|
||||
cache_key = self._make_cache_key(batch)
|
||||
if state.cache_key == cache_key and state.prompt_embeds is not None:
|
||||
return self._restore_cached_outputs(batch, state)
|
||||
|
||||
state.clear_text_cache()
|
||||
|
||||
# perform regular text encoding
|
||||
batch = super().forward(batch, server_args)
|
||||
|
||||
state.cache_key = cache_key
|
||||
self._store_outputs(batch, state)
|
||||
return batch
|
||||
@@ -0,0 +1,204 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
|
||||
from sglang.multimodal_gen.runtime.models.vaes.wanvae import (
|
||||
unpatchify as wan_unpatchify,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch, Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding import (
|
||||
DecodingStage,
|
||||
_ensure_tensor_decode_output,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.image_encoding import (
|
||||
ImageVAEEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.utils import PRECISION_TO_TYPE
|
||||
|
||||
|
||||
class RealtimeVAEState(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.image_latent: torch.Tensor | None = None
|
||||
|
||||
def dispose(self):
|
||||
super().dispose()
|
||||
self.image_latent = None
|
||||
|
||||
|
||||
class RealtimeVAEDecodeState(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reset_causal_decode_state = None
|
||||
|
||||
def dispose(self):
|
||||
reset_causal_decode_state = self.reset_causal_decode_state
|
||||
self.reset_causal_decode_state = None
|
||||
if callable(reset_causal_decode_state):
|
||||
reset_causal_decode_state()
|
||||
|
||||
|
||||
class RealtimeImageVAEEncodingStage(ImageVAEEncodingStage):
|
||||
"""Reuse the first chunk's conditioning image latent across a realtime session."""
|
||||
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> Req:
|
||||
state = None
|
||||
if batch.session is not None:
|
||||
state = batch.session.get_or_create_state(RealtimeVAEState)
|
||||
if batch.block_idx == 0:
|
||||
state.image_latent = None
|
||||
elif state.image_latent is not None:
|
||||
batch.image_latent = state.image_latent
|
||||
return batch
|
||||
|
||||
if batch.condition_image is None:
|
||||
if state is not None and state.image_latent is not None:
|
||||
batch.image_latent = state.image_latent
|
||||
return batch
|
||||
|
||||
batch = super().forward(batch, server_args)
|
||||
|
||||
if state is not None and batch.image_latent is not None:
|
||||
state.image_latent = batch.image_latent
|
||||
return batch
|
||||
|
||||
|
||||
class CausalVaeDecodingStage(DecodingStage):
|
||||
"""Decode realtime chunks with a persistent causal VAE cache when available."""
|
||||
|
||||
@staticmethod
|
||||
def _supports_wan_decoder_cache(vae) -> bool:
|
||||
return all(
|
||||
hasattr(vae, attr)
|
||||
for attr in (
|
||||
"clear_cache",
|
||||
"post_quant_conv",
|
||||
"decoder",
|
||||
"_feat_map",
|
||||
"_conv_idx",
|
||||
)
|
||||
)
|
||||
|
||||
def _get_causal_decode_reset_fn(self):
|
||||
reset_causal_state = getattr(self.vae, "reset_causal_decode_state", None)
|
||||
if callable(reset_causal_state):
|
||||
return reset_causal_state
|
||||
if self._supports_wan_decoder_cache(self.vae):
|
||||
return self.vae.clear_cache
|
||||
return None
|
||||
|
||||
def _decode_wan_with_persistent_cache(
|
||||
self,
|
||||
latents: torch.Tensor,
|
||||
*,
|
||||
first_chunk: bool,
|
||||
) -> torch.Tensor:
|
||||
x = self.vae.post_quant_conv(latents)
|
||||
decoded_frames = []
|
||||
for frame_idx in range(x.shape[2]):
|
||||
self.vae._conv_idx = [0]
|
||||
decoded = self.vae.decoder(
|
||||
x[:, :, frame_idx : frame_idx + 1],
|
||||
feat_cache=self.vae._feat_map,
|
||||
feat_idx=self.vae._conv_idx,
|
||||
first_chunk=first_chunk and frame_idx == 0,
|
||||
)
|
||||
decoded_frames.append(decoded)
|
||||
|
||||
image = torch.cat(decoded_frames, dim=2)
|
||||
if getattr(self.vae.config, "patch_size", None) is not None:
|
||||
image = wan_unpatchify(image, patch_size=self.vae.config.patch_size)
|
||||
return image.clamp(-1.0, 1.0)
|
||||
|
||||
@torch.no_grad()
|
||||
def decode_causal(
|
||||
self,
|
||||
latents: torch.Tensor,
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
first_chunk: bool,
|
||||
) -> torch.Tensor:
|
||||
vae_dtype = PRECISION_TO_TYPE[server_args.pipeline_config.vae_precision]
|
||||
self.vae = self.vae.to(device=get_local_torch_device(), dtype=vae_dtype)
|
||||
latents = latents.to(get_local_torch_device())
|
||||
vae_autocast_enabled = (
|
||||
vae_dtype != torch.float32
|
||||
) and not server_args.disable_autocast
|
||||
|
||||
latents = self.scale_and_shift(latents, server_args)
|
||||
latents = server_args.pipeline_config.preprocess_decoding(
|
||||
latents, server_args, vae=self.vae
|
||||
)
|
||||
|
||||
with torch.autocast(
|
||||
device_type=current_platform.device_type,
|
||||
dtype=vae_dtype,
|
||||
enabled=vae_autocast_enabled,
|
||||
):
|
||||
try:
|
||||
if server_args.pipeline_config.vae_tiling:
|
||||
self.vae.enable_tiling()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not vae_autocast_enabled:
|
||||
latents = latents.to(vae_dtype)
|
||||
|
||||
decode_fn = getattr(self.vae, "causal_decode", None)
|
||||
if callable(decode_fn):
|
||||
decode_output = decode_fn(latents)
|
||||
image = _ensure_tensor_decode_output(decode_output)
|
||||
elif self._supports_wan_decoder_cache(self.vae):
|
||||
image = self._decode_wan_with_persistent_cache(
|
||||
latents,
|
||||
first_chunk=first_chunk,
|
||||
)
|
||||
else:
|
||||
decode_output = self.vae.decode(latents)
|
||||
image = _ensure_tensor_decode_output(decode_output)
|
||||
|
||||
return (image / 2 + 0.5).clamp(0, 1)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> OutputBatch:
|
||||
if batch.session is None:
|
||||
return super().forward(batch, server_args)
|
||||
|
||||
self.load_model()
|
||||
|
||||
reset_causal_state = self._get_causal_decode_reset_fn()
|
||||
decode_state = batch.session.get_or_create_state(RealtimeVAEDecodeState)
|
||||
decode_state.reset_causal_decode_state = reset_causal_state
|
||||
if batch.block_idx == 0 and callable(reset_causal_state):
|
||||
reset_causal_state()
|
||||
|
||||
frames = self.decode_causal(
|
||||
batch.latents,
|
||||
server_args,
|
||||
first_chunk=batch.block_idx == 0,
|
||||
)
|
||||
frames = server_args.pipeline_config.post_decoding(frames, server_args)
|
||||
|
||||
return OutputBatch(
|
||||
output=frames,
|
||||
trajectory_timesteps=batch.trajectory_timesteps,
|
||||
trajectory_latents=batch.trajectory_latents,
|
||||
rollout_trajectory_data=batch.rollout_trajectory_data,
|
||||
trajectory_decoded=None,
|
||||
metrics=batch.metrics,
|
||||
noise_pred=None,
|
||||
)
|
||||
@@ -22,7 +22,9 @@ from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager im
|
||||
ComponentUse,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.condition_encoding import (
|
||||
ConditionEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
@@ -66,7 +68,7 @@ def stack_tensors(name: str, tensors: list[torch.Tensor]) -> torch.Tensor:
|
||||
return torch.stack(tensors, dim=0)
|
||||
|
||||
|
||||
class TextEncodingStage(PipelineStage):
|
||||
class TextEncodingStage(ConditionEncodingStage):
|
||||
"""
|
||||
Stage for encoding text prompts into embeddings for diffusion models.
|
||||
|
||||
|
||||
@@ -199,3 +199,63 @@ class TimestepPreparationStage(PipelineStage):
|
||||
result = VerificationResult()
|
||||
result.add_check("timesteps", batch.timesteps, [V.is_tensor, V.with_dims(1)])
|
||||
return result
|
||||
|
||||
|
||||
class DMDTimestepPreparationStage(PipelineStage):
|
||||
"""Prepare distilled DMD timesteps from pipeline config."""
|
||||
|
||||
deduplicated_tensor_tree_output_fields = ("timesteps",)
|
||||
deduplicated_deepcopy_output_fields = ("scheduler",)
|
||||
|
||||
def __init__(self, scheduler) -> None:
|
||||
super().__init__()
|
||||
self.scheduler = scheduler
|
||||
|
||||
@property
|
||||
def parallelism_type(self) -> StageParallelismType:
|
||||
return StageParallelismType.REPLICATED
|
||||
|
||||
def forward(
|
||||
self,
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> Req:
|
||||
if batch.scheduler is not None and batch.timesteps is not None:
|
||||
return batch
|
||||
|
||||
scheduler = get_or_create_request_scheduler(batch, self.scheduler)
|
||||
num_train_timesteps = getattr(scheduler, "num_train_timesteps", None)
|
||||
if num_train_timesteps is None:
|
||||
num_train_timesteps = scheduler.config.num_train_timesteps
|
||||
num_train_timesteps = int(num_train_timesteps)
|
||||
scheduler.set_timesteps(num_train_timesteps)
|
||||
|
||||
timesteps = torch.tensor(
|
||||
server_args.pipeline_config.dmd_denoising_steps, dtype=torch.long
|
||||
).cpu()
|
||||
if server_args.pipeline_config.warp_denoising_step:
|
||||
scheduler_timesteps = torch.cat(
|
||||
(scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32))
|
||||
)
|
||||
timesteps = scheduler_timesteps[num_train_timesteps - timesteps]
|
||||
|
||||
batch.timesteps = timesteps.to(get_local_torch_device())
|
||||
batch.scheduler = scheduler
|
||||
if not batch.is_warmup:
|
||||
self.log_debug("DMD timesteps: %s", batch.timesteps)
|
||||
return batch
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check(
|
||||
"dmd_denoising_steps",
|
||||
server_args.pipeline_config.dmd_denoising_steps,
|
||||
V.list_not_empty,
|
||||
)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("timesteps", batch.timesteps, [V.is_tensor, V.with_dims(1)])
|
||||
result.add_check("scheduler", batch.scheduler, V.not_none)
|
||||
return result
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from sglang.multimodal_gen.runtime.postprocess.realesrgan_upscaler import (
|
||||
ImageUpscaler,
|
||||
batch_upscale_frames,
|
||||
upscale_frames,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.postprocess.rife_interpolator import (
|
||||
@@ -14,5 +15,6 @@ __all__ = [
|
||||
"FrameInterpolator",
|
||||
"interpolate_video_frames",
|
||||
"ImageUpscaler",
|
||||
"batch_upscale_frames",
|
||||
"upscale_frames",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ The ImageUpscaler wrapper and integration code are original work.
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -270,6 +271,44 @@ class UpscalerModel:
|
||||
def dtype(self) -> torch.dtype:
|
||||
return next(self.net.parameters()).dtype
|
||||
|
||||
def _copy_input_to_device(self, frames: np.ndarray) -> torch.Tensor:
|
||||
return torch.from_numpy(frames).to(self.device)
|
||||
|
||||
def _preprocess_input_tensor(self, imgs_t: torch.Tensor) -> torch.Tensor:
|
||||
imgs_t = imgs_t.permute(0, 3, 1, 2).to(dtype=self.dtype).mul_(1.0 / 255.0)
|
||||
if self.device.type == "cuda":
|
||||
imgs_t = imgs_t.contiguous(memory_format=torch.channels_last)
|
||||
return imgs_t
|
||||
|
||||
@staticmethod
|
||||
def _postprocess_output_tensor(out: torch.Tensor) -> torch.Tensor:
|
||||
out = out.permute(0, 2, 3, 1).clamp(0.0, 1.0).mul_(255.0)
|
||||
return out.to(torch.uint8).contiguous()
|
||||
|
||||
@staticmethod
|
||||
def _copy_output_to_host(out: torch.Tensor) -> np.ndarray:
|
||||
return out.cpu().numpy()
|
||||
|
||||
def _start_cuda_timer(self):
|
||||
if self.device.type != "cuda":
|
||||
return None
|
||||
start = torch.cuda.Event(enable_timing=True)
|
||||
end = torch.cuda.Event(enable_timing=True)
|
||||
start.record()
|
||||
return start, end
|
||||
|
||||
@staticmethod
|
||||
def _stop_cuda_timer(timer) -> None:
|
||||
if timer is not None:
|
||||
timer[1].record()
|
||||
|
||||
@staticmethod
|
||||
def _cuda_elapsed_s(timer, fallback_s: float) -> float:
|
||||
if timer is None:
|
||||
return fallback_s
|
||||
timer[1].synchronize()
|
||||
return timer[0].elapsed_time(timer[1]) / 1000.0
|
||||
|
||||
def _should_use_tiled_upscale(self, h: int, w: int) -> bool:
|
||||
if self.device.type != "cuda":
|
||||
return False
|
||||
@@ -374,6 +413,111 @@ class UpscalerModel:
|
||||
out_np = out.squeeze(0).permute(1, 2, 0).clamp(0.0, 1.0).cpu().numpy()
|
||||
return (out_np * 255.0).astype(np.uint8)
|
||||
|
||||
def upscale_batch(
|
||||
self, frames: list[np.ndarray], outscale: float | None = None
|
||||
) -> list[np.ndarray]:
|
||||
"""Upscale same-resolution HWC uint8 frames in one batched forward pass."""
|
||||
if not frames:
|
||||
return []
|
||||
|
||||
h, w = frames[0].shape[:2]
|
||||
if any(frame.shape[:2] != (h, w) for frame in frames):
|
||||
raise ValueError("All frames in a batch must have the same resolution")
|
||||
|
||||
total_start_time = time.perf_counter()
|
||||
|
||||
start_time = time.perf_counter()
|
||||
imgs = np.stack(frames, axis=0)
|
||||
stack_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
h2d_timer = self._start_cuda_timer()
|
||||
imgs_t = self._copy_input_to_device(imgs)
|
||||
self._stop_cuda_timer(h2d_timer)
|
||||
h2d_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
input_preprocess_timer = self._start_cuda_timer()
|
||||
imgs_t = self._preprocess_input_tensor(imgs_t)
|
||||
self._stop_cuda_timer(input_preprocess_timer)
|
||||
input_preprocess_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
forward_timer = self._start_cuda_timer()
|
||||
with torch.inference_mode():
|
||||
out = self.net(imgs_t)
|
||||
self._stop_cuda_timer(forward_timer)
|
||||
forward_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
resize_timer = None
|
||||
resize_wall_duration_s = 0.0
|
||||
if outscale is not None and outscale != self.scale:
|
||||
start_time = time.perf_counter()
|
||||
resize_timer = self._start_cuda_timer()
|
||||
target_h = int(h * outscale)
|
||||
target_w = int(w * outscale)
|
||||
out = F.interpolate(
|
||||
out, size=(target_h, target_w), mode="bicubic", align_corners=False
|
||||
)
|
||||
self._stop_cuda_timer(resize_timer)
|
||||
resize_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
output_postprocess_timer = self._start_cuda_timer()
|
||||
out = self._postprocess_output_tensor(out)
|
||||
self._stop_cuda_timer(output_postprocess_timer)
|
||||
output_postprocess_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
output_d2h_timer = self._start_cuda_timer()
|
||||
out_np = self._copy_output_to_host(out)
|
||||
self._stop_cuda_timer(output_d2h_timer)
|
||||
output_d2h_wall_duration_s = time.perf_counter() - start_time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
outputs = [frame for frame in out_np]
|
||||
post_duration_s = time.perf_counter() - start_time
|
||||
|
||||
h2d_duration_s = self._cuda_elapsed_s(h2d_timer, h2d_wall_duration_s)
|
||||
input_preprocess_duration_s = self._cuda_elapsed_s(
|
||||
input_preprocess_timer, input_preprocess_wall_duration_s
|
||||
)
|
||||
forward_duration_s = self._cuda_elapsed_s(
|
||||
forward_timer, forward_wall_duration_s
|
||||
)
|
||||
resize_duration_s = self._cuda_elapsed_s(resize_timer, resize_wall_duration_s)
|
||||
output_postprocess_duration_s = self._cuda_elapsed_s(
|
||||
output_postprocess_timer, output_postprocess_wall_duration_s
|
||||
)
|
||||
output_d2h_duration_s = self._cuda_elapsed_s(
|
||||
output_d2h_timer, output_d2h_wall_duration_s
|
||||
)
|
||||
total_duration_s = time.perf_counter() - total_start_time
|
||||
timing_source = "cuda_event" if self.device.type == "cuda" else "wall"
|
||||
logger.info(
|
||||
"RealESRGAN batch upscale: batch=%d input=%dx%d native_scale=%dx outscale=%s "
|
||||
"dtype=%s timing=%s total=%.3fs stack=%.3fs input_h2d=%.3fs "
|
||||
"input_pre=%.3fs forward=%.3fs resize=%.3fs output_post=%.3fs "
|
||||
"output_d2h=%.3fs python_post=%.3fs",
|
||||
len(frames),
|
||||
w,
|
||||
h,
|
||||
self.scale,
|
||||
outscale if outscale is not None else self.scale,
|
||||
self.dtype,
|
||||
timing_source,
|
||||
total_duration_s,
|
||||
stack_duration_s,
|
||||
h2d_duration_s,
|
||||
input_preprocess_duration_s,
|
||||
forward_duration_s,
|
||||
resize_duration_s,
|
||||
output_postprocess_duration_s,
|
||||
output_d2h_duration_s,
|
||||
post_duration_s,
|
||||
)
|
||||
return outputs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ImageUpscaler public class
|
||||
@@ -472,6 +616,42 @@ class ImageUpscaler:
|
||||
outscale = self._scale if self._scale != model.scale else None
|
||||
return [model.upscale(frame, outscale=outscale) for frame in frames]
|
||||
|
||||
def upscale_batched(self, frames: list[np.ndarray]) -> list[np.ndarray]:
|
||||
"""Upscale HWC uint8 frames with batched forwards grouped by resolution."""
|
||||
if not frames:
|
||||
return frames
|
||||
total_start_time = time.perf_counter()
|
||||
model = self._ensure_model_loaded()
|
||||
outscale = self._scale if self._scale != model.scale else None
|
||||
output_frames: list[np.ndarray | None] = [None] * len(frames)
|
||||
groups: dict[tuple[int, ...], list[int]] = {}
|
||||
for idx, frame in enumerate(frames):
|
||||
groups.setdefault(tuple(frame.shape), []).append(idx)
|
||||
|
||||
for shape, indices in groups.items():
|
||||
logger.info(
|
||||
"RealESRGAN upscale group: frames=%d shape=%s indices=%s",
|
||||
len(indices),
|
||||
shape,
|
||||
indices,
|
||||
)
|
||||
group_frames = [frames[idx] for idx in indices]
|
||||
group_outputs = model.upscale_batch(group_frames, outscale=outscale)
|
||||
for idx, output in zip(indices, group_outputs):
|
||||
output_frames[idx] = output
|
||||
|
||||
if any(frame is None for frame in output_frames):
|
||||
raise RuntimeError("RealESRGAN batch upscale did not produce all frames")
|
||||
|
||||
total_duration_s = time.perf_counter() - total_start_time
|
||||
logger.info(
|
||||
"RealESRGAN batch_upscale_frames completed in %.3f seconds for %d frames across %d groups",
|
||||
total_duration_s,
|
||||
len(frames),
|
||||
len(groups),
|
||||
)
|
||||
return [frame for frame in output_frames if frame is not None]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HF download helper
|
||||
@@ -561,6 +741,28 @@ def upscale_frames(
|
||||
List of upscaled uint8 HWC numpy frames.
|
||||
"""
|
||||
upscaler = ImageUpscaler(
|
||||
model_path=model_path, scale=scale, half_precision=half_precision
|
||||
model_path=model_path,
|
||||
scale=scale,
|
||||
half_precision=half_precision,
|
||||
)
|
||||
return upscaler.upscale(frames)
|
||||
|
||||
|
||||
def batch_upscale_frames(
|
||||
frames: list[np.ndarray],
|
||||
model_path: Optional[str] = None,
|
||||
scale: int = 4,
|
||||
) -> list[np.ndarray]:
|
||||
"""
|
||||
Batched Real-ESRGAN upscaling for realtime video paths.
|
||||
|
||||
The default ``upscale_frames`` API intentionally keeps its original
|
||||
per-frame behavior. Call this helper only when the caller can tolerate
|
||||
batched execution and same-shape grouping semantics.
|
||||
"""
|
||||
upscaler = ImageUpscaler(
|
||||
model_path=model_path,
|
||||
scale=scale,
|
||||
half_precision=current_platform.is_cuda(),
|
||||
)
|
||||
return upscaler.upscale_batched(frames)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""session-scoped realtime state, control events, and runtime-only helpers"""
|
||||
|
||||
from sglang.multimodal_gen.runtime.realtime.causal_state import RealtimeCausalDiTState
|
||||
from sglang.multimodal_gen.runtime.realtime.condition_events import (
|
||||
ConditionEvent,
|
||||
ConditionEventQueue,
|
||||
ConditionSamplingParams,
|
||||
ControlSignal,
|
||||
ControlStateSamplingQueue,
|
||||
ControlStateTransition,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
RealtimeSession,
|
||||
RealtimeSessionCache,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseRealtimeState",
|
||||
"ConditionEvent",
|
||||
"ConditionEventQueue",
|
||||
"ConditionSamplingParams",
|
||||
"ControlSignal",
|
||||
"ControlStateSamplingQueue",
|
||||
"ControlStateTransition",
|
||||
"RealtimeCausalDiTState",
|
||||
"RealtimeSession",
|
||||
"RealtimeSessionCache",
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.runtime.realtime.session import BaseRealtimeState
|
||||
|
||||
|
||||
class RealtimeCausalDiTState(BaseRealtimeState):
|
||||
"""persist causal DiT cache and frame position across realtime chunks"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.kv_cache = None
|
||||
self.crossattn_cache = None
|
||||
self.current_chunk_start_frame: int = 0
|
||||
self.chunk_idx: int = 0
|
||||
|
||||
def dispose(self) -> None:
|
||||
self.kv_cache = None
|
||||
self.crossattn_cache = None
|
||||
self.current_chunk_start_frame = 0
|
||||
self.chunk_idx = 0
|
||||
@@ -0,0 +1,289 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlSignal:
|
||||
kind: str
|
||||
payload: Any
|
||||
timestamp_ms: int | None = None
|
||||
seq_id: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlStateTransition:
|
||||
payload: Any
|
||||
timestamp_ms: int | None = None
|
||||
seq_id: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConditionEvent:
|
||||
"""transport envelope for one or more same-kind control signals"""
|
||||
|
||||
kind: str
|
||||
payload: Any
|
||||
|
||||
def iter_signals(self, expand_payload: bool = True):
|
||||
items = (
|
||||
self.payload
|
||||
if self._should_expand_payload(self.payload, expand_payload)
|
||||
else (self.payload,)
|
||||
)
|
||||
for item in items:
|
||||
if isinstance(item, ControlSignal):
|
||||
if item.kind != self.kind:
|
||||
raise ValueError(
|
||||
"control signal kind "
|
||||
f"{item.kind!r} does not match event kind {self.kind!r}"
|
||||
)
|
||||
yield item
|
||||
else:
|
||||
yield ControlSignal(kind=self.kind, payload=item)
|
||||
|
||||
@staticmethod
|
||||
def _should_expand_payload(payload: Any, expand_payload: bool) -> bool:
|
||||
return (
|
||||
expand_payload
|
||||
and isinstance(payload, Sequence)
|
||||
and not isinstance(payload, (str, bytes, bytearray))
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConditionSamplingParams:
|
||||
chunk_size: int
|
||||
default_item: Any = _MISSING
|
||||
repeat_last: bool = True
|
||||
repeat_last_across_empty_chunks: bool = False
|
||||
expand_payload: bool = True
|
||||
|
||||
|
||||
class ConditionEventQueue:
|
||||
"""per-session queue for prompt, camera, audio, and future events
|
||||
|
||||
all events are stored here for per-chunk sampling
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_events: int | Mapping[str, int] = 512,
|
||||
) -> None:
|
||||
self._max_events = max_events
|
||||
self._events: dict[str, deque[ConditionEvent]] = {}
|
||||
self._pending_signals: dict[str, deque[ControlSignal]] = {}
|
||||
self._last_payloads: dict[str, Any] = {}
|
||||
self._last_sampled_seq_ids: dict[str, int | None] = {}
|
||||
self._seen_kinds: set[str] = set()
|
||||
|
||||
def push(self, event: ConditionEvent) -> None:
|
||||
queue = self._queue_for(event.kind)
|
||||
queue.append(event)
|
||||
self._seen_kinds.add(event.kind)
|
||||
|
||||
def replace(self, event: ConditionEvent) -> None:
|
||||
self.clear_kind(event.kind)
|
||||
self.push(event)
|
||||
|
||||
def pop_latest(self, kind: str) -> Any | None:
|
||||
queue = self._events.get(kind)
|
||||
if not queue:
|
||||
return None
|
||||
latest_payload = None
|
||||
has_signal = False
|
||||
for signal in queue.pop().iter_signals():
|
||||
latest_payload = signal.payload
|
||||
has_signal = True
|
||||
self._last_sampled_seq_ids[kind] = signal.seq_id
|
||||
queue.clear()
|
||||
self._seen_kinds.add(kind)
|
||||
if not has_signal:
|
||||
return None
|
||||
return latest_payload
|
||||
|
||||
def has_events(self, kind: str) -> bool:
|
||||
queue = self._events.get(kind)
|
||||
pending = self._pending_signals.get(kind)
|
||||
return bool(queue) or bool(pending)
|
||||
|
||||
def sample_chunk(
|
||||
self,
|
||||
kind: str,
|
||||
params: ConditionSamplingParams,
|
||||
) -> list[Any] | None:
|
||||
"""samples a list of actions for a chunk
|
||||
|
||||
Args:
|
||||
params: the sampling strategy
|
||||
|
||||
"""
|
||||
if params.chunk_size <= 0:
|
||||
return None
|
||||
|
||||
chunk: list[Any] = []
|
||||
pending = self._pending_signals.get(kind)
|
||||
self._drain_signals(kind, pending, chunk, params.chunk_size)
|
||||
|
||||
queue = self._events.get(kind)
|
||||
while len(chunk) < params.chunk_size and queue:
|
||||
event = queue.popleft()
|
||||
signals = deque(event.iter_signals(params.expand_payload))
|
||||
self._drain_signals(kind, signals, chunk, params.chunk_size)
|
||||
if signals:
|
||||
self._pending_signals[kind] = signals
|
||||
|
||||
if len(chunk) == 0 and kind not in self._seen_kinds:
|
||||
if params.default_item is _MISSING:
|
||||
return None
|
||||
return [params.default_item for _ in range(params.chunk_size)]
|
||||
|
||||
if len(chunk) == 0:
|
||||
if params.repeat_last_across_empty_chunks and kind in self._last_payloads:
|
||||
return [self._last_payloads[kind] for _ in range(params.chunk_size)]
|
||||
if params.default_item is _MISSING:
|
||||
return None
|
||||
return [params.default_item for _ in range(params.chunk_size)]
|
||||
|
||||
if not params.repeat_last:
|
||||
return chunk
|
||||
|
||||
pad_item = self._last_payloads.get(kind, params.default_item)
|
||||
if pad_item is _MISSING:
|
||||
return chunk
|
||||
while len(chunk) < params.chunk_size:
|
||||
chunk.append(pad_item)
|
||||
return chunk
|
||||
|
||||
def clear(self) -> None:
|
||||
self._events.clear()
|
||||
self._pending_signals.clear()
|
||||
self._last_payloads.clear()
|
||||
self._last_sampled_seq_ids.clear()
|
||||
self._seen_kinds.clear()
|
||||
|
||||
def clear_kind(self, kind: str) -> None:
|
||||
self._events.pop(kind, None)
|
||||
self._pending_signals.pop(kind, None)
|
||||
self._last_payloads.pop(kind, None)
|
||||
self._last_sampled_seq_ids.pop(kind, None)
|
||||
self._seen_kinds.discard(kind)
|
||||
|
||||
def last_sampled_seq_id(self, kind: str) -> int | None:
|
||||
return self._last_sampled_seq_ids.get(kind)
|
||||
|
||||
def _queue_for(self, kind: str) -> deque[ConditionEvent]:
|
||||
queue = self._events.get(kind)
|
||||
if queue is None:
|
||||
if isinstance(self._max_events, Mapping):
|
||||
maxlen = self._max_events.get(kind, 512)
|
||||
else:
|
||||
maxlen = self._max_events
|
||||
queue = deque(maxlen=maxlen)
|
||||
self._events[kind] = queue
|
||||
return queue
|
||||
|
||||
def _drain_signals(
|
||||
self,
|
||||
kind: str,
|
||||
signals: deque[ControlSignal] | None,
|
||||
chunk: list[Any],
|
||||
chunk_size: int,
|
||||
) -> None:
|
||||
while signals and len(chunk) < chunk_size:
|
||||
signal = signals.popleft()
|
||||
chunk.append(signal.payload)
|
||||
self._last_payloads[kind] = signal.payload
|
||||
self._last_sampled_seq_ids[kind] = signal.seq_id
|
||||
|
||||
|
||||
class ControlStateSamplingQueue:
|
||||
"""state-based control sampler for realtime inputs"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_item: Any,
|
||||
min_pulse_items: int = 1,
|
||||
max_transitions: int = 512,
|
||||
) -> None:
|
||||
self.default_item = default_item
|
||||
self.min_pulse_items = min_pulse_items
|
||||
self._pending: deque[ControlStateTransition] = deque(maxlen=max_transitions)
|
||||
self._current_item = default_item
|
||||
self._current_seq_id: int | None = None
|
||||
self._latest_sampled_seq_id: int | None = None
|
||||
|
||||
def clear(self) -> None:
|
||||
self._pending.clear()
|
||||
self._current_item = self.default_item
|
||||
self._current_seq_id = None
|
||||
self._latest_sampled_seq_id = None
|
||||
|
||||
def push(self, transition: ControlStateTransition) -> None:
|
||||
self._pending.append(transition)
|
||||
|
||||
def push_many(self, transitions: Sequence[ControlStateTransition]) -> None:
|
||||
for transition in transitions:
|
||||
self.push(transition)
|
||||
|
||||
def sample_chunk(self, chunk_size: int) -> list[Any] | None:
|
||||
if chunk_size <= 0:
|
||||
return None
|
||||
|
||||
transitions = self._drain_pending()
|
||||
if not transitions:
|
||||
self._latest_sampled_seq_id = self._current_seq_id
|
||||
return [self._copy_item(self._current_item) for _ in range(chunk_size)]
|
||||
|
||||
pulse = self._latest_non_default_transition(transitions)
|
||||
final = transitions[-1]
|
||||
self._current_item = final.payload
|
||||
self._current_seq_id = final.seq_id
|
||||
|
||||
if pulse is not None and pulse.payload != final.payload:
|
||||
pulse_items = min(self.min_pulse_items, chunk_size)
|
||||
chunk = [self._copy_item(pulse.payload) for _ in range(pulse_items)]
|
||||
chunk.extend(
|
||||
self._copy_item(final.payload) for _ in range(chunk_size - pulse_items)
|
||||
)
|
||||
self._latest_sampled_seq_id = (
|
||||
final.seq_id if len(chunk) > pulse_items else pulse.seq_id
|
||||
)
|
||||
return chunk
|
||||
|
||||
self._latest_sampled_seq_id = final.seq_id
|
||||
return [self._copy_item(final.payload) for _ in range(chunk_size)]
|
||||
|
||||
def latest_sampled_seq_id(self) -> int | None:
|
||||
return self._latest_sampled_seq_id
|
||||
|
||||
def _drain_pending(self) -> list[ControlStateTransition]:
|
||||
transitions = list(self._pending)
|
||||
self._pending.clear()
|
||||
return transitions
|
||||
|
||||
def _latest_non_default_transition(
|
||||
self,
|
||||
transitions: Sequence[ControlStateTransition],
|
||||
) -> ControlStateTransition | None:
|
||||
for transition in reversed(transitions):
|
||||
if transition.payload != self.default_item:
|
||||
return transition
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _copy_item(item: Any) -> Any:
|
||||
if isinstance(item, list):
|
||||
return copy.deepcopy(item)
|
||||
if isinstance(item, dict):
|
||||
return copy.deepcopy(item)
|
||||
return item
|
||||
@@ -0,0 +1,111 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class BaseRealtimeState:
|
||||
"""per-session state owned by pipeline stages"""
|
||||
|
||||
def dispose(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class RealtimeSession:
|
||||
"""reusable state container across realtime request chunks"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._states: dict[type[BaseRealtimeState], BaseRealtimeState] = {}
|
||||
|
||||
@staticmethod
|
||||
def resolve_session_id(req: Any) -> str | None:
|
||||
session_id = req.realtime_session_id
|
||||
if isinstance(session_id, str) and session_id:
|
||||
return session_id
|
||||
return None
|
||||
|
||||
def get_or_create_state(
|
||||
self, state_cls: type[BaseRealtimeState]
|
||||
) -> BaseRealtimeState:
|
||||
state = self._states.get(state_cls)
|
||||
if state is None:
|
||||
state = state_cls()
|
||||
self._states[state_cls] = state
|
||||
return state
|
||||
|
||||
def get_state(self, state_cls: type[BaseRealtimeState]) -> BaseRealtimeState | None:
|
||||
return self._states.get(state_cls)
|
||||
|
||||
def dispose(self) -> None:
|
||||
for state in list(self._states.values()):
|
||||
state.dispose()
|
||||
self._states.clear()
|
||||
|
||||
|
||||
class RealtimeSessionCache:
|
||||
"""lru cache that binds incoming chunks to persistent realtime sessions"""
|
||||
|
||||
def __init__(self, max_sessions: int = 64) -> None:
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: OrderedDict[str, RealtimeSession] = OrderedDict()
|
||||
|
||||
def _dispose_session(
|
||||
self, session_id: str, session: RealtimeSession | None
|
||||
) -> None:
|
||||
if session is None:
|
||||
return
|
||||
try:
|
||||
session.dispose()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to dispose realtime session cache entry %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def release(self, session_id: str) -> bool:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
released = session is not None
|
||||
self._dispose_session(session_id, session)
|
||||
logger.info(
|
||||
"Realtime session release: session_id=%s released=%s",
|
||||
session_id,
|
||||
released,
|
||||
)
|
||||
return released
|
||||
|
||||
def attach(self, req: Any) -> None:
|
||||
session_id = RealtimeSession.resolve_session_id(req)
|
||||
if session_id is None:
|
||||
return
|
||||
|
||||
if session_id not in self._sessions:
|
||||
if req.block_idx > 0:
|
||||
raise ValueError(
|
||||
"Missing realtime session state for "
|
||||
f"session_id={session_id} block_idx={req.block_idx}."
|
||||
)
|
||||
self._sessions[session_id] = req.session or RealtimeSession()
|
||||
elif req.block_idx == 0:
|
||||
old_session = self._sessions[session_id]
|
||||
new_session = req.session or RealtimeSession()
|
||||
if old_session is not new_session:
|
||||
self._dispose_session(session_id, old_session)
|
||||
self._sessions[session_id] = new_session
|
||||
logger.info("Realtime session reset: session_id=%s", session_id)
|
||||
|
||||
req.session = self._sessions[session_id]
|
||||
self._sessions.move_to_end(session_id)
|
||||
self._evict_stale_sessions()
|
||||
|
||||
def _evict_stale_sessions(self) -> None:
|
||||
while len(self._sessions) > self.max_sessions:
|
||||
stale_session_id, stale_session = self._sessions.popitem(last=False)
|
||||
self._dispose_session(stale_session_id, stale_session)
|
||||
logger.debug("Evicted stale realtime session cache: %s", stale_session_id)
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Camera pose and Plucker ray utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def se3_inverse(T: torch.Tensor) -> torch.Tensor:
|
||||
rot = T[:, :3, :3]
|
||||
trans = T[:, :3, 3:]
|
||||
r_inv = rot.transpose(-1, -2)
|
||||
t_inv = -torch.bmm(r_inv, trans)
|
||||
T_inv = torch.eye(4, device=T.device, dtype=T.dtype)[None, :, :].repeat(
|
||||
T.shape[0], 1, 1
|
||||
)
|
||||
T_inv[:, :3, :3] = r_inv
|
||||
T_inv[:, :3, 3:] = t_inv
|
||||
return T_inv
|
||||
|
||||
|
||||
def compute_relative_poses(
|
||||
c2ws_mat: torch.Tensor,
|
||||
framewise: bool = False,
|
||||
normalize_trans: bool = True,
|
||||
) -> torch.Tensor:
|
||||
ref_w2cs = se3_inverse(c2ws_mat[0:1])
|
||||
relative_poses = torch.matmul(ref_w2cs, c2ws_mat)
|
||||
relative_poses[0] = torch.eye(4, device=c2ws_mat.device, dtype=c2ws_mat.dtype)
|
||||
if framewise and len(relative_poses) > 1:
|
||||
relative_poses_framewise = torch.bmm(
|
||||
se3_inverse(relative_poses[:-1]), relative_poses[1:]
|
||||
)
|
||||
relative_poses[1:] = relative_poses_framewise
|
||||
if normalize_trans:
|
||||
translations = relative_poses[:, :3, 3]
|
||||
max_norm = torch.norm(translations, dim=-1).max()
|
||||
if max_norm > 0:
|
||||
relative_poses[:, :3, 3] = translations / max_norm
|
||||
return relative_poses
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def create_meshgrid(
|
||||
n_frames: int,
|
||||
height: int,
|
||||
width: int,
|
||||
*,
|
||||
bias: float = 0.5,
|
||||
device: torch.device | str,
|
||||
dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
x_range = torch.arange(width, device=device, dtype=dtype)
|
||||
y_range = torch.arange(height, device=device, dtype=dtype)
|
||||
grid_y, grid_x = torch.meshgrid(y_range, x_range, indexing="ij")
|
||||
grid_xy = torch.stack([grid_x, grid_y], dim=-1).view([-1, 2]) + bias
|
||||
return grid_xy[None, ...].repeat(n_frames, 1, 1)
|
||||
|
||||
|
||||
def get_plucker_embeddings(
|
||||
c2ws_mat: torch.Tensor,
|
||||
Ks: torch.Tensor,
|
||||
height: int,
|
||||
width: int,
|
||||
) -> torch.Tensor:
|
||||
n_frames = c2ws_mat.shape[0]
|
||||
grid_xy = create_meshgrid(
|
||||
n_frames, height, width, device=c2ws_mat.device, dtype=c2ws_mat.dtype
|
||||
)
|
||||
fx, fy, cx, cy = Ks.chunk(4, dim=-1)
|
||||
i = grid_xy[..., 0]
|
||||
j = grid_xy[..., 1]
|
||||
zs = torch.ones_like(i)
|
||||
xs = (i - cx) / fx * zs
|
||||
ys = (j - cy) / fy * zs
|
||||
directions = torch.stack([xs, ys, zs], dim=-1)
|
||||
directions = directions / directions.norm(dim=-1, keepdim=True)
|
||||
rays_d = directions @ c2ws_mat[:, :3, :3].transpose(-1, -2)
|
||||
rays_o = c2ws_mat[:, :3, 3][:, None, :].expand_as(rays_d)
|
||||
plucker_embeddings = torch.cat([rays_o, rays_d], dim=-1)
|
||||
return plucker_embeddings.view([n_frames, height, width, 6])
|
||||
|
||||
|
||||
def camera_poses_to_plucker(
|
||||
*,
|
||||
c2ws: torch.Tensor,
|
||||
Ks: torch.Tensor,
|
||||
height: int,
|
||||
width: int,
|
||||
spatial_scale: int = 8,
|
||||
device: torch.device | str,
|
||||
dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
plucker = get_plucker_embeddings(c2ws, Ks, height, width)
|
||||
latent_height = height // spatial_scale
|
||||
latent_width = width // spatial_scale
|
||||
plucker = plucker.view(
|
||||
c2ws.shape[0],
|
||||
latent_height,
|
||||
spatial_scale,
|
||||
latent_width,
|
||||
spatial_scale,
|
||||
6,
|
||||
)
|
||||
plucker = plucker.permute(0, 1, 3, 5, 2, 4).contiguous()
|
||||
plucker = plucker.view(
|
||||
c2ws.shape[0],
|
||||
latent_height,
|
||||
latent_width,
|
||||
6 * spatial_scale * spatial_scale,
|
||||
)
|
||||
return (
|
||||
plucker.permute(3, 0, 1, 2)
|
||||
.contiguous()
|
||||
.unsqueeze(0)
|
||||
.to(device=device, dtype=dtype)
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import zlib
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
|
||||
OutputBatch,
|
||||
Req,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
RAW_RGB_CONTENT_TYPE = "application/x-raw-rgb"
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgb-delta-gzip"
|
||||
RAW_RGBA_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgba-delta-gzip"
|
||||
WEBP_FRAME_CONTENT_TYPE = "image/webp"
|
||||
JPEG_FRAME_CONTENT_TYPE = "image/jpeg"
|
||||
RAW_RGB_CHANNELS = 3
|
||||
RAW_RGBA_CHANNELS = 4
|
||||
|
||||
|
||||
def build_delta_gzip_raw_rgb_payload(
|
||||
frames: list[bytes],
|
||||
*,
|
||||
reference_frame: bytes | None = None,
|
||||
) -> bytes:
|
||||
if not frames:
|
||||
return b""
|
||||
|
||||
frame_size = len(frames[0])
|
||||
if reference_frame is not None and len(reference_frame) != frame_size:
|
||||
raise ValueError("raw RGB delta gzip reference frame size mismatch")
|
||||
|
||||
previous = (
|
||||
np.frombuffer(reference_frame, dtype=np.uint8)
|
||||
if reference_frame is not None
|
||||
else None
|
||||
)
|
||||
compressor = zlib.compressobj(level=1, method=zlib.DEFLATED, wbits=31)
|
||||
compressed_chunks = []
|
||||
for frame in frames:
|
||||
if len(frame) != frame_size:
|
||||
raise ValueError("raw RGB delta gzip requires fixed-size frames")
|
||||
current = np.frombuffer(frame, dtype=np.uint8)
|
||||
if previous is None:
|
||||
delta_frame = frame
|
||||
else:
|
||||
delta_frame = np.bitwise_xor(current, previous).tobytes()
|
||||
compressed_chunks.append(compressor.compress(delta_frame))
|
||||
previous = current
|
||||
|
||||
compressed_chunks.append(compressor.flush())
|
||||
return b"".join(compressed_chunks)
|
||||
|
||||
|
||||
def restore_delta_gzip_raw_rgb_payload(
|
||||
payload: bytes,
|
||||
*,
|
||||
bytes_per_frame: int,
|
||||
num_frames: int,
|
||||
reference_frame: bytes | None = None,
|
||||
) -> bytes:
|
||||
if reference_frame is not None and len(reference_frame) != bytes_per_frame:
|
||||
raise ValueError("delta gzip reference frame size mismatch")
|
||||
|
||||
delta_payload = zlib.decompress(payload, wbits=31)
|
||||
expected_size = bytes_per_frame * num_frames
|
||||
if len(delta_payload) != expected_size:
|
||||
raise ValueError(
|
||||
"delta gzip payload size mismatch: "
|
||||
f"expected {expected_size}, got {len(delta_payload)}"
|
||||
)
|
||||
|
||||
restored = bytearray(delta_payload)
|
||||
previous = (
|
||||
np.frombuffer(reference_frame, dtype=np.uint8)
|
||||
if reference_frame is not None
|
||||
else None
|
||||
)
|
||||
for frame_idx in range(num_frames):
|
||||
offset = frame_idx * bytes_per_frame
|
||||
current = np.frombuffer(
|
||||
restored, dtype=np.uint8, count=bytes_per_frame, offset=offset
|
||||
)
|
||||
if previous is not None:
|
||||
current ^= previous
|
||||
previous = current
|
||||
return bytes(restored)
|
||||
|
||||
|
||||
def build_raw_rgb_frame_batches(
|
||||
output: Any,
|
||||
req: Req,
|
||||
output_batch: OutputBatch,
|
||||
post_process_sample_fn: Callable[..., Any],
|
||||
) -> tuple[list[list[bytes]], dict[str, Any]]:
|
||||
"""post-process for realtime responses, returns only the batched frames and metadata"""
|
||||
start = time.monotonic()
|
||||
sample_to_frames_ms = 0.0
|
||||
frames_to_bytes_ms = 0.0
|
||||
raw_bytes = 0
|
||||
num_frames = 0
|
||||
frame_shape = None
|
||||
frame_batches = []
|
||||
if isinstance(output, torch.Tensor):
|
||||
outputs = list(output)
|
||||
else:
|
||||
outputs = output if isinstance(output, Sequence) else [output]
|
||||
|
||||
for sample in outputs:
|
||||
stage_start = time.monotonic()
|
||||
if (
|
||||
isinstance(sample, torch.Tensor)
|
||||
and not req.enable_frame_interpolation
|
||||
and not req.enable_upscaling
|
||||
):
|
||||
frames = _tensor_sample_to_rgb24_array(sample)
|
||||
else:
|
||||
frames = post_process_sample_fn(
|
||||
sample,
|
||||
req.data_type,
|
||||
req.fps,
|
||||
False,
|
||||
None,
|
||||
audio_sample_rate=output_batch.audio_sample_rate,
|
||||
output_compression=req.output_compression,
|
||||
enable_frame_interpolation=req.enable_frame_interpolation,
|
||||
frame_interpolation_exp=req.frame_interpolation_exp,
|
||||
frame_interpolation_scale=req.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=req.frame_interpolation_model_path,
|
||||
enable_upscaling=False,
|
||||
upscaling_model_path=req.upscaling_model_path,
|
||||
upscaling_scale=req.upscaling_scale,
|
||||
)
|
||||
if req.enable_upscaling and frames:
|
||||
from sglang.multimodal_gen.runtime.postprocess import (
|
||||
batch_upscale_frames,
|
||||
)
|
||||
|
||||
frames = batch_upscale_frames(
|
||||
frames,
|
||||
model_path=req.upscaling_model_path,
|
||||
scale=req.upscaling_scale,
|
||||
)
|
||||
sample_to_frames_ms += (time.monotonic() - stage_start) * 1000.0
|
||||
|
||||
stage_start = time.monotonic()
|
||||
|
||||
# numpy frames to RGB24 bytes
|
||||
raw_frames = []
|
||||
for frame in frames:
|
||||
if frame.ndim == 2:
|
||||
frame = frame[:, :, None]
|
||||
if frame.shape[-1] == 1:
|
||||
frame = np.repeat(frame, 3, axis=-1)
|
||||
elif frame.shape[-1] > RAW_RGB_CHANNELS:
|
||||
frame = frame[:, :, :RAW_RGB_CHANNELS]
|
||||
frame = np.ascontiguousarray(frame)
|
||||
frame_shape = tuple(int(dim) for dim in frame.shape)
|
||||
frame_bytes = frame.tobytes()
|
||||
raw_bytes += len(frame_bytes)
|
||||
num_frames += 1
|
||||
raw_frames.append(frame_bytes)
|
||||
frames_to_bytes_ms += (time.monotonic() - stage_start) * 1000.0
|
||||
frame_batches.append(raw_frames)
|
||||
|
||||
total_ms = (time.monotonic() - start) * 1000.0
|
||||
logger.info(
|
||||
"realtime raw RGB frame batch timing: request_id=%s "
|
||||
"chunk_idx=%s sample_to_frames=%.2fms frames_to_bytes=%.2fms "
|
||||
"total=%.2fms batches=%d frames=%d frame_shape=%s "
|
||||
"raw_bytes=%d content_type=%s",
|
||||
req.request_id,
|
||||
req.block_idx,
|
||||
sample_to_frames_ms,
|
||||
frames_to_bytes_ms,
|
||||
total_ms,
|
||||
len(frame_batches),
|
||||
num_frames,
|
||||
frame_shape,
|
||||
raw_bytes,
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
)
|
||||
frame_metadata: dict[str, Any] = {}
|
||||
if frame_shape is not None and len(frame_shape) == 3:
|
||||
frame_height, frame_width, channels = frame_shape
|
||||
frame_metadata = {
|
||||
"format": "rgb24",
|
||||
"width": frame_width,
|
||||
"height": frame_height,
|
||||
"channels": channels,
|
||||
"bytes_per_frame": frame_width * frame_height * channels,
|
||||
}
|
||||
return frame_batches, frame_metadata
|
||||
|
||||
|
||||
def _tensor_sample_to_rgb24_array(sample: torch.Tensor) -> np.ndarray:
|
||||
if sample.dim() == 3:
|
||||
sample = sample.unsqueeze(1)
|
||||
sample = (sample * 255).clamp(0, 255).to(torch.uint8)
|
||||
return sample.permute(1, 2, 3, 0).contiguous().cpu().numpy()
|
||||
@@ -18,6 +18,7 @@ from sglang.multimodal_gen.test.server.testcase_configs import (
|
||||
DiffusionSamplingParams,
|
||||
DiffusionServerArgs,
|
||||
DiffusionTestCase,
|
||||
LINGBOT_WORLD_REALTIME_sampling_params,
|
||||
MODELOPT_T2I_CI_sampling_params,
|
||||
MODELOPT_T2V_CI_sampling_params,
|
||||
MODELOPT_TI2I_CI_sampling_params,
|
||||
@@ -54,6 +55,24 @@ from sglang.multimodal_gen.test.test_utils import (
|
||||
|
||||
_CACHE_DIT_CONFIG_DIR = Path(__file__).parent / "configs"
|
||||
|
||||
|
||||
def _make_lingbot_realtime_plastic_beach_case() -> DiffusionTestCase:
|
||||
return DiffusionTestCase(
|
||||
"lingbot_world_realtime_plastic_beach",
|
||||
DiffusionServerArgs(
|
||||
model_path="robbyant/lingbot-world-fast-diffusers",
|
||||
modality="video",
|
||||
num_gpus=1,
|
||||
extras=["--pipeline-class-name LingBotWorldCausalDMDPipeline"],
|
||||
text_encoder_cpu_offload=True,
|
||||
),
|
||||
LINGBOT_WORLD_REALTIME_sampling_params,
|
||||
run_component_accuracy_check=False,
|
||||
run_models_api_check=False,
|
||||
run_t2v_input_reference_check=False,
|
||||
)
|
||||
|
||||
|
||||
# All test cases with clean default values
|
||||
# To test different models, simply add more DiffusionCase entries
|
||||
ONE_GPU_CASES: list[DiffusionTestCase] = [
|
||||
@@ -743,5 +762,7 @@ if not current_platform.is_hip():
|
||||
)
|
||||
)
|
||||
|
||||
ONE_GPU_CASES.append(_make_lingbot_realtime_plastic_beach_case())
|
||||
|
||||
ONE_GPU_CASES += ONE_GPU_MODELOPT_FP8_CASES
|
||||
TWO_GPU_CASES = _with_default_num_gpus(TWO_GPU_CASES, 2)
|
||||
|
||||
@@ -2605,6 +2605,14 @@
|
||||
"expected_median_denoise_ms": 246.85,
|
||||
"estimated_full_test_time_s": 170.0
|
||||
},
|
||||
"lingbot_world_realtime_plastic_beach": {
|
||||
"stages_ms": {},
|
||||
"denoise_step_ms": {},
|
||||
"expected_e2e_ms": 0.0,
|
||||
"expected_avg_denoise_ms": 0.0,
|
||||
"expected_median_denoise_ms": 0.0,
|
||||
"estimated_full_test_time_s": 126.0
|
||||
},
|
||||
"ltx_2_3_hq_pipeline": {
|
||||
"stages_ms": {
|
||||
"InputValidationStage": 0.08,
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import statistics
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import imageio
|
||||
import msgspec.msgpack
|
||||
import numpy as np
|
||||
import pytest
|
||||
from openai import Client
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
RAW_RGBA_DELTA_GZIP_CONTENT_TYPE,
|
||||
restore_delta_gzip_raw_rgb_payload,
|
||||
)
|
||||
from sglang.multimodal_gen.test.server.testcase_configs import DiffusionSamplingParams
|
||||
from sglang.multimodal_gen.test.test_utils import is_image_url
|
||||
|
||||
_REALTIME_WS_TIMEOUT_SECS = float(
|
||||
os.environ.get("SGLANG_TEST_REALTIME_WS_TIMEOUT_SECS", "1200")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeChunkStats:
|
||||
chunk_index: int
|
||||
request_id: str | None
|
||||
content_type: str
|
||||
num_frames: int
|
||||
raw_bytes: int
|
||||
ws_payload_bytes: int
|
||||
request_prepare_ms: float
|
||||
scheduler_forward_ms: float
|
||||
raw_payload_build_ms: float
|
||||
raw_write_ms: float
|
||||
ws_write_ms: float
|
||||
chunk_total_ms: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeCollectionResult:
|
||||
frames: list[np.ndarray]
|
||||
chunk_stats: list[RealtimeChunkStats]
|
||||
|
||||
|
||||
_REALTIME_CHUNK_STATS_BY_CASE: dict[str, list[RealtimeChunkStats]] = {}
|
||||
_REALTIME_KEY_FRAMES_BY_CASE: dict[str, list[np.ndarray]] = {}
|
||||
|
||||
|
||||
def realtime_ws_url(client: Client) -> str:
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
if base_url.startswith("https://"):
|
||||
return "wss://" + base_url[len("https://") :] + "/realtime_video/generate"
|
||||
if base_url.startswith("http://"):
|
||||
return "ws://" + base_url[len("http://") :] + "/realtime_video/generate"
|
||||
raise ValueError(f"Unsupported realtime client base_url: {base_url}")
|
||||
|
||||
|
||||
def prepare_realtime_first_frame(
|
||||
image_path: Path | str | list[Path | str] | None,
|
||||
) -> bytes | str | None:
|
||||
if image_path is None:
|
||||
return None
|
||||
if isinstance(image_path, list):
|
||||
image_path = image_path[0]
|
||||
if isinstance(image_path, str) and is_image_url(image_path):
|
||||
return image_path
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Realtime first frame file missing: {path}")
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def build_realtime_init_payload(
|
||||
*,
|
||||
model_path: str,
|
||||
sampling_params: DiffusionSamplingParams,
|
||||
output_size: str,
|
||||
first_frame: bytes | str | None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"type": "init",
|
||||
"model": model_path,
|
||||
"prompt": sampling_params.prompt,
|
||||
"size": output_size,
|
||||
"seconds": sampling_params.seconds,
|
||||
"first_frame": first_frame,
|
||||
}
|
||||
optional_fields = {
|
||||
"fps": sampling_params.fps,
|
||||
"num_frames": sampling_params.num_frames,
|
||||
"realtime_output_format": sampling_params.realtime_output_format,
|
||||
}
|
||||
payload.update({k: v for k, v in optional_fields.items() if v is not None})
|
||||
payload.update(dict(sampling_params.extras))
|
||||
return {k: v for k, v in payload.items() if v is not None}
|
||||
|
||||
|
||||
def build_realtime_event_payload(event: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(event)
|
||||
payload.pop("after_chunk", None)
|
||||
payload.setdefault("type", "event")
|
||||
if "kind" not in payload:
|
||||
raise ValueError("realtime event config must include kind")
|
||||
return payload
|
||||
|
||||
|
||||
def parse_realtime_chunk_stats(header: dict[str, Any]) -> RealtimeChunkStats:
|
||||
if header.get("type") != "chunk_stats":
|
||||
raise ValueError(f"Unexpected realtime chunk stats message: {header}")
|
||||
return RealtimeChunkStats(
|
||||
chunk_index=int(header["chunk_index"]),
|
||||
request_id=header.get("request_id"),
|
||||
content_type=str(header.get("content_type", "")),
|
||||
num_frames=int(header.get("num_frames", 0)),
|
||||
raw_bytes=int(header.get("raw_bytes", 0)),
|
||||
ws_payload_bytes=int(header.get("ws_payload_bytes", 0)),
|
||||
request_prepare_ms=float(header.get("request_prepare_ms", 0.0)),
|
||||
scheduler_forward_ms=float(header.get("scheduler_forward_ms", 0.0)),
|
||||
raw_payload_build_ms=float(header.get("raw_payload_build_ms", 0.0)),
|
||||
raw_write_ms=float(header.get("raw_write_ms", 0.0)),
|
||||
ws_write_ms=float(header.get("ws_write_ms", 0.0)),
|
||||
chunk_total_ms=float(header.get("chunk_total_ms", 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def summarize_realtime_perf_stats(
|
||||
chunk_stats: list[RealtimeChunkStats],
|
||||
*,
|
||||
ignore_initial_chunks: int = 0,
|
||||
) -> dict[str, float]:
|
||||
if not chunk_stats:
|
||||
return {}
|
||||
if ignore_initial_chunks < 0:
|
||||
raise ValueError("ignore_initial_chunks must be non-negative")
|
||||
if ignore_initial_chunks >= len(chunk_stats):
|
||||
raise ValueError(
|
||||
"ignore_initial_chunks must leave at least one realtime chunk to guard"
|
||||
)
|
||||
|
||||
ignored_stats = chunk_stats[:ignore_initial_chunks]
|
||||
guarded_stats = chunk_stats[ignore_initial_chunks:]
|
||||
|
||||
metrics = {
|
||||
"request_prepare_ms": [s.request_prepare_ms for s in guarded_stats],
|
||||
"scheduler_forward_ms": [s.scheduler_forward_ms for s in guarded_stats],
|
||||
"raw_payload_build_ms": [s.raw_payload_build_ms for s in guarded_stats],
|
||||
"raw_write_ms": [s.raw_write_ms for s in guarded_stats],
|
||||
"ws_write_ms": [s.ws_write_ms for s in guarded_stats],
|
||||
"chunk_total_ms": [s.chunk_total_ms for s in guarded_stats],
|
||||
"ws_payload_mb": [s.ws_payload_bytes / (1024 * 1024) for s in guarded_stats],
|
||||
}
|
||||
summary: dict[str, float] = {
|
||||
"num_chunks": float(len(chunk_stats)),
|
||||
"total_frames": float(sum(s.num_frames for s in chunk_stats)),
|
||||
"ignored_initial_chunks": float(ignore_initial_chunks),
|
||||
"guarded_chunks": float(len(guarded_stats)),
|
||||
}
|
||||
if ignored_stats:
|
||||
summary["ignored_max_chunk_total_ms"] = max(
|
||||
s.chunk_total_ms for s in ignored_stats
|
||||
)
|
||||
summary["ignored_max_scheduler_forward_ms"] = max(
|
||||
s.scheduler_forward_ms for s in ignored_stats
|
||||
)
|
||||
for name, values in metrics.items():
|
||||
sorted_values = sorted(values)
|
||||
p95_idx = min(len(sorted_values) - 1, int(len(sorted_values) * 0.95))
|
||||
summary[f"avg_{name}"] = statistics.fmean(values)
|
||||
summary[f"p95_{name}"] = sorted_values[p95_idx]
|
||||
summary[f"max_{name}"] = max(values)
|
||||
return summary
|
||||
|
||||
|
||||
def validate_realtime_perf_stats(
|
||||
case_id: str,
|
||||
chunk_stats: list[RealtimeChunkStats],
|
||||
thresholds: dict[str, float],
|
||||
*,
|
||||
ignore_initial_chunks: int = 0,
|
||||
) -> None:
|
||||
if not thresholds:
|
||||
return
|
||||
summary = summarize_realtime_perf_stats(
|
||||
chunk_stats, ignore_initial_chunks=ignore_initial_chunks
|
||||
)
|
||||
if not summary:
|
||||
pytest.fail(f"{case_id}: no realtime chunk stats were received")
|
||||
|
||||
failures = []
|
||||
for metric_name, threshold in thresholds.items():
|
||||
if metric_name not in summary:
|
||||
raise ValueError(
|
||||
f"{case_id}: unknown realtime perf metric {metric_name!r}; "
|
||||
f"available metrics: {sorted(summary)}"
|
||||
)
|
||||
actual = summary[metric_name]
|
||||
if actual > threshold:
|
||||
failures.append(
|
||||
f"{metric_name}: actual={actual:.2f}, limit={threshold:.2f}"
|
||||
)
|
||||
|
||||
if failures:
|
||||
pytest.fail(
|
||||
f"Realtime performance guard failed for {case_id}:\n"
|
||||
+ "\n".join(f" - {failure}" for failure in failures)
|
||||
)
|
||||
|
||||
|
||||
def record_realtime_perf_stats(
|
||||
case_id: str, chunk_stats: list[RealtimeChunkStats]
|
||||
) -> None:
|
||||
_REALTIME_CHUNK_STATS_BY_CASE[case_id] = list(chunk_stats)
|
||||
|
||||
|
||||
def pop_realtime_perf_stats(case_id: str) -> list[RealtimeChunkStats]:
|
||||
return _REALTIME_CHUNK_STATS_BY_CASE.pop(case_id, [])
|
||||
|
||||
|
||||
def select_realtime_key_frames(frames: list[np.ndarray]) -> list[np.ndarray]:
|
||||
if not frames:
|
||||
return []
|
||||
key_indices = [0, len(frames) // 2, len(frames) - 1]
|
||||
return [frames[idx].copy() for idx in key_indices]
|
||||
|
||||
|
||||
def record_realtime_key_frames(case_id: str, frames: list[np.ndarray]) -> None:
|
||||
_REALTIME_KEY_FRAMES_BY_CASE[case_id] = select_realtime_key_frames(frames)
|
||||
|
||||
|
||||
def pop_realtime_key_frames(case_id: str) -> list[np.ndarray] | None:
|
||||
return _REALTIME_KEY_FRAMES_BY_CASE.pop(case_id, None)
|
||||
|
||||
|
||||
def decode_realtime_raw_rgb_frames(
|
||||
header: dict[str, Any],
|
||||
payload: bytes,
|
||||
previous_frame: bytes | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
content_type = header.get("content_type")
|
||||
if content_type not in (
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
RAW_RGBA_DELTA_GZIP_CONTENT_TYPE,
|
||||
):
|
||||
raise ValueError(f"Unsupported realtime frame content type: {content_type}")
|
||||
|
||||
width = int(header["width"])
|
||||
height = int(header["height"])
|
||||
channels = int(header["channels"])
|
||||
num_frames = int(header["num_frames"])
|
||||
bytes_per_frame = int(header["bytes_per_frame"])
|
||||
expected_size = num_frames * bytes_per_frame
|
||||
if content_type in (
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
RAW_RGBA_DELTA_GZIP_CONTENT_TYPE,
|
||||
):
|
||||
if header.get("delta_reference") != "previous-frame":
|
||||
previous_frame = None
|
||||
payload = restore_delta_gzip_raw_rgb_payload(
|
||||
payload,
|
||||
bytes_per_frame=bytes_per_frame,
|
||||
num_frames=num_frames,
|
||||
reference_frame=previous_frame,
|
||||
)
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(
|
||||
f"Realtime payload size mismatch: expected {expected_size}, got {len(payload)}"
|
||||
)
|
||||
|
||||
frames = []
|
||||
for frame_idx in range(num_frames):
|
||||
offset = frame_idx * bytes_per_frame
|
||||
frame = np.frombuffer(
|
||||
payload[offset : offset + bytes_per_frame], dtype=np.uint8
|
||||
)
|
||||
frame = frame.reshape(height, width, channels)
|
||||
if channels > 3:
|
||||
frame = frame[:, :, :3]
|
||||
frames.append(frame.copy())
|
||||
return frames
|
||||
|
||||
|
||||
def encode_realtime_frames_to_mp4(frames: list[np.ndarray], fps: int) -> bytes:
|
||||
if not frames:
|
||||
raise ValueError("Cannot encode empty realtime frame list")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
imageio.mimsave(
|
||||
output_path,
|
||||
frames,
|
||||
fps=fps,
|
||||
format="mp4",
|
||||
codec="libx264",
|
||||
quality=5,
|
||||
)
|
||||
return Path(output_path).read_bytes()
|
||||
finally:
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def collect_realtime_frames(
|
||||
*,
|
||||
ws_url: str,
|
||||
init_payload: dict[str, Any],
|
||||
events: list[dict[str, Any]],
|
||||
num_chunks: int,
|
||||
) -> list[np.ndarray]:
|
||||
return (
|
||||
await collect_realtime_output(
|
||||
ws_url=ws_url,
|
||||
init_payload=init_payload,
|
||||
events=events,
|
||||
num_chunks=num_chunks,
|
||||
)
|
||||
).frames
|
||||
|
||||
|
||||
async def collect_realtime_output(
|
||||
*,
|
||||
ws_url: str,
|
||||
init_payload: dict[str, Any],
|
||||
events: list[dict[str, Any]],
|
||||
num_chunks: int,
|
||||
require_chunk_stats: bool = False,
|
||||
) -> RealtimeCollectionResult:
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
pytest.skip("websockets is required for realtime consistency checks")
|
||||
|
||||
frames: list[np.ndarray] = []
|
||||
chunk_stats: list[RealtimeChunkStats] = []
|
||||
sent_event_indices: set[int] = set()
|
||||
|
||||
async def send_events_for_boundary(ws, completed_chunk: int) -> None:
|
||||
for event_idx, event in enumerate(events):
|
||||
if event_idx in sent_event_indices:
|
||||
continue
|
||||
if int(event.get("after_chunk", 0)) != completed_chunk:
|
||||
continue
|
||||
await ws.send(msgspec.msgpack.encode(build_realtime_event_payload(event)))
|
||||
sent_event_indices.add(event_idx)
|
||||
|
||||
async with websockets.connect(ws_url, max_size=None, ping_interval=None) as ws:
|
||||
await ws.send(msgspec.msgpack.encode(init_payload))
|
||||
await send_events_for_boundary(ws, -1)
|
||||
|
||||
received_chunks: set[int] = set()
|
||||
previous_frame: bytes | None = None
|
||||
while len(received_chunks) < num_chunks or (
|
||||
require_chunk_stats and len(chunk_stats) < len(received_chunks)
|
||||
):
|
||||
header_payload = await asyncio.wait_for(
|
||||
ws.recv(), timeout=_REALTIME_WS_TIMEOUT_SECS
|
||||
)
|
||||
header = msgspec.msgpack.decode(header_payload)
|
||||
message_type = header.get("type")
|
||||
if message_type == "error":
|
||||
pytest.fail(f"Realtime generation failed: {header.get('content')}")
|
||||
if message_type == "chunk_stats":
|
||||
chunk_stats.append(parse_realtime_chunk_stats(header))
|
||||
continue
|
||||
if message_type == "frame_batch":
|
||||
raw_payload = header.pop("payload", None)
|
||||
header["type"] = "frame_batch_header"
|
||||
elif message_type == "frame_batch_header":
|
||||
raw_payload = await asyncio.wait_for(
|
||||
ws.recv(), timeout=_REALTIME_WS_TIMEOUT_SECS
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unexpected realtime message: {header}")
|
||||
if not isinstance(raw_payload, bytes):
|
||||
raise ValueError("Realtime frame payload must be bytes")
|
||||
|
||||
chunk_frames = decode_realtime_raw_rgb_frames(
|
||||
header,
|
||||
raw_payload,
|
||||
previous_frame,
|
||||
)
|
||||
frames.extend(chunk_frames)
|
||||
if chunk_frames:
|
||||
previous_frame = chunk_frames[-1].tobytes()
|
||||
chunk_index = int(header["chunk_index"])
|
||||
if header.get("is_final_frame_batch", True):
|
||||
received_chunks.add(chunk_index)
|
||||
await send_events_for_boundary(ws, chunk_index)
|
||||
|
||||
return RealtimeCollectionResult(frames=frames, chunk_stats=chunk_stats)
|
||||
@@ -23,6 +23,11 @@ from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
|
||||
from sglang.multimodal_gen.test.server import conftest
|
||||
from sglang.multimodal_gen.test.server.realtime_consistency import (
|
||||
pop_realtime_key_frames,
|
||||
pop_realtime_perf_stats,
|
||||
validate_realtime_perf_stats,
|
||||
)
|
||||
from sglang.multimodal_gen.test.server.test_server_utils import (
|
||||
VALIDATOR_REGISTRY,
|
||||
PerformanceValidator,
|
||||
@@ -624,7 +629,9 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
thresholds = get_consistency_thresholds(case.id, is_video=is_video)
|
||||
|
||||
if is_video:
|
||||
output_frames = extract_key_frames_from_video(content)
|
||||
output_frames = pop_realtime_key_frames(case.id)
|
||||
if output_frames is None:
|
||||
output_frames = extract_key_frames_from_video(content)
|
||||
else:
|
||||
output_frames = [image_bytes_to_numpy(content)]
|
||||
|
||||
@@ -727,10 +734,12 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
is_video = case.server_args.modality == "video"
|
||||
|
||||
if is_video:
|
||||
# Extract key frames from video
|
||||
frames = extract_key_frames_from_video(
|
||||
content, num_frames=case.sampling_params.num_frames
|
||||
)
|
||||
# realtime consistency uses websocket raw frames to avoid lossy mp4 drift
|
||||
frames = pop_realtime_key_frames(case.id)
|
||||
if frames is None:
|
||||
frames = extract_key_frames_from_video(
|
||||
content, num_frames=case.sampling_params.num_frames
|
||||
)
|
||||
|
||||
if len(frames) != 3:
|
||||
logger.warning(
|
||||
@@ -1201,11 +1210,12 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
)
|
||||
|
||||
# Single generation - output is reused for both validations
|
||||
is_realtime_case = case.sampling_params.realtime_num_chunks is not None
|
||||
perf_record, content = self.run_and_collect(
|
||||
diffusion_server,
|
||||
case.id,
|
||||
generate_fn,
|
||||
collect_perf=not is_gt_gen_mode,
|
||||
collect_perf=not is_gt_gen_mode and not is_realtime_case,
|
||||
)
|
||||
|
||||
if is_gt_gen_mode:
|
||||
@@ -1223,10 +1233,23 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
raise
|
||||
failures.append((name, str(exc)))
|
||||
|
||||
run_case_check(
|
||||
"performance",
|
||||
lambda: self._validate_and_record(case, perf_record),
|
||||
)
|
||||
if is_realtime_case:
|
||||
run_case_check(
|
||||
"performance",
|
||||
lambda: validate_realtime_perf_stats(
|
||||
case.id,
|
||||
pop_realtime_perf_stats(case.id),
|
||||
case.sampling_params.realtime_perf_thresholds,
|
||||
ignore_initial_chunks=(
|
||||
case.sampling_params.realtime_perf_ignore_initial_chunks
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
run_case_check(
|
||||
"performance",
|
||||
lambda: self._validate_and_record(case, perf_record),
|
||||
)
|
||||
|
||||
if case.server_args.custom_validator == "mesh":
|
||||
from sglang.multimodal_gen.test.server.test_server_utils import (
|
||||
|
||||
@@ -4,6 +4,7 @@ Server management and performance validation for diffusion tests.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import shlex
|
||||
@@ -28,6 +29,15 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
||||
init_logger,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
|
||||
from sglang.multimodal_gen.test.server.realtime_consistency import (
|
||||
build_realtime_init_payload,
|
||||
collect_realtime_output,
|
||||
encode_realtime_frames_to_mp4,
|
||||
prepare_realtime_first_frame,
|
||||
realtime_ws_url,
|
||||
record_realtime_key_frames,
|
||||
record_realtime_perf_stats,
|
||||
)
|
||||
from sglang.multimodal_gen.test.server.testcase_configs import (
|
||||
DiffusionSamplingParams,
|
||||
PerformanceSummary,
|
||||
@@ -1305,6 +1315,55 @@ def get_generate_fn(
|
||||
},
|
||||
)
|
||||
|
||||
def generate_realtime_video(case_id, client) -> tuple[str, bytes]:
|
||||
"""Realtime video generation folded back into mp4 for consistency checks."""
|
||||
if not sampling_params.prompt:
|
||||
pytest.skip(f"{case_id}: no realtime prompt configured")
|
||||
if sampling_params.realtime_num_chunks is None:
|
||||
pytest.skip(f"{case_id}: realtime_num_chunks is not configured")
|
||||
if sampling_params.realtime_num_chunks <= 0:
|
||||
pytest.fail(f"{case_id}: realtime_num_chunks must be positive")
|
||||
|
||||
first_frame = prepare_realtime_first_frame(sampling_params.image_path)
|
||||
init_payload = build_realtime_init_payload(
|
||||
model_path=model_path,
|
||||
sampling_params=sampling_params,
|
||||
output_size=output_size,
|
||||
first_frame=first_frame,
|
||||
)
|
||||
realtime_output = asyncio.run(
|
||||
collect_realtime_output(
|
||||
ws_url=realtime_ws_url(client),
|
||||
init_payload=init_payload,
|
||||
events=list(sampling_params.realtime_events),
|
||||
num_chunks=sampling_params.realtime_num_chunks,
|
||||
require_chunk_stats=bool(sampling_params.realtime_perf_thresholds),
|
||||
)
|
||||
)
|
||||
record_realtime_perf_stats(case_id, realtime_output.chunk_stats)
|
||||
record_realtime_key_frames(case_id, realtime_output.frames)
|
||||
fps = int(sampling_params.fps or 24)
|
||||
video_bytes = encode_realtime_frames_to_mp4(realtime_output.frames, fps=fps)
|
||||
validate_openai_video(video_bytes)
|
||||
|
||||
rid = f"{case_id}-realtime"
|
||||
expected_filename = f"{rid}.mp4"
|
||||
tmp_path = expected_filename
|
||||
Path(tmp_path).write_bytes(video_bytes)
|
||||
expected_width, expected_height = parse_dimensions(output_size)
|
||||
validate_video_file(
|
||||
tmp_path, expected_filename, expected_width, expected_height
|
||||
)
|
||||
upload_file_to_slack(
|
||||
case_id=case_id,
|
||||
model=model_path,
|
||||
prompt=sampling_params.prompt,
|
||||
file_path=tmp_path,
|
||||
origin_file_path=sampling_params.image_path,
|
||||
)
|
||||
os.remove(tmp_path)
|
||||
return (rid, video_bytes)
|
||||
|
||||
def generate_mesh(case_id, client) -> tuple[str, bytes]:
|
||||
"""I2M: Image to Mesh generation using async /v1/meshes API."""
|
||||
import requests as http_requests
|
||||
@@ -1402,7 +1461,9 @@ def get_generate_fn(
|
||||
if modality == "3d":
|
||||
fn = generate_mesh
|
||||
elif modality == "video":
|
||||
if sampling_params.image_path and sampling_params.prompt:
|
||||
if sampling_params.realtime_num_chunks is not None:
|
||||
fn = generate_realtime_video
|
||||
elif sampling_params.image_path and sampling_params.prompt:
|
||||
if getattr(sampling_params, "direct_url_test", False):
|
||||
fn = generate_text_url_image_to_video
|
||||
else:
|
||||
|
||||
@@ -26,7 +26,7 @@ import statistics
|
||||
from dataclasses import dataclass, field, replace
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from typing import Any, Sequence
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
|
||||
from sglang.multimodal_gen.registry import (
|
||||
@@ -246,6 +246,15 @@ class DiffusionSamplingParams:
|
||||
|
||||
num_outputs_per_prompt: int = 1
|
||||
|
||||
# Realtime video consistency harness. When set, server tests use
|
||||
# /v1/realtime_video/generate and fold streamed chunks back into mp4 bytes.
|
||||
realtime_num_chunks: int | None = None
|
||||
realtime_events: list[dict[str, Any]] = field(default_factory=list)
|
||||
realtime_perf_thresholds: dict[str, float] = field(default_factory=dict)
|
||||
realtime_perf_ignore_initial_chunks: int = 0
|
||||
# None keeps the lossless/raw transport used by GT-backed consistency checks.
|
||||
realtime_output_format: str | None = None
|
||||
|
||||
# Additional request-level parameters (e.g. enable_teacache, enable_upscaling, …)
|
||||
# merged directly into the OpenAI extra_body dict.
|
||||
extras: dict = field(default_factory=dict)
|
||||
@@ -301,6 +310,48 @@ class DiffusionTestCase:
|
||||
)
|
||||
|
||||
|
||||
LINGBOT_WORLD_REALTIME_sampling_params = DiffusionSamplingParams(
|
||||
prompt=(
|
||||
"A slow aerial orbit around a pastel floating island hotel in the open "
|
||||
"ocean, hazy sunlight, turquoise water, toy-like architectural detail, "
|
||||
"clean horizon, cinematic but playful."
|
||||
),
|
||||
image_path=(
|
||||
"https://is1-ssl.mzstatic.com/image/thumb/Music/v4/b8/f9/b9/"
|
||||
"b8f9b9f8-a609-bde2-0302-349436ffc508/825646291038.jpg/600x600bb.jpg"
|
||||
),
|
||||
output_size="832x480",
|
||||
num_frames=9,
|
||||
fps=16,
|
||||
realtime_num_chunks=4,
|
||||
realtime_events=[
|
||||
{
|
||||
"after_chunk": 0,
|
||||
"kind": "camera_actions",
|
||||
"payload": {"mode": "state", "transitions": [{"actions": ["w"]}]},
|
||||
},
|
||||
{
|
||||
"after_chunk": 2,
|
||||
"kind": "camera_actions",
|
||||
"payload": {"mode": "state", "transitions": [{"actions": []}]},
|
||||
},
|
||||
],
|
||||
realtime_perf_thresholds={
|
||||
"p95_chunk_total_ms": 5000.0,
|
||||
"p95_scheduler_forward_ms": 4500.0,
|
||||
"p95_ws_payload_mb": 16.0,
|
||||
},
|
||||
realtime_perf_ignore_initial_chunks=2,
|
||||
extras={
|
||||
"seed": 42,
|
||||
"num_inference_steps": 4,
|
||||
"guidance_scale": 1.0,
|
||||
"realtime_causal_sink_size": 9,
|
||||
"realtime_causal_kv_cache_num_frames": 18,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sample_step_indices(
|
||||
step_map: dict[int, float], fractions: Sequence[float]
|
||||
) -> list[int]:
|
||||
|
||||
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
SGL_TEST_FILES_CI_DATA_REVISION = "20874fb9018d082c613a18ba92ab4f32479d3a32"
|
||||
SGL_TEST_FILES_CI_DATA_REVISION = "4a62a21f0f8bcc54c3bc6de5dbf25515bcb92b7e"
|
||||
SGL_TEST_FILES_CONSISTENCY_GT_ROOT = (
|
||||
"https://raw.githubusercontent.com/"
|
||||
f"sgl-project/ci-data/{SGL_TEST_FILES_CI_DATA_REVISION}/"
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.kvcache.causal_attention_cache import (
|
||||
CausalSelfAttentionKVCache,
|
||||
CrossAttentionKVCache,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.causal_denoising import (
|
||||
CausalDMDDenoisingStage,
|
||||
)
|
||||
|
||||
|
||||
class _Progress:
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
|
||||
def update(self):
|
||||
self.count += 1
|
||||
|
||||
|
||||
def test_causal_dmd_chunk_loop_uses_model_input_builder():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
predict_calls = []
|
||||
add_noise_calls = []
|
||||
|
||||
def fake_predict(self, *args, **kwargs):
|
||||
del self, args
|
||||
latent_model_input = kwargs["latent_model_input"]
|
||||
predict_calls.append(
|
||||
(
|
||||
latent_model_input.shape,
|
||||
latent_model_input.dtype,
|
||||
int(kwargs["current_timestep"]),
|
||||
)
|
||||
)
|
||||
x0_btchw = latent_model_input[:, :2].permute(0, 2, 1, 3, 4).float()
|
||||
return x0_btchw + int(kwargs["current_timestep"]), kwargs["current_timestep"]
|
||||
|
||||
def fake_add_noise(self, *args, **kwargs):
|
||||
del self, args
|
||||
add_noise_calls.append(int(kwargs["next_timestep"].item()))
|
||||
return kwargs["x0_btchw"] + 10
|
||||
|
||||
stage._predict_x0_btchw = MethodType(fake_predict, stage)
|
||||
stage._add_noise_for_next_timestep = MethodType(fake_add_noise, stage)
|
||||
|
||||
chunk_latents = torch.zeros(1, 2, 2, 1, 1)
|
||||
condition = torch.ones(1, 1, 2, 1, 1)
|
||||
prepare_call_count = 0
|
||||
|
||||
def prepare_model_input(current_latents):
|
||||
nonlocal prepare_call_count
|
||||
prepare_call_count += 1
|
||||
return torch.cat([current_latents, condition], dim=1)
|
||||
|
||||
progress = _Progress()
|
||||
result, attn_metadata = stage._denoise_causal_dmd_chunk(
|
||||
SimpleNamespace(generator=None),
|
||||
SimpleNamespace(),
|
||||
chunk_latents=chunk_latents,
|
||||
scheduler=SimpleNamespace(),
|
||||
timesteps=torch.tensor([7, 3]),
|
||||
prompt_embeds=None,
|
||||
kv_cache=[],
|
||||
crossattn_cache=[],
|
||||
current_start_tokens=0,
|
||||
start_frame=0,
|
||||
image_kwargs={},
|
||||
pos_cond_kwargs={},
|
||||
target_dtype=torch.float16,
|
||||
autocast_enabled=False,
|
||||
device=torch.device("cpu"),
|
||||
attn_raw_latent_shape=(2, 1, 1),
|
||||
prepare_model_input=prepare_model_input,
|
||||
progress_bar=progress,
|
||||
)
|
||||
|
||||
assert prepare_call_count == 2
|
||||
assert predict_calls == [
|
||||
(torch.Size([1, 3, 2, 1, 1]), torch.float16, 0),
|
||||
(torch.Size([1, 3, 2, 1, 1]), torch.float16, 1),
|
||||
]
|
||||
assert add_noise_calls == [3]
|
||||
assert progress.count == 2
|
||||
assert attn_metadata == 1
|
||||
assert torch.equal(result, torch.full_like(result, 11))
|
||||
|
||||
|
||||
def test_causal_dmd_forward_context_uses_prepare_hooks(monkeypatch):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import causal_denoising
|
||||
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
stage.attn_backend = SimpleNamespace(get_enum=lambda: None)
|
||||
monkeypatch.setattr(
|
||||
causal_denoising, "get_local_torch_device", lambda: torch.device("cpu")
|
||||
)
|
||||
latents = torch.zeros(2, 3, 4, 5, 6)
|
||||
seen = {}
|
||||
|
||||
def fake_prepare_frame_seq_length(self, h, w):
|
||||
self.num_token_per_frame = h * w
|
||||
seen["frame_shape"] = (h, w)
|
||||
return self.num_token_per_frame
|
||||
|
||||
stage._target_dtype = MethodType(lambda self: torch.float16, stage)
|
||||
stage._autocast_enabled = MethodType(lambda self, dtype, server_args: False, stage)
|
||||
stage._get_causal_dmd_scheduler = MethodType(
|
||||
lambda self, batch, server_args: "scheduler", stage
|
||||
)
|
||||
stage._get_causal_dmd_latents = MethodType(lambda self, batch: latents, stage)
|
||||
stage._prepare_frame_seq_length = MethodType(fake_prepare_frame_seq_length, stage)
|
||||
stage._prepare_causal_dmd_timesteps = MethodType(
|
||||
lambda self, batch, server_args, scheduler, device: torch.tensor(
|
||||
[9], device=device
|
||||
),
|
||||
stage,
|
||||
)
|
||||
stage._prepare_causal_dmd_image_kwargs = MethodType(
|
||||
lambda self, batch, server_args, target_dtype: {"image": target_dtype},
|
||||
stage,
|
||||
)
|
||||
stage._prepare_causal_dmd_pos_cond_kwargs = MethodType(
|
||||
lambda self, batch, server_args, target_dtype: {"pos": target_dtype},
|
||||
stage,
|
||||
)
|
||||
stage._prepare_causal_dmd_prompt_embeds = MethodType(
|
||||
lambda self, batch, server_args, target_dtype: ["prompt"],
|
||||
stage,
|
||||
)
|
||||
|
||||
ctx = stage._prepare_causal_dmd_forward_context(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert ctx.target_dtype == torch.float16
|
||||
assert ctx.autocast_enabled is False
|
||||
assert ctx.device == torch.device("cpu")
|
||||
assert ctx.scheduler == "scheduler"
|
||||
assert ctx.timesteps.tolist() == [9]
|
||||
assert ctx.latents is latents
|
||||
assert ctx.prompt_embeds == ["prompt"]
|
||||
assert ctx.image_kwargs == {"image": torch.float16}
|
||||
assert ctx.pos_cond_kwargs == {"pos": torch.float16}
|
||||
assert (ctx.batch_size, ctx.channels, ctx.num_frames, ctx.height, ctx.width) == (
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
)
|
||||
assert seen["frame_shape"] == (5, 6)
|
||||
|
||||
|
||||
def test_causal_dmd_block_updates_context_after_denoising():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
seen = {}
|
||||
|
||||
def fake_denoise(self, *args, **kwargs):
|
||||
del self, args
|
||||
seen["model_input"] = kwargs["prepare_model_input"](
|
||||
kwargs["chunk_latents"]
|
||||
).clone()
|
||||
seen["denoise_pos"] = (
|
||||
kwargs["current_start_tokens"],
|
||||
kwargs["start_frame"],
|
||||
)
|
||||
return kwargs["chunk_latents"] + 2, "metadata"
|
||||
|
||||
def fake_update(self, *args, **kwargs):
|
||||
del self, args
|
||||
seen["context_input"] = kwargs["context_input"].clone()
|
||||
seen["update_pos"] = (
|
||||
kwargs["current_start_tokens"],
|
||||
kwargs["start_frame"],
|
||||
kwargs["attn_metadata"],
|
||||
)
|
||||
|
||||
stage._denoise_causal_dmd_chunk = MethodType(fake_denoise, stage)
|
||||
stage._update_causal_context_cache = MethodType(fake_update, stage)
|
||||
|
||||
chunk_latents = torch.ones(1, 2, 1, 1, 1)
|
||||
condition = torch.full((1, 1, 1, 1, 1), 4.0)
|
||||
result = stage._denoise_and_update_causal_block(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
chunk_latents=chunk_latents,
|
||||
scheduler=SimpleNamespace(),
|
||||
timesteps=torch.tensor([1]),
|
||||
prompt_embeds=None,
|
||||
kv_cache=[],
|
||||
crossattn_cache=[],
|
||||
current_start_tokens=8,
|
||||
start_frame=2,
|
||||
image_kwargs={},
|
||||
pos_cond_kwargs={},
|
||||
target_dtype=torch.float16,
|
||||
autocast_enabled=False,
|
||||
device=torch.device("cpu"),
|
||||
attn_raw_latent_shape=(1, 1, 1),
|
||||
prepare_model_input=lambda x: torch.cat([x, condition], dim=1),
|
||||
prepare_context_input=lambda x: torch.cat([x, condition], dim=1),
|
||||
)
|
||||
|
||||
assert torch.equal(result, chunk_latents + 2)
|
||||
assert seen["denoise_pos"] == (8, 2)
|
||||
assert seen["update_pos"] == (8, 2, "metadata")
|
||||
assert torch.equal(
|
||||
seen["model_input"], torch.cat([chunk_latents, condition], dim=1)
|
||||
)
|
||||
assert torch.equal(
|
||||
seen["context_input"], torch.cat([chunk_latents + 2, condition], dim=1)
|
||||
)
|
||||
|
||||
|
||||
def test_causal_context_warmup_uses_context_cache_update_path():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
stage.num_token_per_frame = 4
|
||||
seen = {}
|
||||
|
||||
def fake_update(self, *args, **kwargs):
|
||||
del self, args
|
||||
seen.update(kwargs)
|
||||
|
||||
stage._update_causal_context_cache = MethodType(fake_update, stage)
|
||||
context_input = torch.randn(1, 2, 3, 4, 4)
|
||||
stage._warm_up_causal_context_cache(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
context_input=context_input,
|
||||
prompt_embeds="prompt",
|
||||
kv_cache=["kv"],
|
||||
crossattn_cache=["cross"],
|
||||
current_start_frame=3,
|
||||
image_kwargs={},
|
||||
pos_cond_kwargs={},
|
||||
target_dtype=torch.float16,
|
||||
autocast_enabled=False,
|
||||
)
|
||||
|
||||
assert seen["context_input"] is context_input
|
||||
assert seen["current_start_tokens"] == 12
|
||||
assert seen["start_frame"] == 3
|
||||
assert seen["attn_metadata"] is None
|
||||
|
||||
|
||||
def test_causal_dmd_timestep_expansion_preserves_fractional_dtype():
|
||||
timestep = torch.tensor(996.3375, dtype=torch.float32)
|
||||
|
||||
expanded = CausalDMDDenoisingStage._expand_timestep(
|
||||
timestep,
|
||||
batch_size=3,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
|
||||
assert expanded.dtype == torch.float32
|
||||
assert torch.equal(expanded, torch.tensor([996.3375, 996.3375, 996.3375]))
|
||||
|
||||
|
||||
def test_causal_cache_helpers_reset_and_forward_model_specific_kwargs():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
stage.num_transformer_blocks = 2
|
||||
kv_cache = [
|
||||
CausalSelfAttentionKVCache(
|
||||
k=torch.empty(1, 1, 1, 1),
|
||||
v=torch.empty(1, 1, 1, 1),
|
||||
global_end_index=torch.tensor([7]),
|
||||
local_end_index=torch.tensor([3]),
|
||||
global_end_index_int=7,
|
||||
local_end_index_int=3,
|
||||
),
|
||||
CausalSelfAttentionKVCache(
|
||||
k=torch.empty(1, 1, 1, 1),
|
||||
v=torch.empty(1, 1, 1, 1),
|
||||
global_end_index=torch.tensor([5]),
|
||||
local_end_index=torch.tensor([2]),
|
||||
),
|
||||
]
|
||||
crossattn_cache = [
|
||||
CrossAttentionKVCache(
|
||||
k=torch.empty(1, 1, 1, 1),
|
||||
v=torch.empty(1, 1, 1, 1),
|
||||
is_init=True,
|
||||
),
|
||||
CrossAttentionKVCache(
|
||||
k=torch.empty(1, 1, 1, 1),
|
||||
v=torch.empty(1, 1, 1, 1),
|
||||
is_init=True,
|
||||
),
|
||||
]
|
||||
stage._reset_causal_caches(
|
||||
kv_cache=kv_cache,
|
||||
crossattn_cache=crossattn_cache,
|
||||
)
|
||||
|
||||
assert [block.is_init for block in crossattn_cache] == [False, False]
|
||||
assert [int(block.global_end_index.item()) for block in kv_cache] == [0, 0]
|
||||
assert [int(block.local_end_index.item()) for block in kv_cache] == [0, 0]
|
||||
assert kv_cache[0].global_end_index_int == 0
|
||||
assert kv_cache[0].local_end_index_int == 0
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_initialize_kv_cache(self, batch_size, dtype, device, **kwargs):
|
||||
calls.append(("kv", batch_size, dtype, device, kwargs))
|
||||
self.causal_kv_cache = ["kv-cache"]
|
||||
|
||||
def fake_initialize_crossattn_cache(self, batch_size, max_text_len, dtype, device):
|
||||
calls.append(("cross", batch_size, max_text_len, dtype, device))
|
||||
self.crossattn_cache = ["cross-cache"]
|
||||
|
||||
stage._initialize_kv_cache = MethodType(fake_initialize_kv_cache, stage)
|
||||
stage._initialize_crossattn_cache = MethodType(
|
||||
fake_initialize_crossattn_cache, stage
|
||||
)
|
||||
|
||||
kv_cache, crossattn_cache = stage._initialize_causal_caches(
|
||||
batch_size=2,
|
||||
max_text_len=128,
|
||||
dtype=torch.bfloat16,
|
||||
device=torch.device("cpu"),
|
||||
kv_cache_kwargs={"sequence_shard_enabled": True},
|
||||
)
|
||||
|
||||
assert kv_cache == ["kv-cache"]
|
||||
assert crossattn_cache == ["cross-cache"]
|
||||
assert calls == [
|
||||
(
|
||||
"kv",
|
||||
2,
|
||||
torch.bfloat16,
|
||||
torch.device("cpu"),
|
||||
{"sequence_shard_enabled": True},
|
||||
),
|
||||
("cross", 2, 128, torch.bfloat16, torch.device("cpu")),
|
||||
]
|
||||
|
||||
|
||||
def test_causal_kv_cache_block_resets_indices_in_place():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
k = torch.ones(1, 4, 2, 3)
|
||||
v = torch.ones(1, 4, 2, 3)
|
||||
global_end_index = torch.tensor([7])
|
||||
local_end_index = torch.tensor([4])
|
||||
cache = CausalSelfAttentionKVCache(
|
||||
k=k,
|
||||
v=v,
|
||||
global_end_index=global_end_index,
|
||||
local_end_index=local_end_index,
|
||||
global_end_index_int=7,
|
||||
local_end_index_int=4,
|
||||
)
|
||||
|
||||
assert cache.k is k
|
||||
assert cache.global_end_index_int == 7
|
||||
detached_k = k.detach()
|
||||
cache.k = detached_k
|
||||
assert cache.k is detached_k
|
||||
|
||||
stage._reset_kv_cache([cache])
|
||||
|
||||
assert cache.global_end_index is global_end_index
|
||||
assert cache.local_end_index is local_end_index
|
||||
assert int(cache.global_end_index.item()) == 0
|
||||
assert int(cache.local_end_index.item()) == 0
|
||||
assert cache.global_end_index_int == 0
|
||||
assert cache.local_end_index_int == 0
|
||||
|
||||
|
||||
def test_causal_kv_cache_allocation_sets_shapes_and_optional_int_indices():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
stage.num_transformer_blocks = 2
|
||||
|
||||
cache = stage._allocate_causal_kv_cache(
|
||||
batch_size=3,
|
||||
kv_cache_size=5,
|
||||
num_attention_heads=7,
|
||||
attention_head_dim=11,
|
||||
dtype=torch.float16,
|
||||
device=torch.device("cpu"),
|
||||
use_int_indices=True,
|
||||
sink_tokens=13,
|
||||
attention_window_size=3,
|
||||
)
|
||||
|
||||
assert len(cache) == 2
|
||||
assert cache[0].k.shape == (3, 5, 7, 11)
|
||||
assert cache[0].v.shape == (3, 5, 7, 11)
|
||||
assert cache[0].global_end_index.shape == (1,)
|
||||
assert cache[0].local_end_index.shape == (1,)
|
||||
assert cache[0].global_end_index_int == 0
|
||||
assert cache[0].local_end_index_int == 0
|
||||
assert cache[0].cache_size == 5
|
||||
assert cache[0].sink_tokens == 13
|
||||
assert cache[0].attention_window_size == 3
|
||||
assert cache[0].allow_growth is False
|
||||
|
||||
|
||||
def test_causal_kv_cache_update_handles_append_roll_and_recompute():
|
||||
cache = CausalSelfAttentionKVCache(
|
||||
k=torch.zeros(1, 4, 1, 1),
|
||||
v=torch.zeros(1, 4, 1, 1),
|
||||
global_end_index=torch.zeros(1, dtype=torch.long),
|
||||
local_end_index=torch.zeros(1, dtype=torch.long),
|
||||
global_end_index_int=0,
|
||||
local_end_index_int=0,
|
||||
)
|
||||
|
||||
first_view = cache.update_and_get_attention_kv(
|
||||
key=torch.tensor([[[[1.0]], [[2.0]], [[3.0]]]]),
|
||||
value=torch.tensor([[[[10.0]], [[20.0]], [[30.0]]]]),
|
||||
current_chunk_start=0,
|
||||
)
|
||||
|
||||
assert first_view.visible_global_end == 3
|
||||
assert first_view.visible_local_end == 3
|
||||
assert cache.global_end_index_int == 3
|
||||
assert cache.local_end_index_int == 3
|
||||
assert first_view.k.flatten().tolist() == [1.0, 2.0, 3.0]
|
||||
|
||||
rolled_view = cache.update_and_get_attention_kv(
|
||||
key=torch.tensor([[[[4.0]], [[5.0]], [[6.0]]]]),
|
||||
value=torch.tensor([[[[40.0]], [[50.0]], [[60.0]]]]),
|
||||
current_chunk_start=3,
|
||||
)
|
||||
|
||||
assert rolled_view.visible_global_end == 6
|
||||
assert rolled_view.visible_local_end == 4
|
||||
assert cache.k.flatten().tolist() == [3.0, 4.0, 5.0, 6.0]
|
||||
|
||||
cache.attention_window_size = 2
|
||||
recompute_view = cache.update_and_get_attention_kv(
|
||||
key=torch.tensor([[[[50.0]]]]),
|
||||
value=torch.tensor([[[[500.0]]]]),
|
||||
current_chunk_start=4,
|
||||
)
|
||||
|
||||
assert recompute_view.local_start_index == 2
|
||||
assert recompute_view.local_end_index == 3
|
||||
assert recompute_view.visible_global_end == 6
|
||||
assert recompute_view.visible_local_end == 4
|
||||
assert recompute_view.k.flatten().tolist() == [50.0, 6.0]
|
||||
|
||||
|
||||
def test_causal_kv_cache_update_grows_without_rolling_when_enabled():
|
||||
cache = CausalSelfAttentionKVCache(
|
||||
k=torch.zeros(1, 2, 1, 1),
|
||||
v=torch.zeros(1, 2, 1, 1),
|
||||
global_end_index=torch.zeros(1, dtype=torch.long),
|
||||
local_end_index=torch.zeros(1, dtype=torch.long),
|
||||
global_end_index_int=0,
|
||||
local_end_index_int=0,
|
||||
allow_growth=True,
|
||||
)
|
||||
|
||||
cache.update_and_get_attention_kv(
|
||||
key=torch.tensor([[[[1.0]], [[2.0]]]]),
|
||||
value=torch.tensor([[[[10.0]], [[20.0]]]]),
|
||||
current_chunk_start=0,
|
||||
)
|
||||
view = cache.update_and_get_attention_kv(
|
||||
key=torch.tensor([[[[3.0]], [[4.0]]]]),
|
||||
value=torch.tensor([[[[30.0]], [[40.0]]]]),
|
||||
current_chunk_start=2,
|
||||
)
|
||||
|
||||
assert cache.cache_size == 4
|
||||
assert cache.attention_window_size == 4
|
||||
assert cache.global_end_index_int == 4
|
||||
assert cache.local_end_index_int == 4
|
||||
assert view.local_start_index == 2
|
||||
assert view.local_end_index == 4
|
||||
assert view.k.flatten().tolist() == [1.0, 2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
def test_crossattn_cache_block_stores_detached_tensors_and_resets():
|
||||
stage = CausalDMDDenoisingStage.__new__(CausalDMDDenoisingStage)
|
||||
stage.num_transformer_blocks = 1
|
||||
k = torch.ones(1, 8, 2, 3)
|
||||
v = torch.ones(1, 8, 2, 3)
|
||||
cache = CrossAttentionKVCache(k=k, v=v, is_init=True)
|
||||
|
||||
assert cache.k is k
|
||||
assert cache.is_init is True
|
||||
detached_v = v.detach()
|
||||
cache.v = detached_v
|
||||
assert cache.v is detached_v
|
||||
new_k = torch.full_like(k, 2.0, requires_grad=True)
|
||||
new_v = torch.full_like(v, 3.0, requires_grad=True)
|
||||
cache.store(new_k, new_v)
|
||||
assert cache.k is not new_k
|
||||
assert cache.v is not new_v
|
||||
assert cache.k.requires_grad is False
|
||||
assert cache.v.requires_grad is False
|
||||
|
||||
stage._reset_crossattn_cache([cache])
|
||||
|
||||
assert cache.k.flatten().tolist() == [2.0] * cache.k.numel()
|
||||
assert cache.v.flatten().tolist() == [3.0] * cache.v.numel()
|
||||
assert cache.is_init is False
|
||||
@@ -0,0 +1,297 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.kvcache.causal_attention_cache import (
|
||||
CrossAttentionKVCache,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.dits.lingbot_world import (
|
||||
CausalLingBotWorldTransformer3DModel,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world import (
|
||||
LingBotWorldCausalDMDDenoisingStage,
|
||||
)
|
||||
|
||||
|
||||
def test_lingbot_denoising_stage_does_not_own_realtime_cache_refs():
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
stage.causal_kv_cache = [object()]
|
||||
stage.crossattn_cache = [object()]
|
||||
|
||||
stage._clear_stage_causal_cache_refs()
|
||||
|
||||
assert stage.causal_kv_cache is None
|
||||
assert stage.crossattn_cache is None
|
||||
|
||||
|
||||
def test_lingbot_realtime_attention_cache_uses_bounded_sink_window():
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
stage.local_attn_size = -1
|
||||
stage.sink_size = 9
|
||||
stage.sliding_window_num_frames = 18
|
||||
stage.num_frames_per_block = 3
|
||||
stage.num_token_per_frame = 10
|
||||
|
||||
assert stage._get_causal_sink_tokens() == 9 * 10
|
||||
assert stage._get_causal_kv_cache_size(sequence_shard_enabled=False) == 18 * 10
|
||||
assert stage._get_causal_kv_cache_size(sequence_shard_enabled=True) == 18 * 10
|
||||
|
||||
|
||||
def test_lingbot_realtime_cache_config_overrides_checkpoint_defaults():
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
stage.local_attn_size = -1
|
||||
stage.sink_size = 9
|
||||
stage.sliding_window_num_frames = 18
|
||||
stage.num_token_per_frame = 10
|
||||
|
||||
server_args = SimpleNamespace(
|
||||
pipeline_config=SimpleNamespace(
|
||||
realtime_causal_sink_size=3,
|
||||
realtime_causal_kv_cache_num_frames=45,
|
||||
)
|
||||
)
|
||||
stage._apply_causal_cache_overrides(SimpleNamespace(), server_args)
|
||||
|
||||
assert stage._get_causal_sink_tokens() == 3 * 10
|
||||
assert stage._get_causal_kv_cache_size(sequence_shard_enabled=False) == 45 * 10
|
||||
|
||||
|
||||
def test_lingbot_realtime_cache_config_uses_request_overrides():
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
stage.local_attn_size = -1
|
||||
stage.sink_size = 9
|
||||
stage.sliding_window_num_frames = 18
|
||||
stage.num_token_per_frame = 10
|
||||
|
||||
batch = SimpleNamespace(
|
||||
realtime_causal_sink_size=4,
|
||||
realtime_causal_kv_cache_num_frames=12,
|
||||
)
|
||||
server_args = SimpleNamespace(
|
||||
pipeline_config=SimpleNamespace(
|
||||
realtime_causal_sink_size=3,
|
||||
realtime_causal_kv_cache_num_frames=45,
|
||||
)
|
||||
)
|
||||
stage._apply_causal_cache_overrides(batch, server_args)
|
||||
|
||||
assert stage._get_causal_sink_tokens() == 4 * 10
|
||||
assert stage._get_causal_kv_cache_size(sequence_shard_enabled=False) == 12 * 10
|
||||
|
||||
|
||||
def test_lingbot_realtime_attention_cache_rolls_with_sink_window():
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
stage.num_transformer_blocks = 1
|
||||
stage.local_attn_size = -1
|
||||
stage.sink_size = 2
|
||||
stage.num_token_per_frame = 1
|
||||
stage.num_frames_per_block = 3
|
||||
stage.sliding_window_num_frames = 6
|
||||
stage.transformer = SimpleNamespace(
|
||||
num_attention_heads=1,
|
||||
attention_head_dim=1,
|
||||
config=SimpleNamespace(
|
||||
arch_config=SimpleNamespace(
|
||||
sink_size=2,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
stage._initialize_kv_cache(
|
||||
batch_size=1,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
assert stage.causal_kv_cache is not None
|
||||
cache = stage.causal_kv_cache[0]
|
||||
assert cache.allow_growth is False
|
||||
assert cache.cache_size == 6
|
||||
assert cache.sink_tokens == 2
|
||||
|
||||
cache.update_and_get_attention_kv(
|
||||
key=torch.ones(1, 3, 1, 1),
|
||||
value=torch.ones(1, 3, 1, 1),
|
||||
current_chunk_start=0,
|
||||
)
|
||||
|
||||
assert cache.local_end_index_int == 3
|
||||
assert cache.global_end_index_int == 3
|
||||
|
||||
cache.update_and_get_attention_kv(
|
||||
key=torch.full((1, 3, 1, 1), 2.0),
|
||||
value=torch.full((1, 3, 1, 1), 2.0),
|
||||
current_chunk_start=3,
|
||||
)
|
||||
third_view = cache.update_and_get_attention_kv(
|
||||
key=torch.full((1, 3, 1, 1), 3.0),
|
||||
value=torch.full((1, 3, 1, 1), 3.0),
|
||||
current_chunk_start=6,
|
||||
)
|
||||
|
||||
assert cache.cache_size == 6
|
||||
assert cache.local_end_index_int == 6
|
||||
assert cache.global_end_index_int == 9
|
||||
assert torch.equal(cache.k[:, :2], torch.ones(1, 2, 1, 1))
|
||||
assert torch.equal(cache.k[:, 2:3], torch.full((1, 1, 1, 1), 2.0))
|
||||
assert torch.equal(cache.k[:, 3:6], torch.full((1, 3, 1, 1), 3.0))
|
||||
assert third_view.k.flatten().tolist() == [
|
||||
1.0,
|
||||
1.0,
|
||||
2.0,
|
||||
3.0,
|
||||
3.0,
|
||||
3.0,
|
||||
]
|
||||
|
||||
fourth_view = cache.update_and_get_attention_kv(
|
||||
key=torch.full((1, 3, 1, 1), 4.0),
|
||||
value=torch.full((1, 3, 1, 1), 4.0),
|
||||
current_chunk_start=9,
|
||||
)
|
||||
|
||||
assert cache.local_end_index_int == 6
|
||||
assert cache.global_end_index_int == 12
|
||||
assert torch.equal(cache.k[:, :2], torch.ones(1, 2, 1, 1))
|
||||
assert torch.equal(cache.k[:, 2:3], torch.full((1, 1, 1, 1), 3.0))
|
||||
assert torch.equal(cache.k[:, 3:6], torch.full((1, 3, 1, 1), 4.0))
|
||||
assert fourth_view.k.flatten().tolist() == [
|
||||
1.0,
|
||||
1.0,
|
||||
3.0,
|
||||
4.0,
|
||||
4.0,
|
||||
4.0,
|
||||
]
|
||||
|
||||
|
||||
def test_lingbot_i2v_model_input_writer_reuses_buffer():
|
||||
latents = torch.ones(1, 16, 3, 2, 2)
|
||||
condition = torch.full((1, 20, 3, 2, 2), 2.0)
|
||||
|
||||
write = LingBotWorldCausalDMDDenoisingStage._build_i2v_model_input_writer(
|
||||
latents=latents,
|
||||
condition=condition,
|
||||
target_dtype=torch.float32,
|
||||
device=latents.device,
|
||||
)
|
||||
first = write(latents)
|
||||
first_ptr = first.data_ptr()
|
||||
second = write(latents + 3.0)
|
||||
|
||||
assert first_ptr == second.data_ptr()
|
||||
assert second.shape == (1, 36, 3, 2, 2)
|
||||
assert torch.equal(second[:, :16], latents + 3.0)
|
||||
assert torch.equal(second[:, 16:], condition)
|
||||
|
||||
|
||||
def test_lingbot_condition_embedding_skips_text_when_crossattn_cache_ready():
|
||||
class _ConditionEmbedder:
|
||||
def __init__(self):
|
||||
self.full_calls = 0
|
||||
self.time_calls = 0
|
||||
|
||||
def time_embedder(self, timestep):
|
||||
self.time_calls += 1
|
||||
return timestep.float().unsqueeze(-1)
|
||||
|
||||
def time_modulation(self, temb):
|
||||
return temb + 1.0
|
||||
|
||||
def __call__(
|
||||
self, timestep, encoder_hidden_states, encoder_hidden_states_image
|
||||
):
|
||||
self.full_calls += 1
|
||||
return timestep.float(), timestep.float(), encoder_hidden_states, None
|
||||
|
||||
model = CausalLingBotWorldTransformer3DModel.__new__(
|
||||
CausalLingBotWorldTransformer3DModel
|
||||
)
|
||||
model.condition_embedder = _ConditionEmbedder()
|
||||
crossattn_cache = [
|
||||
CrossAttentionKVCache(
|
||||
k=torch.empty(1, 1, 1, 1),
|
||||
v=torch.empty(1, 1, 1, 1),
|
||||
is_init=True,
|
||||
)
|
||||
]
|
||||
|
||||
temb, timestep_proj, _, image_states = model._prepare_condition_embeddings(
|
||||
timestep=torch.tensor([[7]]),
|
||||
encoder_hidden_states=torch.ones(1, 2, 3),
|
||||
encoder_hidden_states_image=torch.ones(1, 2, 3),
|
||||
crossattn_cache=crossattn_cache,
|
||||
)
|
||||
|
||||
assert model.condition_embedder.time_calls == 1
|
||||
assert model.condition_embedder.full_calls == 0
|
||||
assert torch.equal(temb, torch.tensor([[7.0]]))
|
||||
assert torch.equal(timestep_proj, torch.tensor([[8.0]]))
|
||||
assert image_states is None
|
||||
|
||||
|
||||
def test_lingbot_context_cache_update_skips_unused_projection(monkeypatch):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world import (
|
||||
lingbot_world_causal_denoising as lingbot_denoising,
|
||||
)
|
||||
|
||||
stage = LingBotWorldCausalDMDDenoisingStage.__new__(
|
||||
LingBotWorldCausalDMDDenoisingStage
|
||||
)
|
||||
calls = []
|
||||
|
||||
class _Transformer:
|
||||
def __call__(self, latent_input, prompt_embeds, timestep, **kwargs):
|
||||
calls.append((latent_input, prompt_embeds, timestep, kwargs))
|
||||
return latent_input
|
||||
|
||||
stage.transformer = _Transformer()
|
||||
monkeypatch.setattr(
|
||||
lingbot_denoising,
|
||||
"current_platform",
|
||||
SimpleNamespace(device_type="cpu"),
|
||||
)
|
||||
|
||||
context_input = torch.ones(2, 3, 4, 5, 6)
|
||||
batch = SimpleNamespace()
|
||||
stage._update_causal_context_cache(
|
||||
batch,
|
||||
SimpleNamespace(pipeline_config=SimpleNamespace(context_noise=7)),
|
||||
context_input=context_input,
|
||||
prompt_embeds="prompt",
|
||||
kv_cache="kv",
|
||||
crossattn_cache="cross",
|
||||
current_start_tokens=12,
|
||||
start_frame=4,
|
||||
image_kwargs={"encoder_hidden_states_image": "image"},
|
||||
pos_cond_kwargs={"c2ws_plucker_emb": "pose"},
|
||||
attn_metadata="metadata",
|
||||
target_dtype=torch.float32,
|
||||
autocast_enabled=False,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
latent_input, prompt_embeds, timestep, kwargs = calls[0]
|
||||
assert latent_input is context_input
|
||||
assert prompt_embeds == "prompt"
|
||||
assert timestep.shape == (2, 1)
|
||||
assert timestep.dtype == torch.long
|
||||
assert timestep.tolist() == [[7], [7]]
|
||||
assert kwargs["kv_cache"] == "kv"
|
||||
assert kwargs["crossattn_cache"] == "cross"
|
||||
assert kwargs["current_start"] == 12
|
||||
assert kwargs["start_frame"] == 4
|
||||
assert kwargs["encoder_hidden_states_image"] == "image"
|
||||
assert kwargs["c2ws_plucker_emb"] == "pose"
|
||||
assert kwargs["skip_final_projection"] is True
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import DataType
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
materialize_output_sample,
|
||||
save_outputs,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
build_raw_rgb_frame_batches,
|
||||
)
|
||||
|
||||
|
||||
def test_materialize_output_sample_converts_tensor_to_uint8_frames():
|
||||
sample = torch.zeros(3, 1, 2, 2)
|
||||
sample[0] = 1.0
|
||||
sample[1] = 0.5
|
||||
|
||||
materialized = materialize_output_sample(sample, DataType.VIDEO, fps=24)
|
||||
|
||||
assert materialized.fps == 24
|
||||
assert materialized.audio is None
|
||||
assert len(materialized.frames) == 1
|
||||
frame = materialized.frames[0]
|
||||
assert frame.shape == (2, 2, 3)
|
||||
assert frame.dtype == np.uint8
|
||||
assert np.all(frame[..., 0] == 255)
|
||||
assert np.all(frame[..., 1] == 127)
|
||||
assert np.all(frame[..., 2] == 0)
|
||||
|
||||
|
||||
def test_save_outputs_can_materialize_without_saving(tmp_path):
|
||||
sample = np.full((2, 2, 3), 0.25, dtype=np.float32)
|
||||
output_path = tmp_path / "image.png"
|
||||
samples_out = []
|
||||
frames_out = []
|
||||
|
||||
paths = save_outputs(
|
||||
[sample],
|
||||
DataType.IMAGE,
|
||||
fps=1,
|
||||
save_output=False,
|
||||
build_output_path=lambda _idx: str(output_path),
|
||||
samples_out=samples_out,
|
||||
frames_out=frames_out,
|
||||
)
|
||||
|
||||
assert paths == [str(output_path)]
|
||||
assert not output_path.exists()
|
||||
assert samples_out[0] is sample
|
||||
assert len(frames_out) == 1
|
||||
assert len(frames_out[0]) == 1
|
||||
assert frames_out[0][0].dtype == np.uint8
|
||||
assert np.all(frames_out[0][0] == 63)
|
||||
|
||||
|
||||
def test_file_path_transport_clears_in_memory_outputs():
|
||||
worker = GPUWorker.__new__(GPUWorker)
|
||||
worker.rank = 0
|
||||
output_batch = OutputBatch(
|
||||
output=[object()],
|
||||
audio=torch.zeros(1),
|
||||
audio_sample_rate=16000,
|
||||
)
|
||||
|
||||
def save_output_paths(batch):
|
||||
batch.output_file_paths = ["/tmp/output.png"]
|
||||
|
||||
worker._materialize_file_path_transport(output_batch, save_output_paths)
|
||||
|
||||
assert output_batch.output_file_paths == ["/tmp/output.png"]
|
||||
assert output_batch.output is None
|
||||
assert output_batch.audio is None
|
||||
assert output_batch.audio_sample_rate is None
|
||||
|
||||
|
||||
def test_raw_rgb_frame_batches_convert_batched_video_tensor_to_thwc_bytes():
|
||||
output = torch.zeros(1, 3, 2, 2, 2)
|
||||
output[0, 0] = 1.0
|
||||
output[0, 1] = 0.5
|
||||
req = type(
|
||||
"Req",
|
||||
(),
|
||||
{
|
||||
"enable_frame_interpolation": False,
|
||||
"enable_upscaling": False,
|
||||
"request_id": "req",
|
||||
"block_idx": 0,
|
||||
},
|
||||
)()
|
||||
output_batch = OutputBatch(audio_sample_rate=None)
|
||||
|
||||
frame_batches, metadata = build_raw_rgb_frame_batches(
|
||||
output,
|
||||
req,
|
||||
output_batch,
|
||||
post_process_sample_fn=lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
assert metadata == {
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 12,
|
||||
}
|
||||
assert len(frame_batches) == 1
|
||||
assert len(frame_batches[0]) == 2
|
||||
first = np.frombuffer(frame_batches[0][0], dtype=np.uint8).reshape(2, 2, 3)
|
||||
assert np.all(first[..., 0] == 255)
|
||||
assert np.all(first[..., 1] == 127)
|
||||
assert np.all(first[..., 2] == 0)
|
||||
@@ -0,0 +1,250 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.runtime.realtime.condition_events import (
|
||||
ConditionEvent,
|
||||
ConditionEventQueue,
|
||||
ConditionSamplingParams,
|
||||
ControlSignal,
|
||||
ControlStateSamplingQueue,
|
||||
ControlStateTransition,
|
||||
)
|
||||
|
||||
|
||||
def test_condition_event_queue_samples_chunk_and_repeats_last_item():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[["w"], ["d"]]))
|
||||
|
||||
chunk = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=4, default_item=[]),
|
||||
)
|
||||
|
||||
assert chunk == [["w"], ["d"], ["d"], ["d"]]
|
||||
|
||||
|
||||
def test_condition_event_contains_multiple_same_kind_control_signals():
|
||||
event = ConditionEvent(
|
||||
kind="camera_actions",
|
||||
payload=[
|
||||
ControlSignal(kind="camera_actions", payload=["w"]),
|
||||
ControlSignal(kind="camera_actions", payload=["d"]),
|
||||
],
|
||||
)
|
||||
signals = list(event.iter_signals())
|
||||
|
||||
assert [signal.kind for signal in signals] == [
|
||||
"camera_actions",
|
||||
"camera_actions",
|
||||
]
|
||||
assert [signal.payload for signal in signals] == [["w"], ["d"]]
|
||||
|
||||
|
||||
def test_condition_event_queue_samples_control_signal_payloads():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(
|
||||
ConditionEvent(
|
||||
kind="camera_actions",
|
||||
payload=[
|
||||
ControlSignal(kind="camera_actions", payload=["w"]),
|
||||
ControlSignal(kind="camera_actions", payload=["a"]),
|
||||
ControlSignal(kind="camera_actions", payload=["s"]),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
|
||||
assert first == [["w"], ["a"]]
|
||||
assert second == [["s"], ["s"]]
|
||||
|
||||
|
||||
def test_condition_event_queue_preserves_event_remainder_across_chunks():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[["w"], ["a"], ["s"]]))
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
|
||||
assert first == [["w"], ["a"]]
|
||||
assert second == [["s"], ["s"]]
|
||||
|
||||
|
||||
def test_condition_event_queue_does_not_persist_last_signal_across_empty_chunks():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[["w"]]))
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
|
||||
assert first == [["w"], ["w"]]
|
||||
assert second == [[], []]
|
||||
|
||||
|
||||
def test_condition_event_queue_can_repeat_last_signal_across_empty_chunks():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[["w"]]))
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(
|
||||
chunk_size=2,
|
||||
default_item=[],
|
||||
repeat_last_across_empty_chunks=True,
|
||||
),
|
||||
)
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(
|
||||
chunk_size=2,
|
||||
default_item=[],
|
||||
repeat_last_across_empty_chunks=True,
|
||||
),
|
||||
)
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[[]]))
|
||||
third = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(
|
||||
chunk_size=2,
|
||||
default_item=[],
|
||||
repeat_last_across_empty_chunks=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert first == [["w"], ["w"]]
|
||||
assert second == [["w"], ["w"]]
|
||||
assert third == [[], []]
|
||||
|
||||
|
||||
def test_condition_event_queue_tracks_sampled_signal_seq_id():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(
|
||||
ConditionEvent(
|
||||
kind="camera_actions",
|
||||
payload=[
|
||||
ControlSignal(kind="camera_actions", payload=["w"], seq_id=7),
|
||||
ControlSignal(kind="camera_actions", payload=[], seq_id=8),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(
|
||||
chunk_size=1,
|
||||
default_item=[],
|
||||
repeat_last_across_empty_chunks=True,
|
||||
),
|
||||
)
|
||||
first_seq_id = queue.last_sampled_seq_id("camera_actions")
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(
|
||||
chunk_size=1,
|
||||
default_item=[],
|
||||
repeat_last_across_empty_chunks=True,
|
||||
),
|
||||
)
|
||||
second_seq_id = queue.last_sampled_seq_id("camera_actions")
|
||||
|
||||
assert first == [["w"]]
|
||||
assert first_seq_id == 7
|
||||
assert second == [[]]
|
||||
assert second_seq_id == 8
|
||||
|
||||
|
||||
def test_condition_event_queue_replace_clears_pending_signals():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(
|
||||
ConditionEvent(kind="camera_actions", payload=[["w"], ["w"], ["w"], ["w"]])
|
||||
)
|
||||
|
||||
first = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=2, default_item=[]),
|
||||
)
|
||||
queue.replace(ConditionEvent(kind="camera_actions", payload=[["d"]]))
|
||||
second = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=3, default_item=[]),
|
||||
)
|
||||
third = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=3, default_item=[]),
|
||||
)
|
||||
|
||||
assert first == [["w"], ["w"]]
|
||||
assert second == [["d"], ["d"], ["d"]]
|
||||
assert third == [[], [], []]
|
||||
|
||||
|
||||
def test_condition_event_queue_returns_none_without_default_item():
|
||||
queue = ConditionEventQueue()
|
||||
|
||||
chunk = queue.sample_chunk("audio", ConditionSamplingParams(chunk_size=2))
|
||||
|
||||
assert chunk is None
|
||||
|
||||
|
||||
def test_condition_event_queue_empty_event_switches_to_default_item():
|
||||
queue = ConditionEventQueue()
|
||||
queue.push(ConditionEvent(kind="camera_actions", payload=[]))
|
||||
|
||||
chunk = queue.sample_chunk(
|
||||
"camera_actions",
|
||||
ConditionSamplingParams(chunk_size=3, default_item=[]),
|
||||
)
|
||||
|
||||
assert chunk == [[], [], []]
|
||||
|
||||
|
||||
def test_control_state_sampling_queue_preserves_short_pulse():
|
||||
queue = ControlStateSamplingQueue(default_item=[], min_pulse_items=1)
|
||||
queue.push(ControlStateTransition(payload=["w"], seq_id=7))
|
||||
queue.push(ControlStateTransition(payload=[], seq_id=8))
|
||||
|
||||
chunk = queue.sample_chunk(3)
|
||||
|
||||
assert chunk == [["w"], [], []]
|
||||
assert queue.latest_sampled_seq_id() == 8
|
||||
assert queue.sample_chunk(3) == [[], [], []]
|
||||
|
||||
|
||||
def test_control_state_sampling_queue_holds_current_state_without_backlog():
|
||||
queue = ControlStateSamplingQueue(default_item=[], min_pulse_items=1)
|
||||
queue.push(ControlStateTransition(payload=["w"], seq_id=7))
|
||||
|
||||
assert queue.sample_chunk(3) == [["w"], ["w"], ["w"]]
|
||||
assert queue.latest_sampled_seq_id() == 7
|
||||
assert queue.sample_chunk(3) == [["w"], ["w"], ["w"]]
|
||||
assert queue.latest_sampled_seq_id() == 7
|
||||
|
||||
|
||||
def test_control_state_sampling_queue_compacts_many_transitions():
|
||||
queue = ControlStateSamplingQueue(default_item=[], min_pulse_items=1)
|
||||
queue.push(ControlStateTransition(payload=["w"], seq_id=7))
|
||||
queue.push(ControlStateTransition(payload=["w", "d"], seq_id=8))
|
||||
queue.push(ControlStateTransition(payload=["d"], seq_id=9))
|
||||
|
||||
chunk = queue.sample_chunk(3)
|
||||
|
||||
assert chunk == [["d"], ["d"], ["d"]]
|
||||
assert queue.latest_sampled_seq_id() == 9
|
||||
@@ -0,0 +1,522 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import msgspec.msgpack
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
RAW_RGBA_DELTA_GZIP_CONTENT_TYPE,
|
||||
build_delta_gzip_raw_rgb_payload,
|
||||
)
|
||||
from sglang.multimodal_gen.test.server.realtime_consistency import (
|
||||
build_realtime_event_payload,
|
||||
build_realtime_init_payload,
|
||||
collect_realtime_output,
|
||||
decode_realtime_raw_rgb_frames,
|
||||
parse_realtime_chunk_stats,
|
||||
pop_realtime_key_frames,
|
||||
prepare_realtime_first_frame,
|
||||
realtime_ws_url,
|
||||
record_realtime_key_frames,
|
||||
select_realtime_key_frames,
|
||||
summarize_realtime_perf_stats,
|
||||
validate_realtime_perf_stats,
|
||||
)
|
||||
from sglang.multimodal_gen.test.server.test_server_utils import get_generate_fn
|
||||
from sglang.multimodal_gen.test.server.testcase_configs import (
|
||||
DiffusionSamplingParams,
|
||||
LINGBOT_WORLD_REALTIME_sampling_params,
|
||||
)
|
||||
|
||||
# Request construction
|
||||
|
||||
|
||||
def test_realtime_ws_url_uses_existing_openai_base_url():
|
||||
client = SimpleNamespace(base_url="http://127.0.0.1:30000/v1")
|
||||
|
||||
assert realtime_ws_url(client) == "ws://127.0.0.1:30000/v1/realtime_video/generate"
|
||||
|
||||
|
||||
def test_realtime_init_payload_uses_sampling_params_and_extras():
|
||||
params = DiffusionSamplingParams(
|
||||
prompt="turn camera left",
|
||||
seconds=1,
|
||||
fps=8,
|
||||
num_frames=6,
|
||||
extras={"seed": 7, "num_inference_steps": 4},
|
||||
realtime_num_chunks=2,
|
||||
)
|
||||
|
||||
payload = build_realtime_init_payload(
|
||||
model_path="robbyant/lingbot-world-fast-diffusers",
|
||||
sampling_params=params,
|
||||
output_size="832x480",
|
||||
first_frame="https://example.com/first.png",
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"type": "init",
|
||||
"model": "robbyant/lingbot-world-fast-diffusers",
|
||||
"prompt": "turn camera left",
|
||||
"size": "832x480",
|
||||
"seconds": 1,
|
||||
"first_frame": "https://example.com/first.png",
|
||||
"fps": 8,
|
||||
"num_frames": 6,
|
||||
"seed": 7,
|
||||
"num_inference_steps": 4,
|
||||
}
|
||||
|
||||
|
||||
def test_realtime_init_payload_can_request_preview_transport():
|
||||
params = DiffusionSamplingParams(
|
||||
prompt="preview transport",
|
||||
realtime_num_chunks=1,
|
||||
realtime_output_format="webp",
|
||||
)
|
||||
|
||||
payload = build_realtime_init_payload(
|
||||
model_path="robbyant/lingbot-world-fast-diffusers",
|
||||
sampling_params=params,
|
||||
output_size="832x480",
|
||||
first_frame=None,
|
||||
)
|
||||
|
||||
assert payload["realtime_output_format"] == "webp"
|
||||
|
||||
|
||||
def test_realtime_first_frame_accepts_url_or_file(tmp_path):
|
||||
frame_path = tmp_path / "first.png"
|
||||
frame_path.write_bytes(b"png-bytes")
|
||||
|
||||
assert prepare_realtime_first_frame("https://example.com/first.png") == (
|
||||
"https://example.com/first.png"
|
||||
)
|
||||
assert prepare_realtime_first_frame(frame_path) == b"png-bytes"
|
||||
|
||||
|
||||
def test_realtime_key_frames_are_selected_from_raw_websocket_frames():
|
||||
frames = [np.full((2, 2, 3), idx, dtype=np.uint8) for idx in range(5)]
|
||||
|
||||
selected = select_realtime_key_frames(frames)
|
||||
assert [int(frame[0, 0, 0]) for frame in selected] == [0, 2, 4]
|
||||
|
||||
record_realtime_key_frames("unit-raw-frames", frames)
|
||||
frames[2][:] = 99
|
||||
popped = pop_realtime_key_frames("unit-raw-frames")
|
||||
|
||||
assert popped is not None
|
||||
assert [int(frame[0, 0, 0]) for frame in popped] == [0, 2, 4]
|
||||
assert pop_realtime_key_frames("unit-raw-frames") is None
|
||||
|
||||
|
||||
def test_realtime_event_payload_strips_test_schedule_metadata():
|
||||
payload = build_realtime_event_payload(
|
||||
{
|
||||
"after_chunk": 0,
|
||||
"kind": "camera_actions",
|
||||
"payload": [["w"], ["d"]],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"type": "event",
|
||||
"kind": "camera_actions",
|
||||
"payload": [["w"], ["d"]],
|
||||
}
|
||||
|
||||
|
||||
# Raw RGB frame decoding
|
||||
|
||||
|
||||
def test_decode_realtime_raw_rgb_frames_splits_payload_by_header_metadata():
|
||||
first = np.arange(12, dtype=np.uint8).reshape(2, 2, 3)
|
||||
second = (np.arange(12, dtype=np.uint8) + 20).reshape(2, 2, 3)
|
||||
header = {
|
||||
"type": "frame_batch_header",
|
||||
"content_type": RAW_RGB_CONTENT_TYPE,
|
||||
"num_frames": 2,
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 12,
|
||||
}
|
||||
|
||||
frames = decode_realtime_raw_rgb_frames(
|
||||
header,
|
||||
first.tobytes() + second.tobytes(),
|
||||
)
|
||||
|
||||
assert len(frames) == 2
|
||||
np.testing.assert_array_equal(frames[0], first)
|
||||
np.testing.assert_array_equal(frames[1], second)
|
||||
|
||||
|
||||
def test_decode_realtime_raw_rgb_frames_rejects_truncated_payload():
|
||||
header = {
|
||||
"type": "frame_batch_header",
|
||||
"content_type": RAW_RGB_CONTENT_TYPE,
|
||||
"num_frames": 1,
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 12,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="payload size mismatch"):
|
||||
decode_realtime_raw_rgb_frames(header, b"too-short")
|
||||
|
||||
|
||||
def test_decode_realtime_delta_gzip_raw_rgb_frames_roundtrips():
|
||||
first = np.arange(12, dtype=np.uint8).reshape(2, 2, 3)
|
||||
second = (np.arange(12, dtype=np.uint8) + 1).reshape(2, 2, 3)
|
||||
header = {
|
||||
"type": "frame_batch_header",
|
||||
"content_type": RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
"num_frames": 2,
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 12,
|
||||
}
|
||||
|
||||
frames = decode_realtime_raw_rgb_frames(
|
||||
header,
|
||||
build_delta_gzip_raw_rgb_payload([first.tobytes(), second.tobytes()]),
|
||||
)
|
||||
|
||||
assert len(frames) == 2
|
||||
np.testing.assert_array_equal(frames[0], first)
|
||||
np.testing.assert_array_equal(frames[1], second)
|
||||
|
||||
|
||||
def test_decode_realtime_rgba_delta_gzip_strips_alpha():
|
||||
first = np.array(
|
||||
[[[1, 2, 3, 255], [4, 5, 6, 255]]],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
second = np.array(
|
||||
[[[1, 2, 4, 255], [4, 6, 6, 255]]],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
header = {
|
||||
"type": "frame_batch_header",
|
||||
"content_type": RAW_RGBA_DELTA_GZIP_CONTENT_TYPE,
|
||||
"num_frames": 2,
|
||||
"width": 2,
|
||||
"height": 1,
|
||||
"channels": 4,
|
||||
"bytes_per_frame": 8,
|
||||
}
|
||||
|
||||
frames = decode_realtime_raw_rgb_frames(
|
||||
header,
|
||||
build_delta_gzip_raw_rgb_payload([first.tobytes(), second.tobytes()]),
|
||||
)
|
||||
|
||||
assert len(frames) == 2
|
||||
np.testing.assert_array_equal(frames[0], first[:, :, :3])
|
||||
np.testing.assert_array_equal(frames[1], second[:, :, :3])
|
||||
|
||||
|
||||
# Stream collection and realtime performance stats
|
||||
|
||||
|
||||
class _FakeRealtimeWebSocket:
|
||||
def __init__(self, messages):
|
||||
self.messages = list(messages)
|
||||
self.sent = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def send(self, payload):
|
||||
self.sent.append(msgspec.msgpack.decode(payload))
|
||||
|
||||
async def recv(self):
|
||||
if not self.messages:
|
||||
raise AssertionError("fake websocket received too many recv calls")
|
||||
return self.messages.pop(0)
|
||||
|
||||
|
||||
def _packed_realtime_frame_message(chunk_index: int, frame: np.ndarray):
|
||||
header = {
|
||||
"type": "frame_batch_header",
|
||||
"content_type": RAW_RGB_CONTENT_TYPE,
|
||||
"chunk_index": chunk_index,
|
||||
"is_final_frame_batch": True,
|
||||
"num_frames": 1,
|
||||
"width": frame.shape[1],
|
||||
"height": frame.shape[0],
|
||||
"channels": frame.shape[2],
|
||||
"bytes_per_frame": frame.nbytes,
|
||||
}
|
||||
return msgspec.msgpack.encode(header), frame.tobytes()
|
||||
|
||||
|
||||
def _packed_realtime_combined_frame_message(chunk_index: int, frame: np.ndarray):
|
||||
header, payload = _packed_realtime_frame_message(chunk_index, frame)
|
||||
message = msgspec.msgpack.decode(header)
|
||||
message["type"] = "frame_batch"
|
||||
message["payload"] = payload
|
||||
return msgspec.msgpack.encode(message)
|
||||
|
||||
|
||||
def _packed_realtime_chunk_stats(chunk_index: int, **overrides):
|
||||
payload = {
|
||||
"type": "chunk_stats",
|
||||
"request_id": f"req-{chunk_index}",
|
||||
"chunk_index": chunk_index,
|
||||
"content_type": RAW_RGB_CONTENT_TYPE,
|
||||
"num_frames": 1,
|
||||
"raw_bytes": 12,
|
||||
"ws_payload_bytes": 128,
|
||||
"request_prepare_ms": 1,
|
||||
"scheduler_forward_ms": 20,
|
||||
"raw_payload_build_ms": 2,
|
||||
"raw_write_ms": 3,
|
||||
"ws_write_ms": 4,
|
||||
"chunk_total_ms": 30,
|
||||
}
|
||||
payload.update(overrides)
|
||||
for key, value in list(payload.items()):
|
||||
if key.endswith("_ms"):
|
||||
payload[key] = max(0, int(value + 0.5))
|
||||
packed = msgspec.msgpack.encode(payload)
|
||||
assert bytes([0xCB]) not in packed
|
||||
return packed
|
||||
|
||||
|
||||
def test_collect_realtime_output_skips_and_records_chunk_stats(monkeypatch):
|
||||
first = np.arange(12, dtype=np.uint8).reshape(2, 2, 3)
|
||||
second = first + 1
|
||||
chunk0_header, chunk0_payload = _packed_realtime_frame_message(0, first)
|
||||
chunk1_header, chunk1_payload = _packed_realtime_frame_message(1, second)
|
||||
websocket = _FakeRealtimeWebSocket(
|
||||
[
|
||||
chunk0_header,
|
||||
chunk0_payload,
|
||||
_packed_realtime_chunk_stats(0, chunk_total_ms=31),
|
||||
chunk1_header,
|
||||
chunk1_payload,
|
||||
_packed_realtime_chunk_stats(1, chunk_total_ms=32),
|
||||
]
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"websockets",
|
||||
SimpleNamespace(connect=lambda *args, **kwargs: websocket),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
collect_realtime_output(
|
||||
ws_url="ws://example.test/v1/realtime_video/generate",
|
||||
init_payload={"type": "init", "prompt": "test"},
|
||||
events=[
|
||||
{
|
||||
"after_chunk": 0,
|
||||
"kind": "camera_actions",
|
||||
"payload": [["w"]],
|
||||
}
|
||||
],
|
||||
num_chunks=2,
|
||||
require_chunk_stats=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.frames) == 2
|
||||
np.testing.assert_array_equal(result.frames[0], first)
|
||||
np.testing.assert_array_equal(result.frames[1], second)
|
||||
assert [stat.chunk_index for stat in result.chunk_stats] == [0, 1]
|
||||
assert [stat.chunk_total_ms for stat in result.chunk_stats] == [31.0, 32.0]
|
||||
assert websocket.sent == [
|
||||
{"type": "init", "prompt": "test"},
|
||||
{
|
||||
"type": "event",
|
||||
"kind": "camera_actions",
|
||||
"payload": [["w"]],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_collect_realtime_output_accepts_combined_frame_batch(monkeypatch):
|
||||
frame = np.arange(12, dtype=np.uint8).reshape(2, 2, 3)
|
||||
websocket = _FakeRealtimeWebSocket(
|
||||
[
|
||||
_packed_realtime_combined_frame_message(0, frame),
|
||||
]
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"websockets",
|
||||
SimpleNamespace(connect=lambda *args, **kwargs: websocket),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
collect_realtime_output(
|
||||
ws_url="ws://example.test/v1/realtime_video/generate",
|
||||
init_payload={"type": "init", "prompt": "test"},
|
||||
events=[],
|
||||
num_chunks=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.frames) == 1
|
||||
np.testing.assert_array_equal(result.frames[0], frame)
|
||||
|
||||
|
||||
def test_realtime_perf_stats_summary_and_thresholds():
|
||||
stats = [
|
||||
parse_realtime_chunk_stats(
|
||||
msgspec.msgpack.decode(
|
||||
_packed_realtime_chunk_stats(
|
||||
0,
|
||||
scheduler_forward_ms=10.0,
|
||||
raw_write_ms=2.0,
|
||||
ws_write_ms=3.0,
|
||||
ws_payload_bytes=1024 * 1024,
|
||||
chunk_total_ms=20.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
parse_realtime_chunk_stats(
|
||||
msgspec.msgpack.decode(
|
||||
_packed_realtime_chunk_stats(
|
||||
1,
|
||||
scheduler_forward_ms=30.0,
|
||||
raw_write_ms=4.0,
|
||||
ws_write_ms=5.0,
|
||||
ws_payload_bytes=2 * 1024 * 1024,
|
||||
chunk_total_ms=40.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
summary = summarize_realtime_perf_stats(stats)
|
||||
|
||||
assert summary["num_chunks"] == 2
|
||||
assert summary["total_frames"] == 2
|
||||
assert summary["guarded_chunks"] == 2
|
||||
assert summary["avg_scheduler_forward_ms"] == 20.0
|
||||
assert summary["p95_chunk_total_ms"] == 40.0
|
||||
assert summary["avg_ws_payload_mb"] == 1.5
|
||||
validate_realtime_perf_stats(
|
||||
"case",
|
||||
stats,
|
||||
{
|
||||
"avg_scheduler_forward_ms": 25.0,
|
||||
"p95_chunk_total_ms": 45.0,
|
||||
"avg_ws_payload_mb": 2.0,
|
||||
},
|
||||
)
|
||||
with pytest.raises(pytest.fail.Exception, match="p95_chunk_total_ms"):
|
||||
validate_realtime_perf_stats(
|
||||
"case",
|
||||
stats,
|
||||
{"p95_chunk_total_ms": 35.0},
|
||||
)
|
||||
|
||||
|
||||
def test_realtime_perf_stats_can_ignore_startup_chunks():
|
||||
stats = [
|
||||
parse_realtime_chunk_stats(
|
||||
msgspec.msgpack.decode(
|
||||
_packed_realtime_chunk_stats(
|
||||
0,
|
||||
scheduler_forward_ms=20000.0,
|
||||
chunk_total_ms=21000.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
parse_realtime_chunk_stats(
|
||||
msgspec.msgpack.decode(
|
||||
_packed_realtime_chunk_stats(
|
||||
1,
|
||||
scheduler_forward_ms=7000.0,
|
||||
chunk_total_ms=7200.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
parse_realtime_chunk_stats(
|
||||
msgspec.msgpack.decode(
|
||||
_packed_realtime_chunk_stats(
|
||||
2,
|
||||
scheduler_forward_ms=2300.0,
|
||||
chunk_total_ms=2800.0,
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
summary = summarize_realtime_perf_stats(stats, ignore_initial_chunks=2)
|
||||
|
||||
assert summary["num_chunks"] == 3
|
||||
assert summary["ignored_initial_chunks"] == 2
|
||||
assert summary["guarded_chunks"] == 1
|
||||
assert summary["ignored_max_chunk_total_ms"] == 21000.0
|
||||
assert summary["p95_chunk_total_ms"] == 2800.0
|
||||
validate_realtime_perf_stats(
|
||||
"case",
|
||||
stats,
|
||||
{"p95_chunk_total_ms": 5000.0, "p95_scheduler_forward_ms": 4500.0},
|
||||
ignore_initial_chunks=2,
|
||||
)
|
||||
with pytest.raises(ValueError, match="leave at least one"):
|
||||
summarize_realtime_perf_stats(stats, ignore_initial_chunks=3)
|
||||
|
||||
|
||||
# Generate function routing
|
||||
|
||||
|
||||
def test_realtime_sampling_params_route_to_realtime_video_generator():
|
||||
params = DiffusionSamplingParams(
|
||||
prompt="turn camera left",
|
||||
realtime_num_chunks=2,
|
||||
)
|
||||
|
||||
generate_fn = get_generate_fn(
|
||||
"robbyant/lingbot-world-fast-diffusers",
|
||||
"video",
|
||||
params,
|
||||
)
|
||||
|
||||
assert generate_fn.__name__ == "generate_realtime_video"
|
||||
|
||||
|
||||
def test_lingbot_realtime_plastic_beach_params_are_lossless_gt_ready():
|
||||
params = LINGBOT_WORLD_REALTIME_sampling_params
|
||||
|
||||
assert "floating island hotel" in params.prompt
|
||||
assert "825646291038" in str(params.image_path)
|
||||
assert params.output_size == "832x480"
|
||||
assert params.realtime_num_chunks == 4
|
||||
assert params.realtime_output_format is None
|
||||
assert params.realtime_perf_ignore_initial_chunks == 2
|
||||
assert params.realtime_perf_thresholds["p95_chunk_total_ms"] == 5000.0
|
||||
assert params.realtime_perf_thresholds["p95_scheduler_forward_ms"] == 4500.0
|
||||
assert params.realtime_events[0]["kind"] == "camera_actions"
|
||||
assert params.realtime_events[0]["payload"]["mode"] == "state"
|
||||
|
||||
|
||||
def test_lingbot_realtime_case_is_registered_by_default():
|
||||
from sglang.multimodal_gen.test.server.gpu_cases import (
|
||||
ONE_GPU_CASES,
|
||||
_make_lingbot_realtime_plastic_beach_case,
|
||||
)
|
||||
|
||||
case = _make_lingbot_realtime_plastic_beach_case()
|
||||
assert case.id == "lingbot_world_realtime_plastic_beach"
|
||||
assert case.run_consistency_check is True
|
||||
assert case.run_perf_check is True
|
||||
assert case.sampling_params.realtime_output_format is None
|
||||
assert any(item.id == case.id for item in ONE_GPU_CASES)
|
||||
@@ -0,0 +1,631 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import msgspec.msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime import (
|
||||
realtime_output_adapter,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
|
||||
RawRGBRealtimeOutputAdapter,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
JPEG_FRAME_CONTENT_TYPE,
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
RAW_RGB_DELTA_GZIP_CONTENT_TYPE,
|
||||
WEBP_FRAME_CONTENT_TYPE,
|
||||
build_delta_gzip_raw_rgb_payload,
|
||||
build_raw_rgb_frame_batches,
|
||||
restore_delta_gzip_raw_rgb_payload,
|
||||
)
|
||||
|
||||
|
||||
def _unpack_frame_batch_messages(payloads):
|
||||
messages = []
|
||||
for payload in payloads:
|
||||
message = msgspec.msgpack.decode(payload)
|
||||
assert message.pop("type") == "frame_batch"
|
||||
frame_payload = message.pop("payload")
|
||||
messages.append((message, frame_payload))
|
||||
return messages
|
||||
|
||||
|
||||
def test_raw_rgb_frame_batches_preserve_frame_bytes_and_metadata():
|
||||
req = SimpleNamespace(
|
||||
request_id="req-1",
|
||||
block_idx=2,
|
||||
data_type="video",
|
||||
fps=24,
|
||||
output_compression=None,
|
||||
enable_frame_interpolation=False,
|
||||
frame_interpolation_exp=1,
|
||||
frame_interpolation_scale=1.0,
|
||||
frame_interpolation_model_path=None,
|
||||
enable_upscaling=False,
|
||||
upscaling_model_path=None,
|
||||
upscaling_scale=1,
|
||||
)
|
||||
output_batch = OutputBatch(audio_sample_rate=None)
|
||||
grayscale = np.array([[1, 2], [3, 4]], dtype=np.uint8)
|
||||
rgba = np.array(
|
||||
[
|
||||
[[5, 6, 7, 8], [9, 10, 11, 12]],
|
||||
[[13, 14, 15, 16], [17, 18, 19, 20]],
|
||||
],
|
||||
dtype=np.uint8,
|
||||
)
|
||||
|
||||
def post_process_sample(*_args, **_kwargs):
|
||||
return [grayscale, rgba]
|
||||
|
||||
frame_batches, metadata = build_raw_rgb_frame_batches(
|
||||
object(),
|
||||
req,
|
||||
output_batch,
|
||||
post_process_sample,
|
||||
)
|
||||
|
||||
assert metadata == {
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 12,
|
||||
}
|
||||
assert len(frame_batches) == 1
|
||||
assert frame_batches[0][0] == bytes([1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4])
|
||||
assert frame_batches[0][1] == bytes([5, 6, 7, 9, 10, 11, 13, 14, 15, 17, 18, 19])
|
||||
assert RAW_RGB_CONTENT_TYPE == "application/x-raw-rgb"
|
||||
|
||||
|
||||
def test_raw_rgb_frame_batches_use_tensor_fast_path_without_postprocess():
|
||||
req = SimpleNamespace(
|
||||
request_id="req-1",
|
||||
block_idx=2,
|
||||
data_type="video",
|
||||
fps=24,
|
||||
output_compression=None,
|
||||
enable_frame_interpolation=False,
|
||||
frame_interpolation_exp=1,
|
||||
frame_interpolation_scale=1.0,
|
||||
frame_interpolation_model_path=None,
|
||||
enable_upscaling=False,
|
||||
upscaling_model_path=None,
|
||||
upscaling_scale=1,
|
||||
)
|
||||
output_batch = OutputBatch(audio_sample_rate=None)
|
||||
output = torch.tensor(
|
||||
[[[[[0.0]], [[0.25]]], [[[0.5]], [[0.75]]], [[[1.0]], [[1.0]]]]]
|
||||
)
|
||||
|
||||
def post_process_sample(*_args, **_kwargs):
|
||||
raise AssertionError("tensor realtime output should not use postprocess")
|
||||
|
||||
frame_batches, metadata = build_raw_rgb_frame_batches(
|
||||
output,
|
||||
req,
|
||||
output_batch,
|
||||
post_process_sample,
|
||||
)
|
||||
|
||||
assert metadata == {
|
||||
"format": "rgb24",
|
||||
"width": 1,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 3,
|
||||
}
|
||||
assert frame_batches == [[bytes([0, 127, 255]), bytes([63, 191, 255])]]
|
||||
|
||||
|
||||
def test_output_batch_uses_raw_frame_transport_names():
|
||||
output_batch = OutputBatch(
|
||||
raw_frame_batches=[[b"rgb"]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={"format": "rgb24"},
|
||||
)
|
||||
|
||||
assert output_batch.raw_frame_batches == [[b"rgb"]]
|
||||
assert output_batch.raw_frame_content_type == RAW_RGB_CONTENT_TYPE
|
||||
assert output_batch.raw_frame_metadata == {"format": "rgb24"}
|
||||
|
||||
|
||||
def test_delta_gzip_raw_rgb_payload_roundtrips_exactly():
|
||||
frames = [
|
||||
bytes([1, 2, 3, 4, 5, 6]),
|
||||
bytes([1, 2, 4, 4, 6, 6]),
|
||||
bytes([2, 2, 4, 5, 6, 7]),
|
||||
]
|
||||
|
||||
payload = build_delta_gzip_raw_rgb_payload(frames)
|
||||
restored = restore_delta_gzip_raw_rgb_payload(
|
||||
payload,
|
||||
bytes_per_frame=6,
|
||||
num_frames=3,
|
||||
)
|
||||
|
||||
assert restored == b"".join(frames)
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_uses_lossless_compressed_payload():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
frame0 = bytes([1, 2, 3]) * 1000
|
||||
frame1 = bytes([1, 2, 4]) * 1000
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-1",
|
||||
width=1000,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=3,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[frame0, frame1]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 1000,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 3000,
|
||||
},
|
||||
)
|
||||
|
||||
stats = await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, stats, frame0 + frame1
|
||||
|
||||
payloads, stats, expected_frames = asyncio.run(run())
|
||||
|
||||
[(first_header, first_payload)] = _unpack_frame_batch_messages(payloads)
|
||||
assert first_header["content_type"] == RAW_RGB_DELTA_GZIP_CONTENT_TYPE
|
||||
assert first_header["encoding"] == "delta-gzip"
|
||||
assert first_header["event_id"] == 3
|
||||
assert first_header["format"] == "rgb24"
|
||||
assert first_header["channels"] == 3
|
||||
assert first_header["bytes_per_frame"] == 3000
|
||||
assert first_header["raw_size"] == 6000
|
||||
assert first_header["total_size"] == len(first_payload)
|
||||
assert first_header["num_frames"] == 2
|
||||
assert first_header["num_frame_batches"] == 1
|
||||
assert first_header["frame_batch_index"] == 0
|
||||
assert "delta_reference" not in first_header
|
||||
assert stats["raw_bytes"] == 6000
|
||||
assert stats["num_batches"] == 1
|
||||
assert stats["num_frames"] == 2
|
||||
restored_frames = restore_delta_gzip_raw_rgb_payload(
|
||||
first_payload,
|
||||
bytes_per_frame=3000,
|
||||
num_frames=2,
|
||||
)
|
||||
assert restored_frames == expected_frames
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_offloads_delta_payload_build(
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
calls.append((fn, args, kwargs))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_output_adapter.asyncio,
|
||||
"to_thread",
|
||||
fake_to_thread,
|
||||
)
|
||||
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
frame0 = bytes([1, 2, 3]) * 1000
|
||||
frame1 = bytes([1, 2, 4]) * 1000
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-offload-delta",
|
||||
width=1000,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=3,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[frame0, frame1]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 1000,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 3000,
|
||||
},
|
||||
)
|
||||
|
||||
await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, frame0 + frame1
|
||||
|
||||
payloads, expected_frames = asyncio.run(run())
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
realtime_output_adapter._build_transport_payload,
|
||||
]
|
||||
[(first_header, first_payload)] = _unpack_frame_batch_messages(payloads)
|
||||
assert first_header["encoding"] == "delta-gzip"
|
||||
assert "delta_reference" not in first_header
|
||||
restored_frames = restore_delta_gzip_raw_rgb_payload(
|
||||
first_payload,
|
||||
bytes_per_frame=3000,
|
||||
num_frames=2,
|
||||
)
|
||||
assert restored_frames == expected_frames
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_can_send_uncompressed_raw_frames():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
frame0 = bytes([1, 2, 3]) * 1000
|
||||
frame1 = bytes([1, 2, 4]) * 1000
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-raw",
|
||||
width=1000,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=3,
|
||||
realtime_output_format="raw",
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[frame0, frame1]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 1000,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 3000,
|
||||
},
|
||||
)
|
||||
|
||||
stats = await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, stats, frame0 + frame1
|
||||
|
||||
payloads, stats, expected_frames = asyncio.run(run())
|
||||
|
||||
[(first_header, first_payload)] = _unpack_frame_batch_messages(payloads)
|
||||
assert first_header["content_type"] == RAW_RGB_CONTENT_TYPE
|
||||
assert first_header["encoding"] == "raw"
|
||||
assert first_header["raw_size"] == 6000
|
||||
assert first_header["total_size"] == 6000
|
||||
assert first_header["num_frames"] == 2
|
||||
assert first_header["num_frame_batches"] == 1
|
||||
assert first_header["frame_batch_index"] == 0
|
||||
assert first_payload == expected_frames
|
||||
assert stats["raw_bytes"] == 6000
|
||||
assert stats["num_batches"] == 1
|
||||
assert stats["num_frames"] == 2
|
||||
assert stats["ws_payload_bytes"] == sum(len(payload) for payload in payloads)
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_uses_previous_frame_reference():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
base_batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-1",
|
||||
width=2,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=9,
|
||||
)
|
||||
next_batch = SimpleNamespace(
|
||||
block_idx=1,
|
||||
request_id="req-2",
|
||||
width=2,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=9,
|
||||
)
|
||||
metadata = {
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 6,
|
||||
}
|
||||
first = OutputBatch(
|
||||
raw_frame_batches=[[bytes([1, 2, 3, 4, 5, 6])]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata=metadata,
|
||||
)
|
||||
second = OutputBatch(
|
||||
raw_frame_batches=[[bytes([1, 2, 4, 4, 6, 6])]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata=metadata,
|
||||
)
|
||||
|
||||
await adapter.send(ws, SimpleNamespace(), first, base_batch)
|
||||
await adapter.send(ws, SimpleNamespace(), second, next_batch)
|
||||
return ws.payloads
|
||||
|
||||
payloads = asyncio.run(run())
|
||||
|
||||
(first_header, first_payload), (second_header, second_payload) = (
|
||||
_unpack_frame_batch_messages(payloads)
|
||||
)
|
||||
assert "delta_reference" not in first_header
|
||||
assert second_header["delta_reference"] == "previous-frame"
|
||||
first_frame = restore_delta_gzip_raw_rgb_payload(
|
||||
first_payload,
|
||||
bytes_per_frame=6,
|
||||
num_frames=1,
|
||||
)
|
||||
second_frame = restore_delta_gzip_raw_rgb_payload(
|
||||
second_payload,
|
||||
bytes_per_frame=6,
|
||||
num_frames=1,
|
||||
reference_frame=first_frame,
|
||||
)
|
||||
assert first_frame == bytes([1, 2, 3, 4, 5, 6])
|
||||
assert second_frame == bytes([1, 2, 4, 4, 6, 6])
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_splits_large_frame_batches():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
frames = [bytes([idx, idx + 1, idx + 2]) for idx in range(17)]
|
||||
batch = SimpleNamespace(
|
||||
block_idx=4,
|
||||
request_id="req-split",
|
||||
width=1,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=12,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[frames],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 1,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 3,
|
||||
},
|
||||
)
|
||||
|
||||
stats = await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, stats
|
||||
|
||||
payloads, stats = asyncio.run(run())
|
||||
|
||||
headers = [header for header, _ in _unpack_frame_batch_messages(payloads)]
|
||||
assert len(headers) == 2
|
||||
assert [header["chunk_index"] for header in headers] == [4, 4]
|
||||
assert [header["frame_batch_index"] for header in headers] == [0, 1]
|
||||
assert [header["num_frame_batches"] for header in headers] == [2, 2]
|
||||
assert [header["num_frames"] for header in headers] == [16, 1]
|
||||
assert [header["is_final_frame_batch"] for header in headers] == [False, True]
|
||||
assert "delta_reference" not in headers[0]
|
||||
assert headers[1]["delta_reference"] == "previous-frame"
|
||||
assert stats["num_batches"] == 2
|
||||
assert stats["num_frames"] == 17
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_can_send_webp_preview_frames():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-webp",
|
||||
width=2,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=5,
|
||||
realtime_output_format="webp",
|
||||
output_compression=90,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[bytes([255, 0, 0, 0, 255, 0])]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 6,
|
||||
},
|
||||
)
|
||||
|
||||
stats = await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, stats
|
||||
|
||||
payloads, stats = asyncio.run(run())
|
||||
|
||||
[(header, frame_payload)] = _unpack_frame_batch_messages(payloads)
|
||||
assert header["content_type"] == WEBP_FRAME_CONTENT_TYPE
|
||||
assert header["format"] == "webp"
|
||||
assert header["encoding"] == "webp"
|
||||
assert header["num_frames"] == 1
|
||||
assert header["is_final_frame_batch"] is True
|
||||
assert frame_payload.startswith(b"RIFF")
|
||||
assert stats["num_batches"] == 1
|
||||
assert stats["num_frames"] == 1
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_offloads_preview_encoding(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
calls.append((fn, args, kwargs))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_output_adapter.asyncio,
|
||||
"to_thread",
|
||||
fake_to_thread,
|
||||
)
|
||||
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-webp-offload",
|
||||
width=2,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=5,
|
||||
realtime_output_format="webp",
|
||||
output_compression=90,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[
|
||||
[
|
||||
bytes([255, 0, 0, 0, 255, 0]),
|
||||
bytes([0, 0, 255, 255, 255, 0]),
|
||||
]
|
||||
],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 6,
|
||||
},
|
||||
)
|
||||
|
||||
await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads
|
||||
|
||||
payloads = asyncio.run(run())
|
||||
|
||||
assert [call[0] for call in calls] == [
|
||||
realtime_output_adapter._build_transport_payload,
|
||||
realtime_output_adapter._build_transport_payload,
|
||||
]
|
||||
(first_header, first_payload), (second_header, second_payload) = (
|
||||
_unpack_frame_batch_messages(payloads)
|
||||
)
|
||||
assert first_header["content_type"] == WEBP_FRAME_CONTENT_TYPE
|
||||
assert first_header["encoding"] == "webp"
|
||||
assert first_header["num_frames"] == 1
|
||||
assert first_header["frame_batch_index"] == 0
|
||||
assert first_header["num_frame_batches"] == 2
|
||||
assert first_header["is_final_frame_batch"] is False
|
||||
assert second_header["content_type"] == WEBP_FRAME_CONTENT_TYPE
|
||||
assert second_header["encoding"] == "webp"
|
||||
assert second_header["num_frames"] == 1
|
||||
assert second_header["frame_batch_index"] == 1
|
||||
assert second_header["num_frame_batches"] == 2
|
||||
assert second_header["is_final_frame_batch"] is True
|
||||
assert first_payload.startswith(b"RIFF")
|
||||
assert second_payload.startswith(b"RIFF")
|
||||
|
||||
|
||||
def test_raw_rgb_realtime_output_adapter_can_send_jpeg_preview_frames():
|
||||
class _WebSocket:
|
||||
def __init__(self):
|
||||
self.payloads = []
|
||||
|
||||
async def send_bytes(self, payload):
|
||||
self.payloads.append(payload)
|
||||
|
||||
async def run():
|
||||
ws = _WebSocket()
|
||||
adapter = RawRGBRealtimeOutputAdapter()
|
||||
batch = SimpleNamespace(
|
||||
block_idx=0,
|
||||
request_id="req-jpeg",
|
||||
width=2,
|
||||
height=1,
|
||||
enable_upscaling=False,
|
||||
realtime_event_id=5,
|
||||
realtime_output_format="jpeg",
|
||||
output_compression=85,
|
||||
)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[bytes([255, 0, 0, 0, 255, 0])]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
raw_frame_metadata={
|
||||
"format": "rgb24",
|
||||
"width": 2,
|
||||
"height": 1,
|
||||
"channels": 3,
|
||||
"bytes_per_frame": 6,
|
||||
},
|
||||
)
|
||||
|
||||
stats = await adapter.send(ws, SimpleNamespace(), result, batch)
|
||||
return ws.payloads, stats
|
||||
|
||||
payloads, stats = asyncio.run(run())
|
||||
|
||||
[(header, frame_payload)] = _unpack_frame_batch_messages(payloads)
|
||||
assert header["content_type"] == JPEG_FRAME_CONTENT_TYPE
|
||||
assert header["format"] == "jpeg"
|
||||
assert header["encoding"] == "jpeg"
|
||||
assert header["num_frames"] == 1
|
||||
assert header["is_final_frame_batch"] is True
|
||||
assert frame_payload.startswith(b"\xff\xd8")
|
||||
assert stats["num_batches"] == 1
|
||||
assert stats["num_frames"] == 1
|
||||
@@ -0,0 +1,883 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import msgspec.msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.lingbot_world import (
|
||||
LingBotWorldCausalDMDConfig,
|
||||
_actions_to_c2ws,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
|
||||
RealtimeEvent,
|
||||
RealtimeVideoGenerationsRequest,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime import (
|
||||
realtime_video_api,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.adapters import (
|
||||
lingbot_world_realtime_adapter as lingbot_realtime,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
|
||||
GenerateSession,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
|
||||
empty_frame_send_stats,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
|
||||
get_realtime_model_adapter,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world import (
|
||||
LingBotWorldCausalDMDDenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime_input_validation import (
|
||||
RealtimeInputValidationStage,
|
||||
RealtimeInputValidationState,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.condition_events import (
|
||||
ControlStateTransition,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.realtime.session import (
|
||||
BaseRealtimeState,
|
||||
RealtimeSessionCache,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.realtime_video import (
|
||||
RAW_RGB_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
|
||||
class _Req(SimpleNamespace):
|
||||
realtime_session_id: str | None = None
|
||||
block_idx: int = 0
|
||||
session = None
|
||||
|
||||
|
||||
class _State(BaseRealtimeState):
|
||||
def __init__(self):
|
||||
self.disposed = False
|
||||
|
||||
def dispose(self) -> None:
|
||||
self.disposed = True
|
||||
|
||||
|
||||
def test_realtime_session_cache_reuses_and_releases_state():
|
||||
cache = RealtimeSessionCache(max_sessions=1)
|
||||
first = _Req(realtime_session_id="session-a", block_idx=0, session=None)
|
||||
cache.attach(first)
|
||||
state = first.session.get_or_create_state(_State)
|
||||
|
||||
second = _Req(realtime_session_id="session-a", block_idx=1, session=None)
|
||||
cache.attach(second)
|
||||
|
||||
assert second.session is first.session
|
||||
assert second.session.get_state(_State) is state
|
||||
assert cache.release("session-a")
|
||||
assert state.disposed
|
||||
assert not cache.release("session-a")
|
||||
|
||||
|
||||
def test_realtime_session_cache_rejects_missing_nonzero_chunk():
|
||||
cache = RealtimeSessionCache(max_sessions=1)
|
||||
req = _Req(realtime_session_id="missing", block_idx=1, session=None)
|
||||
|
||||
try:
|
||||
cache.attach(req)
|
||||
except ValueError as exc:
|
||||
assert "Missing realtime session state" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected missing realtime session to fail")
|
||||
|
||||
|
||||
def test_lingbot_realtime_state_uses_generic_condition_queue():
|
||||
state = lingbot_realtime.LingBotWorldRealtimeState()
|
||||
|
||||
assert state.sample_camera_actions(3) == [[], [], []]
|
||||
state.receive_camera_actions([["w"], ["a"], ["s"], ["d"]])
|
||||
assert state.sample_camera_actions(3) == [["w"], ["a"], ["s"]]
|
||||
assert state.sample_camera_actions(3) == [["d"], [], []]
|
||||
assert state.sample_camera_actions(3) == [[], [], []]
|
||||
|
||||
state.receive_prompt("turn left")
|
||||
assert state.has_prompt()
|
||||
assert state.sample_prompt() == "turn left"
|
||||
assert not state.has_prompt()
|
||||
|
||||
|
||||
def test_lingbot_realtime_camera_events_preserve_short_presses():
|
||||
state = lingbot_realtime.LingBotWorldRealtimeState()
|
||||
|
||||
state.receive_camera_state(["w"], event_id=7)
|
||||
state.receive_camera_state([], event_id=8)
|
||||
|
||||
assert state.sample_camera_actions(3) == [["w"], [], []]
|
||||
assert state.latest_sampled_event_id == 8
|
||||
assert state.sample_camera_actions(3) == [[], [], []]
|
||||
|
||||
|
||||
def test_lingbot_realtime_camera_state_holds_until_release():
|
||||
state = lingbot_realtime.LingBotWorldRealtimeState()
|
||||
|
||||
state.receive_camera_state(["w"], event_id=7)
|
||||
|
||||
assert state.sample_camera_actions(3) == [["w"], ["w"], ["w"]]
|
||||
assert state.latest_sampled_event_id == 7
|
||||
assert state.sample_camera_actions(3) == [["w"], ["w"], ["w"]]
|
||||
|
||||
state.receive_camera_state([], event_id=8)
|
||||
|
||||
assert state.sample_camera_actions(3) == [[], [], []]
|
||||
assert state.latest_sampled_event_id == 8
|
||||
|
||||
|
||||
def test_lingbot_realtime_camera_state_compacts_multiple_pending_updates():
|
||||
state = lingbot_realtime.LingBotWorldRealtimeState()
|
||||
state.receive_camera_state_transitions(
|
||||
[
|
||||
ControlStateTransition(payload=["w"], seq_id=7),
|
||||
ControlStateTransition(payload=["w", "d"], seq_id=8),
|
||||
ControlStateTransition(payload=["d"], seq_id=9),
|
||||
]
|
||||
)
|
||||
|
||||
assert state.sample_camera_actions(3) == [["d"], ["d"], ["d"]]
|
||||
assert state.latest_sampled_event_id == 9
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_ingests_generic_events():
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
|
||||
camera_event = RealtimeEvent(
|
||||
type="event",
|
||||
kind="camera_actions",
|
||||
payload=[["w"], ["d"]],
|
||||
event_id=7,
|
||||
)
|
||||
prompt_event = RealtimeEvent(
|
||||
type="event",
|
||||
kind="prompt",
|
||||
payload="turn left",
|
||||
event_id=8,
|
||||
)
|
||||
|
||||
assert (
|
||||
adapter.ingest_event(session, camera_event)
|
||||
== "kind=camera_actions, mode=script, frames=2"
|
||||
)
|
||||
assert adapter.ingest_event(session, prompt_event) == "kind=prompt, prompt_len=9"
|
||||
state = adapter._state(session)
|
||||
assert state.sample_camera_actions(3) == [["w"], ["d"], []]
|
||||
assert state.sample_prompt() == "turn left"
|
||||
assert state.latest_sampled_event_id == 8
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_ingests_state_camera_events():
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
|
||||
camera_event = RealtimeEvent(
|
||||
type="event",
|
||||
kind="camera_actions",
|
||||
payload={
|
||||
"mode": "state",
|
||||
"transitions": [
|
||||
{"actions": ["w"], "client_ts_ms": 100},
|
||||
{"actions": [], "client_ts_ms": 120},
|
||||
],
|
||||
},
|
||||
event_id=11,
|
||||
)
|
||||
|
||||
assert (
|
||||
adapter.ingest_event(session, camera_event)
|
||||
== "kind=camera_actions, mode=state, transitions=2"
|
||||
)
|
||||
state = adapter._state(session)
|
||||
assert state.sample_camera_actions(3) == [["w"], [], []]
|
||||
assert state.latest_sampled_event_id == 11
|
||||
|
||||
|
||||
def test_generate_session_tracks_active_chunk_context():
|
||||
session = GenerateSession()
|
||||
|
||||
chunk = session.new_chunk()
|
||||
|
||||
assert chunk.session_id == session.id
|
||||
assert chunk.index == 0
|
||||
assert chunk.request_id.startswith(f"{session.id}_")
|
||||
try:
|
||||
session.new_chunk()
|
||||
except RuntimeError as exc:
|
||||
assert "previous realtime chunk" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected active chunk to block new chunk")
|
||||
|
||||
session.generate_chunk_completed()
|
||||
next_chunk = session.new_chunk()
|
||||
|
||||
assert next_chunk.index == 1
|
||||
assert next_chunk.request_id.startswith(f"{session.id}_")
|
||||
assert next_chunk.request_id != chunk.request_id
|
||||
|
||||
|
||||
def test_generate_session_respects_max_chunks():
|
||||
session = GenerateSession()
|
||||
session.set_request(
|
||||
RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
max_chunks=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert not session.reached_max_chunks()
|
||||
session.new_chunk()
|
||||
session.generate_chunk_completed()
|
||||
|
||||
assert session.reached_max_chunks()
|
||||
|
||||
|
||||
def test_generate_loop_overlaps_send_with_next_generation(monkeypatch):
|
||||
events = []
|
||||
|
||||
class _Adapter:
|
||||
async def wait_for_next_chunk(self, session):
|
||||
del session
|
||||
|
||||
def prepare_next_request(self, session, server_args, chunk):
|
||||
del session, server_args
|
||||
return SimpleNamespace(
|
||||
block_idx=chunk.index,
|
||||
request_id=chunk.request_id,
|
||||
condition_inputs={},
|
||||
)
|
||||
|
||||
async def send_output(self, ws, session, result, batch):
|
||||
del ws, session, result
|
||||
events.append(f"send_start_{batch.block_idx}")
|
||||
await asyncio.sleep(0.05)
|
||||
events.append(f"send_end_{batch.block_idx}")
|
||||
return empty_frame_send_stats("test")
|
||||
|
||||
def on_chunk_complete(self, session, result):
|
||||
del result
|
||||
session.generate_chunk_completed()
|
||||
|
||||
async def fake_process_generation_batch(client, batch):
|
||||
del client
|
||||
events.append(f"generate_start_{batch.block_idx}")
|
||||
await asyncio.sleep(0.01)
|
||||
events.append(f"generate_end_{batch.block_idx}")
|
||||
return None, SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_video_api,
|
||||
"get_global_server_args",
|
||||
lambda: SimpleNamespace(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
realtime_video_api,
|
||||
"process_generation_batch",
|
||||
fake_process_generation_batch,
|
||||
)
|
||||
|
||||
session = GenerateSession()
|
||||
session.adapter = _Adapter()
|
||||
session.set_request(
|
||||
RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
max_chunks=2,
|
||||
)
|
||||
)
|
||||
|
||||
class _Ws:
|
||||
async def send_bytes(self, _message):
|
||||
pass
|
||||
|
||||
asyncio.run(realtime_video_api._generate_loop(_Ws(), session))
|
||||
|
||||
assert events.index("generate_start_1") < events.index("send_end_0")
|
||||
assert events.index("send_end_0") < events.index("send_start_1")
|
||||
assert events[-1] == "send_end_1"
|
||||
|
||||
|
||||
def test_send_output_emits_chunk_stats_message():
|
||||
sent_messages = []
|
||||
|
||||
class _Ws:
|
||||
async def send_bytes(self, message):
|
||||
sent_messages.append(message)
|
||||
|
||||
class _Adapter:
|
||||
async def send_output(self, ws, session, result, batch):
|
||||
del ws, session, result, batch
|
||||
return {
|
||||
"header_pack_ms": 0.1,
|
||||
"header_write_ms": 0.2,
|
||||
"raw_payload_build_ms": 3.0,
|
||||
"raw_write_ms": 42.0,
|
||||
"ws_write_ms": 42.2,
|
||||
"raw_bytes": 1200,
|
||||
"ws_payload_bytes": 450,
|
||||
"num_frames": 3,
|
||||
"num_batches": 3,
|
||||
"frame_shape": (1, 2, 3),
|
||||
"content_type": "image/webp",
|
||||
}
|
||||
|
||||
session = GenerateSession()
|
||||
session.adapter = _Adapter()
|
||||
chunk = session.new_chunk()
|
||||
batch = SimpleNamespace(
|
||||
block_idx=7,
|
||||
realtime_event_id=11,
|
||||
condition_inputs={"camera_actions": [["w"]]},
|
||||
)
|
||||
|
||||
stats = asyncio.run(
|
||||
realtime_video_api._send_output_and_log(
|
||||
_Ws(),
|
||||
session,
|
||||
chunk,
|
||||
batch,
|
||||
SimpleNamespace(),
|
||||
request_prepare_ms=1.0,
|
||||
scheduler_forward_ms=2.0,
|
||||
chunk_started=time.perf_counter(),
|
||||
)
|
||||
)
|
||||
|
||||
message = msgspec.msgpack.decode(sent_messages[-1])
|
||||
assert stats["raw_write_ms"] == 42.0
|
||||
assert message["type"] == "chunk_stats"
|
||||
assert message["chunk_index"] == 7
|
||||
assert message["event_id"] == 11
|
||||
assert message["raw_write_ms"] == 42
|
||||
assert message["ws_write_ms"] == 42
|
||||
assert message["ws_payload_bytes"] == 450
|
||||
assert message["content_type"] == "image/webp"
|
||||
assert bytes([0xCB]) not in sent_messages[-1]
|
||||
|
||||
|
||||
def test_listen_generate_request_propagates_disconnect_without_error_write():
|
||||
sent_messages = []
|
||||
|
||||
class _Ws:
|
||||
async def receive_bytes(self):
|
||||
raise realtime_video_api.WebSocketDisconnect(1000, "client close")
|
||||
|
||||
async def send_bytes(self, message):
|
||||
sent_messages.append(message)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
realtime_video_api._listen_generate_request(_Ws(), GenerateSession())
|
||||
)
|
||||
except realtime_video_api.WebSocketDisconnect:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected websocket disconnect to propagate")
|
||||
|
||||
assert sent_messages == []
|
||||
|
||||
|
||||
def test_wait_for_active_session_slot_observes_release(monkeypatch):
|
||||
async def run():
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.clear()
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.add("old-session")
|
||||
|
||||
async def fake_sleep(_seconds):
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.clear()
|
||||
|
||||
monkeypatch.setattr(realtime_video_api.asyncio, "sleep", fake_sleep)
|
||||
try:
|
||||
return await realtime_video_api._wait_for_active_session_slot(
|
||||
timeout_s=1.0,
|
||||
interval_s=0.1,
|
||||
)
|
||||
finally:
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.clear()
|
||||
|
||||
assert asyncio.run(run())
|
||||
|
||||
|
||||
def test_cleanup_realtime_session_keeps_active_slot_during_scheduler_release(
|
||||
monkeypatch,
|
||||
):
|
||||
async def run():
|
||||
session = GenerateSession()
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.clear()
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.add(session.id)
|
||||
release_seen = []
|
||||
|
||||
async def fake_forward(req):
|
||||
release_seen.append(
|
||||
(req.session_id, session.id in realtime_video_api._ACTIVE_SESSION_IDS)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_video_api.async_scheduler_client,
|
||||
"forward",
|
||||
fake_forward,
|
||||
)
|
||||
try:
|
||||
await realtime_video_api._cleanup_realtime_session(session, None, None)
|
||||
still_active_after_cleanup = (
|
||||
session.id in realtime_video_api._ACTIVE_SESSION_IDS
|
||||
)
|
||||
finally:
|
||||
realtime_video_api._ACTIVE_SESSION_IDS.clear()
|
||||
|
||||
return session.id, release_seen, still_active_after_cleanup
|
||||
|
||||
session_id, release_seen, still_active_after_cleanup = asyncio.run(run())
|
||||
|
||||
assert release_seen == [(session_id, True)]
|
||||
assert still_active_after_cleanup
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_prepares_chunk_request(monkeypatch):
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
session.set_request(
|
||||
RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
num_frames=9,
|
||||
fps=24,
|
||||
realtime_causal_sink_size=3,
|
||||
realtime_causal_kv_cache_num_frames=45,
|
||||
)
|
||||
)
|
||||
state = adapter._state(session)
|
||||
state.receive_camera_state(["w"], event_id=11)
|
||||
server_args = SimpleNamespace(
|
||||
pipeline_config=SimpleNamespace(
|
||||
dit_config=SimpleNamespace(
|
||||
arch_config=SimpleNamespace(num_frames_per_block=3)
|
||||
),
|
||||
vae_config=SimpleNamespace(
|
||||
arch_config=SimpleNamespace(temporal_compression_ratio=4)
|
||||
),
|
||||
)
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def fake_build_sampling_params(request_id, **kwargs):
|
||||
seen["request_id"] = request_id
|
||||
seen["kwargs"] = kwargs
|
||||
return SimpleNamespace(
|
||||
request_id=request_id,
|
||||
prompt=kwargs["prompt"],
|
||||
condition_inputs=kwargs["condition_inputs"],
|
||||
realtime_chunk_size=kwargs["realtime_chunk_size"],
|
||||
)
|
||||
|
||||
def fake_prepare_backend_request(server_args, sampling_params):
|
||||
seen["server_args"] = server_args
|
||||
return SimpleNamespace(
|
||||
request_id=sampling_params.request_id,
|
||||
prompt=sampling_params.prompt,
|
||||
condition_inputs=dict(sampling_params.condition_inputs),
|
||||
realtime_chunk_size=sampling_params.realtime_chunk_size,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
lingbot_realtime,
|
||||
"build_sampling_params",
|
||||
fake_build_sampling_params,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lingbot_realtime,
|
||||
"prepare_request",
|
||||
fake_prepare_backend_request,
|
||||
)
|
||||
chunk = session.new_chunk()
|
||||
|
||||
batch = adapter.prepare_next_request(session, server_args, chunk)
|
||||
|
||||
assert seen["request_id"] == chunk.request_id
|
||||
assert seen["kwargs"]["prompt"] == "walk forward"
|
||||
assert seen["kwargs"]["num_frames"] == 21
|
||||
assert seen["kwargs"]["num_inference_steps"] == 4
|
||||
assert seen["kwargs"]["guidance_scale"] == 1.0
|
||||
assert seen["kwargs"]["condition_inputs"] == {
|
||||
"camera_actions": [["w"], ["w"], ["w"]]
|
||||
}
|
||||
assert batch.request_id == chunk.request_id
|
||||
assert batch.condition_inputs == {"camera_actions": [["w"], ["w"], ["w"]]}
|
||||
assert batch.realtime_chunk_size == 3
|
||||
assert batch.session is session.realtime_session
|
||||
assert batch.realtime_session_id == session.id
|
||||
assert batch.block_idx == 0
|
||||
assert batch.return_raw_frames is True
|
||||
assert batch.realtime_event_id == 11
|
||||
assert batch.realtime_causal_sink_size == 3
|
||||
assert batch.realtime_causal_kv_cache_num_frames == 45
|
||||
|
||||
|
||||
def test_lingbot_realtime_condition_horizon_repeats_blank_tail_chunk():
|
||||
config = LingBotWorldCausalDMDConfig()
|
||||
chunk_size = config.dit_config.arch_config.num_frames_per_block
|
||||
latent_channels = config.vae_config.arch_config.z_dim
|
||||
temporal_ratio = config.vae_config.arch_config.temporal_compression_ratio
|
||||
spatial_ratio = config.vae_config.arch_config.spatial_compression_ratio
|
||||
request = RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
num_frames=9,
|
||||
)
|
||||
server_args = SimpleNamespace(pipeline_config=config)
|
||||
|
||||
num_frames = lingbot_realtime.LingBotWorldRealtimeAdapter._condition_num_frames(
|
||||
request=request,
|
||||
server_args=server_args,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
latent_frames = (num_frames - 1) // temporal_ratio + 1
|
||||
raw_request_latent_frames = (request.num_frames - 1) // temporal_ratio + 1
|
||||
|
||||
assert raw_request_latent_frames == chunk_size
|
||||
assert latent_frames == chunk_size * 2
|
||||
|
||||
latent_condition = torch.ones(1, latent_channels, latent_frames, 2, 2)
|
||||
batch = SimpleNamespace(
|
||||
height=2 * spatial_ratio,
|
||||
width=2 * spatial_ratio,
|
||||
)
|
||||
condition_full = config.postprocess_image_latent(latent_condition, batch)
|
||||
first_chunk = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=0,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
tail_chunk = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=1,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
repeated_tail = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=2,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
|
||||
assert torch.count_nonzero(first_chunk[:, :temporal_ratio]) > 0
|
||||
assert torch.count_nonzero(tail_chunk[:, :temporal_ratio]) == 0
|
||||
assert not torch.equal(tail_chunk, first_chunk)
|
||||
assert torch.equal(repeated_tail, tail_chunk)
|
||||
|
||||
long_request = RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
num_frames=45,
|
||||
)
|
||||
assert (
|
||||
lingbot_realtime.LingBotWorldRealtimeAdapter._condition_num_frames(
|
||||
request=long_request,
|
||||
server_args=server_args,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
== 45
|
||||
)
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_ingests_initial_condition_inputs():
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
request = RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
condition_inputs={"camera_actions": [["w"], ["d"], ["a"], ["s"]]},
|
||||
)
|
||||
|
||||
state = adapter._state(session)
|
||||
|
||||
asyncio.run(adapter.on_init(session, request))
|
||||
|
||||
assert state.sample_camera_actions(3) == [["w"], ["d"], ["a"]]
|
||||
assert state.sample_camera_actions(3) == [["s"], [], []]
|
||||
assert state.sample_camera_actions(3) == [[], [], []]
|
||||
|
||||
|
||||
def test_realtime_video_request_accepts_raw_lossless_output_format():
|
||||
request = RealtimeVideoGenerationsRequest(
|
||||
type="init",
|
||||
prompt="walk forward",
|
||||
realtime_output_format="raw",
|
||||
realtime_causal_sink_size=3,
|
||||
realtime_causal_kv_cache_num_frames=45,
|
||||
)
|
||||
|
||||
assert request.realtime_output_format == "raw"
|
||||
assert request.realtime_causal_sink_size == 3
|
||||
assert request.realtime_causal_kv_cache_num_frames == 45
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_does_not_wait_for_idle_chunks():
|
||||
async def run():
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
await adapter.wait_for_next_chunk(session)
|
||||
|
||||
session.generate_chunk_cnt = 1
|
||||
await asyncio.wait_for(adapter.wait_for_next_chunk(session), timeout=1)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_lingbot_realtime_adapter_sends_stale_output_for_client_cutover():
|
||||
adapter = lingbot_realtime.LingBotWorldRealtimeAdapter()
|
||||
session = GenerateSession()
|
||||
session.set_adapter(adapter)
|
||||
state = adapter._state(session)
|
||||
state.receive_camera_actions([["d"]], event_id=7)
|
||||
calls = []
|
||||
|
||||
async def fake_send(ws, session_arg, result_arg, batch_arg):
|
||||
calls.append((ws, session_arg, result_arg, batch_arg))
|
||||
return empty_frame_send_stats("sent")
|
||||
|
||||
adapter.output_adapter = SimpleNamespace(send=fake_send)
|
||||
batch = SimpleNamespace(block_idx=3, realtime_event_id=6)
|
||||
result = OutputBatch(
|
||||
raw_frame_batches=[[b"stale"]],
|
||||
raw_frame_content_type=RAW_RGB_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
stats = asyncio.run(adapter.send_output(SimpleNamespace(), session, result, batch))
|
||||
|
||||
assert stats == empty_frame_send_stats("sent")
|
||||
assert calls[0][1] is session
|
||||
assert calls[0][2] is result
|
||||
assert calls[0][3] is batch
|
||||
|
||||
|
||||
def test_lingbot_i2v_condition_repeats_last_chunk():
|
||||
condition_full = torch.ones(1, 20, 3, 2, 2)
|
||||
|
||||
first = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=0,
|
||||
chunk_size=3,
|
||||
)
|
||||
second = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=1,
|
||||
chunk_size=3,
|
||||
)
|
||||
|
||||
assert torch.equal(first, condition_full)
|
||||
assert second.shape == condition_full.shape
|
||||
assert torch.equal(second, condition_full)
|
||||
|
||||
|
||||
def test_lingbot_i2v_condition_pads_tail_then_repeats_it():
|
||||
condition_full = torch.ones(1, 20, 1, 2, 2)
|
||||
|
||||
first = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=0,
|
||||
chunk_size=3,
|
||||
)
|
||||
second = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=1,
|
||||
chunk_size=3,
|
||||
)
|
||||
|
||||
assert first.shape == (1, 20, 3, 2, 2)
|
||||
assert torch.equal(first[:, :, :1], condition_full)
|
||||
assert torch.count_nonzero(first[:, :, 1:]) == 0
|
||||
assert second.shape == first.shape
|
||||
assert torch.equal(second, first)
|
||||
|
||||
|
||||
def test_lingbot_i2v_condition_uses_available_non_initial_chunks():
|
||||
first_chunk = torch.ones(1, 20, 3, 2, 2)
|
||||
second_chunk = first_chunk * 2
|
||||
condition_full = torch.cat([first_chunk, second_chunk], dim=2)
|
||||
|
||||
second = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=1,
|
||||
chunk_size=3,
|
||||
)
|
||||
third = LingBotWorldCausalDMDDenoisingStage._select_i2v_condition_chunk(
|
||||
condition_full,
|
||||
chunk_idx=2,
|
||||
chunk_size=3,
|
||||
)
|
||||
|
||||
assert torch.equal(second, second_chunk)
|
||||
assert third.shape == second_chunk.shape
|
||||
assert torch.equal(third, second_chunk)
|
||||
|
||||
|
||||
def test_realtime_input_validation_reuses_generator_across_chunks():
|
||||
stage = RealtimeInputValidationStage.__new__(RealtimeInputValidationStage)
|
||||
state = RealtimeInputValidationState()
|
||||
generator = torch.Generator(device="cpu").manual_seed(123)
|
||||
|
||||
first = SimpleNamespace(
|
||||
block_idx=0,
|
||||
generator=generator,
|
||||
seeds=None,
|
||||
seed=123,
|
||||
generator_device="cpu",
|
||||
num_outputs_per_prompt=1,
|
||||
)
|
||||
second = SimpleNamespace(
|
||||
block_idx=1,
|
||||
generator=torch.Generator(device="cpu").manual_seed(123),
|
||||
seeds=None,
|
||||
seed=123,
|
||||
generator_device="cpu",
|
||||
num_outputs_per_prompt=1,
|
||||
)
|
||||
|
||||
stage._cache_generator(first, state)
|
||||
stage._reuse_or_cache_generator(second, state)
|
||||
|
||||
assert second.generator is generator
|
||||
|
||||
|
||||
def test_realtime_registry_resolves_lingbot_adapter():
|
||||
server_args = SimpleNamespace(pipeline_config=LingBotWorldCausalDMDConfig())
|
||||
|
||||
adapter = get_realtime_model_adapter(server_args)
|
||||
|
||||
assert isinstance(adapter, lingbot_realtime.LingBotWorldRealtimeAdapter)
|
||||
|
||||
|
||||
def test_sampling_params_apply_condition_inputs_to_req():
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
|
||||
sampling_params = SamplingParams(
|
||||
prompt="test",
|
||||
num_inference_steps=1,
|
||||
condition_inputs={"camera_actions": [["w"]]},
|
||||
realtime_chunk_size=3,
|
||||
)
|
||||
req = SimpleNamespace(extra={}, condition_inputs={}, realtime_chunk_size=None)
|
||||
|
||||
sampling_params.apply_request_extra(req)
|
||||
|
||||
assert req.condition_inputs == {"camera_actions": [["w"]]}
|
||||
assert req.realtime_chunk_size == 3
|
||||
|
||||
|
||||
def test_realtime_chunk_latent_preparation_uses_chunk_spec():
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import (
|
||||
RealtimeChunkLatentPreparationStage,
|
||||
)
|
||||
|
||||
transformer = SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
arch_config=SimpleNamespace(out_channels=16, num_frames_per_block=3)
|
||||
)
|
||||
)
|
||||
stage = RealtimeChunkLatentPreparationStage.__new__(
|
||||
RealtimeChunkLatentPreparationStage
|
||||
)
|
||||
stage.scheduler = SimpleNamespace(init_noise_sigma=10.0)
|
||||
stage.transformer = transformer
|
||||
batch = SimpleNamespace(
|
||||
batch_size=1,
|
||||
generator=None,
|
||||
height=None,
|
||||
width=None,
|
||||
image_latent=torch.zeros(2, 20, 6, 4, 5, dtype=torch.float32),
|
||||
latents=None,
|
||||
realtime_chunk_size=2,
|
||||
)
|
||||
|
||||
def fake_randn_tensor(shape, generator, device, dtype):
|
||||
del generator
|
||||
return torch.ones(shape, device=device, dtype=dtype)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation.get_local_torch_device",
|
||||
return_value=torch.device("cpu"),
|
||||
),
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.pipelines_core.stages.latent_preparation.randn_tensor",
|
||||
side_effect=fake_randn_tensor,
|
||||
),
|
||||
):
|
||||
result = stage.forward(batch, SimpleNamespace())
|
||||
|
||||
assert tuple(result.latents.shape) == (2, 16, 2, 4, 5)
|
||||
assert result.latents.dtype == torch.float32
|
||||
assert torch.all(result.latents == 1)
|
||||
assert result.raw_latent_shape == result.latents.shape
|
||||
|
||||
|
||||
def test_lingbot_camera_actions_have_deterministic_pose_precision():
|
||||
poses = _actions_to_c2ws([["w"], ["d"]])
|
||||
|
||||
np.testing.assert_allclose(poses[1][:3, 3], [0.0, 0.0, 0.05], atol=1e-6)
|
||||
np.testing.assert_allclose(poses[2][:3, 3], [0.05, 0.0, 0.05], atol=1e-6)
|
||||
|
||||
yaw_pose = _actions_to_c2ws([["l"]])[1]
|
||||
expected_yaw = np.array(
|
||||
[
|
||||
[np.cos(np.deg2rad(6.0)), 0.0, np.sin(np.deg2rad(6.0))],
|
||||
[0.0, 1.0, 0.0],
|
||||
[-np.sin(np.deg2rad(6.0)), 0.0, np.cos(np.deg2rad(6.0))],
|
||||
]
|
||||
)
|
||||
np.testing.assert_allclose(yaw_pose[:3, :3], expected_yaw, atol=1e-6)
|
||||
|
||||
|
||||
def test_lingbot_camera_condition_uses_condition_inputs_without_session():
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.lingbot_world import (
|
||||
LingBotWorldCausalDMDConfig,
|
||||
)
|
||||
|
||||
batch = SimpleNamespace(
|
||||
c2ws_plucker_emb=None,
|
||||
condition_inputs={"camera_actions": [["w"]]},
|
||||
session=None,
|
||||
realtime_chunk_size=3,
|
||||
width=16,
|
||||
height=16,
|
||||
block_idx=0,
|
||||
realtime_session_id=None,
|
||||
)
|
||||
pipeline_config = LingBotWorldCausalDMDConfig()
|
||||
|
||||
condition = pipeline_config.prepare_world_condition(
|
||||
batch=batch,
|
||||
device="cpu",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
spatial_scale = pipeline_config.vae_config.arch_config.spatial_compression_ratio
|
||||
expected_shape = (
|
||||
1,
|
||||
6 * spatial_scale * spatial_scale,
|
||||
batch.realtime_chunk_size,
|
||||
batch.height // spatial_scale,
|
||||
batch.width // spatial_scale,
|
||||
)
|
||||
assert tuple(condition["c2ws_plucker_emb"].shape) == expected_shape
|
||||
assert condition["c2ws_plucker_emb"].dtype == torch.float32
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime_vae import (
|
||||
CausalVaeDecodingStage,
|
||||
RealtimeVAEDecodeState,
|
||||
)
|
||||
|
||||
|
||||
def test_realtime_vae_decode_state_clears_model_cache_on_dispose():
|
||||
calls = []
|
||||
state = RealtimeVAEDecodeState()
|
||||
state.reset_causal_decode_state = lambda: calls.append("reset")
|
||||
|
||||
state.dispose()
|
||||
|
||||
assert calls == ["reset"]
|
||||
assert state.reset_causal_decode_state is None
|
||||
|
||||
|
||||
def test_causal_vae_decoding_stage_keeps_wan_decoder_cache(monkeypatch):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import realtime_vae
|
||||
|
||||
class _WanVAE:
|
||||
def __init__(self):
|
||||
self.config = SimpleNamespace(patch_size=None)
|
||||
self.clear_calls = 0
|
||||
self.decoder_first_chunk_flags = []
|
||||
self._feat_map = []
|
||||
self._conv_idx = [0]
|
||||
|
||||
def to(self, device=None, dtype=None):
|
||||
del device, dtype
|
||||
return self
|
||||
|
||||
def clear_cache(self):
|
||||
self.clear_calls += 1
|
||||
self._feat_map = [None]
|
||||
self._conv_idx = [0]
|
||||
|
||||
def post_quant_conv(self, latents):
|
||||
return latents
|
||||
|
||||
def decoder(self, x, *, feat_cache, feat_idx, first_chunk=False):
|
||||
self.decoder_first_chunk_flags.append(first_chunk)
|
||||
if feat_cache[0] is None:
|
||||
feat_cache[0] = x.detach().clone()
|
||||
else:
|
||||
feat_cache[0] = torch.cat([feat_cache[0], x.detach().clone()], dim=2)
|
||||
feat_idx[0] += 1
|
||||
return x
|
||||
|
||||
class _PipelineConfig:
|
||||
vae_precision = "fp32"
|
||||
vae_tiling = False
|
||||
|
||||
def get_decode_scale_and_shift(self, device, dtype, vae):
|
||||
del device, dtype, vae
|
||||
return 1.0, None
|
||||
|
||||
def preprocess_decoding(self, latents, server_args, vae=None):
|
||||
del server_args, vae
|
||||
return latents
|
||||
|
||||
def post_decoding(self, frames, server_args):
|
||||
del server_args
|
||||
return frames
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_vae,
|
||||
"get_local_torch_device",
|
||||
lambda: torch.device("cpu"),
|
||||
)
|
||||
|
||||
vae = _WanVAE()
|
||||
vae.clear_cache()
|
||||
vae.clear_calls = 0
|
||||
stage = CausalVaeDecodingStage.__new__(CausalVaeDecodingStage)
|
||||
stage.vae = vae
|
||||
server_args = SimpleNamespace(
|
||||
pipeline_config=_PipelineConfig(),
|
||||
disable_autocast=True,
|
||||
)
|
||||
|
||||
first = stage.decode_causal(
|
||||
torch.zeros(1, 1, 2, 1, 1),
|
||||
server_args,
|
||||
first_chunk=True,
|
||||
)
|
||||
second = stage.decode_causal(
|
||||
torch.ones(1, 1, 1, 1, 1),
|
||||
server_args,
|
||||
first_chunk=False,
|
||||
)
|
||||
|
||||
assert tuple(first.shape) == (1, 1, 2, 1, 1)
|
||||
assert tuple(second.shape) == (1, 1, 1, 1, 1)
|
||||
assert vae.clear_calls == 0
|
||||
assert vae.decoder_first_chunk_flags == [True, False, False]
|
||||
assert tuple(vae._feat_map[0].shape) == (1, 1, 3, 1, 1)
|
||||
|
||||
|
||||
def test_causal_vae_decoding_stage_prefers_native_causal_decode(monkeypatch):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import realtime_vae
|
||||
|
||||
class _NativeCausalVAE:
|
||||
def __init__(self):
|
||||
self.config = SimpleNamespace(patch_size=None)
|
||||
self.calls = []
|
||||
self._feat_map = [None]
|
||||
self._conv_idx = [0]
|
||||
|
||||
def to(self, device=None, dtype=None):
|
||||
del device, dtype
|
||||
return self
|
||||
|
||||
def clear_cache(self):
|
||||
self.calls.append("clear_cache")
|
||||
|
||||
def reset_causal_decode_state(self):
|
||||
self.calls.append("reset")
|
||||
|
||||
def post_quant_conv(self, latents):
|
||||
self.calls.append("post_quant_conv")
|
||||
return latents
|
||||
|
||||
def decoder(self, x, *, feat_cache, feat_idx, first_chunk=False):
|
||||
del x, feat_cache, feat_idx, first_chunk
|
||||
self.calls.append("decoder")
|
||||
|
||||
def causal_decode(self, latents):
|
||||
self.calls.append("causal_decode")
|
||||
return latents
|
||||
|
||||
class _PipelineConfig:
|
||||
vae_precision = "fp32"
|
||||
vae_tiling = False
|
||||
|
||||
def get_decode_scale_and_shift(self, device, dtype, vae):
|
||||
del device, dtype, vae
|
||||
return 1.0, None
|
||||
|
||||
def preprocess_decoding(self, latents, server_args, vae=None):
|
||||
del server_args, vae
|
||||
return latents
|
||||
|
||||
monkeypatch.setattr(
|
||||
realtime_vae,
|
||||
"get_local_torch_device",
|
||||
lambda: torch.device("cpu"),
|
||||
)
|
||||
|
||||
vae = _NativeCausalVAE()
|
||||
stage = CausalVaeDecodingStage.__new__(CausalVaeDecodingStage)
|
||||
stage.vae = vae
|
||||
server_args = SimpleNamespace(
|
||||
pipeline_config=_PipelineConfig(),
|
||||
disable_autocast=True,
|
||||
)
|
||||
|
||||
frames = stage.decode_causal(
|
||||
torch.zeros(1, 1, 1, 1, 1),
|
||||
server_args,
|
||||
first_chunk=True,
|
||||
)
|
||||
|
||||
assert tuple(frames.shape) == (1, 1, 1, 1, 1)
|
||||
assert vae.calls == ["causal_decode"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
|
||||
_save_upload_to_path,
|
||||
)
|
||||
|
||||
|
||||
def test_save_upload_to_path_accepts_starlette_upload_file(tmp_path):
|
||||
upload = StarletteUploadFile(
|
||||
io.BytesIO(b"image-bytes"),
|
||||
filename="input.png",
|
||||
)
|
||||
target_path = tmp_path / "input.png"
|
||||
|
||||
saved_path = asyncio.run(_save_upload_to_path(upload, str(target_path)))
|
||||
|
||||
assert saved_path == str(target_path)
|
||||
assert target_path.read_bytes() == b"image-bytes"
|
||||
Reference in New Issue
Block a user