model: support Pi0.5 (#30633)
This commit is contained in:
@@ -104,6 +104,7 @@ diffusion = [
|
||||
"imageio==2.36.0",
|
||||
"imageio-ffmpeg==0.5.1",
|
||||
"moviepy>=2.0.0",
|
||||
"msgpack",
|
||||
"nvidia-modelopt",
|
||||
"opencv-python-headless==4.10.0.84",
|
||||
"PyYAML==6.0.1",
|
||||
|
||||
@@ -46,13 +46,21 @@ def _extract_model_type_override(extra_argv):
|
||||
return model_type, filtered_argv
|
||||
|
||||
|
||||
def _normalize_positional_model_path(extra_argv):
|
||||
"""Allow `sglang serve <model>` while preserving existing flag parsing."""
|
||||
if extra_argv and not extra_argv[0].startswith("-"):
|
||||
return ["--model-path", extra_argv[0], *extra_argv[1:]], True
|
||||
return extra_argv, False
|
||||
|
||||
|
||||
def serve(args, extra_argv):
|
||||
if any(h in extra_argv for h in ("-h", "--help")):
|
||||
# Since the server type is determined by the model, and we don't have a model path,
|
||||
# we can't show the exact help. Instead, we show a general help message and then
|
||||
# the help for both possible server types.
|
||||
print(
|
||||
"Usage: sglang serve --model-path <model-name-or-path> [additional-arguments]\n\n"
|
||||
"Usage: sglang serve <model-name-or-path> [additional-arguments]\n"
|
||||
" or: sglang serve --model-path <model-name-or-path> [additional-arguments]\n\n"
|
||||
"This command can launch either a standard language model server or a diffusion model server.\n"
|
||||
"The server type is determined by the --model-path.\n"
|
||||
"Optional override: --model-type {auto,llm,diffusion} "
|
||||
@@ -91,6 +99,9 @@ def serve(args, extra_argv):
|
||||
load_plugins()
|
||||
|
||||
model_type, dispatch_argv = _extract_model_type_override(extra_argv)
|
||||
dispatch_argv, positional_model_path = _normalize_positional_model_path(
|
||||
dispatch_argv
|
||||
)
|
||||
model_path = get_model_path(dispatch_argv)
|
||||
try:
|
||||
if model_type == "auto":
|
||||
@@ -116,6 +127,8 @@ def serve(args, extra_argv):
|
||||
)
|
||||
add_multimodal_gen_serve_args(parser)
|
||||
parsed_args, remaining_argv = parser.parse_known_args(dispatch_argv)
|
||||
if positional_model_path:
|
||||
parsed_args._sglang_explicit_arg_names = {"model_path"}
|
||||
|
||||
execute_serve_cmd(parsed_args, remaining_argv)
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
|
||||
LTX23PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.mova import MOVAPipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.sana import SanaPipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.stablediffusion3 import (
|
||||
StableDiffusion3PipelineConfig,
|
||||
@@ -71,6 +72,7 @@ __all__ = [
|
||||
"SanaPipelineConfig",
|
||||
"SlidingTileAttnConfig",
|
||||
"MOVAPipelineConfig",
|
||||
"Pi05PipelineConfig",
|
||||
"StableDiffusion3PipelineConfig",
|
||||
"WanT2V480PConfig",
|
||||
"WanI2V480PConfig",
|
||||
|
||||
@@ -59,6 +59,7 @@ class ModelTaskType(Enum):
|
||||
I2I = auto() # Image to Image
|
||||
TI2I = auto() # Image to Image or Text-Image to Image
|
||||
I2M = auto() # Image to Mesh
|
||||
VLA_ACTION = auto() # Vision-language-action policy output
|
||||
|
||||
def is_image_gen(self) -> bool:
|
||||
return (
|
||||
@@ -67,6 +68,22 @@ class ModelTaskType(Enum):
|
||||
or self == ModelTaskType.TI2I
|
||||
)
|
||||
|
||||
def is_action_gen(self) -> bool:
|
||||
return self == ModelTaskType.VLA_ACTION
|
||||
|
||||
def is_mesh_gen(self) -> bool:
|
||||
return self == ModelTaskType.I2M
|
||||
|
||||
def is_video_gen(self) -> bool:
|
||||
return (
|
||||
self == ModelTaskType.I2V
|
||||
or self == ModelTaskType.T2V
|
||||
or self == ModelTaskType.TI2V
|
||||
)
|
||||
|
||||
def is_visual_gen(self) -> bool:
|
||||
return self.is_image_gen() or self.is_video_gen()
|
||||
|
||||
def requires_image_input(self) -> bool:
|
||||
return (
|
||||
self == ModelTaskType.I2V
|
||||
@@ -81,15 +98,17 @@ class ModelTaskType(Enum):
|
||||
or self == ModelTaskType.TI2I
|
||||
or self == ModelTaskType.TI2V
|
||||
or self == ModelTaskType.I2M
|
||||
or self == ModelTaskType.VLA_ACTION
|
||||
)
|
||||
|
||||
def data_type(self) -> DataType:
|
||||
if self == ModelTaskType.I2M:
|
||||
if self.is_action_gen():
|
||||
return DataType.ACTION
|
||||
if self.is_mesh_gen():
|
||||
return DataType.MESH
|
||||
if self.is_image_gen():
|
||||
return DataType.IMAGE
|
||||
else:
|
||||
return DataType.VIDEO
|
||||
return DataType.VIDEO
|
||||
|
||||
|
||||
class STA_Mode(str, Enum):
|
||||
@@ -374,6 +393,10 @@ class PipelineConfig:
|
||||
"""
|
||||
return self.task_type in (ModelTaskType.T2I, ModelTaskType.T2V)
|
||||
|
||||
def supports_native_grouped_requests(self):
|
||||
"""Return whether dynamic batches should run as grouped Req lists."""
|
||||
return False
|
||||
|
||||
def estimate_request_cost(self, batch) -> float:
|
||||
"""Return the relative cost used for batching admission caps.
|
||||
|
||||
@@ -888,6 +911,8 @@ class PipelineConfig:
|
||||
pipeline_config_or_path: str | PipelineConfig | dict[str, Any] | None = (
|
||||
kwargs.get(prefix_with_dot + "pipeline_config", None)
|
||||
or kwargs.get("pipeline_config")
|
||||
or kwargs.get(prefix_with_dot + "pipeline_config_path", None)
|
||||
or kwargs.get("pipeline_config_path")
|
||||
)
|
||||
if model_path is None:
|
||||
raise ValueError("model_path is required in kwargs")
|
||||
@@ -943,36 +968,52 @@ class PipelineConfig:
|
||||
model_id=kwargs.get("model_id"),
|
||||
)
|
||||
if model_info is None:
|
||||
raise ValueError(
|
||||
f"Could not get model info for '{model_path}'. "
|
||||
f"If using a safetensors file, please specify pipeline_class_name"
|
||||
)
|
||||
# 1.5. Adjust pipeline config for fine-tuned VAE if needed
|
||||
pipeline_config_cls = model_info.pipeline_config_cls
|
||||
# If an explicit pipeline_class_name refines the model-default config
|
||||
# (e.g. SanaWMRealtimePipeline -> SanaWMRealtimeConfig, a subclass of
|
||||
# the model-resolved SanaWMPipelineConfig), prefer the pipeline's own
|
||||
# config so realtime-only wiring (the /v1/realtime_video adapter) is
|
||||
# selected. Only applies when the explicit config strictly subclasses
|
||||
# the model default, so non-realtime pipelines are unaffected.
|
||||
if pipeline_class_name:
|
||||
explicit_config_classes = get_pipeline_config_classes(
|
||||
pipeline_class_name
|
||||
)
|
||||
if explicit_config_classes is not None:
|
||||
explicit_config_cls = explicit_config_classes[0]
|
||||
if (
|
||||
isinstance(explicit_config_cls, type)
|
||||
and isinstance(pipeline_config_cls, type)
|
||||
and explicit_config_cls is not pipeline_config_cls
|
||||
and issubclass(explicit_config_cls, pipeline_config_cls)
|
||||
):
|
||||
if pipeline_class_name:
|
||||
config_classes = get_pipeline_config_classes(pipeline_class_name)
|
||||
if config_classes is not None:
|
||||
pipeline_config_cls = config_classes[0]
|
||||
logger.info(
|
||||
f"Refining pipeline config {pipeline_config_cls.__name__} "
|
||||
f"-> {explicit_config_cls.__name__} for explicit "
|
||||
f"pipeline_class_name={pipeline_class_name}"
|
||||
"Using %s from explicit pipeline_class_name=%s",
|
||||
pipeline_config_cls.__name__,
|
||||
pipeline_class_name,
|
||||
)
|
||||
pipeline_config_cls = explicit_config_cls
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not get model info for '{model_path}'. "
|
||||
"Please specify a valid model_id or pipeline_class_name."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not get model info for '{model_path}'. "
|
||||
f"If using a safetensors file, please specify pipeline_class_name"
|
||||
)
|
||||
else:
|
||||
# 1.5. Adjust pipeline config for fine-tuned VAE if needed
|
||||
pipeline_config_cls = model_info.pipeline_config_cls
|
||||
# If an explicit pipeline_class_name refines the model-default config
|
||||
# (e.g. SanaWMRealtimePipeline -> SanaWMRealtimeConfig, a subclass of
|
||||
# the model-resolved SanaWMPipelineConfig), prefer the pipeline's own
|
||||
# config so realtime-only wiring (the /v1/realtime_video adapter) is
|
||||
# selected. Only applies when the explicit config strictly subclasses
|
||||
# the model default, so non-realtime pipelines are unaffected.
|
||||
if pipeline_class_name:
|
||||
explicit_config_classes = get_pipeline_config_classes(
|
||||
pipeline_class_name
|
||||
)
|
||||
if explicit_config_classes is not None:
|
||||
explicit_config_cls = explicit_config_classes[0]
|
||||
if (
|
||||
isinstance(explicit_config_cls, type)
|
||||
and isinstance(pipeline_config_cls, type)
|
||||
and explicit_config_cls is not pipeline_config_cls
|
||||
and issubclass(explicit_config_cls, pipeline_config_cls)
|
||||
):
|
||||
logger.info(
|
||||
f"Refining pipeline config {pipeline_config_cls.__name__} "
|
||||
f"-> {explicit_config_cls.__name__} for explicit "
|
||||
f"pipeline_class_name={pipeline_class_name}"
|
||||
)
|
||||
pipeline_config_cls = explicit_config_cls
|
||||
vae_path = kwargs.get(prefix_with_dot + "vae_path") or kwargs.get("vae_path")
|
||||
if vae_path is None:
|
||||
component_paths = kwargs.get(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import (
|
||||
ModelTaskType,
|
||||
PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.model_deployment_config import (
|
||||
ModelDeploymentConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pi05PipelineConfig(PipelineConfig):
|
||||
"""Configuration for OpenPI / LeRobot Pi0.5 action policies."""
|
||||
|
||||
task_type: ModelTaskType = ModelTaskType.VLA_ACTION
|
||||
should_use_guidance: bool = False
|
||||
enable_autocast: bool = True
|
||||
generator_device: str | None = None
|
||||
|
||||
# OpenPI pi0.5 public checkpoint layout.
|
||||
pi05: bool = True
|
||||
paligemma_variant: str = "gemma_2b"
|
||||
action_expert_variant: str = "gemma_300m"
|
||||
max_token_len: int = 200
|
||||
action_horizon: int = 50
|
||||
action_dim: int = 32
|
||||
state_dim: int = 32
|
||||
output_action_dim: int = 32
|
||||
n_action_steps: int = 50
|
||||
default_num_inference_steps: int = 10
|
||||
time_embedding_min_period: float = 4e-3
|
||||
time_embedding_max_period: float = 4.0
|
||||
tokenizer_name: str = "google/paligemma-3b-pt-224"
|
||||
|
||||
image_keys: tuple[str, ...] = (
|
||||
"base_0_rgb",
|
||||
"left_wrist_0_rgb",
|
||||
"right_wrist_0_rgb",
|
||||
)
|
||||
empty_cameras: int = 0
|
||||
image_size: tuple[int, int] = (224, 224)
|
||||
image_normalization_mean: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
||||
image_normalization_std: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
||||
|
||||
enable_global_prefix_cache: bool = False
|
||||
enable_action_cuda_graph: bool = True
|
||||
prefix_cache_max_entries: int = 1
|
||||
prefix_cache_layout_version: str = "pi05-prefix-v1"
|
||||
offload_prefix_image_encoder: bool = False
|
||||
offload_prefix_image_encoder_after_embed: bool = False
|
||||
offload_prefix_token_embedding: bool = False
|
||||
offload_prefix_language_layers: bool = False
|
||||
offload_prefix_language_layers_after_prefix: bool = False
|
||||
offload_prefix_language_layer_count_after_prefix: int = 0
|
||||
offload_prefix_language_layers_empty_cache: bool = True
|
||||
offload_action_expert_after_denoise: bool = False
|
||||
empty_cache_after_prefix: bool = False
|
||||
|
||||
# Prefix VLM and action expert are separate logical groups. The concrete
|
||||
# process-group construction lands with the native model parallel kernels.
|
||||
prefix_parallel_strategy: str = "tp"
|
||||
action_parallel_strategy: str = "sp"
|
||||
parallel_layout_version: str = "pi05-split-prefix-action-v1"
|
||||
|
||||
skip_unused_lm_head: bool = True
|
||||
materialize_dtype: str = "bf16"
|
||||
loader_component_map: dict[str, tuple[str, ...]] = field(
|
||||
default_factory=lambda: {
|
||||
"vision_tower": ("paligemma_with_expert.paligemma.model.vision_tower.",),
|
||||
"paligemma": ("paligemma_with_expert.paligemma.model.language_model.",),
|
||||
"multi_modal_projector": (
|
||||
"paligemma_with_expert.paligemma.model.multi_modal_projector.",
|
||||
),
|
||||
"action_expert": ("paligemma_with_expert.gemma_expert.",),
|
||||
"action_heads": (
|
||||
"action_in_proj.",
|
||||
"action_out_proj.",
|
||||
"time_mlp_in.",
|
||||
"time_mlp_out.",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def supports_dynamic_batching(self):
|
||||
return True
|
||||
|
||||
def supports_native_grouped_requests(self):
|
||||
return True
|
||||
|
||||
def estimate_request_cost(self, batch) -> float:
|
||||
return float(
|
||||
self.action_horizon * self.action_dim * self.default_num_inference_steps
|
||||
)
|
||||
|
||||
def get_model_deployment_config(self) -> ModelDeploymentConfig:
|
||||
return ModelDeploymentConfig()
|
||||
@@ -4,10 +4,14 @@ from sglang.multimodal_gen.configs.sample.diffusers_generic import (
|
||||
DiffusersGenericSamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.ideogram import Ideogram4SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.pi05 import Pi05SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
|
||||
|
||||
__all__ = [
|
||||
"SamplingParams",
|
||||
"VLASamplingParams",
|
||||
"DiffusersGenericSamplingParams",
|
||||
"Ideogram4SamplingParams",
|
||||
"Pi05SamplingParams",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pi05SamplingParams(VLASamplingParams):
|
||||
"""Sampling parameters for Pi0.5 flow-matching action inference."""
|
||||
|
||||
num_inference_steps: int = 10
|
||||
|
||||
action_horizon: int = 50
|
||||
action_dim: int = 32
|
||||
output_format: str = "list"
|
||||
return_timing: bool = True
|
||||
enable_prefix_cache: bool = True
|
||||
enable_cuda_graph: bool = True
|
||||
|
||||
state: Any = field(default=None, metadata={"batch_sig_exclude": True})
|
||||
images: dict[str, Any] | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
image_masks: dict[str, bool] | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
camera_order: list[str] | tuple[str, ...] | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
noise: Any = field(default=None, metadata={"batch_sig_exclude": True})
|
||||
observation: dict[str, Any] | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
|
||||
def build_request_extra(self) -> dict[str, Any]:
|
||||
extra = super().build_request_extra()
|
||||
observation = dict(self.observation or {})
|
||||
if self.images is not None:
|
||||
observation["images"] = self.images
|
||||
if self.image_masks is not None:
|
||||
observation["image_masks"] = self.image_masks
|
||||
if self.state is not None:
|
||||
observation["state"] = self.state
|
||||
if self.camera_order is not None:
|
||||
observation["camera_order"] = tuple(self.camera_order)
|
||||
if self.prompt is not None:
|
||||
observation["prompt"] = self.prompt
|
||||
if self.noise is not None:
|
||||
observation["noise"] = self.noise
|
||||
|
||||
extra["vla"] = {
|
||||
"observation": observation,
|
||||
"options": {
|
||||
"output_format": self.output_format,
|
||||
"return_timing": self.return_timing,
|
||||
"enable_prefix_cache": self.enable_prefix_cache,
|
||||
"enable_cuda_graph": self.enable_cuda_graph,
|
||||
},
|
||||
}
|
||||
return extra
|
||||
|
||||
def _validate(self):
|
||||
super()._validate()
|
||||
if self.action_horizon <= 0:
|
||||
raise ValueError("action_horizon must be positive")
|
||||
if self.action_dim <= 0:
|
||||
raise ValueError("action_dim must be positive")
|
||||
if self.output_format not in ("list", "numpy"):
|
||||
raise ValueError("output_format must be 'list' or 'numpy'")
|
||||
|
||||
def _set_output_file_name(self):
|
||||
if self.output_file_name is None:
|
||||
self.output_file_name = "pi05_action"
|
||||
super()._set_output_file_name()
|
||||
@@ -76,12 +76,15 @@ class DataType(Enum):
|
||||
IMAGE = auto()
|
||||
VIDEO = auto()
|
||||
MESH = auto()
|
||||
ACTION = auto()
|
||||
|
||||
def get_default_extension(self) -> str:
|
||||
if self == DataType.IMAGE:
|
||||
return "png"
|
||||
if self == DataType.VIDEO:
|
||||
return "mp4"
|
||||
if self == DataType.ACTION:
|
||||
return "json"
|
||||
return "glb"
|
||||
|
||||
|
||||
@@ -246,10 +249,8 @@ class SamplingParams:
|
||||
|
||||
def _set_output_file_ext(self):
|
||||
# add extension if needed
|
||||
if not any(
|
||||
self.output_file_name.endswith(ext)
|
||||
for ext in [".mp4", ".jpg", ".png", ".webp", ".obj", ".glb"]
|
||||
):
|
||||
output_extensions = (".mp4", ".jpg", ".png", ".webp", ".obj", ".glb", ".json")
|
||||
if not any(self.output_file_name.endswith(ext) for ext in output_extensions):
|
||||
self.output_file_name = (
|
||||
f"{self.output_file_name}.{self.data_type.get_default_extension()}"
|
||||
)
|
||||
@@ -329,6 +330,8 @@ class SamplingParams:
|
||||
|
||||
def _adjust_output_quality(self, output_quality: str, data_type: DataType) -> int:
|
||||
"""Convert output_quality string to compression level."""
|
||||
if data_type == DataType.ACTION:
|
||||
return 0
|
||||
output_quality_mapper = {"maximum": 100, "high": 90, "medium": 55, "low": 35}
|
||||
if output_quality == "default":
|
||||
return 50 if data_type == DataType.VIDEO else 75
|
||||
@@ -469,18 +472,22 @@ class SamplingParams:
|
||||
"""
|
||||
check if the sampling params is compatible and valid with server_args
|
||||
"""
|
||||
if pipeline_config.task_type.requires_image_input():
|
||||
task_type = pipeline_config.task_type
|
||||
if task_type.is_action_gen():
|
||||
return
|
||||
|
||||
if task_type.requires_image_input():
|
||||
# requires image input
|
||||
if self.image_path is None:
|
||||
raise ValueError(
|
||||
f"Served model with task type '{pipeline_config.task_type.name}' requires an 'image_path' input, but none was provided"
|
||||
f"Served model with task type '{task_type.name}' requires an 'image_path' input, but none was provided"
|
||||
)
|
||||
|
||||
if not pipeline_config.task_type.accepts_image_input():
|
||||
if not task_type.accepts_image_input():
|
||||
# does not support image input
|
||||
if self.image_path is not None:
|
||||
raise ValueError(
|
||||
f"input_reference is not supported for {pipeline_config.task_type.name} models."
|
||||
f"input_reference is not supported for {task_type.name} models."
|
||||
)
|
||||
|
||||
def _adjust(
|
||||
@@ -494,7 +501,39 @@ class SamplingParams:
|
||||
|
||||
# TODO: SamplingParams should not rely on ServerArgs
|
||||
pipeline_config = server_args.pipeline_config
|
||||
task_type = pipeline_config.task_type
|
||||
self.data_type = task_type.data_type()
|
||||
|
||||
self._adjust_output_path(server_args)
|
||||
if task_type.is_action_gen():
|
||||
self._adjust_action_fields(server_args)
|
||||
return
|
||||
|
||||
if task_type.is_mesh_gen():
|
||||
self._adjust_mesh_fields(server_args, pipeline_config)
|
||||
return
|
||||
|
||||
if task_type.is_visual_gen():
|
||||
self._adjust_visual_fields(server_args, pipeline_config)
|
||||
|
||||
def _adjust_output_path(self, server_args):
|
||||
if self.output_path is None:
|
||||
if server_args.output_path is not None:
|
||||
self.output_path = server_args.output_path
|
||||
logger.debug(
|
||||
f"Overriding output_path with server configuration: {self.output_path}"
|
||||
)
|
||||
else:
|
||||
self.save_output = False
|
||||
|
||||
def _adjust_action_fields(self, server_args):
|
||||
self.return_file_paths_only = False
|
||||
self.num_frames = 1
|
||||
self.adjust_frames = False
|
||||
if self.save_output and not server_args.comfyui_mode:
|
||||
self._set_output_file_name()
|
||||
|
||||
def _adjust_mesh_fields(self, server_args, pipeline_config):
|
||||
if self.guidance_scale is None:
|
||||
try:
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.hunyuan3d import (
|
||||
@@ -507,17 +546,16 @@ class SamplingParams:
|
||||
self.guidance_scale = 1.0
|
||||
except ImportError:
|
||||
self.guidance_scale = 1.0
|
||||
self.return_frames = False
|
||||
self.return_video = False
|
||||
self.num_frames = 1
|
||||
self.adjust_frames = False
|
||||
if self.save_output and not server_args.comfyui_mode:
|
||||
self._set_output_file_name()
|
||||
|
||||
self.data_type = server_args.pipeline_config.task_type.data_type()
|
||||
|
||||
if self.output_path is None:
|
||||
if server_args.output_path is not None:
|
||||
self.output_path = server_args.output_path
|
||||
logger.debug(
|
||||
f"Overriding output_path with server configuration: {self.output_path}"
|
||||
)
|
||||
else:
|
||||
self.save_output = False
|
||||
def _adjust_visual_fields(self, server_args, pipeline_config):
|
||||
if self.guidance_scale is None:
|
||||
self.guidance_scale = 1.0
|
||||
|
||||
# Process negative prompt
|
||||
if self.negative_prompt is not None and not self.negative_prompt.isspace():
|
||||
@@ -576,7 +614,6 @@ class SamplingParams:
|
||||
if not server_args.pipeline_config.allow_set_num_frames():
|
||||
logger.debug("Setting `num_frames` to 1 for image generation model")
|
||||
self.num_frames = 1
|
||||
|
||||
else:
|
||||
# mandatory frame adjusting logic, mod
|
||||
# NOTE: We must apply adjust_num_frames BEFORE the SP alignment logic below.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
_sanitize_filename,
|
||||
)
|
||||
from sglang.multimodal_gen.utils import StoreBoolean, expand_path_fields
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLASamplingParams:
|
||||
"""Sampling parameters for VLA/action-generation policies."""
|
||||
|
||||
data_type: DataType = DataType.ACTION
|
||||
request_id: str | None = field(default=None, metadata={"batch_sig_exclude": True})
|
||||
prompt: str | list[str] | None = field(
|
||||
default="", metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
num_outputs_per_prompt: int = 1
|
||||
seed: int | list[int] = field(default=42, metadata={"batch_sig_exclude": True})
|
||||
generator_device: str | None = None
|
||||
num_inference_steps: int = 10
|
||||
|
||||
output_path: str | None = field(default=None, metadata={"batch_sig_exclude": True})
|
||||
output_file_name: str | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
save_output: bool = False
|
||||
return_file_paths_only: bool = False
|
||||
|
||||
profile: bool = field(default=False, metadata={"batch_sig_exclude": True})
|
||||
num_profiled_timesteps: int = field(default=5, metadata={"batch_sig_exclude": True})
|
||||
profile_all_stages: bool = field(
|
||||
default=False, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
debug: bool = field(default=False, metadata={"batch_sig_exclude": True})
|
||||
perf_dump_path: str | None = field(
|
||||
default=None, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
suppress_logs: bool = field(default=False, metadata={"batch_sig_exclude": True})
|
||||
|
||||
enable_sequence_shard: bool | None = None
|
||||
max_sequence_length: int | None = None
|
||||
no_override_protected_fields: bool = field(
|
||||
default=False, metadata={"batch_sig_exclude": True}
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.data_type = DataType.ACTION
|
||||
self._validate()
|
||||
|
||||
env_steps = os.environ.get("SGLANG_TEST_NUM_INFERENCE_STEPS")
|
||||
if env_steps is not None and self.num_inference_steps is not None:
|
||||
self.num_inference_steps = int(env_steps)
|
||||
|
||||
def build_request_extra(self) -> dict[str, Any]:
|
||||
extra = {}
|
||||
diffusers_kwargs = getattr(self, "diffusers_kwargs", None)
|
||||
if diffusers_kwargs:
|
||||
extra["diffusers_kwargs"] = diffusers_kwargs
|
||||
explicit_fields = getattr(self, "_explicit_fields", None)
|
||||
if explicit_fields is not None:
|
||||
extra["explicit_fields"] = sorted(explicit_fields)
|
||||
return extra
|
||||
|
||||
def apply_request_extra(self, req: Any) -> None:
|
||||
req.extra.update(self.build_request_extra())
|
||||
|
||||
def _validate(self):
|
||||
if (
|
||||
not isinstance(self.num_outputs_per_prompt, int)
|
||||
or self.num_outputs_per_prompt <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"num_outputs_per_prompt must be a positive int, "
|
||||
f"got {self.num_outputs_per_prompt!r}"
|
||||
)
|
||||
|
||||
if isinstance(self.seed, list):
|
||||
if not self.seed:
|
||||
raise ValueError("seed list must not be empty")
|
||||
for seed in self.seed:
|
||||
if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
|
||||
raise ValueError(
|
||||
f"seed list must contain non-negative ints, got {self.seed!r}"
|
||||
)
|
||||
elif (
|
||||
isinstance(self.seed, bool)
|
||||
or not isinstance(self.seed, int)
|
||||
or self.seed < 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"seed must be a non-negative int or list of ints, got {self.seed!r}"
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(self.num_inference_steps, int)
|
||||
or self.num_inference_steps <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"num_inference_steps must be a positive int, "
|
||||
f"got {self.num_inference_steps!r}"
|
||||
)
|
||||
|
||||
if self.generator_device not in (None, "cuda", "musa", "cpu"):
|
||||
raise ValueError(
|
||||
"generator_device must be one of None, 'cuda', 'musa', or 'cpu', "
|
||||
f"got {self.generator_device!r}"
|
||||
)
|
||||
|
||||
def _validate_with_pipeline_config(self, pipeline_config):
|
||||
if not pipeline_config.task_type.is_action_gen():
|
||||
raise ValueError(
|
||||
f"VLASamplingParams requires an ACTION pipeline, got {pipeline_config.task_type.name}"
|
||||
)
|
||||
|
||||
def _adjust(self, server_args: "ServerArgs"):
|
||||
expand_path_fields(self)
|
||||
self.data_type = DataType.ACTION
|
||||
self.return_file_paths_only = False
|
||||
if self.output_path is None and server_args.output_path is not None:
|
||||
self.output_path = server_args.output_path
|
||||
if self.output_path is None:
|
||||
self.save_output = False
|
||||
if self.save_output and not server_args.comfyui_mode:
|
||||
self._set_output_file_name()
|
||||
|
||||
def _set_output_file_ext(self):
|
||||
if self.output_file_name and not self.output_file_name.endswith(".json"):
|
||||
self.output_file_name = f"{self.output_file_name}.json"
|
||||
|
||||
def _set_output_file_name(self):
|
||||
if self.output_file_name is None:
|
||||
self.output_file_name = "vla_action"
|
||||
self.output_file_name = _sanitize_filename(self.output_file_name)
|
||||
self._set_output_file_ext()
|
||||
|
||||
def output_file_path(self):
|
||||
if self.output_path is None or self.output_file_name is None:
|
||||
return None
|
||||
return os.path.join(self.output_path, self.output_file_name)
|
||||
|
||||
def _merge_with_user_params(
|
||||
self,
|
||||
user_params: "VLASamplingParams",
|
||||
explicit_fields: set[str] | None = None,
|
||||
):
|
||||
if user_params is None:
|
||||
return
|
||||
|
||||
predefined_fields = set(type(self).__annotations__.keys())
|
||||
allow_override_protected = not user_params.no_override_protected_fields
|
||||
for field_info in dataclasses.fields(user_params):
|
||||
field_name = field_info.name
|
||||
user_value = getattr(user_params, field_name)
|
||||
if field_info.default is not dataclasses.MISSING:
|
||||
default_class_value = field_info.default
|
||||
elif field_info.default_factory is not dataclasses.MISSING:
|
||||
default_class_value = field_info.default_factory()
|
||||
else:
|
||||
default_class_value = dataclasses.MISSING
|
||||
|
||||
if explicit_fields is not None:
|
||||
is_user_modified = field_name in explicit_fields
|
||||
else:
|
||||
is_user_modified = user_value != default_class_value
|
||||
is_protected_field = field_name in predefined_fields
|
||||
if is_user_modified and (
|
||||
allow_override_protected or not is_protected_field
|
||||
):
|
||||
setattr(self, field_name, user_value)
|
||||
|
||||
if explicit_fields is not None:
|
||||
self._explicit_fields = set(explicit_fields)
|
||||
self.__post_init__()
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(parser: Any) -> Any:
|
||||
def add_argument(*name_or_flags, **kwargs):
|
||||
kwargs.setdefault("default", argparse.SUPPRESS)
|
||||
return parser.add_argument(*name_or_flags, **kwargs)
|
||||
|
||||
add_argument(
|
||||
"--prompt",
|
||||
type=str,
|
||||
nargs="+",
|
||||
help="Language instruction(s) for the VLA policy.",
|
||||
)
|
||||
add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
help="Number of action denoising steps.",
|
||||
)
|
||||
add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
type=int,
|
||||
help="Number of candidate actions to generate per observation.",
|
||||
)
|
||||
add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
nargs="+",
|
||||
help="Random seed for action noise generation.",
|
||||
)
|
||||
add_argument(
|
||||
"--generator-device",
|
||||
type=str,
|
||||
choices=["cuda", "musa", "cpu"],
|
||||
help="Device for random generator. Default: use the model-specific setting.",
|
||||
)
|
||||
add_argument(
|
||||
"--profile",
|
||||
action="store_true",
|
||||
help="Enable torch profiler for action denoising.",
|
||||
)
|
||||
add_argument(
|
||||
"--num-profiled-timesteps",
|
||||
type=int,
|
||||
help="Number of denoising timesteps to profile after warmup.",
|
||||
)
|
||||
add_argument(
|
||||
"--profile-all-stages",
|
||||
action="store_true",
|
||||
dest="profile_all_stages",
|
||||
help="Used with --profile, profile all pipeline stages.",
|
||||
)
|
||||
add_argument("--debug", action="store_true")
|
||||
add_argument(
|
||||
"--enable-sequence-shard",
|
||||
action=StoreBoolean,
|
||||
help="Enable sequence dimension shard with sequence parallelism.",
|
||||
)
|
||||
add_argument(
|
||||
"--max-sequence-length",
|
||||
type=int,
|
||||
help="Maximum prefix sequence length.",
|
||||
)
|
||||
add_argument(
|
||||
"--no-override-protected-fields",
|
||||
action="store_true",
|
||||
help="If set, disallow user params to override subclass-defined fields.",
|
||||
)
|
||||
return parser
|
||||
|
||||
@classmethod
|
||||
def get_cli_args(cls, args: argparse.Namespace):
|
||||
sampling_params_fields = {attr.name for attr in dataclasses.fields(cls)}
|
||||
args_attrs = set(vars(args).keys())
|
||||
attrs = sampling_params_fields & args_attrs
|
||||
cli_args = {
|
||||
attr: getattr(args, attr)
|
||||
for attr in attrs
|
||||
if hasattr(args, attr) and getattr(args, attr) is not None
|
||||
}
|
||||
if isinstance(cli_args.get("seed"), list) and len(cli_args["seed"]) == 1:
|
||||
cli_args["seed"] = cli_args["seed"][0]
|
||||
return cli_args
|
||||
|
||||
def output_size_str(self) -> str:
|
||||
return "action"
|
||||
|
||||
def seconds(self) -> float:
|
||||
return 0.0
|
||||
@@ -76,6 +76,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.mova import (
|
||||
MOVA360PConfig,
|
||||
MOVA720PConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import (
|
||||
QwenImageEditPipelineConfig,
|
||||
QwenImageEditPlus_2511_PipelineConfig,
|
||||
@@ -137,6 +138,7 @@ from sglang.multimodal_gen.configs.sample.mova import (
|
||||
MOVA_360P_SamplingParams,
|
||||
MOVA_720P_SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.pi05 import Pi05SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.qwenimage import (
|
||||
QwenImage2512SamplingParams,
|
||||
QwenImageEditPlusSamplingParams,
|
||||
@@ -629,6 +631,20 @@ def get_model_info(
|
||||
|
||||
# Registration of model configs
|
||||
def _register_configs():
|
||||
# Pi0.5 / OpenPI / LeRobot action policies.
|
||||
register_configs(
|
||||
sampling_param_cls=Pi05SamplingParams,
|
||||
pipeline_config_cls=Pi05PipelineConfig,
|
||||
hf_model_paths=[
|
||||
"lerobot/pi05_base",
|
||||
"lerobot/pi05_libero_base",
|
||||
],
|
||||
model_detectors=[
|
||||
lambda hf_id: "pi05" in hf_id.lower(),
|
||||
lambda hf_id: "pi0.5" in hf_id.lower(),
|
||||
],
|
||||
)
|
||||
|
||||
# LTX-2
|
||||
register_configs(
|
||||
sampling_param_cls=LTX2SamplingParams,
|
||||
|
||||
@@ -602,10 +602,11 @@ class GroupCoordinator:
|
||||
group = self.device_group
|
||||
metadata_group = self.cpu_group
|
||||
assert src < self.world_size, f"Invalid src rank ({src})"
|
||||
src = self.ranks[src]
|
||||
src_rank_in_group = src
|
||||
src_global_rank = self.ranks[src_rank_in_group]
|
||||
|
||||
rank = self.rank
|
||||
if rank == src:
|
||||
if rank == src_global_rank:
|
||||
metadata_list: List[Tuple[Any, Any]] = []
|
||||
assert isinstance(
|
||||
tensor_dict, dict
|
||||
@@ -614,7 +615,7 @@ class GroupCoordinator:
|
||||
# `metadata_list` lives in CPU memory.
|
||||
# `broadcast_object_list` has serialization & deserialization,
|
||||
# all happening on CPU. Therefore, we can use the CPU group.
|
||||
self.broadcast_object(metadata_list, src=src)
|
||||
self.broadcast_object(metadata_list, src=src_rank_in_group)
|
||||
async_handles = []
|
||||
for tensor in tensor_list:
|
||||
if tensor.numel() == 0:
|
||||
@@ -623,19 +624,22 @@ class GroupCoordinator:
|
||||
if tensor.is_cpu:
|
||||
# use metadata_group for CPU tensors
|
||||
handle = torch.distributed.broadcast(
|
||||
tensor, src=src, group=metadata_group, async_op=True
|
||||
tensor,
|
||||
src=src_global_rank,
|
||||
group=metadata_group,
|
||||
async_op=True,
|
||||
)
|
||||
else:
|
||||
# use group for GPU tensors
|
||||
handle = torch.distributed.broadcast(
|
||||
tensor, src=src, group=group, async_op=True
|
||||
tensor, src=src_global_rank, group=group, async_op=True
|
||||
)
|
||||
async_handles.append(handle)
|
||||
for async_handle in async_handles:
|
||||
async_handle.wait()
|
||||
|
||||
else:
|
||||
metadata_list = self.broadcast_object(None, src=src)
|
||||
metadata_list = self.broadcast_object(None, src=src_rank_in_group)
|
||||
tensor_dict = {}
|
||||
async_handles = []
|
||||
for key, value in metadata_list:
|
||||
@@ -650,12 +654,15 @@ class GroupCoordinator:
|
||||
if tensor.is_cpu:
|
||||
# use metadata_group for CPU tensors
|
||||
handle = torch.distributed.broadcast(
|
||||
tensor, src=src, group=metadata_group, async_op=True
|
||||
tensor,
|
||||
src=src_global_rank,
|
||||
group=metadata_group,
|
||||
async_op=True,
|
||||
)
|
||||
else:
|
||||
# use group for GPU tensors
|
||||
handle = torch.distributed.broadcast(
|
||||
tensor, src=src, group=group, async_op=True
|
||||
tensor, src=src_global_rank, group=group, async_op=True
|
||||
)
|
||||
async_handles.append(handle)
|
||||
_update_nested_dict(tensor_dict, key, tensor)
|
||||
|
||||
@@ -376,6 +376,34 @@ class DiffGenerator:
|
||||
return None
|
||||
return results[0] if len(results) == 1 else results
|
||||
|
||||
def generate_action(
|
||||
self,
|
||||
sampling_params_kwargs: dict | None = None,
|
||||
external_trace_header: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
sampling_params_kwargs = sampling_params_kwargs or {}
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
self.server_args.model_path,
|
||||
server_args=self.server_args,
|
||||
**sampling_params_kwargs,
|
||||
)
|
||||
if sampling_params.data_type != DataType.ACTION:
|
||||
raise ValueError(
|
||||
f"generate_action requires an ACTION pipeline, got {sampling_params.data_type}"
|
||||
)
|
||||
|
||||
req = prepare_request(
|
||||
server_args=self.server_args,
|
||||
sampling_params=sampling_params,
|
||||
external_trace_header=external_trace_header,
|
||||
)
|
||||
output_batch = self._send_to_scheduler_and_wait_for_response(req)
|
||||
if output_batch.error:
|
||||
raise RuntimeError(output_batch.error)
|
||||
if output_batch.output is None:
|
||||
raise RuntimeError("action policy returned no output")
|
||||
return output_batch.output[0]
|
||||
|
||||
def _resolve_prompts(
|
||||
self,
|
||||
prompt: str | list[str] | None,
|
||||
@@ -430,9 +458,13 @@ class DiffGenerator:
|
||||
and output_index < len(output_batch.metrics_list)
|
||||
):
|
||||
metrics = output_batch.metrics_list[output_index]
|
||||
if req.data_type == DataType.ACTION:
|
||||
size = ("action",)
|
||||
else:
|
||||
size = (req.height, req.width, req.num_frames)
|
||||
return dict(
|
||||
prompt=req.prompt,
|
||||
size=(req.height, req.width, req.num_frames),
|
||||
size=size,
|
||||
generation_time=generation_time,
|
||||
peak_memory_mb=output_batch.peak_memory_mb,
|
||||
metrics=metrics.to_dict() if metrics else {},
|
||||
|
||||
@@ -14,10 +14,7 @@ from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai import (
|
||||
image_api,
|
||||
video_api,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai import image_api, video_api
|
||||
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
|
||||
VertexGenerateReqInput,
|
||||
)
|
||||
@@ -33,6 +30,8 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
prepare_request,
|
||||
save_outputs,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla import api as vla_api
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla import openpi
|
||||
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs, get_global_server_args
|
||||
from sglang.multimodal_gen.runtime.server_warmup import (
|
||||
@@ -403,6 +402,9 @@ def create_app(server_args: ServerArgs):
|
||||
app.include_router(image_api.router)
|
||||
app.include_router(video_api.router)
|
||||
app.include_router(realtime_video_api.router)
|
||||
if server_args.pipeline_config.task_type.is_action_gen():
|
||||
app.include_router(vla_api.router)
|
||||
app.include_router(openpi.router)
|
||||
app.include_router(mesh_api.router)
|
||||
app.include_router(weights_api.router)
|
||||
app.include_router(rollout_api.router)
|
||||
|
||||
@@ -8,6 +8,7 @@ This module provides a consolidated interface for generating videos using
|
||||
diffusion models.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -445,11 +446,13 @@ def prepare_request(
|
||||
if not isinstance(req.prompt, str):
|
||||
raise TypeError(f"`prompt` must be a string, but got {type(req.prompt)}")
|
||||
|
||||
if (req.width is not None and req.width <= 0) or (
|
||||
req.height is not None and req.height <= 0
|
||||
req_width = getattr(req, "width", None)
|
||||
req_height = getattr(req, "height", None)
|
||||
if (req_width is not None and req_width <= 0) or (
|
||||
req_height is not None and req_height <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"Height and width must be positive, got height={req.height}, width={req.width}"
|
||||
f"Height and width must be positive, got height={req_height}, width={req_width}"
|
||||
)
|
||||
|
||||
if server_args.enable_trace:
|
||||
@@ -661,6 +664,21 @@ def save_outputs(
|
||||
output_paths: list[str] = []
|
||||
for idx, sample in enumerate(outputs):
|
||||
save_file_path = build_output_path(idx)
|
||||
if data_type == DataType.ACTION:
|
||||
if samples_out is not None:
|
||||
samples_out.append(sample)
|
||||
if audios_out is not None:
|
||||
audios_out.append(None)
|
||||
if frames_out is not None:
|
||||
frames_out.append([])
|
||||
if save_output and save_file_path:
|
||||
os.makedirs(os.path.dirname(save_file_path) or ".", exist_ok=True)
|
||||
with open(save_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(sample, f, ensure_ascii=False)
|
||||
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
|
||||
output_paths.append(save_file_path)
|
||||
continue
|
||||
|
||||
if data_type == DataType.VIDEO:
|
||||
sample = attach_audio_to_video_sample(sample, audio, idx)
|
||||
|
||||
@@ -711,6 +729,9 @@ def post_process_sample(
|
||||
upscaling_scale: int = 4,
|
||||
) -> list[Any]:
|
||||
"""materialize frames and save outputs (optional)"""
|
||||
if data_type == DataType.ACTION:
|
||||
return []
|
||||
|
||||
materialized = materialize_output_sample(
|
||||
sample,
|
||||
data_type,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,89 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
|
||||
action_generation_response,
|
||||
action_metadata,
|
||||
action_raw_response,
|
||||
infer_action,
|
||||
pack_msgpack,
|
||||
unpack_msgpack,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
|
||||
run_action_msgpack_ws,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.srt.utils.json_response import orjson_response
|
||||
|
||||
router = APIRouter(prefix="/v1/actions", tags=["actions"])
|
||||
|
||||
|
||||
def _wants_msgpack(request: Request) -> bool:
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
accept = request.headers.get("accept", "").lower()
|
||||
return "msgpack" in content_type or "msgpack" in accept
|
||||
|
||||
|
||||
def _response_format(payload: dict) -> str:
|
||||
runtime = payload.get("runtime") or {}
|
||||
response_format = str(runtime.get("response_format", "envelope")).lower()
|
||||
if response_format not in ("envelope", "raw"):
|
||||
raise ValueError("runtime.response_format must be 'envelope' or 'raw'")
|
||||
return response_format
|
||||
|
||||
|
||||
def _prefer_numpy_output(payload: dict) -> None:
|
||||
runtime = payload.setdefault("runtime", {})
|
||||
runtime.setdefault("output_format", "numpy")
|
||||
|
||||
|
||||
@router.post("/generations")
|
||||
async def create_action_generation(request: Request):
|
||||
server_args: ServerArgs = request.app.state.server_args
|
||||
try:
|
||||
if "msgpack" in request.headers.get("content-type", "").lower():
|
||||
payload = unpack_msgpack(await request.body())
|
||||
else:
|
||||
payload = await request.json()
|
||||
wants_msgpack = _wants_msgpack(request)
|
||||
if wants_msgpack:
|
||||
_prefer_numpy_output(payload)
|
||||
output = await infer_action(payload, server_args)
|
||||
if _response_format(payload) == "raw":
|
||||
response = action_raw_response(output, preserve_numpy=wants_msgpack)
|
||||
else:
|
||||
response = action_generation_response(
|
||||
output,
|
||||
server_args,
|
||||
preserve_numpy=wants_msgpack,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if wants_msgpack:
|
||||
return Response(
|
||||
content=pack_msgpack(response), media_type="application/msgpack"
|
||||
)
|
||||
return orjson_response(response)
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
async def action_metadata_endpoint(request: Request):
|
||||
return orjson_response(action_metadata(request.app.state.server_args))
|
||||
|
||||
|
||||
@router.websocket("/realtime")
|
||||
async def action_realtime_ws(websocket: WebSocket):
|
||||
server_args: ServerArgs = websocket.app.state.server_args
|
||||
await run_action_msgpack_ws(
|
||||
websocket,
|
||||
server_args,
|
||||
prepare_payload=_prefer_numpy_output,
|
||||
build_response=lambda output: action_generation_response(
|
||||
output,
|
||||
server_args,
|
||||
preserve_numpy=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
|
||||
run_action_msgpack_ws,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _prefer_numpy_output(observation: dict[str, Any]) -> None:
|
||||
observation.setdefault("output_format", "numpy")
|
||||
|
||||
|
||||
@router.websocket("/openpi/policy")
|
||||
async def openpi_policy_ws(websocket: WebSocket):
|
||||
server_args: ServerArgs = websocket.app.state.server_args
|
||||
await run_action_msgpack_ws(
|
||||
websocket,
|
||||
server_args,
|
||||
prepare_payload=_prefer_numpy_output,
|
||||
build_response=lambda output: output,
|
||||
)
|
||||
@@ -0,0 +1,443 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import io
|
||||
import time
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
def pack_numpy_payload(obj):
|
||||
if isinstance(obj, (np.ndarray, np.generic)) and obj.dtype.kind in ("V", "O", "c"):
|
||||
raise ValueError(f"Unsupported dtype: {obj.dtype}")
|
||||
if isinstance(obj, np.ndarray):
|
||||
return {
|
||||
b"__ndarray__": True,
|
||||
b"data": obj.tobytes(),
|
||||
b"dtype": obj.dtype.str,
|
||||
b"shape": obj.shape,
|
||||
}
|
||||
if isinstance(obj, np.generic):
|
||||
return {
|
||||
b"__npgeneric__": True,
|
||||
b"data": obj.item(),
|
||||
b"dtype": obj.dtype.str,
|
||||
}
|
||||
return obj
|
||||
|
||||
|
||||
def unpack_numpy_payload(obj):
|
||||
ndarray_marker = obj.get("__ndarray__") or obj.get(b"__ndarray__")
|
||||
npgeneric_marker = obj.get("__npgeneric__") or obj.get(b"__npgeneric__")
|
||||
data = obj.get("data", obj.get(b"data"))
|
||||
dtype = obj.get("dtype", obj.get(b"dtype"))
|
||||
shape = obj.get("shape", obj.get(b"shape"))
|
||||
if ndarray_marker:
|
||||
return np.ndarray(
|
||||
buffer=data,
|
||||
dtype=np.dtype(dtype),
|
||||
shape=shape,
|
||||
)
|
||||
if npgeneric_marker:
|
||||
return np.dtype(dtype).type(data)
|
||||
return obj
|
||||
|
||||
|
||||
def pack_msgpack(payload: Any) -> bytes:
|
||||
import msgpack
|
||||
|
||||
return msgpack.packb(payload, default=pack_numpy_payload, use_bin_type=True)
|
||||
|
||||
|
||||
def unpack_msgpack(payload: bytes) -> Any:
|
||||
import msgpack
|
||||
|
||||
return msgpack.unpackb(payload, object_hook=unpack_numpy_payload, raw=False)
|
||||
|
||||
|
||||
def _decode_b64_image(payload: dict[str, Any]) -> Image.Image:
|
||||
data = payload.get("b64_json") or payload.get("base64")
|
||||
if not data:
|
||||
raise ValueError("image payload requires b64_json")
|
||||
if isinstance(data, str) and "," in data and data.startswith("data:"):
|
||||
data = data.split(",", 1)[1]
|
||||
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
|
||||
|
||||
|
||||
def _decode_tensor_payload(payload: dict[str, Any]) -> Any:
|
||||
values = payload.get("values")
|
||||
if values is None:
|
||||
values = payload.get("data")
|
||||
if values is None:
|
||||
return payload
|
||||
dtype = payload.get("dtype")
|
||||
array = np.asarray(values, dtype=np.dtype(dtype) if dtype else None)
|
||||
shape = payload.get("shape")
|
||||
if shape is not None:
|
||||
array = array.reshape(tuple(shape))
|
||||
return array
|
||||
|
||||
|
||||
def _normalize_image_value(value: Any) -> Any:
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
if "b64_json" in value or "base64" in value:
|
||||
return _decode_b64_image(value)
|
||||
if "values" in value or "data" in value:
|
||||
return _decode_tensor_payload(value)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_observation(observation: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = dict(observation)
|
||||
images = normalized.get("images")
|
||||
if isinstance(images, dict):
|
||||
normalized["images"] = {
|
||||
name: _normalize_image_value(value) for name, value in images.items()
|
||||
}
|
||||
state = normalized.get("state")
|
||||
if isinstance(state, dict):
|
||||
normalized["state"] = _decode_tensor_payload(state)
|
||||
observation_state = normalized.get("observation.state")
|
||||
if isinstance(observation_state, dict):
|
||||
normalized["observation.state"] = _decode_tensor_payload(observation_state)
|
||||
noise = normalized.get("noise")
|
||||
if isinstance(noise, dict):
|
||||
normalized["noise"] = _decode_tensor_payload(noise)
|
||||
observation_noise = normalized.get("observation.noise")
|
||||
if isinstance(observation_noise, dict):
|
||||
normalized["observation.noise"] = _decode_tensor_payload(observation_noise)
|
||||
return normalized
|
||||
|
||||
|
||||
def images_from_observation(
|
||||
observation: dict[str, Any],
|
||||
pipeline_config: Any,
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(observation.get("images"), dict):
|
||||
images = dict(observation["images"])
|
||||
else:
|
||||
images = {}
|
||||
for key in pipeline_config.image_keys:
|
||||
if key in observation:
|
||||
images[key] = observation[key]
|
||||
full_key = f"observation.images.{key}"
|
||||
if full_key in observation:
|
||||
images[key] = observation[full_key]
|
||||
return {name: _normalize_image_value(value) for name, value in images.items()}
|
||||
|
||||
|
||||
def action_metadata(server_args: ServerArgs) -> dict[str, Any]:
|
||||
pipeline_config = server_args.pipeline_config
|
||||
policy_family = getattr(
|
||||
pipeline_config,
|
||||
"policy_family",
|
||||
type(pipeline_config).__name__.removesuffix("PipelineConfig").lower(),
|
||||
)
|
||||
return {
|
||||
"object": "action.metadata",
|
||||
"model": server_args.model_id or server_args.model_path,
|
||||
"model_path": server_args.model_path,
|
||||
"policy_family": policy_family,
|
||||
"input": {
|
||||
"image_keys": list(pipeline_config.image_keys),
|
||||
"image_size": list(pipeline_config.image_size),
|
||||
"state_dim": pipeline_config.state_dim,
|
||||
},
|
||||
"output": {
|
||||
"action_type": "continuous",
|
||||
"action_horizon": pipeline_config.action_horizon,
|
||||
"action_dim": pipeline_config.output_action_dim,
|
||||
"padded_action_dim": pipeline_config.action_dim,
|
||||
"dtype": "float32",
|
||||
},
|
||||
"runtime": {
|
||||
"materialize_dtype": pipeline_config.materialize_dtype,
|
||||
"enable_autocast": pipeline_config.enable_autocast,
|
||||
"parallelism": {
|
||||
"num_gpus": server_args.num_gpus,
|
||||
"tp_size": server_args.tp_size,
|
||||
"sp_degree": server_args.sp_degree,
|
||||
"ulysses_degree": server_args.ulysses_degree,
|
||||
"ring_degree": server_args.ring_degree,
|
||||
"prefix_strategy": pipeline_config.prefix_parallel_strategy,
|
||||
"action_strategy": pipeline_config.action_parallel_strategy,
|
||||
"layout_version": pipeline_config.parallel_layout_version,
|
||||
},
|
||||
},
|
||||
"defaults": {
|
||||
"num_inference_steps": pipeline_config.default_num_inference_steps,
|
||||
"prefix_cache": (
|
||||
"auto" if pipeline_config.enable_global_prefix_cache else False
|
||||
),
|
||||
"cuda_graph": "auto" if pipeline_config.enable_action_cuda_graph else False,
|
||||
},
|
||||
"capabilities": {
|
||||
"exact_prefix_cache": True,
|
||||
"cuda_graph": pipeline_config.enable_action_cuda_graph,
|
||||
"realtime_websocket": True,
|
||||
"openpi_websocket": True,
|
||||
"batch_inputs": False,
|
||||
"multiple_candidates": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _runtime_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
if value == "auto":
|
||||
return default
|
||||
if value in ("true", "1", "yes"):
|
||||
return True
|
||||
if value in ("false", "0", "no"):
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _action_request_to_observation(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if "input" not in payload:
|
||||
return _normalize_observation(payload)
|
||||
|
||||
input_payload = payload.get("input") or {}
|
||||
observation = dict(input_payload.get("observation") or {})
|
||||
if "task" in input_payload:
|
||||
observation["prompt"] = input_payload["task"]
|
||||
elif "prompt" in input_payload:
|
||||
observation["prompt"] = input_payload["prompt"]
|
||||
if "images" in input_payload:
|
||||
observation["images"] = input_payload["images"]
|
||||
if "state" in input_payload:
|
||||
observation["state"] = input_payload["state"]
|
||||
if "noise" in input_payload:
|
||||
observation["noise"] = input_payload["noise"]
|
||||
return _normalize_observation(observation)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _resolve_action_sampling_params_cls_cached(
|
||||
model_path: str,
|
||||
backend: str | None,
|
||||
model_id: str | None,
|
||||
pipeline_class_name: str | None,
|
||||
) -> type[VLASamplingParams]:
|
||||
if pipeline_class_name:
|
||||
from sglang.multimodal_gen.registry import get_pipeline_config_classes
|
||||
|
||||
config_classes = get_pipeline_config_classes(pipeline_class_name)
|
||||
if config_classes is not None:
|
||||
_, sampling_params_cls = config_classes
|
||||
if issubclass(sampling_params_cls, VLASamplingParams):
|
||||
return sampling_params_cls
|
||||
|
||||
from sglang.multimodal_gen.registry import get_model_info
|
||||
|
||||
model_info = get_model_info(
|
||||
model_path,
|
||||
backend=backend,
|
||||
model_id=model_id,
|
||||
)
|
||||
sampling_params_cls = model_info.sampling_param_cls
|
||||
if not issubclass(sampling_params_cls, VLASamplingParams):
|
||||
raise ValueError(
|
||||
f"Action endpoint requires VLASamplingParams, got {sampling_params_cls.__name__}"
|
||||
)
|
||||
return sampling_params_cls
|
||||
|
||||
|
||||
def _resolve_action_sampling_params_cls(
|
||||
server_args: ServerArgs,
|
||||
) -> type[VLASamplingParams]:
|
||||
return _resolve_action_sampling_params_cls_cached(
|
||||
server_args.model_path,
|
||||
getattr(server_args, "backend", None),
|
||||
getattr(server_args, "model_id", None),
|
||||
getattr(server_args, "pipeline_class_name", None),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _sampling_params_field_names(
|
||||
sampling_params_cls: type[VLASamplingParams],
|
||||
) -> frozenset[str]:
|
||||
return frozenset(field.name for field in dataclasses.fields(sampling_params_cls))
|
||||
|
||||
|
||||
def build_action_sampling_params(
|
||||
payload: dict[str, Any],
|
||||
server_args: ServerArgs,
|
||||
) -> VLASamplingParams:
|
||||
pipeline_config = server_args.pipeline_config
|
||||
observation = _action_request_to_observation(payload)
|
||||
parameters = dict(payload.get("parameters") or {})
|
||||
runtime = dict(payload.get("runtime") or {})
|
||||
if "return_timing" in payload and "return_timing" not in runtime:
|
||||
runtime["return_timing"] = payload["return_timing"]
|
||||
images = images_from_observation(observation, pipeline_config)
|
||||
state = observation.get("state")
|
||||
if state is None:
|
||||
state = observation.get("observation.state")
|
||||
noise = observation.get("noise")
|
||||
if noise is None:
|
||||
noise = observation.get("observation.noise")
|
||||
prompt = observation.get("prompt") or observation.get("task") or ""
|
||||
prefix_cache = runtime.get("prefix_cache")
|
||||
if prefix_cache is None:
|
||||
prefix_cache = observation.get("enable_prefix_cache")
|
||||
if prefix_cache is None:
|
||||
prefix_cache = observation.get("enable_pi_prefix_cache")
|
||||
cuda_graph = runtime.get("cuda_graph")
|
||||
if cuda_graph is None:
|
||||
cuda_graph = observation.get("enable_cuda_graph")
|
||||
if cuda_graph is None:
|
||||
cuda_graph = observation.get("enable_pi_cuda_graph")
|
||||
output_format = str(
|
||||
runtime.get(
|
||||
"output_format",
|
||||
parameters.get(
|
||||
"output_format",
|
||||
observation.get("output_format", "list"),
|
||||
),
|
||||
)
|
||||
).lower()
|
||||
if output_format not in ("list", "numpy"):
|
||||
raise ValueError("output_format must be 'list' or 'numpy'")
|
||||
|
||||
sampling_params_cls = _resolve_action_sampling_params_cls(server_args)
|
||||
sampling_kwargs = {
|
||||
"request_id": payload.get("request_id") or payload.get("id"),
|
||||
"prompt": prompt,
|
||||
"images": images,
|
||||
"image_masks": observation.get("image_masks"),
|
||||
"camera_order": observation.get("camera_order"),
|
||||
"state": state,
|
||||
"noise": noise,
|
||||
"observation": observation,
|
||||
"action_horizon": int(
|
||||
parameters.get(
|
||||
"action_horizon",
|
||||
observation.get("action_horizon", pipeline_config.action_horizon),
|
||||
)
|
||||
),
|
||||
"action_dim": int(
|
||||
parameters.get(
|
||||
"action_dim",
|
||||
observation.get("action_dim", pipeline_config.action_dim),
|
||||
)
|
||||
),
|
||||
"num_inference_steps": int(
|
||||
parameters.get(
|
||||
"num_inference_steps",
|
||||
observation.get(
|
||||
"num_inference_steps",
|
||||
pipeline_config.default_num_inference_steps,
|
||||
),
|
||||
)
|
||||
),
|
||||
"output_format": output_format,
|
||||
"return_timing": _runtime_bool(runtime.get("return_timing"), True),
|
||||
"enable_prefix_cache": _runtime_bool(prefix_cache, True),
|
||||
"enable_cuda_graph": _runtime_bool(cuda_graph, True),
|
||||
}
|
||||
supported_fields = _sampling_params_field_names(sampling_params_cls)
|
||||
sp = sampling_params_cls(
|
||||
**{
|
||||
name: value
|
||||
for name, value in sampling_kwargs.items()
|
||||
if name in supported_fields
|
||||
}
|
||||
)
|
||||
sp._adjust(server_args)
|
||||
return sp
|
||||
|
||||
|
||||
async def infer_action(
|
||||
payload: dict[str, Any],
|
||||
server_args: ServerArgs,
|
||||
) -> dict[str, Any]:
|
||||
sp = build_action_sampling_params(payload, server_args)
|
||||
req = prepare_request(server_args, sp)
|
||||
response = await async_scheduler_client.forward(req)
|
||||
if getattr(response, "error", None):
|
||||
raise RuntimeError(response.error)
|
||||
if response.output is None:
|
||||
raise RuntimeError("action policy returned no output")
|
||||
return response.output[0]
|
||||
|
||||
|
||||
def action_generation_response(
|
||||
output: dict[str, Any],
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
preserve_numpy: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
actions = output["actions"]
|
||||
if isinstance(actions, np.ndarray):
|
||||
action_shape = list(actions.shape)
|
||||
action_values = actions if preserve_numpy else actions.tolist()
|
||||
else:
|
||||
horizon = len(actions) if isinstance(actions, list) else 0
|
||||
action_dim = len(actions[0]) if horizon and isinstance(actions[0], list) else 0
|
||||
action_shape = [horizon, action_dim]
|
||||
action_values = actions
|
||||
response = {
|
||||
"id": output.get("request_id") or f"act_{uuid.uuid4().hex}",
|
||||
"object": "action.generation",
|
||||
"created": int(time.time()),
|
||||
"model": server_args.model_id or server_args.model_path,
|
||||
"data": [
|
||||
{
|
||||
"index": 0,
|
||||
"input_index": 0,
|
||||
"candidate_index": 0,
|
||||
"action": {
|
||||
"type": "continuous",
|
||||
"dtype": "float32",
|
||||
"shape": action_shape,
|
||||
"values": action_values,
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"action_horizon": action_shape[0] if action_shape else 0,
|
||||
"action_dim": action_shape[1] if len(action_shape) > 1 else 0,
|
||||
"denoise_steps": output.get("parameters", {}).get(
|
||||
"num_inference_steps",
|
||||
server_args.pipeline_config.default_num_inference_steps,
|
||||
),
|
||||
"prefix_cache_hit": bool(output.get("cache", {}).get("hit", False)),
|
||||
},
|
||||
}
|
||||
if "timings" in output:
|
||||
response["timings"] = output["timings"]
|
||||
if "cache" in output:
|
||||
response["cache"] = output["cache"]
|
||||
if "parallel" in output:
|
||||
response["parallel"] = output["parallel"]
|
||||
return response
|
||||
|
||||
|
||||
def action_raw_response(
|
||||
output: dict[str, Any],
|
||||
*,
|
||||
preserve_numpy: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
response = dict(output)
|
||||
actions = response.get("actions")
|
||||
if isinstance(actions, np.ndarray) and not preserve_numpy:
|
||||
response["actions"] = actions.tolist()
|
||||
return response
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
|
||||
action_metadata,
|
||||
infer_action,
|
||||
pack_msgpack,
|
||||
unpack_msgpack,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
async def run_action_msgpack_ws(
|
||||
websocket: WebSocket,
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
prepare_payload: Callable[[dict[str, Any]], None],
|
||||
build_response: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
) -> None:
|
||||
await websocket.accept()
|
||||
await websocket.send_bytes(pack_msgpack(action_metadata(server_args)))
|
||||
|
||||
prev_total_time = None
|
||||
while True:
|
||||
try:
|
||||
start_time = time.monotonic()
|
||||
payload = unpack_msgpack(await websocket.receive_bytes())
|
||||
prepare_payload(payload)
|
||||
infer_start = time.monotonic()
|
||||
output = await infer_action(payload, server_args)
|
||||
response = build_response(output)
|
||||
response.setdefault("server_timing", {})["infer_ms"] = (
|
||||
time.monotonic() - infer_start
|
||||
) * 1000
|
||||
if prev_total_time is not None:
|
||||
response["server_timing"]["prev_total_ms"] = prev_total_time * 1000
|
||||
await websocket.send_bytes(pack_msgpack(response))
|
||||
prev_total_time = time.monotonic() - start_time
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
await websocket.send_bytes(
|
||||
pack_msgpack({"error": traceback.format_exc()})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.close(code=1011, reason="Internal server error")
|
||||
raise
|
||||
@@ -279,6 +279,12 @@ def launch_server(server_args: ServerArgs, launch_http_server: bool = True):
|
||||
logger.debug("All workers are ready")
|
||||
|
||||
if launch_http_server:
|
||||
if server_args.pipeline_config.task_type.is_action_gen():
|
||||
logger.info(
|
||||
"VLA pipeline ready: model=%s; per-request details are "
|
||||
"debug-only (use --log-level debug).",
|
||||
server_args.model_id or server_args.model_path,
|
||||
)
|
||||
logger.info("Starting FastAPI server.")
|
||||
if server_args.webui:
|
||||
logger.info("Launch FastAPI server in another process because of webui.")
|
||||
|
||||
@@ -420,6 +420,7 @@ class LocalAttention(nn.Module):
|
||||
softmax_scale: float | None = None,
|
||||
causal: bool = False,
|
||||
supported_attention_backends: set[AttentionBackendEnum] | None = None,
|
||||
compute_dtype: torch.dtype | None = None,
|
||||
**extra_impl_args,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -430,7 +431,7 @@ class LocalAttention(nn.Module):
|
||||
if num_kv_heads is None:
|
||||
num_kv_heads = num_heads
|
||||
|
||||
dtype = get_compute_dtype()
|
||||
dtype = compute_dtype or get_compute_dtype()
|
||||
attn_backend = get_attn_backend(
|
||||
head_size, dtype, supported_attention_backends=supported_attention_backends
|
||||
)
|
||||
|
||||
@@ -179,14 +179,20 @@ class skip_init_modules:
|
||||
def __enter__(self):
|
||||
# Save originals
|
||||
self._orig_reset = {}
|
||||
for cls in (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d):
|
||||
for cls in (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d, nn.Embedding):
|
||||
self._orig_reset[cls] = cls.reset_parameters
|
||||
cls.reset_parameters = lambda self: None # skip init
|
||||
from transformers.modeling_utils import PreTrainedModel
|
||||
|
||||
self._pretrained_model_cls = PreTrainedModel
|
||||
self._orig_post_init = PreTrainedModel.post_init
|
||||
PreTrainedModel.post_init = lambda self: None
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
# restore originals
|
||||
for cls, orig in self._orig_reset.items():
|
||||
cls.reset_parameters = orig
|
||||
self._pretrained_model_cls.post_init = self._orig_post_init
|
||||
|
||||
|
||||
def _normalize_component_type(module_type: str) -> str:
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
from collections.abc import Callable, Generator, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
import filelock
|
||||
@@ -27,6 +27,7 @@ except ImportError:
|
||||
|
||||
from sglang.multimodal_gen import envs
|
||||
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
|
||||
from sglang.multimodal_gen.runtime.loader.weight_load_plan import WeightLoadPlan
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -183,12 +184,20 @@ def safetensors_weights_iterator(
|
||||
hf_weights_files: list[str],
|
||||
to_cpu: bool = True,
|
||||
use_runai_model_streamer: bool | None = None,
|
||||
key_filter: Callable[[str], bool] | None = None,
|
||||
clone_streamed_tensors: bool = True,
|
||||
weight_load_plan: WeightLoadPlan | None = None,
|
||||
) -> Generator[tuple[str, torch.Tensor], None, None]:
|
||||
"""Iterate over the weights in the model safetensor files."""
|
||||
enable_tqdm = (
|
||||
not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
|
||||
)
|
||||
device = "cpu" if to_cpu else str(get_local_torch_device())
|
||||
if weight_load_plan is not None:
|
||||
checkpoint_device = torch.device(weight_load_plan.checkpoint_load_device)
|
||||
to_cpu = checkpoint_device.type == "cpu"
|
||||
device = str(checkpoint_device)
|
||||
else:
|
||||
device = "cpu" if to_cpu else str(get_local_torch_device())
|
||||
if use_runai_model_streamer is None:
|
||||
use_runai_model_streamer = (
|
||||
HAS_RUNAI_MODEL_STREAMER and envs.SGLANG_USE_RUNAI_MODEL_STREAMER
|
||||
@@ -233,13 +242,24 @@ def safetensors_weights_iterator(
|
||||
_raise_if_duplicate_safetensors_keys(hf_weights_files)
|
||||
|
||||
if use_runai_model_streamer:
|
||||
logger.info(
|
||||
"Loading safetensors with Run:ai Model Streamer to %s",
|
||||
"cpu" if to_cpu else device,
|
||||
)
|
||||
with SafetensorsStreamer() as streamer:
|
||||
streamer.stream_files(hf_weights_files)
|
||||
if to_cpu:
|
||||
streamer.stream_files(hf_weights_files)
|
||||
else:
|
||||
streamer.stream_files(hf_weights_files, device=device)
|
||||
for name, tensor in streamer.get_tensors():
|
||||
if key_filter is not None and not key_filter(name):
|
||||
continue
|
||||
if to_cpu:
|
||||
yield name, tensor.clone().detach()
|
||||
elif clone_streamed_tensors:
|
||||
yield name, tensor.clone().detach()
|
||||
else:
|
||||
yield name, tensor.to(device)
|
||||
yield name, tensor
|
||||
else:
|
||||
for st_file in tqdm(
|
||||
hf_weights_files,
|
||||
@@ -249,6 +269,8 @@ def safetensors_weights_iterator(
|
||||
):
|
||||
with safe_open(st_file, framework="pt", device=device) as f:
|
||||
for name in f.keys(): # noqa: SIM118
|
||||
if key_filter is not None and not key_filter(name):
|
||||
continue
|
||||
param = f.get_tensor(name)
|
||||
yield name, param
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
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:
|
||||
elif getattr(req, "return_frames", False):
|
||||
self._materialize_frame_outputs_for_return(output_batch, req)
|
||||
|
||||
def _materialize_raw_frame_transport(
|
||||
@@ -518,7 +518,11 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
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:
|
||||
if (
|
||||
self.rank != 0
|
||||
or output_batch.output is None
|
||||
or not getattr(req, "return_frames", False)
|
||||
):
|
||||
return
|
||||
|
||||
if (
|
||||
@@ -692,7 +696,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
mismatched = [
|
||||
field
|
||||
for field in shared_output_fields
|
||||
if getattr(req, field) != getattr(first_req, field)
|
||||
if getattr(req, field, None) != getattr(first_req, field, None)
|
||||
]
|
||||
if mismatched:
|
||||
raise ValueError(
|
||||
|
||||
@@ -282,6 +282,9 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
if len(reqs) == 1 or not allow_dynamic_batching:
|
||||
return self.worker.execute_forward(reqs)
|
||||
|
||||
if self.server_args.pipeline_config.supports_native_grouped_requests():
|
||||
return self._execute_generation_grouped(reqs)
|
||||
|
||||
merged_req = self._try_merge_generation_reqs(reqs)
|
||||
if merged_req is None:
|
||||
return self._execute_generation_sequential(reqs)
|
||||
@@ -327,6 +330,48 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
error_msg=f"Dynamic batching failed: {e}",
|
||||
)
|
||||
|
||||
def _execute_generation_grouped(self, reqs: List[Req]) -> List[OutputBatch]:
|
||||
batch_size = len(reqs)
|
||||
try:
|
||||
output_batch = self.worker.execute_forward(reqs)
|
||||
if output_batch.error:
|
||||
logger.error(
|
||||
"Native grouped execution returned error. Returning per-request errors: %s",
|
||||
output_batch.error,
|
||||
)
|
||||
return self._build_dynamic_batch_error_outputs(
|
||||
reqs=reqs,
|
||||
error_msg=output_batch.error,
|
||||
)
|
||||
|
||||
split_outputs = self._split_batched_output(output_batch, reqs)
|
||||
if split_outputs is None:
|
||||
logger.error(
|
||||
"Failed to split native grouped output cleanly. Returning per-request errors."
|
||||
)
|
||||
return self._build_dynamic_batch_error_outputs(
|
||||
reqs=reqs,
|
||||
error_msg="Native grouped execution failed: could not split output.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Processed native grouped batch of %d/%d request(s) with max_delay=%.2fms",
|
||||
batch_size,
|
||||
self._batching_max_size,
|
||||
self._batching_delay_s * 1000.0,
|
||||
)
|
||||
return split_outputs
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Native grouped execution failed (%s). Returning per-request errors.",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return self._build_dynamic_batch_error_outputs(
|
||||
reqs=reqs,
|
||||
error_msg=f"Native grouped execution failed: {e}",
|
||||
)
|
||||
|
||||
def _execute_generation_sequential(self, reqs: List[Req]) -> List[OutputBatch]:
|
||||
return [self.worker.execute_forward([req]) for req in reqs]
|
||||
|
||||
@@ -452,7 +497,10 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
candidate_req.prompt, str
|
||||
):
|
||||
return "prompt_type"
|
||||
if base_req.image_path is not None or candidate_req.image_path is not None:
|
||||
if (
|
||||
getattr(base_req, "image_path", None) is not None
|
||||
or getattr(candidate_req, "image_path", None) is not None
|
||||
):
|
||||
return "image_conditioning"
|
||||
if base_req.return_file_paths_only != candidate_req.return_file_paths_only:
|
||||
return "return_file_paths_only"
|
||||
@@ -486,7 +534,10 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
):
|
||||
return False
|
||||
|
||||
if base_req.image_path is not None or candidate_req.image_path is not None:
|
||||
if (
|
||||
getattr(base_req, "image_path", None) is not None
|
||||
or getattr(candidate_req, "image_path", None) is not None
|
||||
):
|
||||
return False
|
||||
if base_req.return_file_paths_only != candidate_req.return_file_paths_only:
|
||||
return False
|
||||
@@ -722,8 +773,14 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
|
||||
outputs: list[OutputBatch] = []
|
||||
start = 0
|
||||
for req, req_count in zip(reqs, per_req_counts):
|
||||
for req_index, (req, req_count) in enumerate(zip(reqs, per_req_counts)):
|
||||
end = start + req_count
|
||||
metrics = (
|
||||
deepcopy(output_batch.metrics_list[req_index])
|
||||
if output_batch.metrics_list is not None
|
||||
and req_index < len(output_batch.metrics_list)
|
||||
else deepcopy(output_batch.metrics)
|
||||
)
|
||||
split = OutputBatch(
|
||||
output=self._slice_batched_value(
|
||||
output_batch.output, start, end, total_items
|
||||
@@ -748,7 +805,7 @@ class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisag
|
||||
output_file_paths=self._slice_batched_value(
|
||||
output_batch.output_file_paths, start, end, total_items
|
||||
),
|
||||
metrics=deepcopy(output_batch.metrics),
|
||||
metrics=metrics,
|
||||
noise_pred=self._slice_batched_value(
|
||||
output_batch.noise_pred, start, end, total_items
|
||||
),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.runtime.models.vlas.pi05_policy import Pi05PolicyModel
|
||||
|
||||
__all__ = ["Pi05PolicyModel"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.configs.sample.pi05 import Pi05SamplingParams
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.models.vlas import Pi05PolicyModel
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
||||
ComposedPipelineBase,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.pi05_preprocess import (
|
||||
Pi05Preprocessor,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.vla import (
|
||||
VLAActionDenoisingStage,
|
||||
VLAActionPostprocessStage,
|
||||
VLAObservationPreprocessStage,
|
||||
VLAPrefixEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import VLAPrefixCacheManager
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class Pi05Pipeline(ComposedPipelineBase):
|
||||
pipeline_name = "Pi05Pipeline"
|
||||
pipeline_config_cls = Pi05PipelineConfig
|
||||
sampling_params_cls = Pi05SamplingParams
|
||||
_required_config_modules: list[str] = []
|
||||
|
||||
def validate_disagg_role(self, role: RoleType) -> None:
|
||||
if role != RoleType.MONOLITHIC:
|
||||
raise ValueError(
|
||||
"Pi05Pipeline v1 supports same-process execution only. "
|
||||
"Use prefix/action logical groups inside one worker; cross-node "
|
||||
"multimodal_gen disaggregation is a v2 target."
|
||||
)
|
||||
|
||||
def load_modules(
|
||||
self,
|
||||
server_args: ServerArgs,
|
||||
loaded_modules: dict[str, torch.nn.Module] | None = None,
|
||||
) -> dict[str, torch.nn.Module]:
|
||||
if loaded_modules is not None:
|
||||
return loaded_modules
|
||||
|
||||
pipeline_config: Pi05PipelineConfig = server_args.pipeline_config
|
||||
pipeline_config.offload_prefix_image_encoder = (
|
||||
pipeline_config.offload_prefix_image_encoder
|
||||
or bool(server_args.image_encoder_cpu_offload)
|
||||
)
|
||||
pipeline_config.offload_prefix_token_embedding = (
|
||||
pipeline_config.offload_prefix_token_embedding
|
||||
or bool(server_args.text_encoder_cpu_offload)
|
||||
)
|
||||
logger.info(
|
||||
"Pi05 memory config: prefix_cache=%s/%s, action_cuda_graph=%s, "
|
||||
"offload_image=%s, offload_image_after_embed=%s, "
|
||||
"offload_tokens=%s, offload_language_layers=%s, "
|
||||
"offload_language_after_prefix=%s/%s, "
|
||||
"offload_action_after_denoise=%s, empty_cache_after_prefix=%s",
|
||||
pipeline_config.enable_global_prefix_cache,
|
||||
pipeline_config.prefix_cache_max_entries,
|
||||
pipeline_config.enable_action_cuda_graph,
|
||||
pipeline_config.offload_prefix_image_encoder,
|
||||
pipeline_config.offload_prefix_image_encoder_after_embed,
|
||||
pipeline_config.offload_prefix_token_embedding,
|
||||
pipeline_config.offload_prefix_language_layers,
|
||||
pipeline_config.offload_prefix_language_layers_after_prefix,
|
||||
pipeline_config.offload_prefix_language_layer_count_after_prefix,
|
||||
pipeline_config.offload_action_expert_after_denoise,
|
||||
pipeline_config.empty_cache_after_prefix,
|
||||
)
|
||||
policy_model = Pi05PolicyModel.from_pretrained(
|
||||
self.model_path,
|
||||
pipeline_config,
|
||||
)
|
||||
if (
|
||||
pipeline_config.prefix_parallel_strategy
|
||||
== pipeline_config.action_parallel_strategy
|
||||
== "tp"
|
||||
):
|
||||
raise ValueError(
|
||||
"VLA action expert should not share the prefix TP layout. "
|
||||
"Use SP, Ulysses, Ring, DP, or monolithic fallback for the "
|
||||
"action path."
|
||||
)
|
||||
return {
|
||||
"policy_model": policy_model,
|
||||
}
|
||||
|
||||
def initialize_pipeline(self, server_args: ServerArgs) -> None:
|
||||
pipeline_config: Pi05PipelineConfig = server_args.pipeline_config
|
||||
self.preprocessor = Pi05Preprocessor(pipeline_config)
|
||||
self.prefix_cache = VLAPrefixCacheManager(
|
||||
max_entries=pipeline_config.prefix_cache_max_entries
|
||||
)
|
||||
|
||||
def create_pipeline_stages(self, server_args: ServerArgs):
|
||||
self.add_stage(
|
||||
VLAObservationPreprocessStage(self.preprocessor),
|
||||
"pi05_preprocess",
|
||||
)
|
||||
self.add_stage(
|
||||
VLAPrefixEncodingStage(
|
||||
self.get_module("policy_model"),
|
||||
self.prefix_cache,
|
||||
),
|
||||
"pi05_prefix",
|
||||
)
|
||||
self.add_stage(
|
||||
VLAActionDenoisingStage(self.get_module("policy_model")),
|
||||
"pi05_action_denoise",
|
||||
)
|
||||
self.add_stage(
|
||||
VLAActionPostprocessStage(),
|
||||
"pi05_postprocess",
|
||||
)
|
||||
|
||||
|
||||
EntryClass = Pi05Pipeline
|
||||
@@ -979,7 +979,12 @@ class ComposedPipelineBase(ABC):
|
||||
|
||||
# Execute each stage
|
||||
if not batch.is_warmup and not batch.suppress_logs:
|
||||
logger.info(
|
||||
stage_logger = (
|
||||
logger.debug
|
||||
if server_args.pipeline_config.task_type.is_action_gen()
|
||||
else logger.info
|
||||
)
|
||||
stage_logger(
|
||||
"Running pipeline stages: %s",
|
||||
list(self._stage_name_mapping.keys()),
|
||||
main_process_only=True,
|
||||
@@ -1007,7 +1012,12 @@ class ComposedPipelineBase(ABC):
|
||||
)
|
||||
|
||||
if not batches[0].is_warmup and not batches[0].suppress_logs:
|
||||
logger.info(
|
||||
stage_logger = (
|
||||
logger.debug
|
||||
if server_args.pipeline_config.task_type.is_action_gen()
|
||||
else logger.info
|
||||
)
|
||||
stage_logger(
|
||||
"Running grouped pipeline stages: %s",
|
||||
list(self._stage_name_mapping.keys()),
|
||||
main_process_only=True,
|
||||
|
||||
@@ -57,6 +57,10 @@ class PipelineExecutor(ABC):
|
||||
batch: Any,
|
||||
server_args: ServerArgs,
|
||||
) -> None:
|
||||
if isinstance(batch, list):
|
||||
if not batch:
|
||||
return
|
||||
batch = batch[0]
|
||||
self.component_residency_manager.begin_request(stages, batch, server_args)
|
||||
|
||||
def before_stage(
|
||||
|
||||
@@ -22,7 +22,10 @@ from typing import Any, Optional, Sequence, Union
|
||||
import PIL.Image
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.post_training.rl_dataclasses import (
|
||||
RolloutTrajectoryData,
|
||||
)
|
||||
@@ -320,9 +323,11 @@ class Req:
|
||||
@property
|
||||
def resolution_key(self) -> str | None:
|
||||
"""Return the batching config resolution key, e.g. "1024x1024"."""
|
||||
if self.width is None or self.height is None:
|
||||
width = getattr(self, "width", None)
|
||||
height = getattr(self, "height", None)
|
||||
if width is None or height is None:
|
||||
return None
|
||||
return f"{int(self.width)}x{int(self.height)}"
|
||||
return f"{int(width)}x{int(height)}"
|
||||
|
||||
def set_as_warmup(self, warmup_steps: int = 1):
|
||||
self.is_warmup = True
|
||||
@@ -339,6 +344,13 @@ class Req:
|
||||
|
||||
def validate(self):
|
||||
"""Initialize dependent fields after dataclass initialization."""
|
||||
if getattr(self.sampling_params, "data_type", None) == DataType.ACTION:
|
||||
self.do_classifier_free_guidance = False
|
||||
if self.negative_prompt_embeds is None:
|
||||
self.negative_prompt_embeds = []
|
||||
self.metrics = RequestMetrics(request_id=self.request_id)
|
||||
return
|
||||
|
||||
# Prefer true_cfg_scale when it is explicitly provided.
|
||||
cfg_scale = (
|
||||
self.true_cfg_scale
|
||||
@@ -360,6 +372,22 @@ class Req:
|
||||
def log(self, server_args: ServerArgs):
|
||||
if self.is_warmup or self.suppress_logs:
|
||||
return
|
||||
if getattr(self.sampling_params, "data_type", None) == DataType.ACTION:
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
logger.debug(
|
||||
"VLA request: prompt=%s seed=%s steps=%s outputs=%s action=%sx%s "
|
||||
"save_output=%s",
|
||||
_sanitize_for_logging(self.prompt, key_hint="prompt"),
|
||||
self.seed,
|
||||
self.num_inference_steps,
|
||||
self.num_outputs_per_prompt,
|
||||
getattr(self, "action_horizon", None),
|
||||
getattr(self, "action_dim", None),
|
||||
self.save_output,
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: in some cases (e.g., TI2I), height and weight might be undecided at this moment
|
||||
if self.height:
|
||||
target_height = align_to(self.height, 16)
|
||||
|
||||
@@ -42,6 +42,12 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.timestep_preparation im
|
||||
DMDTimestepPreparationStage,
|
||||
TimestepPreparationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.vla import (
|
||||
VLAActionDenoisingStage,
|
||||
VLAActionPostprocessStage,
|
||||
VLAObservationPreprocessStage,
|
||||
VLAPrefixEncodingStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PipelineStage",
|
||||
@@ -60,4 +66,8 @@ __all__ = [
|
||||
"ImageEncodingStage",
|
||||
"ImageVAEEncodingStage",
|
||||
"TextEncodingStage",
|
||||
"VLAObservationPreprocessStage",
|
||||
"VLAPrefixEncodingStage",
|
||||
"VLAActionDenoisingStage",
|
||||
"VLAActionPostprocessStage",
|
||||
]
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.runtime.vla.observation import VLAObservationBatch
|
||||
|
||||
|
||||
def _tensor_from_image(value: Any) -> torch.Tensor:
|
||||
if isinstance(value, torch.Tensor):
|
||||
tensor = value.detach()
|
||||
if tensor.ndim == 4:
|
||||
if tensor.shape[0] != 1:
|
||||
raise ValueError("Pi05 v1 expects one observation per request")
|
||||
tensor = tensor[0]
|
||||
if tensor.ndim != 3:
|
||||
raise ValueError(f"Expected image tensor with 3 dims, got {tensor.shape}")
|
||||
if tensor.shape[0] in (1, 3, 4):
|
||||
tensor = tensor[:3]
|
||||
elif tensor.shape[-1] in (1, 3, 4):
|
||||
tensor = tensor[..., :3].permute(2, 0, 1)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not infer image channels from shape {tensor.shape}"
|
||||
)
|
||||
is_integer = not tensor.is_floating_point()
|
||||
tensor = tensor.to(dtype=torch.float32)
|
||||
if is_integer or tensor.max() > 2.0:
|
||||
tensor = tensor / 255.0
|
||||
return tensor
|
||||
|
||||
if isinstance(value, Image.Image):
|
||||
image = value.convert("RGB")
|
||||
arr = np.asarray(image, dtype=np.float32) / 255.0
|
||||
return torch.from_numpy(arr).permute(2, 0, 1)
|
||||
|
||||
if isinstance(value, (np.ndarray, list)):
|
||||
arr = np.asarray(value)
|
||||
if arr.ndim != 3:
|
||||
raise ValueError(f"Expected HWC image array, got shape {arr.shape}")
|
||||
tensor = torch.from_numpy(np.ascontiguousarray(arr))
|
||||
if tensor.shape[0] in (1, 3, 4):
|
||||
tensor = tensor[:3]
|
||||
elif tensor.shape[-1] in (1, 3, 4):
|
||||
tensor = tensor[..., :3].permute(2, 0, 1)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not infer image channels from shape {tensor.shape}"
|
||||
)
|
||||
is_integer = not tensor.is_floating_point()
|
||||
tensor = tensor.to(dtype=torch.float32)
|
||||
if is_integer or tensor.max() > 2.0:
|
||||
tensor = tensor / 255.0
|
||||
return tensor
|
||||
|
||||
raise TypeError(f"Unsupported Pi05 image type: {type(value)}")
|
||||
|
||||
|
||||
def _resize_with_pad_image_tensor(
|
||||
tensor: torch.Tensor, size: tuple[int, int]
|
||||
) -> torch.Tensor:
|
||||
height, width = size
|
||||
if tensor.shape[-2:] == (height, width):
|
||||
return tensor
|
||||
_, cur_height, cur_width = tensor.shape
|
||||
ratio = max(cur_width / width, cur_height / height)
|
||||
resized_height = int(cur_height / ratio)
|
||||
resized_width = int(cur_width / ratio)
|
||||
tensor = F.interpolate(
|
||||
tensor[None],
|
||||
size=(resized_height, resized_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)[0]
|
||||
pad_h0, rem_h = divmod(height - resized_height, 2)
|
||||
pad_w0, rem_w = divmod(width - resized_width, 2)
|
||||
return F.pad(
|
||||
tensor,
|
||||
(pad_w0, pad_w0 + rem_w, pad_h0, pad_h0 + rem_h),
|
||||
mode="constant",
|
||||
value=0.0,
|
||||
)
|
||||
|
||||
|
||||
class Pi05Preprocessor:
|
||||
def __init__(self, config: Pi05PipelineConfig):
|
||||
self.config = config
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(config.tokenizer_name)
|
||||
self.tokenizer.padding_side = "right"
|
||||
|
||||
def _tokenize(self, prompt: list[str], state: torch.Tensor | None):
|
||||
if state is None:
|
||||
state_for_prompt = torch.zeros(1, 0, dtype=torch.float32)
|
||||
else:
|
||||
state_for_prompt = state.detach().cpu().to(torch.float32)
|
||||
bins = np.linspace(-1, 1, 256 + 1)[:-1]
|
||||
state_np = state_for_prompt.numpy()
|
||||
discretized = np.digitize(state_np, bins=bins) - 1
|
||||
full_prompts = []
|
||||
for idx, task in enumerate(prompt):
|
||||
cleaned = task.strip().replace("_", " ").replace("\n", " ")
|
||||
state_str = " ".join(map(str, discretized[idx]))
|
||||
full_prompts.append(f"Task: {cleaned}, State: {state_str};\nAction: ")
|
||||
|
||||
encoded = self.tokenizer(
|
||||
full_prompts,
|
||||
max_length=self.config.max_token_len,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
)
|
||||
return encoded["input_ids"].to(torch.long), encoded["attention_mask"].to(
|
||||
torch.bool
|
||||
)
|
||||
|
||||
def __call__(self, raw_observation: dict[str, Any]) -> VLAObservationBatch:
|
||||
prompt_value = raw_observation.get("prompt", "")
|
||||
if isinstance(prompt_value, list):
|
||||
prompt = [str(x) for x in prompt_value]
|
||||
else:
|
||||
prompt = [str(prompt_value)]
|
||||
if len(prompt) != 1:
|
||||
raise ValueError("Pi05 v1 expects one prompt per action request")
|
||||
|
||||
raw_images = raw_observation.get("images") or {}
|
||||
image_masks_in = raw_observation.get("image_masks") or {}
|
||||
camera_order = tuple(
|
||||
raw_observation.get("camera_order") or self.config.image_keys
|
||||
)
|
||||
|
||||
images: dict[str, torch.Tensor] = {}
|
||||
image_masks: dict[str, torch.Tensor] = {}
|
||||
for key in camera_order:
|
||||
value = raw_images.get(key)
|
||||
is_present = value is not None and bool(image_masks_in.get(key, True))
|
||||
if is_present:
|
||||
tensor = _tensor_from_image(value)
|
||||
tensor = _resize_with_pad_image_tensor(tensor, self.config.image_size)
|
||||
tensor = tensor * 2.0 - 1.0
|
||||
else:
|
||||
channels = 3
|
||||
height, width = self.config.image_size
|
||||
tensor = torch.ones(channels, height, width, dtype=torch.float32) * -1.0
|
||||
|
||||
images[key] = tensor.unsqueeze(0)
|
||||
image_masks[key] = torch.tensor([is_present], dtype=torch.bool)
|
||||
|
||||
state = raw_observation.get("state")
|
||||
state_tensor = None
|
||||
if state is not None:
|
||||
state_tensor = torch.as_tensor(state, dtype=torch.float32)
|
||||
if state_tensor.ndim == 1:
|
||||
state_tensor = state_tensor.unsqueeze(0)
|
||||
if state_tensor.shape[0] != 1:
|
||||
raise ValueError("Pi05 v1 expects one state vector per request")
|
||||
if state_tensor.shape[-1] > self.config.state_dim:
|
||||
raise ValueError(
|
||||
f"Pi05 state dim must be <= {self.config.state_dim}, "
|
||||
f"got {state_tensor.shape[-1]}"
|
||||
)
|
||||
|
||||
noise = raw_observation.get("noise")
|
||||
noise_tensor = None
|
||||
if noise is not None:
|
||||
noise_tensor = torch.as_tensor(noise, dtype=torch.float32)
|
||||
if noise_tensor.ndim == 2:
|
||||
noise_tensor = noise_tensor.unsqueeze(0)
|
||||
expected = (1, self.config.action_horizon, self.config.action_dim)
|
||||
if tuple(noise_tensor.shape) != expected:
|
||||
raise ValueError(
|
||||
f"Pi05 noise must have shape {expected}, "
|
||||
f"got {tuple(noise_tensor.shape)}"
|
||||
)
|
||||
|
||||
tokens = raw_observation.get("tokens")
|
||||
if tokens is None:
|
||||
tokens = raw_observation.get("tokenized_prompt")
|
||||
token_masks = raw_observation.get("token_masks")
|
||||
if token_masks is None:
|
||||
token_masks = raw_observation.get("tokenized_prompt_mask")
|
||||
if tokens is not None:
|
||||
tokens_tensor = torch.as_tensor(tokens, dtype=torch.long)
|
||||
if tokens_tensor.ndim == 1:
|
||||
tokens_tensor = tokens_tensor.unsqueeze(0)
|
||||
if token_masks is None:
|
||||
token_masks_tensor = tokens_tensor != self.tokenizer.pad_token_id
|
||||
else:
|
||||
token_masks_tensor = torch.as_tensor(token_masks, dtype=torch.bool)
|
||||
if token_masks_tensor.ndim == 1:
|
||||
token_masks_tensor = token_masks_tensor.unsqueeze(0)
|
||||
else:
|
||||
tokens_tensor, token_masks_tensor = self._tokenize(prompt, state_tensor)
|
||||
|
||||
return VLAObservationBatch(
|
||||
prompt=prompt,
|
||||
images=images,
|
||||
image_masks=image_masks,
|
||||
state=state_tensor,
|
||||
noise=noise_tensor,
|
||||
tokens=tokens_tensor,
|
||||
token_masks=token_masks_tensor,
|
||||
batch_size=1,
|
||||
metadata={"camera_order": camera_order},
|
||||
)
|
||||
@@ -0,0 +1,496 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
|
||||
OutputBatch,
|
||||
Req,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.vla.observation import (
|
||||
collate_vla_observation_batches,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.vla.parallel import (
|
||||
broadcast_prefix_context,
|
||||
broadcast_tensor_from_rank,
|
||||
get_vla_split_group,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import (
|
||||
PrefixContext,
|
||||
VLAPrefixCacheManager,
|
||||
slice_prefix_context,
|
||||
)
|
||||
|
||||
|
||||
def vla_state(batch: Req) -> dict[str, Any]:
|
||||
"""Per-request scratchpad shared by the VLA pipeline stages."""
|
||||
|
||||
return batch.extra["vla"]
|
||||
|
||||
|
||||
def vla_timings(batch: Req) -> dict[str, float]:
|
||||
return vla_state(batch).setdefault("timings", {})
|
||||
|
||||
|
||||
def vla_options(batch: Req) -> dict[str, Any]:
|
||||
return vla_state(batch).get("options") or {}
|
||||
|
||||
|
||||
def materialize_vla_action_batch(
|
||||
actions: Any,
|
||||
action_dim: int,
|
||||
output_format: str,
|
||||
) -> Any:
|
||||
output_format = output_format.lower()
|
||||
if isinstance(actions, torch.Tensor):
|
||||
actions_out = actions[..., :action_dim].detach().float().cpu().numpy()
|
||||
if output_format != "numpy":
|
||||
actions_out = actions_out.tolist()
|
||||
elif isinstance(actions, np.ndarray):
|
||||
actions_out = actions[..., :action_dim].astype(np.float32, copy=False)
|
||||
if output_format != "numpy":
|
||||
actions_out = actions_out.tolist()
|
||||
else:
|
||||
actions_out = actions
|
||||
if not isinstance(actions_out, list):
|
||||
return actions_out
|
||||
if not actions_out:
|
||||
return []
|
||||
first = actions_out[0]
|
||||
if isinstance(first, list) and first and isinstance(first[0], list):
|
||||
actions_out = [[step[:action_dim] for step in sample] for sample in actions_out]
|
||||
else:
|
||||
actions_out = [[step[:action_dim] for step in actions_out]]
|
||||
if output_format == "numpy":
|
||||
return np.asarray(actions_out, dtype=np.float32)
|
||||
return actions_out
|
||||
|
||||
|
||||
def synchronize_vla_action_tensor(actions: torch.Tensor | None) -> None:
|
||||
if actions is not None and actions.device.type == "cuda":
|
||||
torch.cuda.synchronize(actions.device)
|
||||
|
||||
|
||||
def _effective_prefix_cache_enabled(
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> bool:
|
||||
options = vla_options(batch)
|
||||
return bool(options.get("enable_prefix_cache", True)) and bool(
|
||||
server_args.pipeline_config.enable_global_prefix_cache
|
||||
)
|
||||
|
||||
|
||||
def _grouped_fingerprint(
|
||||
batch: Req,
|
||||
server_args: ServerArgs,
|
||||
) -> tuple[Any, ...]:
|
||||
if (
|
||||
batch.is_warmup
|
||||
or get_vla_split_group() is not None
|
||||
or _effective_prefix_cache_enabled(batch, server_args)
|
||||
or batch.generator is not None
|
||||
):
|
||||
return ("single", id(batch))
|
||||
|
||||
observation = vla_state(batch).get("observation_batch")
|
||||
camera_order = tuple(observation.metadata.get("camera_order", ()))
|
||||
image_shapes = tuple(
|
||||
(
|
||||
name,
|
||||
tuple(observation.images[name].shape),
|
||||
bool(observation.image_masks[name].item()),
|
||||
)
|
||||
for name in camera_order
|
||||
)
|
||||
return (
|
||||
"grouped",
|
||||
camera_order,
|
||||
image_shapes,
|
||||
None if observation.state is None else tuple(observation.state.shape),
|
||||
None if observation.noise is None else tuple(observation.noise.shape),
|
||||
tuple(observation.tokens.shape),
|
||||
tuple(observation.token_masks.shape),
|
||||
batch.action_horizon,
|
||||
batch.action_dim,
|
||||
batch.num_inference_steps,
|
||||
)
|
||||
|
||||
|
||||
class VLAObservationPreprocessStage(PipelineStage):
|
||||
def __init__(self, preprocessor: Any):
|
||||
super().__init__()
|
||||
self.preprocessor = preprocessor
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.ENCODER
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
start = time.perf_counter()
|
||||
state = vla_state(batch)
|
||||
raw_observation = dict(state.get("observation") or {})
|
||||
raw_observation.setdefault("prompt", batch.prompt)
|
||||
observation = self.preprocessor(raw_observation)
|
||||
state["observation_batch"] = observation
|
||||
vla_timings(batch)["preprocess_ms"] = (time.perf_counter() - start) * 1000
|
||||
return batch
|
||||
|
||||
|
||||
class VLAPrefixEncodingStage(PipelineStage):
|
||||
def __init__(
|
||||
self,
|
||||
policy_model: Any,
|
||||
prefix_cache: VLAPrefixCacheManager,
|
||||
):
|
||||
super().__init__()
|
||||
self.policy_model = policy_model
|
||||
self.prefix_cache = prefix_cache
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.ENCODER
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> list[Req]:
|
||||
results: list[Req | None] = [None] * len(batches)
|
||||
for fingerprint, group in self._group_requests_by_fingerprint(
|
||||
batches,
|
||||
lambda batch: _grouped_fingerprint(batch, server_args),
|
||||
):
|
||||
group_batches = [batch for _, batch in group]
|
||||
if len(group_batches) == 1 or fingerprint[0] == "single":
|
||||
for index, batch in group:
|
||||
results[index] = self(batch, server_args)
|
||||
continue
|
||||
|
||||
prefix_start = time.perf_counter()
|
||||
observations = [
|
||||
vla_state(batch)["observation_batch"] for batch in group_batches
|
||||
]
|
||||
grouped_observation = collate_vla_observation_batches(observations)
|
||||
prefix_context = self.policy_model.encode_prefix(grouped_observation)
|
||||
prefix_ms = (time.perf_counter() - prefix_start) * 1000
|
||||
|
||||
for offset, (index, batch) in enumerate(group):
|
||||
state = vla_state(batch)
|
||||
state["observation_group"] = grouped_observation
|
||||
state["prefix_context_group"] = prefix_context
|
||||
state["prefix_context"] = slice_prefix_context(
|
||||
prefix_context,
|
||||
offset,
|
||||
)
|
||||
state["cache"] = {
|
||||
"hit": False,
|
||||
"scope": "request",
|
||||
"prefix_len": prefix_context.prefix_len,
|
||||
"grouped": True,
|
||||
"batch_size": len(group_batches),
|
||||
}
|
||||
timings = vla_timings(batch)
|
||||
timings["cache_lookup_ms"] = 0.0
|
||||
timings["prefix_ms"] = prefix_ms
|
||||
results[index] = batch
|
||||
|
||||
if (
|
||||
server_args.pipeline_config.empty_cache_after_prefix
|
||||
and torch.cuda.is_available()
|
||||
):
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return [result for result in results if result is not None]
|
||||
|
||||
def _recv_prefix_result(self, batch: Req, split: Any) -> Req:
|
||||
state = vla_state(batch)
|
||||
state["prefix_context"] = broadcast_prefix_context(
|
||||
None,
|
||||
split,
|
||||
src=split.prefix_root,
|
||||
)
|
||||
state["cache"] = split.broadcast_object_from_rank(
|
||||
None,
|
||||
src=split.prefix_root,
|
||||
)
|
||||
timings = split.broadcast_object_from_rank(None, src=split.prefix_root)
|
||||
vla_timings(batch).update(timings)
|
||||
return batch
|
||||
|
||||
def _send_prefix_result(
|
||||
self,
|
||||
batch: Req,
|
||||
split: Any,
|
||||
prefix_context: Any,
|
||||
) -> None:
|
||||
broadcast_prefix_context(
|
||||
prefix_context,
|
||||
split,
|
||||
src=split.prefix_root,
|
||||
)
|
||||
split.broadcast_object_from_rank(
|
||||
vla_state(batch)["cache"],
|
||||
src=split.prefix_root,
|
||||
)
|
||||
split.broadcast_object_from_rank(
|
||||
vla_timings(batch),
|
||||
src=split.prefix_root,
|
||||
)
|
||||
|
||||
def get_cached_context(
|
||||
self, batch: Req, server_args: ServerArgs, observation: Any
|
||||
) -> tuple[str, PrefixContext]:
|
||||
"""try querying the cache for PrefixContext with prefix cache key built from observations and other keys"""
|
||||
cache_enabled = _effective_prefix_cache_enabled(batch, server_args)
|
||||
if cache_enabled:
|
||||
cache_key = self.policy_model.build_prefix_cache_key(observation)
|
||||
cached_context = self.prefix_cache.get(cache_key)
|
||||
else:
|
||||
cache_key = None
|
||||
cached_context = None
|
||||
return cache_key, cached_context
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
state = vla_state(batch)
|
||||
if batch.is_warmup:
|
||||
state["prefix_context"] = None
|
||||
state["cache"] = {"hit": False, "warmup": True}
|
||||
return batch
|
||||
|
||||
split = get_vla_split_group()
|
||||
if split is not None and not split.is_prefix_rank:
|
||||
return self._recv_prefix_result(batch, split)
|
||||
|
||||
observation = state["observation_batch"]
|
||||
cache_start = time.perf_counter()
|
||||
cache_enabled = _effective_prefix_cache_enabled(batch, server_args)
|
||||
|
||||
# 1. try querying the per-request LRU prefix kv cache
|
||||
cache_key, cached_context = self.get_cached_context(
|
||||
batch, server_args, observation
|
||||
)
|
||||
|
||||
vla_timings(batch)["cache_lookup_ms"] = (
|
||||
time.perf_counter() - cache_start
|
||||
) * 1000
|
||||
|
||||
# 2. prepare VLAState
|
||||
if cached_context is not None:
|
||||
state["prefix_context"] = cached_context
|
||||
state["cache"] = {
|
||||
"hit": True,
|
||||
"scope": "global",
|
||||
"mode": "exact",
|
||||
"prefix_len": cached_context.prefix_len,
|
||||
}
|
||||
if split is not None:
|
||||
self._send_prefix_result(batch, split, cached_context)
|
||||
return batch
|
||||
|
||||
prefix_start = time.perf_counter()
|
||||
|
||||
# 3. run encoding
|
||||
prefix_context = self.policy_model.encode_prefix(observation)
|
||||
if cache_key is not None:
|
||||
prefix_context.cache_key_digest = cache_key
|
||||
|
||||
vla_timings(batch)["prefix_ms"] = (time.perf_counter() - prefix_start) * 1000
|
||||
state["prefix_context"] = prefix_context
|
||||
state["cache"] = {
|
||||
"hit": False,
|
||||
"scope": "global" if cache_enabled else "request",
|
||||
"mode": "exact" if cache_enabled else "disabled",
|
||||
"prefix_len": prefix_context.prefix_len,
|
||||
}
|
||||
|
||||
# 4. update prefix kv cache
|
||||
if cache_key is not None:
|
||||
self.prefix_cache.put(cache_key, prefix_context)
|
||||
if split is not None:
|
||||
self._send_prefix_result(batch, split, prefix_context)
|
||||
if (
|
||||
server_args.pipeline_config.empty_cache_after_prefix
|
||||
and torch.cuda.is_available()
|
||||
):
|
||||
torch.cuda.empty_cache()
|
||||
return batch
|
||||
|
||||
|
||||
class VLAActionDenoisingStage(PipelineStage):
|
||||
def __init__(self, policy_model: Any):
|
||||
super().__init__()
|
||||
self.policy_model = policy_model
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.DENOISER
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> list[Req]:
|
||||
results: list[Req | None] = [None] * len(batches)
|
||||
|
||||
def action_fingerprint(batch: Req) -> tuple[Any, ...]:
|
||||
prefix_context = vla_state(batch).get("prefix_context_group")
|
||||
if prefix_context is None:
|
||||
return ("single", id(batch))
|
||||
return (
|
||||
"grouped",
|
||||
id(prefix_context),
|
||||
batch.num_inference_steps,
|
||||
str(vla_options(batch).get("output_format") or "list"),
|
||||
)
|
||||
|
||||
for _, group in self._group_requests_by_fingerprint(
|
||||
batches,
|
||||
action_fingerprint,
|
||||
):
|
||||
group_batches = [batch for _, batch in group]
|
||||
prefix_context = vla_state(group_batches[0]).get("prefix_context_group")
|
||||
if len(group_batches) == 1 or prefix_context is None:
|
||||
for index, batch in group:
|
||||
results[index] = self(batch, server_args)
|
||||
continue
|
||||
|
||||
start = time.perf_counter()
|
||||
options = vla_options(group_batches[0])
|
||||
observation = vla_state(group_batches[0])["observation_group"]
|
||||
actions = self.policy_model.sample_actions(
|
||||
observation,
|
||||
prefix_context,
|
||||
noise=observation.noise,
|
||||
num_steps=group_batches[0].num_inference_steps,
|
||||
use_cuda_graph=bool(options.get("enable_cuda_graph", True)),
|
||||
generator=None,
|
||||
)
|
||||
synchronize_vla_action_tensor(actions)
|
||||
actions_out = materialize_vla_action_batch(
|
||||
actions,
|
||||
server_args.pipeline_config.output_action_dim,
|
||||
str(options.get("output_format") or "list"),
|
||||
)
|
||||
action_ms = (time.perf_counter() - start) * 1000
|
||||
parallel_info = self.policy_model.action_parallel_info(prefix_context)
|
||||
|
||||
for offset, (index, batch) in enumerate(group):
|
||||
state = vla_state(batch)
|
||||
vla_timings(batch)["action_denoise_ms"] = action_ms
|
||||
state["parallel"] = parallel_info
|
||||
state["actions_output"] = actions_out[offset]
|
||||
results[index] = batch
|
||||
|
||||
return [result for result in results if result is not None]
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
start = time.perf_counter()
|
||||
state = vla_state(batch)
|
||||
observation = state.get("observation_batch")
|
||||
split = get_vla_split_group()
|
||||
prefix_context = state.get("prefix_context")
|
||||
should_run_action = (
|
||||
split is None or self.policy_model.should_run_action_denoise(prefix_context)
|
||||
)
|
||||
parallel_info = self.policy_model.action_parallel_info(prefix_context)
|
||||
if batch.is_warmup:
|
||||
actions = (
|
||||
self.policy_model.warmup_actions(batch_size=1)
|
||||
if should_run_action
|
||||
else None
|
||||
)
|
||||
elif should_run_action:
|
||||
# broadcast PrefixContext from action root rank to action ranks
|
||||
options = vla_options(batch)
|
||||
noise = observation.noise if observation is not None else None
|
||||
actions = self.policy_model.sample_actions(
|
||||
observation,
|
||||
prefix_context,
|
||||
noise=noise,
|
||||
num_steps=batch.num_inference_steps,
|
||||
use_cuda_graph=bool(options.get("enable_cuda_graph", True)),
|
||||
generator=batch.generator,
|
||||
)
|
||||
synchronize_vla_action_tensor(actions)
|
||||
else:
|
||||
actions = None
|
||||
|
||||
if split is not None:
|
||||
if should_run_action:
|
||||
vla_timings(batch)["action_denoise_ms"] = (
|
||||
time.perf_counter() - start
|
||||
) * 1000
|
||||
actions = broadcast_tensor_from_rank(
|
||||
actions,
|
||||
split,
|
||||
src=split.action_root,
|
||||
device=self.policy_model.device,
|
||||
)
|
||||
timings = split.broadcast_object_from_rank(
|
||||
vla_timings(batch) if should_run_action else None,
|
||||
src=split.action_root,
|
||||
)
|
||||
vla_timings(batch).update(timings)
|
||||
parallel_info = split.broadcast_object_from_rank(
|
||||
parallel_info if should_run_action else None,
|
||||
src=split.action_root,
|
||||
)
|
||||
else:
|
||||
vla_timings(batch)["action_denoise_ms"] = (
|
||||
time.perf_counter() - start
|
||||
) * 1000
|
||||
state["parallel"] = parallel_info
|
||||
state["actions"] = actions
|
||||
return batch
|
||||
|
||||
|
||||
class VLAActionPostprocessStage(PipelineStage):
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.DENOISER
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> OutputBatch:
|
||||
start = time.perf_counter()
|
||||
state = vla_state(batch)
|
||||
action_dim = server_args.pipeline_config.output_action_dim
|
||||
options = vla_options(batch)
|
||||
actions_out = state.get("actions_output")
|
||||
if actions_out is None:
|
||||
action_batch = materialize_vla_action_batch(
|
||||
state["actions"],
|
||||
action_dim,
|
||||
str(options.get("output_format") or "list"),
|
||||
)
|
||||
actions_out = (
|
||||
action_batch[0] if isinstance(action_batch, list) else action_batch
|
||||
)
|
||||
if isinstance(action_batch, np.ndarray):
|
||||
actions_out = action_batch[0]
|
||||
|
||||
payload = {
|
||||
"request_id": batch.request_id,
|
||||
"actions": actions_out,
|
||||
}
|
||||
payload["parameters"] = {"num_inference_steps": batch.num_inference_steps}
|
||||
if options.get("return_timing", True):
|
||||
timings = dict(vla_timings(batch))
|
||||
timings["postprocess_ms"] = (time.perf_counter() - start) * 1000
|
||||
payload["timings"] = timings
|
||||
if not batch.is_warmup:
|
||||
payload["cache"] = state.get("cache", {})
|
||||
if state.get("parallel") is not None:
|
||||
payload["parallel"] = state["parallel"]
|
||||
|
||||
return OutputBatch(
|
||||
output=[payload],
|
||||
metrics=batch.metrics,
|
||||
)
|
||||
@@ -398,6 +398,8 @@ class ServerArgsAutoTuner:
|
||||
|
||||
def _default_layerwise_components_for_unset_placement(self) -> list[str]:
|
||||
args = self.server_args
|
||||
if args.pipeline_config.task_type.is_action_gen():
|
||||
return []
|
||||
if (
|
||||
args.is_arg_explicitly_set("layerwise_offload_components")
|
||||
or args.dit_layerwise_offload is True
|
||||
|
||||
@@ -615,6 +615,17 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
# CPU platform does not need offload
|
||||
return
|
||||
|
||||
if self.pipeline_config.task_type.is_action_gen():
|
||||
if self.dit_cpu_offload is None:
|
||||
self.dit_cpu_offload = False
|
||||
if self.text_encoder_cpu_offload is None:
|
||||
self.text_encoder_cpu_offload = False
|
||||
if self.image_encoder_cpu_offload is None:
|
||||
self.image_encoder_cpu_offload = False
|
||||
if self.vae_cpu_offload is None:
|
||||
self.vae_cpu_offload = False
|
||||
return
|
||||
|
||||
# TODO: to be handled by each platform
|
||||
if current_platform.get_device_total_memory() / BYTES_PER_GB < 30:
|
||||
logger.info(
|
||||
@@ -1106,6 +1117,20 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
self.use_fsdp_inference = False
|
||||
self.dit_layerwise_offload = False
|
||||
self.layerwise_offload_components = None
|
||||
if (
|
||||
self.dit_cpu_offload
|
||||
or self.text_encoder_cpu_offload
|
||||
or self.image_encoder_cpu_offload
|
||||
or self.vae_cpu_offload
|
||||
):
|
||||
logger.warning(
|
||||
"Disabling component CPU offload on MPS because CPU-to-MPS "
|
||||
"module relocation can produce invalid diffusion outputs."
|
||||
)
|
||||
self.dit_cpu_offload = False
|
||||
self.text_encoder_cpu_offload = False
|
||||
self.image_encoder_cpu_offload = False
|
||||
self.vae_cpu_offload = False
|
||||
|
||||
def is_arg_explicitly_set(self, arg_name: str) -> bool:
|
||||
return arg_name in self._explicit_arg_names
|
||||
@@ -1283,12 +1308,14 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pipeline",
|
||||
"--pipeline-class-name",
|
||||
dest="pipeline_class_name",
|
||||
type=str,
|
||||
default=ServerArgs.pipeline_class_name,
|
||||
help=(
|
||||
"Override pipeline class selection from model_index.json. "
|
||||
"Must match a registered pipeline_name."
|
||||
"Advanced override for pipeline class selection from the model registry "
|
||||
"or model_index.json. Must match a registered pipeline_name."
|
||||
),
|
||||
)
|
||||
# attention
|
||||
@@ -2173,7 +2200,7 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
|
||||
# Create a set of argument names that were present on the command line.
|
||||
# This handles both styles: '--arg=value' and '--arg value'.
|
||||
provided_arg_names = set()
|
||||
provided_arg_names = set(getattr(args, "_sglang_explicit_arg_names", ()))
|
||||
for arg in raw_argv:
|
||||
if arg.startswith("--"):
|
||||
# For '--arg=value', this gets 'arg'; for '--arg', this also gets 'arg'.
|
||||
@@ -2192,6 +2219,8 @@ class ServerArgs(DisaggServerArgsMixin):
|
||||
|
||||
# Populate provided_args if the argument from the namespace was on the command line.
|
||||
for k, v in vars(args).items():
|
||||
if k.startswith("_sglang_"):
|
||||
continue
|
||||
if k in provided_arg_names:
|
||||
provided_args[k] = v
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.warmup_request_builder import (
|
||||
build_warmup_reqs,
|
||||
should_include_warmup_image,
|
||||
supports_synthetic_warmup,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -85,13 +86,19 @@ def is_realtime_serving(server_args: ServerArgs) -> bool:
|
||||
|
||||
|
||||
def should_run_synthetic_server_warmup(server_args: ServerArgs) -> bool:
|
||||
return should_run_server_warmup(server_args) and not is_realtime_serving(
|
||||
server_args
|
||||
return (
|
||||
should_run_server_warmup(server_args)
|
||||
and supports_synthetic_warmup(server_args)
|
||||
and not is_realtime_serving(server_args)
|
||||
)
|
||||
|
||||
|
||||
def should_run_explicit_client_warmup(server_args: ServerArgs) -> bool:
|
||||
return server_args.warmup and server_args.warmup_resolutions is not None
|
||||
return (
|
||||
server_args.warmup
|
||||
and server_args.warmup_resolutions is not None
|
||||
and supports_synthetic_warmup(server_args)
|
||||
)
|
||||
|
||||
|
||||
def format_warmup_req(req_or_group: Any) -> str:
|
||||
@@ -102,9 +109,12 @@ def format_warmup_req(req_or_group: Any) -> str:
|
||||
if req is None:
|
||||
return prefix
|
||||
|
||||
shape = f"{req.width}x{req.height}"
|
||||
if req.num_frames is not None and req.num_frames > 1:
|
||||
shape += f"x{req.num_frames}f"
|
||||
width = getattr(req, "width", None)
|
||||
height = getattr(req, "height", None)
|
||||
shape = "action" if width is None or height is None else f"{width}x{height}"
|
||||
num_frames = getattr(req, "num_frames", None)
|
||||
if num_frames is not None and num_frames > 1:
|
||||
shape += f"x{num_frames}f"
|
||||
|
||||
default_steps = req.extra.get("cache_dit_num_inference_steps")
|
||||
if default_steps is not None and default_steps != req.num_inference_steps:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Shared VLA runtime contracts and execution infrastructure."""
|
||||
@@ -0,0 +1,213 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import (
|
||||
PrefixContext,
|
||||
VLADensePrefixCache,
|
||||
)
|
||||
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
|
||||
set_graph_pool_id,
|
||||
)
|
||||
from sglang.srt.model_executor.runner_utils.pool import (
|
||||
get_or_create_global_graph_memory_pool,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VLADenoiseGraphSignature:
|
||||
batch_size: int
|
||||
prefix_len: int
|
||||
action_horizon: int
|
||||
action_dim: int
|
||||
dtype: str
|
||||
parallel_layout: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CapturedDenoiseGraph:
|
||||
graph: torch.cuda.CUDAGraph
|
||||
static_prefix_context: PrefixContext
|
||||
static_x_t: torch.Tensor
|
||||
static_timestep: torch.Tensor
|
||||
static_output: torch.Tensor
|
||||
current_context_id: int | None = None
|
||||
current_context_digest: str | None = None
|
||||
|
||||
|
||||
def _clone_past_key_values(past_key_values: Any) -> Any:
|
||||
return VLADensePrefixCache(
|
||||
tuple(
|
||||
(keys.detach().clone(), values.detach().clone(), sliding_window)
|
||||
for keys, values, sliding_window in past_key_values
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _copy_past_key_values_(dst: Any, src: Any) -> None:
|
||||
for (dst_keys, dst_values, _), (src_keys, src_values, _) in zip(
|
||||
dst, src, strict=True
|
||||
):
|
||||
dst_keys.copy_(src_keys)
|
||||
dst_values.copy_(src_values)
|
||||
|
||||
|
||||
def _clone_prefix_context(prefix_context: PrefixContext) -> PrefixContext:
|
||||
return PrefixContext(
|
||||
past_key_values=_clone_past_key_values(prefix_context.past_key_values),
|
||||
prefix_pad_masks=prefix_context.prefix_pad_masks.detach().clone(),
|
||||
prefix_len=prefix_context.prefix_len,
|
||||
layout=dict(prefix_context.layout),
|
||||
cache_key_digest=prefix_context.cache_key_digest,
|
||||
)
|
||||
|
||||
|
||||
def _copy_prefix_context_(dst: PrefixContext, src: PrefixContext) -> None:
|
||||
dst.prefix_pad_masks.copy_(src.prefix_pad_masks)
|
||||
_copy_past_key_values_(dst.past_key_values, src.past_key_values)
|
||||
dst.cache_key_digest = src.cache_key_digest
|
||||
|
||||
|
||||
class VLADenoiseGraphRunner:
|
||||
"""Full CUDA graph runner for one VLA action-denoise step.
|
||||
|
||||
Each signature owns fixed input and output buffers. This does not use
|
||||
diffusion BCG and does not capture prefix encoding or token decode.
|
||||
"""
|
||||
|
||||
def __init__(self, enabled: bool = True):
|
||||
self.enabled = enabled
|
||||
self._captured: dict[VLADenoiseGraphSignature, _CapturedDenoiseGraph] = {}
|
||||
self._disabled_signatures: set[VLADenoiseGraphSignature] = set()
|
||||
self._capture_stream: torch.cuda.Stream | None = None
|
||||
self._graph_pool: Any = None
|
||||
|
||||
def _sync_context_if_needed(
|
||||
self,
|
||||
captured: _CapturedDenoiseGraph,
|
||||
prefix_context: PrefixContext,
|
||||
) -> None:
|
||||
context_id = id(prefix_context.past_key_values)
|
||||
context_digest = prefix_context.cache_key_digest
|
||||
if (
|
||||
context_digest is not None
|
||||
and captured.current_context_digest == context_digest
|
||||
):
|
||||
captured.current_context_id = context_id
|
||||
return
|
||||
if captured.current_context_id == context_id:
|
||||
return
|
||||
_copy_prefix_context_(captured.static_prefix_context, prefix_context)
|
||||
captured.current_context_id = context_id
|
||||
captured.current_context_digest = context_digest
|
||||
|
||||
def _capture(
|
||||
self,
|
||||
signature: VLADenoiseGraphSignature,
|
||||
step_fn: Callable[..., torch.Tensor],
|
||||
prefix_context: PrefixContext,
|
||||
x_t: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
) -> _CapturedDenoiseGraph:
|
||||
static_prefix_context = _clone_prefix_context(prefix_context)
|
||||
static_x_t = x_t.detach().clone()
|
||||
static_timestep = timestep.detach().clone()
|
||||
|
||||
device_module = torch.get_device_module(x_t.device)
|
||||
if self._capture_stream is None:
|
||||
self._capture_stream = device_module.Stream(device=x_t.device)
|
||||
if self._graph_pool is None:
|
||||
self._graph_pool = get_or_create_global_graph_memory_pool(device_module)
|
||||
set_graph_pool_id(self._graph_pool)
|
||||
|
||||
# warm up lazy kernels and workspaces before capture
|
||||
device_module.synchronize()
|
||||
with device_module.stream(self._capture_stream), torch.inference_mode():
|
||||
step_fn(
|
||||
static_prefix_context,
|
||||
static_x_t,
|
||||
static_timestep,
|
||||
)
|
||||
self._capture_stream.synchronize()
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with (
|
||||
device_module.graph(
|
||||
cuda_graph=graph,
|
||||
pool=self._graph_pool,
|
||||
stream=self._capture_stream,
|
||||
),
|
||||
torch.inference_mode(),
|
||||
):
|
||||
static_output = step_fn(
|
||||
static_prefix_context,
|
||||
static_x_t,
|
||||
static_timestep,
|
||||
)
|
||||
self._capture_stream.synchronize()
|
||||
|
||||
captured = _CapturedDenoiseGraph(
|
||||
graph=graph,
|
||||
static_prefix_context=static_prefix_context,
|
||||
static_x_t=static_x_t,
|
||||
static_timestep=static_timestep,
|
||||
static_output=static_output,
|
||||
current_context_id=id(prefix_context.past_key_values),
|
||||
current_context_digest=prefix_context.cache_key_digest,
|
||||
)
|
||||
self._captured[signature] = captured
|
||||
logger.info(
|
||||
"Captured VLA denoise CUDA graph: batch=%d prefix=%d action=%dx%d "
|
||||
"dtype=%s",
|
||||
signature.batch_size,
|
||||
signature.prefix_len,
|
||||
signature.action_horizon,
|
||||
signature.action_dim,
|
||||
signature.dtype,
|
||||
)
|
||||
return captured
|
||||
|
||||
def capture_or_run(
|
||||
self,
|
||||
signature: VLADenoiseGraphSignature,
|
||||
step_fn: Callable[..., torch.Tensor],
|
||||
prefix_context: PrefixContext,
|
||||
x_t: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
if not self.enabled or signature in self._disabled_signatures:
|
||||
return step_fn(prefix_context, x_t, timestep)
|
||||
|
||||
if x_t.device.type != "cuda":
|
||||
return step_fn(prefix_context, x_t, timestep)
|
||||
|
||||
captured = self._captured.get(signature)
|
||||
try:
|
||||
if captured is None:
|
||||
captured = self._capture(
|
||||
signature, step_fn, prefix_context, x_t, timestep
|
||||
)
|
||||
captured.graph.replay()
|
||||
else:
|
||||
self._sync_context_if_needed(captured, prefix_context)
|
||||
captured.static_x_t.copy_(x_t)
|
||||
captured.static_timestep.copy_(timestep)
|
||||
captured.graph.replay()
|
||||
return captured.static_output
|
||||
except Exception:
|
||||
self._disabled_signatures.add(signature)
|
||||
self._captured.pop(signature, None)
|
||||
logger.warning(
|
||||
"VLA denoise CUDA graph disabled for signature %s",
|
||||
signature,
|
||||
exc_info=True,
|
||||
)
|
||||
return step_fn(prefix_context, x_t, timestep)
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.managers.mm_utils import tensor_hash
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLAObservationBatch:
|
||||
prompt: list[str]
|
||||
images: dict[str, torch.Tensor]
|
||||
image_masks: dict[str, torch.Tensor]
|
||||
state: torch.Tensor | None
|
||||
noise: torch.Tensor | None
|
||||
tokens: torch.Tensor
|
||||
token_masks: torch.Tensor
|
||||
batch_size: int
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def tensor_fingerprint(tensor: torch.Tensor) -> str:
|
||||
"""Hash tensor content with SRT's CPU/CUDA implementation."""
|
||||
|
||||
shape = ",".join(str(dim) for dim in tensor.shape)
|
||||
return f"{tensor.dtype}:{shape}:{tensor_hash(tensor):016x}"
|
||||
|
||||
|
||||
def collate_vla_observation_batches(
|
||||
observations: list[VLAObservationBatch],
|
||||
) -> VLAObservationBatch:
|
||||
first = observations[0]
|
||||
camera_order = tuple(first.metadata.get("camera_order", ()))
|
||||
images = {
|
||||
name: torch.cat([obs.images[name] for obs in observations], dim=0)
|
||||
for name in camera_order
|
||||
}
|
||||
image_masks = {
|
||||
name: torch.cat([obs.image_masks[name] for obs in observations], dim=0)
|
||||
for name in camera_order
|
||||
}
|
||||
states = [obs.state for obs in observations]
|
||||
noises = [obs.noise for obs in observations]
|
||||
if any(item is None for item in states) and not all(
|
||||
item is None for item in states
|
||||
):
|
||||
raise ValueError("Cannot collate mixed VLA state presence")
|
||||
if any(item is None for item in noises) and not all(
|
||||
item is None for item in noises
|
||||
):
|
||||
raise ValueError("Cannot collate mixed VLA noise presence")
|
||||
state = (
|
||||
None
|
||||
if states[0] is None
|
||||
else torch.cat([item for item in states if item is not None], dim=0)
|
||||
)
|
||||
noise = (
|
||||
None
|
||||
if noises[0] is None
|
||||
else torch.cat([item for item in noises if item is not None], dim=0)
|
||||
)
|
||||
return VLAObservationBatch(
|
||||
prompt=[prompt for obs in observations for prompt in obs.prompt],
|
||||
images=images,
|
||||
image_masks=image_masks,
|
||||
state=state,
|
||||
noise=noise,
|
||||
tokens=torch.cat([obs.tokens for obs in observations], dim=0),
|
||||
token_masks=torch.cat([obs.token_masks for obs in observations], dim=0),
|
||||
batch_size=len(observations),
|
||||
metadata={"camera_order": camera_order},
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
get_sp_group,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.distributed.group_coordinator import GroupCoordinator
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import get_world_rank
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import (
|
||||
PrefixContext,
|
||||
VLADensePrefixCache,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VLASplitGroup:
|
||||
"""Runtime view for VLA prefix/action split execution.
|
||||
|
||||
This reuses the existing SP group as the coordination group. It is not a
|
||||
separate parallel topology:
|
||||
1. `prefix_root` computes/fetches PrefixContext and broadcasts it once.
|
||||
2. `action_root` owns fallback action denoise and initial noise broadcast.
|
||||
3. `action_ranks` may all participate in action SP when the policy allows it.
|
||||
|
||||
All rank fields are global ranks; GroupCoordinator APIs take group-local
|
||||
ranks, so call `group_rank_for` before collective helpers.
|
||||
"""
|
||||
|
||||
group: GroupCoordinator
|
||||
prefix_root: int
|
||||
action_root: int
|
||||
action_ranks: tuple[int, ...]
|
||||
rank: int
|
||||
|
||||
@property
|
||||
def is_prefix_rank(self) -> bool:
|
||||
return self.rank == self.prefix_root
|
||||
|
||||
@property
|
||||
def is_action_rank(self) -> bool:
|
||||
return self.rank in self.action_ranks
|
||||
|
||||
@property
|
||||
def uses_action_sp(self) -> bool:
|
||||
return len(self.action_ranks) > 1
|
||||
|
||||
def group_rank_for(self, global_rank: int) -> int:
|
||||
return self.group.ranks.index(global_rank)
|
||||
|
||||
def broadcast_object_from_rank(self, obj, *, src: int):
|
||||
return self.group.broadcast_object(
|
||||
obj if self.rank == src else None,
|
||||
src=self.group_rank_for(src),
|
||||
)
|
||||
|
||||
|
||||
def get_vla_split_group() -> VLASplitGroup | None:
|
||||
if not dist.is_available() or not dist.is_initialized():
|
||||
return None
|
||||
if not model_parallel_is_initialized():
|
||||
return None
|
||||
group = get_sp_group()
|
||||
if group.world_size <= 1:
|
||||
return None
|
||||
# v1 maps the split view onto SP: first rank does prefix encode, last rank
|
||||
# is the action fallback/root, and all SP ranks are eligible action ranks.
|
||||
return VLASplitGroup(
|
||||
group=group,
|
||||
prefix_root=group.ranks[0],
|
||||
action_root=group.ranks[-1],
|
||||
action_ranks=tuple(group.ranks),
|
||||
rank=get_world_rank(),
|
||||
)
|
||||
|
||||
|
||||
def broadcast_tensor_from_rank(
|
||||
tensor: torch.Tensor | None,
|
||||
split: VLASplitGroup,
|
||||
*,
|
||||
src: int,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor | None:
|
||||
payload = (
|
||||
{"is_none": tensor is None, "tensor": tensor} if split.rank == src else None
|
||||
)
|
||||
payload = split.group.broadcast_tensor_dict(
|
||||
payload,
|
||||
src=split.group_rank_for(src),
|
||||
)
|
||||
if payload["is_none"]:
|
||||
return None
|
||||
output = payload["tensor"]
|
||||
if output.device != device:
|
||||
output = output.to(device)
|
||||
return output
|
||||
|
||||
|
||||
def broadcast_prefix_context(
|
||||
context: PrefixContext | None,
|
||||
split: VLASplitGroup,
|
||||
*,
|
||||
src: int,
|
||||
) -> PrefixContext | None:
|
||||
if split.rank == src and context is None:
|
||||
payload = {"is_none": True}
|
||||
elif split.rank == src:
|
||||
prefix_pad_masks = context.prefix_pad_masks
|
||||
prefix_pad_masks_is_bool = prefix_pad_masks.dtype == torch.bool
|
||||
if prefix_pad_masks_is_bool:
|
||||
prefix_pad_masks = prefix_pad_masks.to(torch.uint8)
|
||||
payload = {
|
||||
"is_none": False,
|
||||
"prefix_pad_masks": prefix_pad_masks,
|
||||
"prefix_pad_masks_is_bool": prefix_pad_masks_is_bool,
|
||||
"prefix_len": context.prefix_len,
|
||||
"layout": dict(context.layout),
|
||||
"cache_key_digest": context.cache_key_digest,
|
||||
"num_layers": len(context.past_key_values),
|
||||
}
|
||||
for i, (keys, values, sliding_window) in enumerate(context.past_key_values):
|
||||
payload[f"layer_{i}_keys"] = keys
|
||||
payload[f"layer_{i}_values"] = values
|
||||
payload[f"layer_{i}_sliding_window"] = sliding_window
|
||||
else:
|
||||
payload = None
|
||||
|
||||
payload = split.group.broadcast_tensor_dict(
|
||||
payload,
|
||||
src=split.group_rank_for(src),
|
||||
)
|
||||
if payload["is_none"]:
|
||||
return None
|
||||
|
||||
kv_layers = []
|
||||
for i in range(int(payload["num_layers"])):
|
||||
kv_layers.append(
|
||||
(
|
||||
payload[f"layer_{i}_keys"],
|
||||
payload[f"layer_{i}_values"],
|
||||
payload[f"layer_{i}_sliding_window"],
|
||||
)
|
||||
)
|
||||
|
||||
prefix_pad_masks = payload["prefix_pad_masks"]
|
||||
if payload.get("prefix_pad_masks_is_bool"):
|
||||
prefix_pad_masks = prefix_pad_masks.to(torch.bool)
|
||||
|
||||
return PrefixContext(
|
||||
past_key_values=VLADensePrefixCache(tuple(kv_layers)),
|
||||
prefix_pad_masks=prefix_pad_masks,
|
||||
prefix_len=int(payload["prefix_len"]),
|
||||
layout=dict(payload["layout"]),
|
||||
cache_key_digest=payload["cache_key_digest"],
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrefixContext:
|
||||
"""Request-local observation K/V reused by every action denoise step.
|
||||
|
||||
The optional digest identifies an exact server-level cache entry; suffix K/V
|
||||
is step-dependent and never becomes part of this context.
|
||||
"""
|
||||
|
||||
past_key_values: Any
|
||||
prefix_pad_masks: torch.Tensor
|
||||
prefix_len: int
|
||||
layout: dict[str, Any] = field(default_factory=dict)
|
||||
cache_key_digest: str | None = None
|
||||
|
||||
|
||||
class VLADensePrefixCache:
|
||||
"""a lightweight and naive dense per-layer K/V container for prefix fill and suffix attention.
|
||||
|
||||
Mutable instances collect prefix K/V layer by layer. Read-only instances
|
||||
prepend that fixed K/V to the current suffix K/V without changing storage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
layers: Iterable[tuple[torch.Tensor, torch.Tensor, Any]] | None = None,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
):
|
||||
# cached_keys, cached_values, sliding_window
|
||||
self.layers = list(layers or ())
|
||||
self.read_only = read_only
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.layers)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.layers)
|
||||
|
||||
def __getitem__(self, layer_idx: int):
|
||||
return self.layers[layer_idx]
|
||||
|
||||
def get_seq_length(self) -> int:
|
||||
return 0 if not self.layers else int(self.layers[0][0].shape[-2])
|
||||
|
||||
def get_prefix(self, layer_idx: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
prefix_keys, prefix_values, _ = self.layers[layer_idx]
|
||||
return prefix_keys, prefix_values
|
||||
|
||||
def update(
|
||||
self,
|
||||
key_states: torch.Tensor,
|
||||
value_states: torch.Tensor,
|
||||
layer_idx: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""update the cache with fresh kv from each layer, return the appended prefix kv"""
|
||||
if self.read_only:
|
||||
prefix_keys, prefix_values = self.get_prefix(layer_idx)
|
||||
return (
|
||||
torch.cat([prefix_keys, key_states], dim=-2),
|
||||
torch.cat([prefix_values, value_states], dim=-2),
|
||||
)
|
||||
|
||||
if layer_idx == len(self.layers):
|
||||
self.layers.append((key_states, value_states, None))
|
||||
return key_states, value_states
|
||||
if layer_idx > len(self.layers):
|
||||
raise IndexError(f"Invalid VLA prefix cache layer: {layer_idx}")
|
||||
cached_keys, cached_values, sliding_window = self.layers[layer_idx]
|
||||
key_states = torch.cat([cached_keys, key_states], dim=-2)
|
||||
value_states = torch.cat([cached_values, value_states], dim=-2)
|
||||
self.layers[layer_idx] = (key_states, value_states, sliding_window)
|
||||
return key_states, value_states
|
||||
|
||||
|
||||
def slice_prefix_context(context: PrefixContext, index: int) -> PrefixContext:
|
||||
return PrefixContext(
|
||||
past_key_values=VLADensePrefixCache(
|
||||
tuple(
|
||||
(
|
||||
keys[index : index + 1],
|
||||
values[index : index + 1],
|
||||
sliding_window,
|
||||
)
|
||||
for keys, values, sliding_window in context.past_key_values
|
||||
)
|
||||
),
|
||||
prefix_pad_masks=context.prefix_pad_masks[index : index + 1],
|
||||
prefix_len=context.prefix_len,
|
||||
layout=dict(context.layout),
|
||||
cache_key_digest=context.cache_key_digest,
|
||||
)
|
||||
|
||||
|
||||
class VLAPrefixCacheManager:
|
||||
"""Bounded exact-match LRU for server-level VLA PrefixContext reuse.
|
||||
|
||||
Partial-match prefix cache does not work well VLA scenario (with multiple combinations of keys).
|
||||
|
||||
Request-local denoise reuse does not go through this cache. Partial-prefix
|
||||
K/V reuse is invalid for VLA prefix blocks that use full attention.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 128):
|
||||
self.max_entries = max(0, int(max_entries))
|
||||
self._cache: OrderedDict[str, PrefixContext] = OrderedDict()
|
||||
|
||||
@staticmethod
|
||||
def make_key(
|
||||
*,
|
||||
model_revision: str,
|
||||
tokenizer_id: str,
|
||||
camera_order: tuple[str, ...],
|
||||
image_hashes: dict[str, str],
|
||||
token_digest: str,
|
||||
token_mask_digest: str,
|
||||
masks: dict[str, bool],
|
||||
positions_version: str,
|
||||
dtype: str,
|
||||
parallel_layout_version: str,
|
||||
cache_namespace: str = "vla",
|
||||
) -> str:
|
||||
# hash the effective prefix inputs plus runtime compatibility dimensions
|
||||
payload = {
|
||||
"cache_namespace": cache_namespace,
|
||||
"model_revision": model_revision,
|
||||
"tokenizer_id": tokenizer_id,
|
||||
"camera_order": list(camera_order),
|
||||
"image_hashes": image_hashes,
|
||||
"token_digest": token_digest,
|
||||
"token_mask_digest": token_mask_digest,
|
||||
"masks": masks,
|
||||
"positions_version": positions_version,
|
||||
"dtype": dtype,
|
||||
"parallel_layout_version": parallel_layout_version,
|
||||
}
|
||||
serialized = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
def get(self, key: str) -> PrefixContext | None:
|
||||
context = self._cache.get(key)
|
||||
if context is not None:
|
||||
self._cache.move_to_end(key)
|
||||
return context
|
||||
|
||||
def put(self, key: str, context: PrefixContext) -> None:
|
||||
if self.max_entries == 0:
|
||||
return
|
||||
if len(self._cache) >= self.max_entries and key not in self._cache:
|
||||
self._cache.popitem(last=False)
|
||||
context.cache_key_digest = key
|
||||
self._cache[key] = context
|
||||
self._cache.move_to_end(key)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Build synthetic diffusion warmup requests.
|
||||
"""Build synthetic generation warmup requests.
|
||||
|
||||
Default server warmup should cover a representative serving path before the
|
||||
first real request, without copying user traffic. It starts from the model's
|
||||
@@ -17,10 +17,7 @@ from copy import copy
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.registry import get_pipeline_config_classes
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.server_args import (
|
||||
@@ -146,7 +143,7 @@ def _fallback_warmup_resolution(server_args: ServerArgs) -> tuple[int, int]:
|
||||
|
||||
|
||||
def _is_video_warmup_task(server_args: ServerArgs) -> bool:
|
||||
return server_args.pipeline_config.task_type.data_type() == DataType.VIDEO
|
||||
return server_args.pipeline_config.task_type.is_video_gen()
|
||||
|
||||
|
||||
def _warmup_resolution_alignment(server_args: ServerArgs) -> int:
|
||||
@@ -234,7 +231,7 @@ def _resolve_warmup_num_frames(
|
||||
*,
|
||||
server_based_warmup: bool,
|
||||
) -> int:
|
||||
num_frames = sampling_defaults.num_frames
|
||||
num_frames = getattr(sampling_defaults, "num_frames", 1)
|
||||
if (
|
||||
not server_based_warmup
|
||||
or not _is_video_warmup_task(server_args)
|
||||
@@ -247,9 +244,9 @@ def _resolve_warmup_num_frames(
|
||||
|
||||
|
||||
def _effective_cfg_scale(sampling_defaults: SamplingParams) -> float | None:
|
||||
if sampling_defaults.true_cfg_scale is not None:
|
||||
if getattr(sampling_defaults, "true_cfg_scale", None) is not None:
|
||||
return sampling_defaults.true_cfg_scale
|
||||
return sampling_defaults.guidance_scale
|
||||
return getattr(sampling_defaults, "guidance_scale", None)
|
||||
|
||||
|
||||
def _resolve_warmup_steps(
|
||||
@@ -295,6 +292,8 @@ def should_include_warmup_image(
|
||||
server_args: ServerArgs, server_based_warmup: bool
|
||||
) -> bool:
|
||||
task_type = server_args.pipeline_config.task_type
|
||||
if not supports_synthetic_warmup(server_args):
|
||||
return False
|
||||
if not task_type.accepts_image_input():
|
||||
return False
|
||||
if task_type.requires_image_input():
|
||||
@@ -306,6 +305,11 @@ def should_include_warmup_image(
|
||||
return True
|
||||
|
||||
|
||||
def supports_synthetic_warmup(server_args: ServerArgs) -> bool:
|
||||
task_type = server_args.pipeline_config.task_type
|
||||
return task_type.is_visual_gen() or task_type.is_mesh_gen()
|
||||
|
||||
|
||||
def build_warmup_reqs(
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
@@ -315,6 +319,8 @@ def build_warmup_reqs(
|
||||
server_based_warmup: bool = False,
|
||||
) -> list[Req]:
|
||||
task_type = server_args.pipeline_config.task_type
|
||||
if not supports_synthetic_warmup(server_args):
|
||||
return []
|
||||
sampling_defaults = get_model_sampling_defaults(server_args)
|
||||
|
||||
if warmup_resolutions is None:
|
||||
@@ -327,7 +333,7 @@ def build_warmup_reqs(
|
||||
else:
|
||||
resolutions = [parse_size(resolution) for resolution in warmup_resolutions]
|
||||
|
||||
negative_prompt: Any = sampling_defaults.negative_prompt
|
||||
negative_prompt: Any = getattr(sampling_defaults, "negative_prompt", None)
|
||||
cfg_scale = _effective_cfg_scale(sampling_defaults)
|
||||
warmup_steps = _resolve_warmup_steps(
|
||||
server_args,
|
||||
|
||||
@@ -30,6 +30,7 @@ from sglang.multimodal_gen.test.server.testcase_configs import (
|
||||
MULTI_FRAME_I2I_sampling_params,
|
||||
MULTI_IMAGE_TI2I_sampling_params,
|
||||
MULTI_IMAGE_TI2I_UPLOAD_sampling_params,
|
||||
PI05_ACTION_CI_sampling_params,
|
||||
SANA_WM_TI2V_CI_sampling_params,
|
||||
T2I_sampling_params,
|
||||
T2V_sampling_params,
|
||||
@@ -102,6 +103,16 @@ ONE_GPU_CASES: list[DiffusionTestCase] = [
|
||||
run_models_api_check=False,
|
||||
run_t2v_input_reference_check=False,
|
||||
),
|
||||
DiffusionTestCase(
|
||||
"pi05_action_http",
|
||||
DiffusionServerArgs(
|
||||
model_path="lerobot/pi05_base",
|
||||
),
|
||||
PI05_ACTION_CI_sampling_params,
|
||||
run_perf_check=False,
|
||||
run_component_accuracy_check=False,
|
||||
run_t2v_input_reference_check=False,
|
||||
),
|
||||
DiffusionTestCase(
|
||||
"flux_image_t2i",
|
||||
DiffusionServerArgs(model_path=DEFAULT_FLUX_1_DEV_MODEL_NAME_FOR_TEST),
|
||||
|
||||
@@ -7,6 +7,7 @@ Each collected request prints a performance log before validation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
@@ -14,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
import openai
|
||||
import pytest
|
||||
import requests
|
||||
@@ -47,8 +49,11 @@ from sglang.multimodal_gen.test.test_utils import (
|
||||
SGL_TEST_FILES_CI_DATA_REVISION,
|
||||
_consistency_gt_filenames,
|
||||
_get_consistency_gt_dir,
|
||||
action_gt_exists,
|
||||
compare_with_gt,
|
||||
extract_key_frames_from_video,
|
||||
get_action_consistency_gt_candidates,
|
||||
get_action_consistency_gt_remote_files,
|
||||
get_consistency_gt_candidates,
|
||||
get_consistency_gt_remote_files,
|
||||
get_consistency_threshold_path,
|
||||
@@ -56,6 +61,7 @@ from sglang.multimodal_gen.test.test_utils import (
|
||||
get_dynamic_server_port,
|
||||
gt_exists,
|
||||
image_bytes_to_numpy,
|
||||
load_action_consistency_gt,
|
||||
load_consistency_gt,
|
||||
save_consistency_failure_artifact,
|
||||
wait_for_req_perf_record,
|
||||
@@ -593,6 +599,10 @@ class DiffusionServerBase:
|
||||
)
|
||||
return
|
||||
|
||||
if case.server_args.modality == "action":
|
||||
self._validate_action_consistency(case, content)
|
||||
return
|
||||
|
||||
num_gpus = case.server_args.num_gpus
|
||||
is_video = case.server_args.modality == "video"
|
||||
output_format = case.sampling_params.output_format
|
||||
@@ -727,6 +737,88 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
f"max_mean_abs_diff={result.max_mean_abs_diff:.4f})"
|
||||
)
|
||||
|
||||
def _extract_action_array(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
expected_horizon: int,
|
||||
expected_dim: int,
|
||||
) -> np.ndarray:
|
||||
action = payload["data"][0]["action"]
|
||||
values = action["values"]
|
||||
assert action["shape"] == [expected_horizon, expected_dim]
|
||||
array = np.asarray(values, dtype=np.float32)
|
||||
assert array.shape == (expected_horizon, expected_dim)
|
||||
assert np.isfinite(array).all()
|
||||
return array
|
||||
|
||||
def _validate_action_consistency(
|
||||
self,
|
||||
case: DiffusionTestCase,
|
||||
content: bytes,
|
||||
) -> None:
|
||||
payload = json.loads(content.decode("utf-8"))
|
||||
expected_horizon = int(case.sampling_params.extras.get("action_horizon", 50))
|
||||
expected_dim = int(case.sampling_params.extras.get("action_dim", 32))
|
||||
output = self._extract_action_array(payload, expected_horizon, expected_dim)
|
||||
|
||||
num_gpus = case.server_args.num_gpus
|
||||
if not action_gt_exists(case.id, num_gpus):
|
||||
names = ", ".join(get_action_consistency_gt_candidates(case.id, num_gpus))
|
||||
logger.error(f"""
|
||||
--- MISSING ACTION GROUND TRUTH DETECTED ---
|
||||
GT action JSON not found for '{case.id}'.
|
||||
|
||||
Add the expected file to sgl-project/ci-data in diffusion-ci/consistency_gt/sglang_generated/ with naming:
|
||||
Action: {case.id}_{{n}}gpu.json
|
||||
|
||||
For this case, expected file(s): {names}
|
||||
|
||||
Repository: https://github.com/sgl-project/ci-data (path: diffusion-ci/consistency_gt/sglang_generated/, with optional platform subdirectories such as 5090/)
|
||||
Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
""")
|
||||
pytest.fail(
|
||||
f"GT action JSON not found for {case.id}. See logs for instructions to add GT."
|
||||
)
|
||||
|
||||
gt_payload = load_action_consistency_gt(case.id, num_gpus)
|
||||
gt = self._extract_action_array(gt_payload, expected_horizon, expected_dim)
|
||||
abs_diff = np.abs(output - gt)
|
||||
max_abs_diff = float(abs_diff.max())
|
||||
mean_abs_diff = float(abs_diff.mean())
|
||||
max_abs_threshold = float(
|
||||
case.sampling_params.extras.get("action_max_abs_diff_threshold", 0.05)
|
||||
)
|
||||
mean_abs_threshold = float(
|
||||
case.sampling_params.extras.get("action_mean_abs_diff_threshold", 0.005)
|
||||
)
|
||||
|
||||
if max_abs_diff > max_abs_threshold or mean_abs_diff > mean_abs_threshold:
|
||||
gt_remote_info = "\n".join(
|
||||
f" - {filename}: {url}"
|
||||
for filename, url in get_action_consistency_gt_remote_files(
|
||||
case.id,
|
||||
num_gpus,
|
||||
)
|
||||
)
|
||||
pytest.fail(
|
||||
f"Action consistency check failed for {case.id}:\n"
|
||||
f" max_abs_diff={max_abs_diff:.6f} "
|
||||
f"(threshold {max_abs_threshold:.6f})\n"
|
||||
f" mean_abs_diff={mean_abs_diff:.6f} "
|
||||
f"(threshold {mean_abs_threshold:.6f})\n"
|
||||
f" Compared GT files and links:\n{gt_remote_info}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[Consistency] %s: PASSED action GT check "
|
||||
"(shape=%sx%s, max_abs_diff=%.6f, mean_abs_diff=%.6f)",
|
||||
case.id,
|
||||
expected_horizon,
|
||||
expected_dim,
|
||||
max_abs_diff,
|
||||
mean_abs_diff,
|
||||
)
|
||||
|
||||
def _save_gt_output(
|
||||
self,
|
||||
case: DiffusionTestCase,
|
||||
@@ -749,6 +841,12 @@ Pinned revision used by this check: {SGL_TEST_FILES_CI_DATA_REVISION}
|
||||
num_gpus = case.server_args.num_gpus
|
||||
is_video = case.server_args.modality == "video"
|
||||
|
||||
if case.server_args.modality == "action":
|
||||
output_path = out_dir / f"{case.id}_{num_gpus}gpu.json"
|
||||
output_path.write_bytes(content)
|
||||
logger.info(f"Saved GT action JSON: {output_path}")
|
||||
return
|
||||
|
||||
if is_video:
|
||||
# realtime consistency uses websocket raw frames to avoid lossy mp4 drift
|
||||
frames = pop_realtime_key_frames(case.id)
|
||||
|
||||
@@ -854,6 +854,7 @@ VALIDATOR_REGISTRY = {
|
||||
"default": PerformanceValidator,
|
||||
"video": VideoPerformanceValidator,
|
||||
"mesh": MeshValidator,
|
||||
"action": PerformanceValidator,
|
||||
}
|
||||
|
||||
|
||||
@@ -1501,8 +1502,109 @@ def get_generate_fn(
|
||||
|
||||
pytest.fail(f"{case_id}: mesh generation timed out after {max_wait}s")
|
||||
|
||||
def generate_action(case_id, client) -> tuple[str, bytes]:
|
||||
"""VLA action generation using /v1/actions/generations."""
|
||||
import numpy as np
|
||||
import requests as http_requests
|
||||
|
||||
extra = dict(sampling_params.extras)
|
||||
action_horizon = int(extra.get("action_horizon", 50))
|
||||
action_dim = int(extra.get("action_dim", 32))
|
||||
state_dim = int(extra.get("state_dim", action_dim))
|
||||
image_size = int(extra.get("image_size", 64))
|
||||
camera_order = tuple(
|
||||
extra.get(
|
||||
"camera_order",
|
||||
("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb"),
|
||||
)
|
||||
)
|
||||
|
||||
def tensor_payload(array):
|
||||
return {
|
||||
"dtype": str(array.dtype),
|
||||
"shape": list(array.shape),
|
||||
"values": array.tolist(),
|
||||
}
|
||||
|
||||
def image_payload(camera_index: int):
|
||||
y = np.arange(image_size, dtype=np.uint16)[:, None]
|
||||
x = np.arange(image_size, dtype=np.uint16)[None, :]
|
||||
image = np.stack(
|
||||
(
|
||||
(x + camera_index * 17) % 256 + np.zeros_like(y),
|
||||
(y + camera_index * 29) % 256 + np.zeros_like(x),
|
||||
(x + y + camera_index * 41) % 256,
|
||||
),
|
||||
axis=-1,
|
||||
)
|
||||
return tensor_payload(image.astype(np.uint8))
|
||||
|
||||
rng = np.random.default_rng(int(extra.get("seed", 0)))
|
||||
request_id = f"{case_id}-{int(time.time() * 1000)}"
|
||||
payload = {
|
||||
"request_id": request_id,
|
||||
"model": model_path,
|
||||
"input": {
|
||||
"task": sampling_params.prompt or "pick up the blue block",
|
||||
"observation": {
|
||||
"images": {
|
||||
camera: image_payload(index)
|
||||
for index, camera in enumerate(camera_order)
|
||||
},
|
||||
"camera_order": list(camera_order),
|
||||
"state": tensor_payload(
|
||||
np.linspace(-0.5, 0.5, state_dim, dtype=np.float32)
|
||||
),
|
||||
"noise": tensor_payload(
|
||||
rng.standard_normal((action_horizon, action_dim)).astype(
|
||||
np.float32
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
"parameters": {
|
||||
"action_horizon": action_horizon,
|
||||
"action_dim": action_dim,
|
||||
"num_inference_steps": int(extra.get("num_inference_steps", 2)),
|
||||
},
|
||||
"runtime": {
|
||||
"return_timing": True,
|
||||
"prefix_cache": bool(extra.get("enable_prefix_cache", False)),
|
||||
"cuda_graph": bool(extra.get("enable_cuda_graph", True)),
|
||||
"output_format": "list",
|
||||
},
|
||||
}
|
||||
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
endpoint = (
|
||||
f"{base_url}/actions/generations"
|
||||
if base_url.endswith("/v1")
|
||||
else f"{base_url}/v1/actions/generations"
|
||||
)
|
||||
response = http_requests.post(endpoint, json=payload, timeout=600)
|
||||
if response.status_code != 200:
|
||||
pytest.fail(f"{case_id}: action generation failed: {response.text}")
|
||||
|
||||
body = response.json()
|
||||
action = body["data"][0]["action"]
|
||||
if action["shape"] != [action_horizon, action_dim]:
|
||||
pytest.fail(
|
||||
f"{case_id}: action shape mismatch: {action['shape']} "
|
||||
f"!= {[action_horizon, action_dim]}"
|
||||
)
|
||||
values = action["values"]
|
||||
if not all(
|
||||
isinstance(value, (int, float)) and np.isfinite(value)
|
||||
for row in values
|
||||
for value in row
|
||||
):
|
||||
pytest.fail(f"{case_id}: action response contains non-finite values")
|
||||
return body["id"], response.content
|
||||
|
||||
if modality == "3d":
|
||||
fn = generate_mesh
|
||||
elif modality == "action":
|
||||
fn = generate_action
|
||||
elif modality == "video":
|
||||
if sampling_params.realtime_num_chunks is not None:
|
||||
fn = generate_realtime_video
|
||||
|
||||
@@ -167,7 +167,7 @@ class DiffusionServerArgs:
|
||||
"""Configuration for a single model/scenario test case."""
|
||||
|
||||
model_path: str # HF repo or local path
|
||||
modality: str | None = None # auto-inferred: "image" or "video" or "3d"
|
||||
modality: str | None = None # auto-inferred: "image", "video", "3d", or "action"
|
||||
|
||||
custom_validator: str | None = None # auto-derived unless explicitly overridden
|
||||
# resources
|
||||
@@ -208,6 +208,8 @@ class DiffusionServerArgs:
|
||||
self.custom_validator = "video"
|
||||
elif self.modality == "3d":
|
||||
self.custom_validator = "mesh"
|
||||
elif self.modality == "action":
|
||||
self.custom_validator = "action"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
@@ -219,6 +221,8 @@ def _infer_modality_from_model_path(model_path: str) -> str:
|
||||
task_type = model_info.pipeline_config_cls.task_type
|
||||
if task_type == ModelTaskType.I2M:
|
||||
return "3d"
|
||||
if task_type.is_action_gen():
|
||||
return "action"
|
||||
if task_type.is_image_gen():
|
||||
return "image"
|
||||
return "video"
|
||||
@@ -357,6 +361,23 @@ LINGBOT_WORLD_REALTIME_sampling_params = DiffusionSamplingParams(
|
||||
)
|
||||
|
||||
|
||||
PI05_ACTION_CI_sampling_params = DiffusionSamplingParams(
|
||||
prompt="pick up the blue block",
|
||||
extras={
|
||||
"action_horizon": 50,
|
||||
"action_dim": 32,
|
||||
"state_dim": 32,
|
||||
"image_size": 64,
|
||||
"num_inference_steps": 2,
|
||||
"seed": 0,
|
||||
"enable_prefix_cache": False,
|
||||
"enable_cuda_graph": True,
|
||||
"action_max_abs_diff_threshold": 0.05,
|
||||
"action_mean_abs_diff_threshold": 0.005,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sample_step_indices(
|
||||
step_map: dict[int, float], fractions: Sequence[float]
|
||||
) -> list[int]:
|
||||
@@ -646,6 +667,8 @@ def get_default_sampling_params_for_model_task(
|
||||
return TI2V_sampling_params
|
||||
if task_type == ModelTaskType.I2M:
|
||||
return HUNYUAN3D_SHAPE_sampling_params
|
||||
if task_type.is_action_gen():
|
||||
return PI05_ACTION_CI_sampling_params
|
||||
raise ValueError(f"No default sampling params for model task {task_type!r}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("SGLANG_RUN_PI05_E2E") != "1",
|
||||
reason="set SGLANG_RUN_PI05_E2E=1 to run Pi0.5 GPU e2e tests",
|
||||
)
|
||||
|
||||
_MODEL_PATH = os.getenv("SGLANG_PI05_E2E_MODEL", "lerobot/pi05_base")
|
||||
_CAMERA_ORDER = ("base_0_rgb", "left_wrist_0_rgb", "right_wrist_0_rgb")
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
return int(os.getenv(name, str(default)))
|
||||
|
||||
|
||||
def _env_float(name: str) -> float | None:
|
||||
value = os.getenv(name)
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
def _image(camera_index: int) -> np.ndarray:
|
||||
height = width = _env_int("SGLANG_PI05_E2E_IMAGE_SIZE", 224)
|
||||
y = np.arange(height, dtype=np.uint16)[:, None]
|
||||
x = np.arange(width, dtype=np.uint16)[None, :]
|
||||
image = np.stack(
|
||||
(
|
||||
(x + camera_index * 17) % 256 + np.zeros_like(y),
|
||||
(y + camera_index * 29) % 256 + np.zeros_like(x),
|
||||
(x + y + camera_index * 41) % 256,
|
||||
),
|
||||
axis=-1,
|
||||
)
|
||||
return image.astype(np.uint8)
|
||||
|
||||
|
||||
def _action_request_kwargs(tag: str) -> dict:
|
||||
action_horizon = _env_int("SGLANG_PI05_E2E_ACTION_HORIZON", 50)
|
||||
action_dim = _env_int("SGLANG_PI05_E2E_ACTION_DIM", 32)
|
||||
rng = np.random.default_rng(_env_int("SGLANG_PI05_E2E_NOISE_SEED", 0))
|
||||
prompt = os.getenv("SGLANG_PI05_E2E_PROMPT", "pick up the blue block")
|
||||
return {
|
||||
"prompt": f"{prompt} [{tag}]",
|
||||
"images": {name: _image(idx) for idx, name in enumerate(_CAMERA_ORDER)},
|
||||
"camera_order": list(_CAMERA_ORDER),
|
||||
"state": np.linspace(
|
||||
-0.5,
|
||||
0.5,
|
||||
_env_int("SGLANG_PI05_E2E_STATE_DIM", 32),
|
||||
dtype=np.float32,
|
||||
),
|
||||
"noise": rng.standard_normal((action_horizon, action_dim)).astype(np.float32),
|
||||
"action_horizon": action_horizon,
|
||||
"action_dim": action_dim,
|
||||
"num_inference_steps": _env_int("SGLANG_PI05_E2E_NUM_STEPS", 2),
|
||||
"return_timing": True,
|
||||
"enable_prefix_cache": True,
|
||||
"enable_cuda_graph": os.getenv("SGLANG_PI05_E2E_CUDA_GRAPH", "1") != "0",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pi05_generator():
|
||||
num_gpus = _env_int("SGLANG_PI05_E2E_NUM_GPUS", 1)
|
||||
kwargs = {
|
||||
"model_path": _MODEL_PATH,
|
||||
"num_gpus": num_gpus,
|
||||
"warmup": False,
|
||||
"trust_remote_code": False,
|
||||
}
|
||||
if num_gpus > 1:
|
||||
kwargs.update(
|
||||
{
|
||||
"sp_degree": _env_int("SGLANG_PI05_E2E_SP_DEGREE", num_gpus),
|
||||
"ulysses_degree": _env_int(
|
||||
"SGLANG_PI05_E2E_ULYSSES_DEGREE",
|
||||
num_gpus,
|
||||
),
|
||||
"ring_degree": _env_int("SGLANG_PI05_E2E_RING_DEGREE", 1),
|
||||
}
|
||||
)
|
||||
generator = DiffGenerator.from_pretrained(local_mode=True, **kwargs)
|
||||
try:
|
||||
yield generator
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
def _actions(output: dict) -> np.ndarray:
|
||||
return np.asarray(output["actions"], dtype=np.float32)
|
||||
|
||||
|
||||
def _assert_action_output(
|
||||
output: dict, *, expect_cache_hit: bool | None = None
|
||||
) -> None:
|
||||
actions = _actions(output)
|
||||
assert actions.shape[0] == _env_int("SGLANG_PI05_E2E_ACTION_HORIZON", 50)
|
||||
expected_output_dim = os.getenv("SGLANG_PI05_E2E_OUTPUT_ACTION_DIM")
|
||||
if expected_output_dim is not None:
|
||||
assert actions.shape[1] == int(expected_output_dim)
|
||||
else:
|
||||
assert 0 < actions.shape[1] <= _env_int("SGLANG_PI05_E2E_ACTION_DIM", 32)
|
||||
assert np.isfinite(actions).all()
|
||||
timings = output.get("timings") or {}
|
||||
assert timings.get("preprocess_ms", 0.0) >= 0.0
|
||||
assert timings.get("prefix_ms", 0.0) >= 0.0
|
||||
assert timings.get("action_denoise_ms", 0.0) > 0.0
|
||||
assert timings.get("postprocess_ms", 0.0) >= 0.0
|
||||
|
||||
cache = output.get("cache") or {}
|
||||
if expect_cache_hit is not None:
|
||||
assert bool(cache.get("hit")) is expect_cache_hit
|
||||
|
||||
parallel = output.get("parallel") or {}
|
||||
num_gpus = _env_int("SGLANG_PI05_E2E_NUM_GPUS", 1)
|
||||
assert bool(parallel.get("split_group", False)) is (num_gpus > 1)
|
||||
if num_gpus > 1:
|
||||
assert int(parallel["world_size"]) == num_gpus
|
||||
assert parallel["prefix_root"] == 0
|
||||
assert parallel["action_root"] == num_gpus - 1
|
||||
assert bool(parallel.get("action_sequence_parallel")) is True
|
||||
|
||||
|
||||
def test_pi05_python_action_e2e(pi05_generator):
|
||||
output = pi05_generator.generate_action(_action_request_kwargs("e2e"))
|
||||
_assert_action_output(output)
|
||||
|
||||
|
||||
def test_pi05_python_action_consistency(pi05_generator):
|
||||
first = pi05_generator.generate_action(_action_request_kwargs("consistency"))
|
||||
second = pi05_generator.generate_action(_action_request_kwargs("consistency"))
|
||||
_assert_action_output(first, expect_cache_hit=False)
|
||||
_assert_action_output(second, expect_cache_hit=True)
|
||||
|
||||
first_actions = _actions(first)
|
||||
second_actions = _actions(second)
|
||||
np.testing.assert_allclose(
|
||||
first_actions,
|
||||
second_actions,
|
||||
rtol=_env_float("SGLANG_PI05_E2E_CONSISTENCY_RTOL") or 1e-3,
|
||||
atol=_env_float("SGLANG_PI05_E2E_CONSISTENCY_ATOL") or 1e-3,
|
||||
)
|
||||
|
||||
gt_path = os.getenv("SGLANG_PI05_E2E_CONSISTENCY_GT")
|
||||
if gt_path is None:
|
||||
return
|
||||
path = Path(gt_path)
|
||||
if os.getenv("SGLANG_PI05_E2E_UPDATE_GT") == "1":
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.save(path, first_actions)
|
||||
else:
|
||||
np.testing.assert_allclose(
|
||||
first_actions,
|
||||
np.load(path),
|
||||
rtol=_env_float("SGLANG_PI05_E2E_GT_RTOL") or 1e-2,
|
||||
atol=_env_float("SGLANG_PI05_E2E_GT_ATOL") or 1e-2,
|
||||
)
|
||||
|
||||
|
||||
def test_pi05_python_action_perf(pi05_generator):
|
||||
for _ in range(_env_int("SGLANG_PI05_E2E_PERF_WARMUP", 1)):
|
||||
pi05_generator.generate_action(_action_request_kwargs("perf"))
|
||||
|
||||
records = []
|
||||
for _ in range(_env_int("SGLANG_PI05_E2E_PERF_REPEAT", 3)):
|
||||
start = time.perf_counter()
|
||||
output = pi05_generator.generate_action(_action_request_kwargs("perf"))
|
||||
wall_ms = (time.perf_counter() - start) * 1000
|
||||
_assert_action_output(output, expect_cache_hit=True)
|
||||
records.append(
|
||||
{
|
||||
"wall_ms": wall_ms,
|
||||
"timings": output.get("timings") or {},
|
||||
"parallel": output.get("parallel") or {},
|
||||
}
|
||||
)
|
||||
|
||||
wall = [record["wall_ms"] for record in records]
|
||||
denoise = [record["timings"].get("action_denoise_ms", 0.0) for record in records]
|
||||
summary = {
|
||||
"model": _MODEL_PATH,
|
||||
"num_gpus": _env_int("SGLANG_PI05_E2E_NUM_GPUS", 1),
|
||||
"num_steps": _env_int("SGLANG_PI05_E2E_NUM_STEPS", 2),
|
||||
"median_wall_ms": statistics.median(wall),
|
||||
"median_action_denoise_ms": statistics.median(denoise),
|
||||
"records": records,
|
||||
}
|
||||
|
||||
dump_path = os.getenv("SGLANG_PI05_E2E_PERF_DUMP")
|
||||
if dump_path:
|
||||
path = Path(dump_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
|
||||
max_wall_ms = _env_float("SGLANG_PI05_E2E_MAX_WALL_MS")
|
||||
if max_wall_ms is not None:
|
||||
assert summary["median_wall_ms"] <= max_wall_ms
|
||||
max_denoise_ms = _env_float("SGLANG_PI05_E2E_MAX_ACTION_DENOISE_MS")
|
||||
if max_denoise_ms is not None:
|
||||
assert summary["median_action_denoise_ms"] <= max_denoise_ms
|
||||
@@ -1082,6 +1082,30 @@ def get_consistency_gt_candidates(
|
||||
]
|
||||
|
||||
|
||||
def _action_consistency_gt_filenames(case_id: str, num_gpus: int) -> list[str]:
|
||||
case_id = get_consistency_gt_case_id(case_id)
|
||||
return [f"{case_id}_{num_gpus}gpu.json"]
|
||||
|
||||
|
||||
def get_action_consistency_gt_candidate_sets(
|
||||
case_id: str,
|
||||
num_gpus: int,
|
||||
) -> list[list[str]]:
|
||||
candidates = _action_consistency_gt_filenames(case_id, num_gpus)
|
||||
if _is_ascend_consistency_case(case_id) or current_platform.is_npu():
|
||||
return [candidates]
|
||||
platform = get_consistency_platform()
|
||||
return [[f"{platform}/{candidate}" for candidate in candidates], candidates]
|
||||
|
||||
|
||||
def get_action_consistency_gt_candidates(case_id: str, num_gpus: int) -> list[str]:
|
||||
return [
|
||||
candidate
|
||||
for candidate_set in get_action_consistency_gt_candidate_sets(case_id, num_gpus)
|
||||
for candidate in candidate_set
|
||||
]
|
||||
|
||||
|
||||
def get_consistency_gt_remote_files(
|
||||
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
|
||||
) -> list[tuple[str, str]]:
|
||||
@@ -1097,6 +1121,19 @@ def get_consistency_gt_remote_files(
|
||||
)
|
||||
|
||||
|
||||
def get_action_consistency_gt_remote_files(
|
||||
case_id: str, num_gpus: int
|
||||
) -> list[tuple[str, str]]:
|
||||
files = _find_remote_action_consistency_gt_files(case_id, num_gpus)
|
||||
if files:
|
||||
return files
|
||||
filenames = get_action_consistency_gt_candidates(case_id, num_gpus)
|
||||
return [
|
||||
(filename, f"{SGL_TEST_FILES_CONSISTENCY_GT_BASE}/{filename}")
|
||||
for filename in filenames
|
||||
]
|
||||
|
||||
|
||||
def _remote_consistency_gt_candidates(
|
||||
base_url: str,
|
||||
case_id: str,
|
||||
@@ -1300,6 +1337,35 @@ def _find_remote_consistency_gt_files(
|
||||
return []
|
||||
|
||||
|
||||
def _find_remote_action_consistency_gt_files(
|
||||
case_id: str,
|
||||
num_gpus: int,
|
||||
) -> list[tuple[str, str]]:
|
||||
for filenames in get_action_consistency_gt_candidate_sets(case_id, num_gpus):
|
||||
for base_url in _remote_consistency_gt_base_urls(case_id):
|
||||
candidates = [
|
||||
(filename, f"{base_url}/{filename}") for filename in filenames
|
||||
]
|
||||
if _is_official_consistency_gt_base_url(base_url):
|
||||
candidates = [
|
||||
(filename, url)
|
||||
for filename, url in candidates
|
||||
if _official_consistency_gt_candidate_is_declared(case_id, filename)
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
uncertain_candidate = None
|
||||
for filename, url in candidates:
|
||||
exists = _remote_file_exists(url)
|
||||
if exists is True:
|
||||
return [(filename, url)]
|
||||
if exists is None and uncertain_candidate is None:
|
||||
uncertain_candidate = (filename, url)
|
||||
if uncertain_candidate is not None:
|
||||
return [uncertain_candidate]
|
||||
return []
|
||||
|
||||
|
||||
def _get_consistency_gt_dir() -> Path | None:
|
||||
"""Return the local GT directory when configured."""
|
||||
d = os.environ.get("SGLANG_CONSISTENCY_GT_DIR")
|
||||
@@ -1320,6 +1386,13 @@ def _get_consistency_gt_cache_key(
|
||||
return f"{platform}:{case_id}:{num_gpus}:{is_video}:{output_format or ''}:{source}"
|
||||
|
||||
|
||||
def _get_action_consistency_gt_cache_key(case_id: str, num_gpus: int) -> str:
|
||||
gt_dir = _get_consistency_gt_dir()
|
||||
source = str(gt_dir) if gt_dir is not None else "remote"
|
||||
platform = get_consistency_platform()
|
||||
return f"{platform}:{case_id}:{num_gpus}:action:{source}"
|
||||
|
||||
|
||||
def load_consistency_gt(
|
||||
case_id: str,
|
||||
num_gpus: int,
|
||||
@@ -1398,6 +1471,60 @@ def load_consistency_gt(
|
||||
return loaded_gt
|
||||
|
||||
|
||||
def _load_remote_gt_json(url: str) -> dict[str, Any]:
|
||||
last_error: Exception | None = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
resp = requests.get(url, timeout=60)
|
||||
try:
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
last_error = FileNotFoundError(f"GT JSON not found: {url}")
|
||||
if resp.status_code not in (403, 429) and resp.status_code < 500:
|
||||
break
|
||||
finally:
|
||||
resp.close()
|
||||
except (ValueError, requests.RequestException) as exc:
|
||||
last_error = exc
|
||||
raise FileNotFoundError(f"GT JSON not found: {url}") from last_error
|
||||
|
||||
|
||||
def load_action_consistency_gt(case_id: str, num_gpus: int) -> dict[str, Any]:
|
||||
cache_key = _get_action_consistency_gt_cache_key(case_id, num_gpus)
|
||||
cached = _consistency_gt_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
gt_dir = _get_consistency_gt_dir()
|
||||
if gt_dir is not None:
|
||||
path = None
|
||||
for fn in get_action_consistency_gt_candidates(case_id, num_gpus):
|
||||
candidate = gt_dir / fn
|
||||
if candidate.exists():
|
||||
path = candidate
|
||||
break
|
||||
if path is None:
|
||||
candidates = get_action_consistency_gt_candidates(case_id, num_gpus)
|
||||
raise FileNotFoundError(
|
||||
f"GT action JSON not found in {gt_dir}. Tried: {', '.join(candidates)}"
|
||||
)
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
loaded_gt = json.load(f)
|
||||
logger.info("Loaded action GT for %s from %s", case_id, path)
|
||||
else:
|
||||
remote_files = _find_remote_action_consistency_gt_files(case_id, num_gpus)
|
||||
if not remote_files:
|
||||
candidates = get_action_consistency_gt_candidates(case_id, num_gpus)
|
||||
raise FileNotFoundError(
|
||||
f"GT action JSON not found for {case_id}. Tried: {', '.join(candidates)}"
|
||||
)
|
||||
loaded_gt = _load_remote_gt_json(remote_files[0][1])
|
||||
logger.info("Loaded action GT for %s from %s", case_id, remote_files[0][1])
|
||||
|
||||
_consistency_gt_cache[cache_key] = loaded_gt
|
||||
return loaded_gt
|
||||
|
||||
|
||||
def load_gt_embeddings(
|
||||
case_id: str,
|
||||
num_gpus: int,
|
||||
@@ -1449,6 +1576,26 @@ def gt_exists(
|
||||
return found
|
||||
|
||||
|
||||
def action_gt_exists(case_id: str, num_gpus: int) -> bool:
|
||||
gt_dir = _get_consistency_gt_dir()
|
||||
if gt_dir is not None:
|
||||
return any(
|
||||
(gt_dir / candidate).exists()
|
||||
for candidate_set in get_action_consistency_gt_candidate_sets(
|
||||
case_id, num_gpus
|
||||
)
|
||||
for candidate in candidate_set
|
||||
)
|
||||
|
||||
cache_key = _get_action_consistency_gt_cache_key(case_id, num_gpus)
|
||||
if cache_key in _gt_exists_remote_cache:
|
||||
return True
|
||||
found = bool(_find_remote_action_consistency_gt_files(case_id, num_gpus))
|
||||
if found:
|
||||
_gt_exists_remote_cache.add(cache_key)
|
||||
return found
|
||||
|
||||
|
||||
def extract_key_frames_from_video(
|
||||
video_bytes: bytes,
|
||||
num_frames: int | None = None,
|
||||
|
||||
@@ -41,12 +41,17 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.image_encoding import (
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import (
|
||||
InputValidationStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_warmup import format_warmup_req
|
||||
from sglang.multimodal_gen.runtime.server_warmup import (
|
||||
format_warmup_req,
|
||||
should_run_explicit_client_warmup,
|
||||
should_run_synthetic_server_warmup,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.warmup_request_builder import (
|
||||
DEFAULT_PLACEHOLDER_PROMPT,
|
||||
SERVER_WARMUP_IMAGE_FALLBACK_RESOLUTION,
|
||||
build_warmup_reqs,
|
||||
should_include_warmup_image,
|
||||
supports_synthetic_warmup,
|
||||
)
|
||||
|
||||
|
||||
@@ -642,7 +647,12 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
|
||||
ModelTaskType.I2I: True,
|
||||
ModelTaskType.I2V: True,
|
||||
ModelTaskType.I2M: True,
|
||||
ModelTaskType.VLA_ACTION: False,
|
||||
}
|
||||
request_based_expected = {
|
||||
task_type: task_type.accepts_image_input() for task_type in ModelTaskType
|
||||
}
|
||||
request_based_expected[ModelTaskType.VLA_ACTION] = False
|
||||
|
||||
for task_type in ModelTaskType:
|
||||
server_args = MagicMock()
|
||||
@@ -655,10 +665,84 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
should_include_warmup_image(server_args, server_based_warmup=False),
|
||||
task_type.accepts_image_input(),
|
||||
request_based_expected[task_type],
|
||||
task_type.name,
|
||||
)
|
||||
|
||||
def test_action_pipeline_skips_synthetic_warmup_before_sampling_defaults(self):
|
||||
server_args = MagicMock()
|
||||
server_args.pipeline_config.task_type = ModelTaskType.VLA_ACTION
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.warmup_request_builder.get_model_sampling_defaults"
|
||||
) as get_defaults,
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.warmup_request_builder._resolve_default_warmup_resolution"
|
||||
) as resolve_resolution,
|
||||
):
|
||||
reqs = build_warmup_reqs(
|
||||
server_args,
|
||||
warmup_resolutions=None,
|
||||
server_based_warmup=True,
|
||||
)
|
||||
|
||||
self.assertEqual(reqs, [])
|
||||
get_defaults.assert_not_called()
|
||||
resolve_resolution.assert_not_called()
|
||||
|
||||
def test_action_pipeline_disables_synthetic_warmup(self):
|
||||
server_args = MagicMock()
|
||||
server_args.warmup = True
|
||||
server_args.server_warmup = True
|
||||
server_args.warmup_resolutions = ["512x512"]
|
||||
server_args.pipeline_config.task_type = ModelTaskType.VLA_ACTION
|
||||
|
||||
self.assertFalse(supports_synthetic_warmup(server_args))
|
||||
self.assertFalse(should_run_synthetic_server_warmup(server_args))
|
||||
self.assertFalse(should_run_explicit_client_warmup(server_args))
|
||||
|
||||
def test_mesh_pipeline_builds_image_conditioned_warmup(self):
|
||||
server_args = MagicMock()
|
||||
server_args.warmup = True
|
||||
server_args.server_warmup = True
|
||||
server_args.warmup_steps = 1
|
||||
server_args.warmup_resolutions = None
|
||||
server_args.enable_cfg_parallel = False
|
||||
server_args.enable_torch_compile = False
|
||||
server_args.enable_breakable_cuda_graph = False
|
||||
server_args.backend = "native"
|
||||
server_args.pipeline_class_name = None
|
||||
server_args.is_arg_explicitly_set.return_value = False
|
||||
server_args.pipeline_config = SimpleNamespace(
|
||||
task_type=ModelTaskType.I2M,
|
||||
vae_stride=None,
|
||||
vae_scale_factor=None,
|
||||
vae_config=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.warmup_request_builder.get_model_sampling_defaults",
|
||||
return_value=SamplingParams(width=512, height=512),
|
||||
),
|
||||
patch(
|
||||
"sglang.multimodal_gen.runtime.server_warmup.is_realtime_serving",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
reqs = build_warmup_reqs(
|
||||
server_args,
|
||||
warmup_resolutions=None,
|
||||
warmup_input_path="/tmp/warmup.png",
|
||||
server_based_warmup=True,
|
||||
)
|
||||
self.assertTrue(should_run_synthetic_server_warmup(server_args))
|
||||
|
||||
self.assertEqual(len(reqs), 1)
|
||||
self.assertEqual(reqs[0].data_type, ModelTaskType.I2M.data_type())
|
||||
self.assertEqual(reqs[0].image_path, ["/tmp/warmup.png"])
|
||||
|
||||
def test_server_based_warmup_keeps_ti2i_image_input(self):
|
||||
server_args = MagicMock()
|
||||
server_args.warmup_steps = 1
|
||||
|
||||
@@ -413,6 +413,37 @@ def test_consistency_gt_case_alias_reuses_canonical_filename(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_action_gt_candidates_prefer_platform_then_default(monkeypatch):
|
||||
monkeypatch.setenv(test_utils.CONSISTENCY_PLATFORM_ENV, "h100")
|
||||
|
||||
assert test_utils.get_action_consistency_gt_candidates("unit_action", 1) == [
|
||||
"h100/unit_action_1gpu.json",
|
||||
"unit_action_1gpu.json",
|
||||
]
|
||||
|
||||
|
||||
def test_remote_action_gt_uses_sglang_generated(monkeypatch):
|
||||
monkeypatch.setenv(test_utils.CONSISTENCY_PLATFORM_ENV, "h100")
|
||||
sglang_prefix = test_utils.SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE + "/"
|
||||
monkeypatch.setattr(
|
||||
test_utils,
|
||||
"_remote_file_exists",
|
||||
lambda url: url.startswith(sglang_prefix),
|
||||
)
|
||||
|
||||
files = test_utils._find_remote_action_consistency_gt_files("unit_action", 1)
|
||||
|
||||
assert files == [
|
||||
(
|
||||
"h100/unit_action_1gpu.json",
|
||||
(
|
||||
f"{test_utils.SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE}"
|
||||
"/h100/unit_action_1gpu.json"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_threshold_metadata_merges_platform_override():
|
||||
metadata = test_utils._merge_threshold_metadata(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import dataclasses
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.configs.sample.pi05 import Pi05SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
|
||||
action_generation_response,
|
||||
action_metadata,
|
||||
action_raw_response,
|
||||
build_action_sampling_params,
|
||||
pack_msgpack,
|
||||
unpack_msgpack,
|
||||
)
|
||||
|
||||
|
||||
def _server_args(config: Pi05PipelineConfig | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
model_id=None,
|
||||
model_path="lerobot/pi05_base",
|
||||
output_path=None,
|
||||
comfyui_mode=False,
|
||||
num_gpus=1,
|
||||
tp_size=1,
|
||||
sp_degree=1,
|
||||
ulysses_degree=1,
|
||||
ring_degree=1,
|
||||
pipeline_config=config or Pi05PipelineConfig(),
|
||||
)
|
||||
|
||||
|
||||
def test_pi05_uses_vla_sampling_params_not_visual_sampling_params():
|
||||
params = Pi05SamplingParams()
|
||||
field_names = {field.name for field in dataclasses.fields(params)}
|
||||
|
||||
assert isinstance(params, VLASamplingParams)
|
||||
assert not isinstance(params, SamplingParams)
|
||||
assert "action_horizon" in field_names
|
||||
assert "action_dim" in field_names
|
||||
assert "height" not in field_names
|
||||
assert "width" not in field_names
|
||||
assert "fps" not in field_names
|
||||
assert "negative_prompt" not in field_names
|
||||
assert "return_frames" not in field_names
|
||||
assert "diffusers_kwargs" not in field_names
|
||||
|
||||
|
||||
def test_action_adjust_skips_visual_image_video_logic():
|
||||
params = SamplingParams()
|
||||
params.num_frames = 0
|
||||
params.adjust_frames = True
|
||||
params.return_file_paths_only = True
|
||||
|
||||
params._adjust(_server_args())
|
||||
|
||||
assert params.data_type == DataType.ACTION
|
||||
assert params.num_frames == 1
|
||||
assert params.adjust_frames is False
|
||||
assert params.return_file_paths_only is False
|
||||
|
||||
|
||||
def test_action_request_schema_builds_pi05_sampling_params():
|
||||
image = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
payload = {
|
||||
"request_id": "action-req-1",
|
||||
"model": "lerobot/pi05_base",
|
||||
"input": {
|
||||
"task": "pick up the block",
|
||||
"observation": {
|
||||
"images": {
|
||||
"base_0_rgb": {
|
||||
"dtype": "uint8",
|
||||
"shape": [8, 8, 3],
|
||||
"values": image.tolist(),
|
||||
},
|
||||
},
|
||||
"state": {
|
||||
"dtype": "float32",
|
||||
"shape": [32],
|
||||
"values": np.arange(32, dtype=np.float32).tolist(),
|
||||
},
|
||||
},
|
||||
},
|
||||
"parameters": {
|
||||
"action_horizon": 25,
|
||||
"action_dim": 32,
|
||||
"num_inference_steps": 4,
|
||||
},
|
||||
"runtime": {
|
||||
"return_timing": "false",
|
||||
"prefix_cache": False,
|
||||
"cuda_graph": "0",
|
||||
},
|
||||
}
|
||||
|
||||
params = build_action_sampling_params(payload, _server_args())
|
||||
|
||||
assert params.prompt == "pick up the block"
|
||||
assert params.request_id == "action-req-1"
|
||||
assert params.action_horizon == 25
|
||||
assert params.action_dim == 32
|
||||
assert params.num_inference_steps == 4
|
||||
assert not params.return_timing
|
||||
assert not params.enable_prefix_cache
|
||||
assert not params.enable_cuda_graph
|
||||
assert params.return_file_paths_only is False
|
||||
assert params.save_output is False
|
||||
assert set(params.images) == {"base_0_rgb"}
|
||||
assert params.images["base_0_rgb"].shape == (8, 8, 3)
|
||||
assert params.state.shape == (32,)
|
||||
|
||||
vla_state = params.build_request_extra()["vla"]
|
||||
assert vla_state["observation"]["prompt"] == "pick up the block"
|
||||
assert not vla_state["options"]["enable_prefix_cache"]
|
||||
|
||||
|
||||
def test_openpi_raw_observation_compatibility_fields_are_normalized():
|
||||
payload = {
|
||||
"task": "push the cube",
|
||||
"observation.images.base_0_rgb": np.ones((4, 4, 3), dtype=np.uint8),
|
||||
"observation.state": {
|
||||
"dtype": "float32",
|
||||
"shape": [32],
|
||||
"data": [0.25] * 32,
|
||||
},
|
||||
"observation.noise": {
|
||||
"dtype": "float32",
|
||||
"shape": [50, 32],
|
||||
"data": np.zeros((50, 32), dtype=np.float32).tolist(),
|
||||
},
|
||||
"enable_pi_prefix_cache": False,
|
||||
"enable_pi_cuda_graph": False,
|
||||
}
|
||||
|
||||
params = build_action_sampling_params(payload, _server_args())
|
||||
|
||||
assert params.prompt == "push the cube"
|
||||
assert set(params.images) == {"base_0_rgb"}
|
||||
assert params.state.shape == (32,)
|
||||
assert params.noise.shape == (50, 32)
|
||||
assert not params.enable_prefix_cache
|
||||
assert not params.enable_cuda_graph
|
||||
|
||||
|
||||
def test_action_metadata_reports_policy_shape_and_capabilities():
|
||||
config = Pi05PipelineConfig(
|
||||
image_keys=("front", "wrist"),
|
||||
image_size=(256, 256),
|
||||
state_dim=8,
|
||||
action_horizon=10,
|
||||
action_dim=32,
|
||||
output_action_dim=7,
|
||||
enable_action_cuda_graph=True,
|
||||
)
|
||||
|
||||
metadata = action_metadata(_server_args(config))
|
||||
|
||||
assert metadata["object"] == "action.metadata"
|
||||
assert metadata["policy_family"] == "pi05"
|
||||
assert metadata["input"]["image_keys"] == ["front", "wrist"]
|
||||
assert metadata["input"]["image_size"] == [256, 256]
|
||||
assert metadata["input"]["state_dim"] == 8
|
||||
assert metadata["output"]["action_horizon"] == 10
|
||||
assert metadata["output"]["action_dim"] == 7
|
||||
assert metadata["output"]["padded_action_dim"] == 32
|
||||
assert metadata["runtime"]["materialize_dtype"] == "bf16"
|
||||
assert metadata["runtime"]["enable_autocast"] is True
|
||||
assert metadata["runtime"]["parallelism"]["num_gpus"] == 1
|
||||
assert metadata["runtime"]["parallelism"]["prefix_strategy"] == "tp"
|
||||
assert metadata["runtime"]["parallelism"]["action_strategy"] == "sp"
|
||||
assert metadata["defaults"]["prefix_cache"] is False
|
||||
assert metadata["capabilities"]["realtime_websocket"]
|
||||
assert metadata["capabilities"]["openpi_websocket"]
|
||||
|
||||
|
||||
def test_action_generation_response_uses_actual_output_parameters():
|
||||
output = {
|
||||
"request_id": "action-response-1",
|
||||
"actions": [[1.0, 2.0], [3.0, 4.0]],
|
||||
"parameters": {"num_inference_steps": 3},
|
||||
"timings": {"preprocess_ms": 1.5},
|
||||
"cache": {"hit": True},
|
||||
"parallel": {"split_group": False},
|
||||
}
|
||||
|
||||
response = action_generation_response(output, _server_args())
|
||||
|
||||
assert response["id"] == "action-response-1"
|
||||
assert response["object"] == "action.generation"
|
||||
assert response["data"][0]["action"]["shape"] == [2, 2]
|
||||
assert response["data"][0]["action"]["values"] == output["actions"]
|
||||
assert response["usage"]["action_horizon"] == 2
|
||||
assert response["usage"]["action_dim"] == 2
|
||||
assert response["usage"]["denoise_steps"] == 3
|
||||
assert response["usage"]["prefix_cache_hit"] is True
|
||||
assert response["timings"] == output["timings"]
|
||||
assert response["cache"] == output["cache"]
|
||||
assert response["parallel"] == output["parallel"]
|
||||
|
||||
|
||||
def test_action_raw_response_preserves_policy_payload_shape():
|
||||
actions = np.arange(6, dtype=np.float32).reshape(2, 3)
|
||||
output = {
|
||||
"actions": actions,
|
||||
"timings": {"preprocess_ms": 1.5},
|
||||
}
|
||||
|
||||
response = action_raw_response(output)
|
||||
|
||||
assert response["actions"] == actions.tolist()
|
||||
assert response["timings"] == output["timings"]
|
||||
|
||||
|
||||
def test_action_raw_response_can_preserve_numpy_for_msgpack():
|
||||
actions = np.arange(6, dtype=np.float32).reshape(2, 3)
|
||||
|
||||
response = action_raw_response({"actions": actions}, preserve_numpy=True)
|
||||
|
||||
assert response["actions"] is actions
|
||||
|
||||
|
||||
def test_msgpack_roundtrip_preserves_string_keys_and_numpy_payloads():
|
||||
payload = {
|
||||
"task": "pick",
|
||||
"array": np.arange(6, dtype=np.float32).reshape(2, 3),
|
||||
"scalar": np.float32(1.25),
|
||||
}
|
||||
|
||||
decoded = unpack_msgpack(pack_msgpack(payload))
|
||||
|
||||
assert decoded["task"] == "pick"
|
||||
np.testing.assert_array_equal(decoded["array"], payload["array"])
|
||||
assert decoded["scalar"] == payload["scalar"]
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import (
|
||||
PrefixContext,
|
||||
VLAPrefixCacheManager,
|
||||
)
|
||||
|
||||
|
||||
def _context() -> PrefixContext:
|
||||
return PrefixContext(
|
||||
past_key_values=("kv",),
|
||||
prefix_pad_masks=torch.ones(1, 3, dtype=torch.bool),
|
||||
prefix_len=3,
|
||||
)
|
||||
|
||||
|
||||
def test_pi05_prefix_cache_full_hit_returns_context():
|
||||
manager = VLAPrefixCacheManager(max_entries=4)
|
||||
key = "a"
|
||||
context = _context()
|
||||
|
||||
manager.put(key, context)
|
||||
cached = manager.get(key)
|
||||
|
||||
assert cached is context
|
||||
assert context.cache_key_digest == key
|
||||
|
||||
|
||||
def test_pi05_prefix_cache_different_key_misses():
|
||||
manager = VLAPrefixCacheManager(max_entries=4)
|
||||
manager.put("a", _context())
|
||||
|
||||
assert manager.get("b") is None
|
||||
|
||||
|
||||
def test_pi05_prefix_cache_zero_capacity_does_not_retain_context():
|
||||
manager = VLAPrefixCacheManager(max_entries=0)
|
||||
context = _context()
|
||||
|
||||
manager.put("a", context)
|
||||
|
||||
assert manager.get("a") is None
|
||||
assert context.cache_key_digest is None
|
||||
|
||||
|
||||
def test_pi05_prefix_cache_evicts_least_recently_used_entry():
|
||||
manager = VLAPrefixCacheManager(max_entries=2)
|
||||
context_a = _context()
|
||||
context_b = _context()
|
||||
context_c = _context()
|
||||
manager.put("a", context_a)
|
||||
manager.put("b", context_b)
|
||||
assert manager.get("a") is context_a
|
||||
|
||||
manager.put("c", context_c)
|
||||
|
||||
assert manager.get("a") is context_a
|
||||
assert manager.get("b") is None
|
||||
assert manager.get("c") is context_c
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
import sglang.multimodal_gen.runtime.models.vlas.pi05_policy as pi05_policy_module
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.runtime.models.vlas.pi05_core import (
|
||||
Pi05SiglipAttention,
|
||||
patch_siglip_vision_attention_to_native,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.vlas.pi05_policy import (
|
||||
Pi05CheckpointManifest,
|
||||
Pi05PolicyModel,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.vla.denoise_cuda_graph import (
|
||||
VLADenoiseGraphRunner,
|
||||
_CapturedDenoiseGraph,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.vla.parallel import VLASplitGroup
|
||||
from sglang.multimodal_gen.runtime.vla.prefix_cache import (
|
||||
PrefixContext,
|
||||
VLADensePrefixCache,
|
||||
)
|
||||
|
||||
|
||||
def _prefix_context(value: float, digest: str | None) -> PrefixContext:
|
||||
keys = torch.full((1, 1, 2, 4), value)
|
||||
values = torch.full((1, 1, 2, 4), value)
|
||||
return PrefixContext(
|
||||
past_key_values=VLADensePrefixCache(((keys, values, None),)),
|
||||
prefix_pad_masks=torch.ones(1, 2, dtype=torch.bool),
|
||||
prefix_len=2,
|
||||
cache_key_digest=digest,
|
||||
)
|
||||
|
||||
|
||||
def test_vla_split_group_marks_all_action_ranks():
|
||||
split = VLASplitGroup(
|
||||
group=SimpleNamespace(world_size=2),
|
||||
prefix_root=0,
|
||||
action_root=1,
|
||||
action_ranks=(0, 1),
|
||||
rank=0,
|
||||
)
|
||||
|
||||
assert split.is_prefix_rank
|
||||
assert split.is_action_rank
|
||||
assert split.uses_action_sp
|
||||
|
||||
|
||||
def test_denoise_graph_skips_prefix_copy_for_same_digest(monkeypatch):
|
||||
runner = VLADenoiseGraphRunner(enabled=True)
|
||||
static_context = _prefix_context(1.0, "same")
|
||||
captured = _CapturedDenoiseGraph(
|
||||
graph=object(),
|
||||
static_prefix_context=static_context,
|
||||
static_x_t=torch.empty(1, 2, 4),
|
||||
static_timestep=torch.empty(1),
|
||||
static_output=torch.empty(1, 2, 4),
|
||||
current_context_id=123,
|
||||
current_context_digest="same",
|
||||
)
|
||||
|
||||
def fail_copy(*args, **kwargs):
|
||||
raise AssertionError("PrefixContext should not be copied on digest hit")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sglang.multimodal_gen.runtime.vla.denoise_cuda_graph._copy_prefix_context_",
|
||||
fail_copy,
|
||||
)
|
||||
|
||||
runner._sync_context_if_needed(captured, _prefix_context(2.0, "same"))
|
||||
|
||||
assert captured.static_prefix_context.past_key_values[0][0].eq(1.0).all()
|
||||
|
||||
|
||||
def test_runai_direct_gpu_loader_does_not_reject_split_roles(monkeypatch):
|
||||
class FakeSafeOpen:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def keys(self):
|
||||
return ["action.weight"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
pi05_policy_module,
|
||||
"safe_open",
|
||||
lambda *args, **kwargs: FakeSafeOpen(),
|
||||
)
|
||||
|
||||
model = Pi05PolicyModel.__new__(Pi05PolicyModel)
|
||||
model.device = torch.device("cuda")
|
||||
model.runtime_role = "action"
|
||||
model.config = Pi05PipelineConfig()
|
||||
model.manifest = Pi05CheckpointManifest(
|
||||
model_path="fake",
|
||||
safetensor_files=["fake.safetensors"],
|
||||
)
|
||||
model._should_read_source_key = lambda key: True
|
||||
target_state = {
|
||||
"action.weight": SimpleNamespace(device=SimpleNamespace(type="cuda")),
|
||||
}
|
||||
|
||||
assert model._should_stream_weights_to_gpu(target_state, {})
|
||||
|
||||
model.runtime_role = "idle"
|
||||
assert model._should_stream_weights_to_gpu(target_state, {})
|
||||
|
||||
|
||||
def test_pi05_loader_maps_unfused_prefix_weights_to_parallel_targets():
|
||||
q_key = (
|
||||
"paligemma_with_expert.paligemma.model.language_model.layers.0."
|
||||
"self_attn.q_proj.weight"
|
||||
)
|
||||
gate_key = (
|
||||
"paligemma_with_expert.paligemma.model.language_model.layers.0."
|
||||
"mlp.gate_proj.weight"
|
||||
)
|
||||
|
||||
assert (
|
||||
"paligemma_with_expert.paligemma.model.language_model.layers.0."
|
||||
"self_attn.qkv_proj.weight",
|
||||
"q",
|
||||
) in Pi05PolicyModel._candidate_target_weights(q_key)
|
||||
assert (
|
||||
"paligemma_with_expert.paligemma.model.language_model.layers.0."
|
||||
"mlp.gate_up_proj.weight",
|
||||
0,
|
||||
) in Pi05PolicyModel._candidate_target_weights(gate_key)
|
||||
|
||||
|
||||
def test_action_parallel_info_reports_single_rank_without_process_group():
|
||||
model = Pi05PolicyModel.__new__(Pi05PolicyModel)
|
||||
model.runtime_role = "all"
|
||||
|
||||
info = model.action_parallel_info(prefix_context=None)
|
||||
|
||||
assert info == {
|
||||
"split_group": False,
|
||||
"runtime_role": "all",
|
||||
"action_sequence_parallel": False,
|
||||
}
|
||||
|
||||
|
||||
class _FakeSiglipAttention(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.embed_dim = 8
|
||||
self.num_heads = 2
|
||||
self.head_dim = 4
|
||||
self.scale = self.head_dim**-0.5
|
||||
self.dropout = 0.0
|
||||
self.q_proj = nn.Linear(self.embed_dim, self.embed_dim)
|
||||
self.k_proj = nn.Linear(self.embed_dim, self.embed_dim)
|
||||
self.v_proj = nn.Linear(self.embed_dim, self.embed_dim)
|
||||
self.out_proj = nn.Linear(self.embed_dim, self.embed_dim)
|
||||
|
||||
|
||||
def test_siglip_attention_patch_uses_native_wrapper_once():
|
||||
layer = SimpleNamespace(self_attn=_FakeSiglipAttention())
|
||||
vision_model = SimpleNamespace(encoder=SimpleNamespace(layers=[layer]))
|
||||
|
||||
patch_siglip_vision_attention_to_native(vision_model)
|
||||
first = layer.self_attn
|
||||
patch_siglip_vision_attention_to_native(vision_model)
|
||||
|
||||
assert isinstance(first, Pi05SiglipAttention)
|
||||
assert layer.self_attn is first
|
||||
@@ -32,6 +32,10 @@ from sglang.srt.environ import envs
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KNOWN_NON_DIFFUSERS_DIFFUSION_MODEL_PATTERNS: dict[str, str] = {
|
||||
"lerobot/pi05": "Pi05Pipeline",
|
||||
"lerobot--pi05": "Pi05Pipeline",
|
||||
"pi05": "Pi05Pipeline",
|
||||
"pi0.5": "Pi05Pipeline",
|
||||
"hunyuan3d": "Hunyuan3D2Pipeline",
|
||||
"flux.2-dev-nvfp4": "Flux2NvfpPipeline",
|
||||
"comfy-org/ideogram-4": "Ideogram4Nvfp4Pipeline",
|
||||
|
||||
Reference in New Issue
Block a user