[diffusion] feat: measure warmup memory and layer usage per phase for residency calibration (1/4) (#37916)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-09-06 19:42:42 +08:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 938dc5621d
commit a176ba2f7b
57 changed files with 4022 additions and 178 deletions
@@ -201,6 +201,9 @@ class PipelineConfig:
native_only_components: ClassVar[tuple[str, ...]] = ()
task_type: ModelTaskType = ModelTaskType.I2I
skip_input_image_preprocess: bool = False
# False when changing component placement after a calibration request is
# known to alter the pipeline's numerical path.
supports_auto_residency: bool = True
# Components that cannot fall back to a native Transformers/Diffusers
# implementation because their pipeline requires SGLang-specific behavior.
native_only_components: tuple[str, ...] = ()
@@ -723,3 +723,6 @@ class LTX2PipelineConfig(PipelineConfig):
@dataclasses.dataclass
class LTX23PipelineConfig(LTX2PipelineConfig):
"""Configuration overrides for LTX-2.3."""
# original-mode lora swaps invalidate post-warmup timing calibration
supports_auto_residency: bool = False
@@ -755,6 +755,8 @@ class QwenImageEditPlus_2511_PipelineConfig(QwenImageEditPlusPipelineConfig):
class QwenImageLayeredPipelineConfig(QwenImageEditPipelineConfig):
resolution: int = 640
vae_precision: str = "bf16"
# promoting the auxiliary components regresses first-request latency
supports_auto_residency: bool = False
def get_model_deployment_config(self) -> ModelDeploymentConfig:
return ModelDeploymentConfig(
@@ -56,6 +56,9 @@ class SanaWMPipelineConfig(PipelineConfig):
optional 6-DoF camera trajectory, optional Stage-2 LTX-2 refiner)."""
task_type: ModelTaskType = ModelTaskType.TI2V
# The current two-stage path is not numerically invariant when its primary
# transformer and connectors are promoted after warmup.
supports_auto_residency: bool = False
# SanaWMBeforeDenoisingStage._splice_first_frame handles condition-image
# resize + VAE-encode itself, so bypass the framework's generic TI2V
@@ -136,6 +136,58 @@ def _sanitize_filename(name: str, replacement: str = "_", max_length: int = 150)
return ascii_name
_SEQUENCE_SHARD_PIPELINE_FAMILIES = ("wan", "helios", "joy", "cosmos3")
def resolve_sequence_shard(
pipeline_config: Any, enable_sequence_shard: bool | None
) -> bool:
"""Whether this pipeline shards the sequence dim instead of aligning frames.
Shared by ``SamplingParams._adjust_visual_fields`` and the synthetic
warmup builder so warmup requests follow the same frame contract as real
requests.
"""
pipeline_name_lower = pipeline_config.__class__.__name__.lower()
return any(
family in pipeline_name_lower for family in _SEQUENCE_SHARD_PIPELINE_FAMILIES
) and (enable_sequence_shard is None or enable_sequence_shard)
def align_num_frames_for_num_gpus(
num_frames: int,
*,
num_gpus: int,
vae_config: Any,
round_down: bool,
) -> int:
"""Align the latent frame count to be divisible by ``num_gpus``."""
if num_gpus <= 1:
return num_frames
use_temporal_scaling_frames = vae_config.use_temporal_scaling_frames
temporal_scale_factor = vae_config.arch_config.temporal_compression_ratio
if use_temporal_scaling_frames:
orig_latent_num_frames = (num_frames - 1) // temporal_scale_factor + 1
else:
orig_latent_num_frames = num_frames
if orig_latent_num_frames % num_gpus == 0:
return num_frames
if round_down:
# Ensure we have at least 1 batch per GPU
new_latent_num_frames = max(1, (orig_latent_num_frames // num_gpus)) * num_gpus
else:
new_latent_num_frames = math.ceil(orig_latent_num_frames / num_gpus) * num_gpus
if use_temporal_scaling_frames:
# Convert back to frames, keeping num_frames-1 a multiple of the
# temporal scale factor
return (new_latent_num_frames - 1) * temporal_scale_factor + 1
return new_latent_num_frames
class DataType(Enum):
IMAGE = auto()
VIDEO = auto()
@@ -792,14 +844,9 @@ class SamplingParams:
)
logger.warning(error_msg)
pipeline_name_lower = server_args.pipeline_config.__class__.__name__.lower()
if (
"wan" in pipeline_name_lower
or "helios" in pipeline_name_lower
or "joy" in pipeline_name_lower
or "cosmos3" in pipeline_name_lower
) and (self.enable_sequence_shard is None or self.enable_sequence_shard):
if resolve_sequence_shard(
server_args.pipeline_config, self.enable_sequence_shard
):
self.enable_sequence_shard = True
logger.debug("Automatically enabled enable_sequence_shard")
else:
@@ -832,43 +879,13 @@ class SamplingParams:
)
if self.adjust_frames:
# Adjust number of frames based on number of GPUs for video task
use_temporal_scaling_frames = (
pipeline_config.vae_config.use_temporal_scaling_frames
new_num_frames = align_num_frames_for_num_gpus(
self.num_frames,
num_gpus=server_args.num_gpus,
vae_config=pipeline_config.vae_config,
round_down=self.num_frames_round_down,
)
num_frames = self.num_frames
num_gpus = server_args.num_gpus
temporal_scale_factor = (
pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
if use_temporal_scaling_frames:
orig_latent_num_frames = (
num_frames - 1
) // temporal_scale_factor + 1
else:
orig_latent_num_frames = num_frames
if orig_latent_num_frames % server_args.num_gpus != 0:
# Adjust latent frames to be divisible by number of GPUs
if self.num_frames_round_down:
# Ensure we have at least 1 batch per GPU
new_latent_num_frames = (
max(1, (orig_latent_num_frames // num_gpus)) * num_gpus
)
else:
new_latent_num_frames = (
math.ceil(orig_latent_num_frames / num_gpus) * num_gpus
)
if use_temporal_scaling_frames:
# Convert back to number of frames, ensuring num_frames-1 is a multiple of temporal_scale_factor
new_num_frames = (
new_latent_num_frames - 1
) * temporal_scale_factor + 1
else:
new_num_frames = new_latent_num_frames
if new_num_frames != self.num_frames:
logger.info(
"Adjusting number of frames from %s to %s based on number of GPUs (%s)",
self.num_frames,
+7
View File
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
SGLANG_DIFFUSION_TEST_FORCE_HOST_AVAILABLE_GIB: float | None = None
SGLANG_DIFFUSION_TEST_CAP_DEVICE_MEMORY_GIB: float | None = None
SGLANG_DIFFUSION_STAGE_LOGGING: bool = False
SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY: bool = False
SGLANG_DIFFUSION_MINIMAX_H3_ADALN_GPU_PLANS: int = 64
SGLANG_DIFFUSION_MINIMAX_H3_ADALN_FP32: bool = False
SGLANG_DIFFUSION_CFG_GATE_STEP: float = 1.0
@@ -265,6 +266,12 @@ environment_variables: dict[str, Callable[[], Any]] = {
# If set, sgl_diffusion will enable stage logging, which will print the time
# taken for each stage
"SGLANG_DIFFUSION_STAGE_LOGGING": _lazy_bool("SGLANG_DIFFUSION_STAGE_LOGGING"),
# Kill-switch for the warmup-calibrated auto residency promotion that runs
# under `--performance-mode auto` with server warmup. Set to disable the
# promotion without giving up the rest of the auto performance policy.
"SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY": _lazy_bool(
"SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY"
),
# Plan slots in the MiniMax-H3 --minimax-h3-adaln-online GPU slab
# (9.25 MiB per slot-timestep; 64 x width 4 = 2.31 GiB). A request needs
# up to num_inference_steps - 1 slots; the default covers the 50-step
@@ -140,6 +140,18 @@ class IpcA2AState:
"""Drop mappings that belong to a model-parallel group being replaced."""
self.__init__()
def drop_staging(self) -> None:
"""Release the cached staging buffers (both ranks call this at the same point).
Staging is keyed by message size and only evicted by count, so a warmup
probe at the full serving shape leaves buffers sized for it behind.
"""
if not self.staging:
return
if torch.cuda.is_available():
torch.cuda.synchronize()
self.staging.clear()
def _share(self, t, group):
"""Exchange `t` with the peer via torch IPC, re-opening the handle in
the LOCAL device context (the mapping is only dereferenceable from the
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: Apache-2.0
"""Control-request protocol between the HTTP process and scheduler workers.
These types are cross-process IPC contracts, not utilities: the HTTP side
constructs them (scheduler_client treats them as ``_CONTROL_REQ_TYPES`` and
fans them out to every replica) and each scheduler dispatches them through
``Scheduler.request_handlers``. Keep this module import-light -- both
processes import it, and the HTTP process must not drag in torch-heavy
worker modules through it.
"""
from typing import List, Optional, Union
import msgspec
class SetLoraReq(msgspec.Struct):
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0
merge_mode: Optional[str] = None
lora_alpha: Optional[Union[int, List[Optional[int]]]] = None
class MergeLoraWeightsReq(msgspec.Struct):
target: str = "all"
strength: float = 1.0
class UnmergeLoraWeightsReq(msgspec.Struct):
target: str = "all"
class ListLorasReq(msgspec.Struct):
pass
class ShutdownReq(msgspec.Struct):
pass
class ReleaseRealtimeSessionReq(msgspec.Struct):
session_id: str
class GetDisaggStatsReq(msgspec.Struct):
"""Request to get disagg pipeline metrics from the scheduler."""
pass
@@ -19,13 +19,15 @@ from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
GenerationResult,
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
GenerationResult,
expand_request_outputs,
format_lora_message,
prepare_request,
@@ -264,7 +264,9 @@ async def stats_endpoint(request: Request):
Returns queue depth, request counts, latency, throughput, etc.
Sends a GetDisaggStatsReq to the scheduler via ZMQ and returns the result.
"""
from sglang.multimodal_gen.runtime.entrypoints.utils import GetDisaggStatsReq
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
GetDisaggStatsReq,
)
server_args: ServerArgs = request.app.state.server_args
response: dict = {
@@ -5,11 +5,13 @@ from fastapi import APIRouter, Body, HTTPException
from pydantic import BaseModel, Field
from sglang.multimodal_gen.registry import get_model_info
from sglang.multimodal_gen.runtime.entrypoints.utils import (
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
format_lora_message,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
@@ -8,6 +8,9 @@ from typing import TYPE_CHECKING
import msgspec.msgpack
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
ReleaseRealtimeSessionReq,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
@@ -28,9 +31,6 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.timer import (
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
process_generation_batch,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ReleaseRealtimeSessionReq,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
@@ -4,11 +4,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
@@ -67,3 +66,12 @@ def get_realtime_model_adapter(
"Realtime video is not supported for pipeline config "
f"{type(pipeline_config).__name__}; no realtime adapter is registered."
)
def has_realtime_model_adapter(server_args: ServerArgs) -> bool:
"""Whether the resolved pipeline config has a registered adapter."""
_register_builtin_realtime_model_adapters()
return any(
config_cls in _REALTIME_ADAPTER_REGISTRY
for config_cls in type(server_args.pipeline_config).__mro__
)
@@ -18,12 +18,14 @@ from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
format_lora_message,
save_outputs,
)
@@ -160,49 +160,6 @@ def _close_cached_cuda_video_buffer() -> None:
atexit.register(_close_cached_cuda_video_buffer)
@dataclass
class SetLoraReq:
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0
merge_mode: Optional[str] = None
lora_alpha: Optional[Union[int, List[Optional[int]]]] = None
@dataclass
class MergeLoraWeightsReq:
target: str = "all"
strength: float = 1.0
@dataclass
class UnmergeLoraWeightsReq:
target: str = "all"
@dataclass
class ListLorasReq:
pass
@dataclass
class ShutdownReq:
pass
@dataclass
class ReleaseRealtimeSessionReq:
session_id: str
@dataclass
class GetDisaggStatsReq:
"""Request to get disagg pipeline metrics from the scheduler."""
pass
def format_lora_message(
lora_nickname: Union[str, List[str]],
target: Union[str, List[str]],
@@ -12,8 +12,8 @@ from sglang.multimodal_gen.runtime.disaggregation.orchestrator import (
DiffusionServer,
)
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.entrypoints.control_requests import ShutdownReq
from sglang.multimodal_gen.runtime.entrypoints.http_server import create_app
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
from sglang.multimodal_gen.runtime.managers.gpu_worker import run_scheduler_process
from sglang.multimodal_gen.runtime.scheduler_client import SchedulerClient
from sglang.multimodal_gen.runtime.server_args import (
@@ -41,6 +41,20 @@ def _maybe_wait(tensor: torch.Tensor) -> torch.Tensor:
_A2A_STAGING_BUFFERS: dict[tuple[str, torch.dtype, int], torch.Tensor] = {}
def drop_a2a_staging_buffers() -> None:
"""Release the cached all-to-all staging buffers on this rank.
The cache only ever grows to the largest message seen, so a warmup probe
at the full serving shape leaves buffers sized for it behind; the caller
releases them at a point every rank reaches together.
"""
if not _A2A_STAGING_BUFFERS:
return
if torch.cuda.is_available():
torch.cuda.synchronize()
_A2A_STAGING_BUFFERS.clear()
def _a2a_staging_buffer(
role: str, shape: tuple[int, ...], dtype: torch.dtype, device: torch.device
) -> torch.Tensor:
@@ -15,6 +15,8 @@ import numpy as np
import torch
from setproctitle import setproctitle
from sglang.multimodal_gen.runtime.warmup_request_builder import lighten_warmup_req
from sglang.multimodal_gen.runtime.utils.logging_utils import ( # isort: skip
globally_suppress_loggers,
)
@@ -49,7 +51,17 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
post_process_sample,
save_outputs,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.auto_residency import (
DefaultWorkload,
WarmupMemoryRecord,
estimate_default_workload_peak_bytes,
resolve_default_workload,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
peek_global_component_residency_manager,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
LayerwiseUsageTracker,
configure_layerwise_offload_modules,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.memory_occupation_controller import (
@@ -93,14 +105,6 @@ from sglang.srt.utils.network import NetworkAddress
logger = init_logger(__name__)
OFFLOAD_DISABLE_RECOMMENDATION_ORDER = (
"vae",
"image_encoder",
"text_encoder",
"text_encoder_2",
"transformer",
)
@dataclass
class _ExpandedOutputParts:
@@ -129,6 +133,64 @@ def _worker_cpu_intra_op_threads(num_gpus: int) -> int | None:
return max(1, min(16, cpu_count // max(1, num_gpus)))
OFFLOAD_DISABLE_RECOMMENDATION_ORDER = (
"vae",
"image_encoder",
"text_encoder",
"text_encoder_2",
"transformer",
)
PROBE_FIT_MIN_MARGIN_BYTES = 1 << 30
def _shape_label(req: Req) -> str:
return f"{req.width}x{req.height}x{req.num_frames or 1}f"
def fit_auto_residency_probe(
req: Req,
*,
records: list[WarmupMemoryRecord],
free_bytes: int,
total_bytes: int,
server_args: ServerArgs,
) -> tuple[Req, int | None, int]:
"""Shrink a full-shape probe until its extrapolated peak fits the memory left.
The probe measures the default workload under the load-safe placement, so
a probe the card cannot hold would only be found out by running out of
memory. The bounded warmup that runs before it gives one measurement to
extrapolate from; while that extrapolation exceeds free memory minus the
reserve, frames go first and then area, the ladder the OOM retry walks.
Returns the fitted request, its estimate and the number of shrink steps.
"""
# Only the probe has to fit, so the margin is allocator slack, not the
# planner's placement reserve (which held back 4 GiB of a 32 GiB card and
# shrank a probe that had 10 GiB to spare).
budget = free_bytes - max(PROBE_FIT_MIN_MARGIN_BYTES, total_bytes // 50)
# The bounded warmup already ran at the smallest measured shape; a probe
# below it measures nothing new and degenerate shapes fail inside models.
floor_units = min((record.workload_units() for record in records), default=0)
fitted, steps = req, 0
while True:
units = (
max(1, int(fitted.width or 1))
* max(1, int(fitted.height or 1))
* max(1, int(fitted.num_frames or 1))
)
estimate = estimate_default_workload_peak_bytes(
records=records, target_units=units
)
if estimate is None or estimate <= budget or units <= floor_units:
return fitted, estimate, steps
lighter = lighten_warmup_req(server_args, fitted)
if lighter is None:
return fitted, estimate, steps
fitted, steps = lighter, steps + 1
class GPUWorker(GPUWorkerPostTrainingMixin):
"""
A worker that executes the model on a single GPU.
@@ -163,6 +225,10 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
load_snapshot.peak_allocated_mb if load_snapshot is not None else 0.0
)
self._runtime_peak_reserved_mb = 0.0
# Warmup probes run the default workload's full shape and may exceed any
# serving request; keep their peak out of the runtime figure.
self._warmup_peak_reserved_mb = 0.0
self._release_warmup_pool_before_serving = False
self._runtime_peak_allocated_mb = 0.0
self.sp_group = get_sp_group()
self.sp_cpu_group = self.sp_group.cpu_group
@@ -173,6 +239,26 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
self.cfg_cpu_group = self.cfg_group.cpu_group
self._realtime_sessions = RealtimeSessionCache(max_sessions=1)
self.memory_occupation: MemoryOccupationController | None = None
# per-rank memory measurements of server warmup forwards; consumed by
# the auto-residency placement decision before the server turns ready
self._auto_residency_warmup_records: list[WarmupMemoryRecord] = []
# default workload resolved once for the per-request residency hint
self._cached_default_workload: DefaultWorkload | None = None
self._cached_default_workload_failed = False
def _default_workload_for_hint(self) -> DefaultWorkload | None:
if (
self._cached_default_workload is None
and not self._cached_default_workload_failed
):
try:
self._cached_default_workload = resolve_default_workload(
self.server_args
)
except Exception:
logger.debug("Default workload unresolvable", exc_info=True)
self._cached_default_workload_failed = True
return self._cached_default_workload
def release_realtime_session(self, session_id: str) -> OutputBatch:
"""release the session of a realtime connection"""
@@ -364,24 +450,37 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
remaining_gpu_mem_gb = (
current_platform.get_device_total_memory() / (1024**3) - peak_reserved_gb
)
can_stay_resident = self.get_can_stay_resident_components(remaining_gpu_mem_gb)
try:
can_stay_resident = self.get_can_stay_resident_components(
remaining_gpu_mem_gb
)
except Exception:
# a debug-only hint must never fail a completed request
logger.debug("Residency hint unavailable", exc_info=True)
can_stay_resident = []
pool_overhead_gb = peak_reserved_gb - peak_allocated_gb
pool_overhead_pct = (
pool_overhead_gb / peak_reserved_gb * 100 if peak_reserved_gb else 0.0
)
residency_hint = (
f" Components that can remain on GPU: {can_stay_resident}. "
"Make it explicit with --component-residency <name>=resident; "
"--performance-mode auto with server warmup applies safe "
"adjustments automatically."
if can_stay_resident
else ""
)
logger.debug(
"GPU memory: peak=%.2f GB, allocated=%.2f GB, pool=%.2f GB (%.1f%%), "
"headroom=%.2f GB. Components that can remain on GPU: %s. "
"Adjust --cpu-offload-components or --layerwise-offload-components "
"to change residency.",
"headroom=%.2f GB.%s",
peak_reserved_gb,
peak_allocated_gb,
pool_overhead_gb,
pool_overhead_pct,
remaining_gpu_mem_gb,
can_stay_resident,
residency_hint,
)
def execute_forward(
@@ -409,6 +508,8 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
return self._execute_forward_batch(batch)
req = batch[0]
if req.is_warmup and req.extra.get("auto_residency_full_shape_probe"):
self._fit_auto_residency_probe(req)
return self._execute_forward_common(
req,
forward_fn=lambda: self.pipeline.forward(req, self.server_args),
@@ -502,9 +603,57 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
"""
output_batch = None
forward_failed = False
# Prewarm reqs (is_warmup=False) run a different offload layout and
# must not contaminate the calibration records. Pipelines that cannot
# apply a residency plan also skip the temporary per-layer hooks.
measure_server_warmup = (
req.is_warmup
and bool(req.extra.get("server_based_warmup"))
and self.server_args.pipeline_config.supports_auto_residency
and current_platform.is_cuda()
)
warmup_workload = (
(
int(req.width or 0),
int(req.height or 0),
int(req.num_frames or 1),
max(1, int(req.num_inference_steps or 1)),
)
if measure_server_warmup
else None
)
warmup_baseline_allocated_bytes = 0
layerwise_usage_tracker: LayerwiseUsageTracker | None = None
layerwise_layer_uses_by_stage: dict[
str, dict[str, dict[str, tuple[int, ...]]]
] = {}
try:
if measure_server_warmup:
# Drop the previous request's allocator pool so each probe
# starts from the same placement and can return released
# component storage before its allocated peak is measured.
torch.get_device_module().empty_cache()
self._release_warmup_pool(req)
if not current_platform.is_cpu() and not current_platform.is_mps():
torch.get_device_module().reset_peak_memory_stats()
if measure_server_warmup:
warmup_baseline_allocated_bytes = (
torch.get_device_module().memory_allocated()
)
if (
self.server_args.performance_mode == "auto"
and self.pipeline is not None
):
layerwise_usage_tracker = LayerwiseUsageTracker(
self.pipeline.modules,
stage_name_provider=(
lambda: (
req.metrics.active_stage_name
if req.metrics is not None
else None
)
),
)
start_time = (
execution_start_time
@@ -559,7 +708,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
req_label = req.request_id[:8] if req.request_id else "unnamed"
with maybe_record_function(f"SAVE_OUTPUTS {req_label}"):
self._materialize_output_transport(output_batch, req, save_output_paths)
self._record_output_peak_memory(output_batch)
self._record_output_peak_memory(output_batch, is_warmup=req.is_warmup)
collect_perf = (
req.perf_dump_path is not None or envs.SGLANG_DIFFUSION_STAGE_LOGGING
@@ -615,12 +764,115 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
if output_batch is None:
output_batch = OutputBatch()
output_batch.error = f"Error executing {error_context}: {e}"
self._record_output_peak_memory(output_batch)
self._record_output_peak_memory(output_batch, is_warmup=req.is_warmup)
# clean cache if OOM
if not current_platform.is_cpu():
torch.get_device_module().empty_cache()
finally:
# also runs on the propagate_forward_errors re-raise: a warmup
# forward that never completed must still leave a failed record,
# or the estimator would plan from the remaining partial data
if measure_server_warmup:
assert warmup_workload is not None
if layerwise_usage_tracker is not None:
(
layerwise_layer_uses,
layerwise_layer_uses_by_stage,
) = layerwise_usage_tracker.finish_with_stages()
else:
layerwise_layer_uses = {}
self._record_server_warmup_memory(
req=req,
workload=warmup_workload,
baseline_allocated_bytes=warmup_baseline_allocated_bytes,
succeeded=output_batch is not None and output_batch.error is None,
layerwise_layer_uses=layerwise_layer_uses,
layerwise_layer_uses_by_stage=layerwise_layer_uses_by_stage,
)
return output_batch
def _record_server_warmup_memory(
self,
*,
req: Req,
workload: tuple[int, int, int, int],
baseline_allocated_bytes: int,
succeeded: bool,
layerwise_layer_uses: dict[str, dict[str, tuple[int, ...]]] | None = None,
layerwise_layer_uses_by_stage: (
dict[str, dict[str, dict[str, tuple[int, ...]]]] | None
) = None,
) -> None:
phase_allocated_peaks: dict[str, int] = {}
phase_components: dict[str, tuple[str, ...]] = {}
phase_used_components: dict[str, tuple[str, ...]] = {}
phase_full_weight_transition_components: dict[str, tuple[str, ...]] = {}
untracked_active_components: tuple[str, ...] = ()
residency_manager = peek_global_component_residency_manager()
if residency_manager is not None:
for phase_name, peak in residency_manager.take_warmup_phase_peaks().items():
phase_allocated_peaks[phase_name] = peak.allocated_bytes
phase_components[phase_name] = peak.active_components
phase_used_components[phase_name] = peak.used_components
phase_full_weight_transition_components[phase_name] = (
peak.full_weight_transition_components
)
untracked_active_components = residency_manager.current_device_components()
request_allocated_peak = max(
int(torch.get_device_module().max_memory_allocated()),
max(phase_allocated_peaks.values(), default=0),
)
if request_allocated_peak > max(phase_allocated_peaks.values(), default=0):
# Work after the residency-managed stage timeline (for example,
# output materialization) must remain a placement constraint.
# A reserved-only increase is not a second live placement. Record
# it separately so post-placement validation can still require
# allocator headroom without charging cache to candidate deltas.
phase_allocated_peaks["request:untracked"] = request_allocated_peak
phase_components["request:untracked"] = untracked_active_components
phase_used_components["request:untracked"] = ()
phase_full_weight_transition_components["request:untracked"] = ()
metrics = req.metrics
width, height, num_frames, num_inference_steps = workload
self._auto_residency_warmup_records.append(
WarmupMemoryRecord(
width=width,
height=height,
num_frames=num_frames,
baseline_allocated_bytes=int(baseline_allocated_bytes),
peak_allocated_bytes=request_allocated_peak,
succeeded=succeeded,
peak_reserved_bytes=int(
torch.get_device_module().max_memory_reserved()
),
phase_peak_allocated_bytes=phase_allocated_peaks,
phase_active_components=phase_components,
phase_used_components=phase_used_components,
phase_full_weight_transition_components=(
phase_full_weight_transition_components
),
layerwise_layer_uses=layerwise_layer_uses or {},
layerwise_layer_uses_by_stage=layerwise_layer_uses_by_stage or {},
num_inference_steps=num_inference_steps,
total_duration_ms=(
float(metrics.total_duration_ms) if metrics is not None else 0.0
),
stage_duration_ms=(dict(metrics.stages) if metrics is not None else {}),
step_duration_ms=(tuple(metrics.steps) if metrics is not None else ()),
step_duration_ms_by_stage=(
{
stage_name: tuple(durations)
for stage_name, durations in metrics.steps_by_stage.items()
}
if metrics is not None
else {}
),
stage_iterations=(
dict(metrics.stage_iterations) if metrics is not None else {}
),
)
)
def _materialize_output_transport(
self,
output_batch: OutputBatch,
@@ -733,16 +985,100 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
)
return np.asarray(materialized.frames)
def _record_output_peak_memory(self, output_batch: OutputBatch) -> None:
def _fit_auto_residency_probe(self, req: Req) -> None:
"""Size the full-shape probe to what the card has left, on every rank alike."""
records = [r for r in self._auto_residency_warmup_records if r.succeeded]
if not records or not current_platform.is_cuda():
return
device = current_platform.get_device(self.local_rank)
free_bytes = int(
current_platform.get_available_gpu_memory(empty_cache=True) * (1 << 30)
)
total_bytes = int(torch.cuda.get_device_properties(device).total_memory)
_, _, steps = fit_auto_residency_probe(
req,
records=records,
free_bytes=free_bytes,
total_bytes=total_bytes,
server_args=self.server_args,
)
requested_units = (
max(1, int(req.width or 1))
* max(1, int(req.height or 1))
* max(1, int(req.num_frames or 1))
)
estimate = estimate_default_workload_peak_bytes(
records=records, target_units=requested_units
)
# Ranks see different free memory and hold different records; the
# forward must run one shape everywhere, so the most cautious rank wins.
agreed = torch.tensor([steps], dtype=torch.int64, device=device)
agreed = get_replica_group().all_reduce(
agreed, op=torch.distributed.ReduceOp.MAX
)
steps = int(agreed.item())
if steps == 0:
return
fitted = req
for _ in range(steps):
lighter = lighten_warmup_req(self.server_args, fitted)
if lighter is None:
break
fitted = lighter
if self.is_output_rank:
logger.warning(
"Auto residency probe %s would not fit: extrapolated peak %.1f GiB "
"against %.1f GiB free; probing at %s instead",
_shape_label(req),
(estimate or 0) / (1 << 30),
free_bytes / (1 << 30),
_shape_label(fitted),
)
req.sampling_params = fitted.sampling_params
def _release_warmup_pool(self, req: Req) -> None:
"""Drop what the full-shape probe left behind before the next request.
The probe runs a shape serving may never see. Its cached allocator
blocks would become the floor of every runtime peak measurement, and
the all-to-all staging buffers it created (IPC and Ulysses) stay
allocated at its message size. The request after the probe (the
bounded re-warm) regrows all of them at a serving-sized shape.
"""
if req.is_warmup and req.extra.get("auto_residency_full_shape_probe"):
self._release_warmup_pool_before_serving = True
return
if not self._release_warmup_pool_before_serving:
return
self._release_warmup_pool_before_serving = False
if current_platform.is_cpu() or current_platform.is_mps():
return
from sglang.multimodal_gen.runtime.distributed.device_communicators.ipc_a2a import (
IPC_A2A,
)
from sglang.multimodal_gen.runtime.layers.usp import drop_a2a_staging_buffers
IPC_A2A.drop_staging()
drop_a2a_staging_buffers()
torch.get_device_module().empty_cache()
def _record_output_peak_memory(
self, output_batch: OutputBatch, *, is_warmup: bool = False
) -> None:
if current_platform.is_cpu():
return
snapshot = capture_memory_snapshot()
self._runtime_peak_reserved_mb = max(
self._runtime_peak_reserved_mb, snapshot.peak_reserved_mb
)
self._runtime_peak_allocated_mb = max(
self._runtime_peak_allocated_mb, snapshot.peak_allocated_mb
)
if is_warmup:
self._warmup_peak_reserved_mb = max(
self._warmup_peak_reserved_mb, snapshot.peak_reserved_mb
)
else:
self._runtime_peak_reserved_mb = max(
self._runtime_peak_reserved_mb, snapshot.peak_reserved_mb
)
self._runtime_peak_allocated_mb = max(
self._runtime_peak_allocated_mb, snapshot.peak_allocated_mb
)
if self.is_output_rank:
output_batch.peak_memory_mb = snapshot.peak_reserved_mb
@@ -755,6 +1091,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
[
self._load_peak_reserved_mb,
self._runtime_peak_reserved_mb,
self._warmup_peak_reserved_mb,
self._load_peak_allocated_mb,
self._runtime_peak_allocated_mb,
],
@@ -769,6 +1106,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
(
load_peak_mb,
runtime_peak_mb,
warmup_peak_mb,
load_peak_allocated_mb,
runtime_peak_allocated_mb,
) = peaks.tolist()
@@ -789,6 +1127,9 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
peak_allocated_mb=runtime_peak_allocated_mb,
),
)
metrics.record_memory_snapshot(
"warmup_peak", replace(snapshot, peak_reserved_mb=warmup_peak_mb)
)
def _forward_group(self, batch: list[Req]) -> OutputBatch:
assert self.pipeline is not None
@@ -0,0 +1,690 @@
# SPDX-License-Identifier: Apache-2.0
"""Warmup-calibrated automatic component residency placement.
Under ``--performance-mode auto`` with server warmup, each rank measures the
peak GPU memory of bounded synthetic warmup requests and a low-step probe at
the complete default serving shape, then selects a complete serving placement
for every eligible component under the measured memory constraints.
When no full-shape measurement is available, the fallback estimate splits the
measured peak into persistent weights and workload-scaled activations. Scaling
the whole peak would multiply resident weights by the video frame/area cap
ratio (~16x for Wan-class defaults) and residency adjustment would never trigger.
The planner targets the model default workload only (default resolution,
default frames, batch=1). Larger shapes, batches, or multi-image inputs need
explicit ``--component-residency``.
Loading and serving are deliberately separate placement states. The existing
auto policy provides the initial state; when that state can complete loading
and calibration, this module optimizes the long-lived serving state and
validates the transition with a post-placement warmup. It does not force a
single placement to serve two different lifecycle objectives.
"""
from __future__ import annotations
import statistics
from typing import TYPE_CHECKING, Iterable, Mapping
import msgspec
from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency import (
COMPONENT_OFFLOAD,
LAYERWISE_OFFLOAD,
RESIDENT,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload_components import (
is_dit_component_name,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.server_args import ServerArgs
logger = init_logger(__name__)
GIB_BYTES = 1024**3
# Activation memory rarely scales perfectly linearly with workload units;
# pad the extrapolated activation part before checking the budget.
ACTIVATION_EXTRAPOLATION_MARGIN = 1.2
# A target-shape measurement plus the mandatory post-placement warmup justifies
# a tighter reserve than an extrapolated estimate. Both retain an absolute
# floor for allocator slack, shape variance, and CUDA graph or compile pools.
MEASURED_VRAM_RESERVE_FRACTION = 0.05
EXTRAPOLATED_VRAM_RESERVE_FRACTION = 0.10
MIN_VRAM_RESERVE_BYTES = 4 * GIB_BYTES
# The absolute floor is sized for datacenter cards, where either fraction can
# dominate. On a 12 GiB card a flat 4 GiB would fence off a third of the device,
# so cap the floor as a share of what is actually there.
MAX_VRAM_RESERVE_FRACTION = 0.20
# A feasible placement is not automatically useful. Predictions inside this
# interval are treated as latency-equivalent. The joint optimizer then avoids
# changing strategy, minimizes device memory, preserves the faster estimate,
# and finally minimizes HostPin. The raw estimate is already an upper bound:
# transfer time is capped by the measured request.
ESTIMATED_PINNED_H2D_BYTES_PER_SECOND = 24 * GIB_BYTES
MIN_LATENCY_EQUIVALENCE_NS = 50_000_000
MAX_LATENCY_EQUIVALENCE_NS = 100_000_000
LATENCY_EQUIVALENCE_FRACTION = 0.01
# The transfer model ranks feasible placements; the mandatory warmup is the
# authority on whether a selected placement actually helped. Allow normal
# measurement noise, but undo a round whose calibrated request is materially
# slower than the original layout.
POST_ADJUSTMENT_REGRESSION_FRACTION = 0.05
PLACEMENT_STATUS_SKIPPED = "skipped"
PLACEMENT_STATUS_ADJUSTED = "adjusted"
PLACEMENT_STATUS_VALIDATED = "validated"
PLACEMENT_STATUS_ROLLED_BACK = "rolled_back"
PLACEMENT_STATUS_ROLLBACK_FAILED = "rollback_failed"
def describe_error(error: BaseException) -> str:
"""Never-empty error text (str(AssertionError()) is "" and would be
dropped by any truthiness filter)."""
text = str(error)
return f"{type(error).__name__}: {text}" if text else type(error).__name__
class WarmupMemoryRecord(msgspec.Struct, frozen=True):
"""Per-rank memory measurement of one server warmup forward."""
width: int
height: int
num_frames: int
baseline_allocated_bytes: int
peak_allocated_bytes: int
succeeded: bool
peak_reserved_bytes: int = 0
phase_peak_allocated_bytes: dict[str, int] = {}
phase_active_components: dict[str, tuple[str, ...]] = {}
phase_used_components: dict[str, tuple[str, ...]] = {}
phase_full_weight_transition_components: dict[str, tuple[str, ...]] = {}
layerwise_layer_uses: dict[str, dict[str, tuple[int, ...]]] = {}
layerwise_layer_uses_by_stage: dict[str, dict[str, dict[str, tuple[int, ...]]]] = {}
num_inference_steps: int = 1
total_duration_ms: float = 0.0
stage_duration_ms: dict[str, float] = {}
step_duration_ms: tuple[float, ...] = ()
step_duration_ms_by_stage: dict[str, tuple[float, ...]] = {}
stage_iterations: dict[str, tuple[int, int]] = {}
def workload_units(self) -> int:
return max(1, self.width) * max(1, self.height) * max(1, self.num_frames)
class ResidencyTarget(msgspec.Struct, frozen=True):
"""One complete target state for an auto-managed component."""
component_name: str
residency_mode: str
target_resident_weight_bytes: int
# Estimated per-request host-to-device traffic this target removes.
h2d_bytes_per_request: int
# Layerwise candidates jointly choose stage-scoped GPU residency and host
# pinning. None is used by ordinary component placement.
target_layerwise_resident_layers: tuple[int, ...] | None = None
target_layerwise_pinned_layers: tuple[tuple[int, ...], ...] | None = None
pinned_host_delta_bytes: int = 0
host_unpin_scratch_bytes: int = 0
host_pin_scratch_bytes: int = 0
host_materialize_scratch_bytes: int = 0
# Signed device-memory delta while applying the placement before the
# validation warmup. Layerwise -> resident materializes every managed
# layer immediately; a demotion can release those bytes first and fund a
# later materialization in the same transaction.
device_transition_delta_bytes: int = 0
permanent_residency: bool = False
# Device-memory delta relative to the measured placement. A component
# already loaded for its own phase has a different delta from phases where
# it is absent; keeping both avoids adding the same weights twice.
active_device_delta_bytes: int = 0
# Delta when the component is already present because of async prefetch,
# but is not the semantic owner of this phase.
present_device_delta_bytes: int = 0
inactive_device_delta_bytes: int = 0
# None preserves the historical derived target for hand-built callers:
# partial layerwise targets remain layerwise, every other option is
# resident. Generated complete-state frontiers set this explicitly.
target_residency_mode: str | None = None
current_placement: bool = False
target_device_weight_bytes: int = 0
target_pinned_host_bytes: int = 0
def target_mode(self) -> str:
if self.target_residency_mode is not None:
return self.target_residency_mode
if (
self.target_layerwise_resident_layers is not None
and not self.permanent_residency
):
return LAYERWISE_OFFLOAD
return RESIDENT
def option_key(self) -> str:
target_mode = self.target_mode()
if target_mode == COMPONENT_OFFLOAD:
return f"{self.component_name}:component-offload"
if self.target_layerwise_resident_layers is None:
return f"{self.component_name}:resident"
layer_counts = ",".join(
str(count) for count in self.target_layerwise_resident_layers
)
pinned = "|".join(
",".join(str(index) for index in indices) or "-"
for indices in self.target_layerwise_pinned_layers or ()
)
permanence = "permanent" if self.permanent_residency else "stage"
return f"{self.component_name}:{permanence}:layers={layer_counts}:pins={pinned}"
class DefaultWorkload(msgspec.Struct, frozen=True):
"""The model-default request shape the planner is calibrated for."""
width: int | None
height: int | None
num_frames: int
num_inference_steps: int
def workload_units(self) -> int | None:
if self.width is None or self.height is None:
return None
return max(1, self.width) * max(1, self.height) * max(1, self.num_frames)
def describe(self) -> str:
if self.width is None or self.height is None:
return "model-default"
return f"{self.width}x{self.height}x{self.num_frames}f"
class RankResidencyReport(msgspec.Struct, frozen=True):
"""One rank's inputs to the replica-wide placement decision."""
rank: int
budget_bytes: int
estimated_peak_bytes: int | None
target_workload_measured: bool = False
observed_reserved_bytes: int = 0
estimated_peak_bytes_by_phase: dict[str, int] = {}
active_components_by_phase: dict[str, tuple[str, ...]] = {}
used_components_by_phase: dict[str, tuple[str, ...]] = {}
full_weight_transition_components_by_phase: dict[str, tuple[str, ...]] = {}
current_device_weight_bytes_by_component: dict[str, int] = {}
node_rank: int = 0
pinned_host_bytes: int = 0
host_pin_capacity_bytes: int = 0
host_transition_headroom_bytes: int = 0
device_transition_allocated_bytes: int = 0
estimated_request_duration_ns: int = 0
measured_request_duration_ns: int = 0
candidate_latency_savings_ns: dict[str, int] = {}
candidates: list[ResidencyTarget] = []
skip_reason: str | None = None
def resolve_default_workload(server_args: ServerArgs) -> DefaultWorkload:
"""Resolve the default request shape the planner is optimized for."""
from sglang.multimodal_gen.runtime.warmup_request_builder import (
get_model_sampling_defaults,
resolve_default_workload_shape,
)
defaults = get_model_sampling_defaults(server_args)
width, height, num_frames = resolve_default_workload_shape(server_args, defaults)
return DefaultWorkload(
width=width,
height=height,
num_frames=num_frames,
num_inference_steps=defaults.num_inference_steps or 1,
)
def resolve_measured_default_workload(
workload: DefaultWorkload, records: Iterable[WarmupMemoryRecord]
) -> DefaultWorkload:
"""Fill an implicit default resolution from the executed warmup.
Image-edit pipelines can derive their output size from the input image, so
the sampling defaults legitimately omit width and height. The warmup record
is captured after input validation and therefore contains the effective
serving shape. Keep the model-default frame count because video warmup may
intentionally cap frames before measurement.
"""
if workload.workload_units() is not None:
return workload
measured = [
record
for record in records
if record.succeeded and record.width > 0 and record.height > 0
]
if not measured:
return workload
representative = max(measured, key=lambda record: record.width * record.height)
return DefaultWorkload(
width=representative.width,
height=representative.height,
num_frames=workload.num_frames,
num_inference_steps=workload.num_inference_steps,
)
def estimate_layerwise_layer_uses(
*,
records: Iterable[WarmupMemoryRecord],
target_units: int | None,
target_num_inference_steps: int,
) -> dict[str, dict[str, tuple[int, ...]]]:
"""Estimate per-request layer calls from the same calibration forward.
A full-shape memory probe deliberately runs only a few denoise steps.
Stage-attributed calls use that stage's measured and target iteration
counts, so independent shape, paint, refiner, and chunk loops are not all
multiplied by one request-wide ratio. Legacy records retain the repeated
DiT-layer heuristic.
"""
successful = [record for record in records if record.succeeded]
if target_units is not None:
covering = [
record for record in successful if record.workload_units() >= target_units
]
if covering:
successful = covering
estimated: dict[str, dict[str, list[int]]] = {}
for record in successful:
source_steps = max(1, record.num_inference_steps)
component_stages = _component_stages(record)
repeated_stages = _repeated_stages(record, component_stages)
for component_name, groups in record.layerwise_layer_uses.items():
component = estimated.setdefault(component_name, {})
for layer_name, counts in groups.items():
target = component.setdefault(layer_name, [0] * len(counts))
if len(target) != len(counts):
continue
stage_counts = [
(
stage_name,
stage_components[component_name][layer_name],
)
for stage_name, stage_components in (
record.layerwise_layer_uses_by_stage.items()
)
if component_name in stage_components
and layer_name in stage_components[component_name]
and len(stage_components[component_name][layer_name]) == len(counts)
]
for layer_index, count in enumerate(counts):
if stage_counts:
measured_by_stage = sum(
per_layer_counts[layer_index]
for _, per_layer_counts in stage_counts
)
scaled = max(0, count - measured_by_stage)
for stage_name, per_layer_counts in stage_counts:
measured_iterations, target_iterations = _stage_iterations(
record,
stage_name,
repeated_stages=repeated_stages,
target_num_inference_steps=(target_num_inference_steps),
)
stage_count = per_layer_counts[layer_index]
if stage_count <= 1:
scaled += stage_count
else:
scaled += (
stage_count * target_iterations
+ measured_iterations
- 1
) // measured_iterations
else:
scaled = count
component_is_repeated = is_dit_component_name(
component_name
) or any(
stage_name in repeated_stages
for stage_name in component_stages.get(component_name, ())
)
if (
component_is_repeated
and count > 1
and target_num_inference_steps > source_steps
):
scaled = (
count * target_num_inference_steps + source_steps - 1
) // source_steps
target[layer_index] = max(target[layer_index], scaled)
return {
component_name: {
layer_name: tuple(counts) for layer_name, counts in groups.items()
}
for component_name, groups in estimated.items()
}
def _component_stages(
record: WarmupMemoryRecord,
*,
timed_stage_names: set[str] | None = None,
) -> dict[str, set[str]]:
component_stages: dict[str, set[str]] = {}
phase_components = record.phase_used_components or record.phase_active_components
for phase_name, components in phase_components.items():
fields = phase_name.split(":", 2)
if len(fields) < 2 or not fields[0].isdigit():
continue
stage_name = fields[1]
if timed_stage_names is not None and stage_name not in timed_stage_names:
continue
for component_name in components:
component_stages.setdefault(component_name, set()).add(stage_name)
return component_stages
def _repeated_stages(
record: WarmupMemoryRecord,
component_stages: Mapping[str, set[str]],
) -> set[str]:
stages = {
stage_name
for component_name, stage_names in component_stages.items()
if is_dit_component_name(component_name)
for stage_name in stage_names
}
stages.update(record.stage_iterations)
stages.update(
stage_name
for stage_name in set(record.stage_duration_ms).union(
*(stage_names for stage_names in component_stages.values())
)
if stage_name.endswith("DenoisingStage")
and not stage_name.endswith("BeforeDenoisingStage")
)
return stages
def _stage_iterations(
record: WarmupMemoryRecord,
stage_name: str,
*,
repeated_stages: set[str],
target_num_inference_steps: int,
) -> tuple[int, int]:
explicit = record.stage_iterations.get(stage_name)
if explicit is not None:
return max(1, explicit[0]), max(1, explicit[1])
measured = max(1, record.num_inference_steps)
target = (
max(1, target_num_inference_steps)
if stage_name in repeated_stages
else measured
)
return measured, target
def estimate_default_workload_timing(
*,
records: Iterable[WarmupMemoryRecord],
target_units: int | None,
target_num_inference_steps: int,
) -> tuple[int, dict[str, int], dict[str, tuple[str, ...]]]:
"""Estimate full-request and stage durations from the warmup workload.
Repeated stages scale by their own measured/default iteration counts. The
full-shape probe intentionally executes only a few steps, so using its raw
total would make every one-shot encoder transfer look important relative
to a long video request.
"""
successful = [record for record in records if record.succeeded]
if not successful:
return 0, {}, {}
if target_units is not None:
at_target = [
record for record in successful if record.workload_units() >= target_units
]
if at_target:
successful = at_target
representative = max(
successful,
key=lambda record: (
record.workload_units(),
record.total_duration_ms,
),
)
if representative.total_duration_ms <= 0 or not representative.stage_duration_ms:
return 0, {}, {}
component_stages = _component_stages(
representative,
timed_stage_names=set(representative.stage_duration_ms),
)
repeated_stages = _repeated_stages(representative, component_stages)
stage_duration_ns: dict[str, int] = {}
for stage_name, duration_ms in representative.stage_duration_ms.items():
measured_iterations, target_iterations = _stage_iterations(
representative,
stage_name,
repeated_stages=repeated_stages,
target_num_inference_steps=target_num_inference_steps,
)
step_durations = representative.step_duration_ms_by_stage.get(stage_name, ())
if not step_durations and len(repeated_stages) == 1:
step_durations = representative.step_duration_ms
if stage_name in repeated_stages and step_durations:
steady_steps = (
step_durations[1:] if len(step_durations) > 1 else step_durations
)
non_step_ms = max(0.0, duration_ms - sum(step_durations))
target_duration_ms = non_step_ms + (
statistics.median(steady_steps) * target_iterations
)
else:
target_duration_ms = duration_ms * target_iterations / measured_iterations
stage_duration_ns[stage_name] = max(0, int(target_duration_ms * 1_000_000))
measured_stage_ms = sum(representative.stage_duration_ms.values())
untracked_ms = max(0.0, representative.total_duration_ms - measured_stage_ms)
request_duration_ns = sum(stage_duration_ns.values()) + int(
untracked_ms * 1_000_000
)
return (
request_duration_ns,
stage_duration_ns,
{
component_name: tuple(sorted(stage_names))
for component_name, stage_names in component_stages.items()
},
)
def estimate_default_workload_peak_bytes(
*,
records: Iterable[WarmupMemoryRecord],
target_units: int | None,
constant_weight_bytes: int = 0,
) -> int | None:
"""Extrapolate live warmup memory to the default workload.
Real measurements use allocated bytes. Cached allocator blocks are
reclaimable and are covered by the explicit VRAM reserve; treating them as
live memory would charge the same storage again when adding resident
weights.
Preference order:
1. A measurement at or above the target workload bounds the peak directly.
2. Two distinct measured workload sizes fit ``peak = constant + slope *
units``; only the fitted linear part is extrapolated (and padded).
Under offload the pre-forward baseline is nearly empty, so a
single-point split cannot separate constant costs (streamed weights,
attention workspace, tiled VAE decode) from workload-linear
activations -- measured on Wan2.1-14B, single-point scaling
overestimated a ~30 GiB peak as ~183 GiB.
3. One usable size: scale everything above the pre-forward allocated
baseline (conservative; may block adjustment but never over-allocates).
Returns None when the estimate cannot be trusted: no successful records,
the target workload is unknown (an unknown target would silently equate the
area/frame-capped warmup peak with the real serving peak), or a probe at or
below the target ran out of memory. That last case is not missing data but
a measurement: the card could not hold the target as it is already
configured, so making more weights resident can only make it worse. A probe
that failed strictly above the target says nothing about the target and is
dropped instead.
"""
records = list(records)
if target_units is None:
return None
failed_units = [
record.workload_units() for record in records if not record.succeeded
]
if any(units <= target_units for units in failed_units):
return None
records = [record for record in records if record.succeeded]
if not records:
return None
peak_by_units: dict[int, int] = {}
for record in records:
units = record.workload_units()
peak = record.peak_allocated_bytes
peak_by_units[units] = max(peak_by_units.get(units, 0), peak)
covering_peaks = [
peak for units, peak in peak_by_units.items() if units >= target_units
]
if covering_peaks:
return max(covering_peaks)
if len(peak_by_units) >= 2:
# fit on the two largest sizes: closest to the target, best local slope
(large_units, large_peak), (small_units, small_peak) = sorted(
peak_by_units.items(), reverse=True
)[:2]
slope = (large_peak - small_peak) / (large_units - small_units)
if slope >= 0:
constant = max(
large_peak - slope * large_units,
min(constant_weight_bytes, large_peak),
)
return int(
constant + slope * target_units * ACTIVATION_EXTRAPOLATION_MARGIN
)
# negative slope is measurement noise; fall through to the
# conservative single-point estimate
estimates = []
for record in records:
peak = record.peak_allocated_bytes
baseline = min(
max(record.baseline_allocated_bytes, constant_weight_bytes), peak
)
activation = peak - baseline
ratio = target_units / record.workload_units()
estimates.append(
baseline + int(activation * ratio * ACTIVATION_EXTRAPOLATION_MARGIN)
)
return max(estimates)
def estimate_workload_phase_peaks(
*,
records: Iterable[WarmupMemoryRecord],
target_units: int | None,
component_weight_bytes: Mapping[str, int],
) -> tuple[
dict[str, int],
dict[str, tuple[str, ...]],
dict[str, tuple[str, ...]],
dict[str, tuple[str, ...]],
]:
"""Estimate each measured execution phase at the target workload.
A component already active in a phase is part of that phase's measured
peak. Keeping a component resident therefore adds no weight bytes to its
own component-offload phase, while it adds the full footprint to phases
where the component was absent. Measurements with different active layouts
remain separate constraints; combining one layout's peak with another
layout's component set would describe a state that never occurred.
"""
successful = [record for record in records if record.succeeded]
if target_units is not None:
covering = [
record for record in successful if record.workload_units() >= target_units
]
if covering:
successful = covering
grouped: dict[
tuple[str, tuple[str, ...], tuple[str, ...], tuple[str, ...]],
list[WarmupMemoryRecord],
] = {}
for record in successful:
used_by_phase = record.phase_used_components or record.phase_active_components
for phase_name in record.phase_peak_allocated_bytes:
active = tuple(sorted(record.phase_active_components.get(phase_name, ())))
used = tuple(sorted(used_by_phase.get(phase_name, ())))
full_weight_transitions = tuple(
sorted(
record.phase_full_weight_transition_components.get(phase_name, ())
)
)
grouped.setdefault(
(phase_name, active, used, full_weight_transitions), []
).append(record)
layouts_per_phase: dict[str, int] = {}
for phase_name, _, _, _ in grouped:
layouts_per_phase[phase_name] = layouts_per_phase.get(phase_name, 0) + 1
estimated_peaks: dict[str, int] = {}
active_components: dict[str, tuple[str, ...]] = {}
used_components: dict[str, tuple[str, ...]] = {}
full_weight_transition_components: dict[str, tuple[str, ...]] = {}
layout_indices: dict[str, int] = {}
for (
phase_name,
active,
used,
full_weight_transitions,
), phase_records in sorted(grouped.items()):
output_name = phase_name
if layouts_per_phase[phase_name] > 1:
index = layout_indices.get(phase_name, 0)
layout_indices[phase_name] = index + 1
output_name = f"{phase_name}:layout:{index}"
weight_floor = sum(component_weight_bytes.get(name, 0) for name in active)
phase_measurements = [
WarmupMemoryRecord(
width=record.width,
height=record.height,
num_frames=record.num_frames,
baseline_allocated_bytes=min(
record.baseline_allocated_bytes,
record.phase_peak_allocated_bytes[phase_name],
),
peak_allocated_bytes=record.phase_peak_allocated_bytes[phase_name],
succeeded=True,
)
for record in phase_records
]
estimate = estimate_default_workload_peak_bytes(
records=phase_measurements,
target_units=target_units,
constant_weight_bytes=weight_floor,
)
if estimate is None:
continue
estimated_peaks[output_name] = estimate
active_components[output_name] = active
used_components[output_name] = used
full_weight_transition_components[output_name] = full_weight_transitions
return (
estimated_peaks,
active_components,
used_components,
full_weight_transition_components,
)
@@ -1,4 +1,4 @@
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Mapping, MutableMapping, Protocol, Sequence
@@ -58,6 +58,14 @@ class ResidencyState:
batch_is_warmup: bool = False
@dataclass(frozen=True, slots=True)
class WarmupPhasePeak:
active_components: tuple[str, ...]
allocated_bytes: int
used_components: tuple[str, ...] = ()
full_weight_transition_components: tuple[str, ...] = ()
class ResidencyBatch(Protocol):
is_warmup: bool
@@ -129,6 +137,13 @@ class ComponentResidencyManager:
] = {}
self._uses_seen: dict[str, ComponentUse] = {}
self._modules_seen: dict[str, nn.Module] = {}
self._track_warmup_memory = False
self._warmup_phase_key: str | None = None
self._warmup_phase_components: tuple[str, ...] = ()
self._warmup_phase_used_components: tuple[str, ...] = ()
self._warmup_phase_full_weight_transition_components: tuple[str, ...] = ()
self._warmup_phase_peaks: dict[str, WarmupPhasePeak] = {}
self._completed_warmup_phase_peaks: dict[str, WarmupPhasePeak] = {}
def refresh_pipeline(self, pipeline: ComponentResidencyPipeline) -> None:
custom_strategies = dict(pipeline.component_residency_strategies)
@@ -178,6 +193,24 @@ class ComponentResidencyManager:
self._ordered_uses = tuple(
use for uses in self._stage_uses_by_index for use in uses
)
self._track_warmup_memory = (
self.state.batch_is_warmup
and self.server_args.pipeline_config.supports_auto_residency
and current_platform.is_cuda()
and torch.get_device_module().is_available()
)
self._warmup_phase_key = None
self._warmup_phase_components = ()
self._warmup_phase_used_components = ()
self._warmup_phase_full_weight_transition_components = ()
self._warmup_phase_peaks = {}
self._completed_warmup_phase_peaks = {}
if self._track_warmup_memory:
# GPUWorker reset the request peak before entering the pipeline.
# Start the first interval without another reset so preprocessing
# before stage 0 remains part of the placement constraints.
self._warmup_phase_key = "request:before-stage"
self._warmup_phase_components = self._warmup_active_components()
self._validate_explicit_nonresident_components()
def _validate_explicit_nonresident_components(self) -> None:
@@ -224,6 +257,49 @@ class ComponentResidencyManager:
self.state.stage_index = stage_index
self.state.stage_name = self.stage_name(stage)
self.state.next_stage_name = self._next_stage_name(stage_index)
if self._track_warmup_memory:
self._begin_warmup_phase(
key=f"{stage_index}:{self.state.stage_name}:setup",
components=self._warmup_active_components(),
used_components=(),
)
@contextmanager
def full_weight_transition(self, component_names: Iterable[str]) -> Iterator[None]:
"""Measure request logic that temporarily materializes complete weights."""
names = tuple(sorted(set(component_names)))
if not self._track_warmup_memory or not names:
yield
return
previous_phase = (
self._warmup_phase_key,
self._warmup_phase_components,
self._warmup_phase_used_components,
self._warmup_phase_full_weight_transition_components,
)
self._begin_warmup_phase(
key=(
f"{self.state.stage_index}:{self.state.stage_name}:"
f"full-weight-transition:{','.join(names)}"
),
components=self._warmup_active_components(),
used_components=(),
full_weight_transition_components=names,
)
try:
yield
finally:
previous_key, components, used_components, transitions = previous_phase
if previous_key is None:
self._record_warmup_phase_peak()
self._warmup_phase_key = None
else:
self._begin_warmup_phase(
key=previous_key,
components=components,
used_components=used_components,
full_weight_transition_components=transitions,
)
def begin_stage(self) -> None:
"""Prepare a stage that declares one uninterrupted component use."""
@@ -233,6 +309,7 @@ class ComponentResidencyManager:
def end_stage(self) -> None:
"""Close the component interval owned by the current stage."""
self._record_warmup_phase_peak()
if self._active_use is None:
return
if self._active_use.stage_name != self.state.stage_name:
@@ -263,7 +340,13 @@ class ComponentResidencyManager:
self._active_use_module is not None
and active_module is not self._active_use_module
)
requires_prepare = active_module is not None and (
self._active_use_module is None
or module_changed
or use.target_dtype != previous_use.target_dtype
)
if module_changed:
self._begin_warmup_transition(previous_use, None)
self._disable_active_nvtx()
self._finish_use(
previous_use,
@@ -271,18 +354,19 @@ class ComponentResidencyManager:
keep_on_warmup=False,
force=True,
)
if active_module is not None and (
self._active_use_module is None
or module_changed
or use.target_dtype != previous_use.target_dtype
):
self._begin_warmup_transition(None, use)
elif requires_prepare:
self._begin_warmup_transition(previous_use, use)
if requires_prepare:
active_module = self._prepare_forward_use(use, module=active_module)
self._begin_warmup_use(use)
self._active_use = use
self._active_use_module = active_module
self.state.current_use = use
self._enable_nvtx_for_use(use, active_module)
return
if self._active_use is not None:
self._begin_warmup_transition(self._active_use, None)
self._disable_active_nvtx()
self._finish_use(
self._active_use,
@@ -292,8 +376,10 @@ class ComponentResidencyManager:
self._active_use = None
self._active_use_module = None
self.state.current_use = None
self._begin_warmup_transition(None, use)
self._mark_current_use(use)
module = self._prepare_forward_use(use, module=module)
self._begin_warmup_use(use)
self._active_use = use
self._active_use_module = module
self._enable_nvtx_for_use(use, module)
@@ -303,6 +389,7 @@ class ComponentResidencyManager:
"""End one sequential component use interval."""
if self._active_use is None or not self._same_use(self._active_use, use):
return
self._begin_warmup_transition(self._active_use, None)
self._disable_active_nvtx()
self._finish_use(
self._active_use,
@@ -316,6 +403,7 @@ class ComponentResidencyManager:
self._active_use = None
self._active_use_module = None
self.state.current_use = None
self._begin_warmup_between_uses()
self._prefetch_next_memory_intensive_use()
@contextmanager
@@ -378,6 +466,7 @@ class ComponentResidencyManager:
if self._active_use is None:
return
active_use = self._active_use
self._begin_warmup_transition(active_use, None)
self._disable_active_nvtx()
self._finish_use(
active_use,
@@ -387,6 +476,7 @@ class ComponentResidencyManager:
self._active_use = None
self._active_use_module = None
self.state.current_use = None
self._begin_warmup_between_uses()
if prefetch_next:
self._prefetch_next_memory_intensive_use()
@@ -483,8 +573,11 @@ class ComponentResidencyManager:
self._uses_seen[use.component_name] = use
self._modules_seen[use.component_name] = module
self._begin_warmup_prefetch(use)
if strategy.prefetch_for_use(module, use, self.state):
self._prefetched_use_keys.add(self._use_key(use))
else:
self._begin_warmup_between_uses()
def _finish_use(
self,
@@ -534,11 +627,154 @@ class ComponentResidencyManager:
not self._is_single_dit_component(component_name) or keep_single_dit
)
strategy = self.strategy_for(component_name, module)
if self._track_warmup_memory:
will_prepare = self.state.batch_is_warmup and preferred
self._begin_warmup_phase(
key=f"request:cleanup:{component_name}",
components=self._warmup_active_components(
(use,) if will_prepare else ()
),
used_components=(component_name,) if will_prepare else (),
)
was_on_supported_device = self._module_on_supported_device(module)
strategy.finish_request(module, use, self.state, preferred=preferred)
self._empty_cache_after_large_release(
use, strategy, module, was_on_supported_device
)
if self._track_warmup_memory:
self._record_warmup_phase_peak()
self._warmup_phase_peaks["idle"] = WarmupPhasePeak(
active_components=self._warmup_active_components(),
allocated_bytes=int(torch.get_device_module().memory_allocated()),
used_components=(),
)
self._completed_warmup_phase_peaks = dict(self._warmup_phase_peaks)
self._track_warmup_memory = False
def _begin_warmup_phase(
self,
*,
key: str,
components: tuple[str, ...],
used_components: tuple[str, ...],
full_weight_transition_components: tuple[str, ...] = (),
) -> None:
if not self._track_warmup_memory:
return
self._record_warmup_phase_peak()
self._warmup_phase_key = key
self._warmup_phase_components = tuple(sorted(set(components)))
self._warmup_phase_used_components = tuple(sorted(set(used_components)))
self._warmup_phase_full_weight_transition_components = tuple(
sorted(set(full_weight_transition_components))
)
torch.get_device_module().reset_peak_memory_stats()
def _begin_warmup_transition(
self, previous: ComponentUse | None, upcoming: ComponentUse | None
) -> None:
if not self._track_warmup_memory:
return
previous_name = previous.component_name if previous is not None else "idle"
upcoming_name = upcoming.component_name if upcoming is not None else "idle"
self._begin_warmup_phase(
key=(
f"{self.state.stage_index}:{self.state.stage_name}:transition:"
f"{previous_name}->{upcoming_name}"
),
components=self._warmup_active_components(
tuple(use for use in (previous, upcoming) if use is not None)
),
used_components=tuple(
use.component_name for use in (previous, upcoming) if use is not None
),
)
def _begin_warmup_use(self, use: ComponentUse) -> None:
phase = use.phase or use.component_name
self._begin_warmup_phase(
key=f"{self.state.stage_index}:{self.state.stage_name}:use:{phase}",
components=self._warmup_active_components((use,)),
used_components=(use.component_name,),
)
def _begin_warmup_between_uses(self) -> None:
self._begin_warmup_phase(
key=f"{self.state.stage_index}:{self.state.stage_name}:between",
components=self._warmup_active_components(),
used_components=(),
)
def _begin_warmup_prefetch(self, use: ComponentUse) -> None:
phase = use.phase or use.component_name
self._begin_warmup_phase(
key=f"{self.state.stage_index}:{self.state.stage_name}:prefetch:{phase}",
components=self._warmup_active_components((use,)),
used_components=(use.component_name,),
)
def _warmup_active_components(
self, active_uses: Sequence[ComponentUse] = ()
) -> tuple[str, ...]:
components = {use.component_name for use in active_uses}
for component_name, module in self.pipeline.modules.items():
if not isinstance(module, nn.Module):
continue
if is_layerwise_offloaded_module(module):
continue
if self._module_on_supported_device(module):
components.add(component_name)
return tuple(sorted(components))
def _record_warmup_phase_peak(self) -> None:
if not self._track_warmup_memory or self._warmup_phase_key is None:
return
peak = WarmupPhasePeak(
active_components=self._warmup_phase_components,
allocated_bytes=int(torch.get_device_module().max_memory_allocated()),
used_components=self._warmup_phase_used_components,
full_weight_transition_components=(
self._warmup_phase_full_weight_transition_components
),
)
previous = self._warmup_phase_peaks.get(self._warmup_phase_key)
if previous is None:
self._warmup_phase_peaks[self._warmup_phase_key] = peak
else:
self._warmup_phase_peaks[self._warmup_phase_key] = WarmupPhasePeak(
active_components=tuple(
sorted(
set(previous.active_components) & set(peak.active_components)
)
),
allocated_bytes=max(previous.allocated_bytes, peak.allocated_bytes),
used_components=tuple(
sorted(set(previous.used_components) & set(peak.used_components))
),
full_weight_transition_components=tuple(
sorted(
set(previous.full_weight_transition_components)
& set(peak.full_weight_transition_components)
)
),
)
def take_warmup_phase_peaks(
self,
) -> dict[str, WarmupPhasePeak]:
"""Return and clear the most recently completed warmup phase peaks."""
peaks = self._completed_warmup_phase_peaks
self._completed_warmup_phase_peaks = {}
return peaks
def current_device_components(self) -> tuple[str, ...]:
"""Components whose complete module is currently on the device.
Dormant layerwise-managed modules are excluded because only their
resident window is present. Active layerwise uses are attributed by
the managed phase timeline instead.
"""
return self._warmup_active_components()
def stage_name(self, stage: ComponentResidencyStage) -> str:
return self._stage_names_by_id.get(id(stage), stage.__class__.__name__)
@@ -703,6 +939,11 @@ class ComponentResidencyManager:
_GLOBAL_COMPONENT_RESIDENCY_MANAGER: ComponentResidencyManager | None = None
def peek_global_component_residency_manager() -> ComponentResidencyManager | None:
"""Return the process-global manager without creating one."""
return _GLOBAL_COMPONENT_RESIDENCY_MANAGER
def get_global_component_residency_manager(
pipeline: ComponentResidencyPipeline,
server_args: ServerArgs,
@@ -5,7 +5,15 @@ import threading
from collections.abc import Mapping, Sequence
from contextlib import nullcontext
from time import perf_counter
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Set,
Tuple,
)
import torch
from torch.distributed.tensor import DTensor
@@ -2205,3 +2213,99 @@ def configure_layerwise_offload_modules(
elif warn_missing:
logger.debug("No selected pipeline component enabled layerwise offload")
return configured_component_names
class LayerwiseUsageTracker:
"""Temporary per-layer call counters for one calibration request.
Managers cannot provide this information when layerwise offload has not
been configured yet or was disabled by a resident placement. Observe the
declared layer groups directly and remove every hook before returning, so
ordinary serving requests pay no counter or hook-dispatch cost.
"""
def __init__(
self,
modules: Mapping[str, object],
*,
stage_name_provider: Callable[[], str | None] | None = None,
) -> None:
self._handles: list[Any] = []
self._counts: dict[str, dict[str, list[int]]] = {}
self._counts_by_stage: dict[str, dict[str, dict[str, list[int]]]] = {}
self._stage_name_provider = stage_name_provider
for component_name, module in modules.items():
if not isinstance(module, LayerwiseOffloadableModuleMixin):
continue
named_modules = dict(module.named_modules())
component_counts: dict[str, list[int]] = {}
for layer_name in module.layer_names:
layers = named_modules.get(layer_name)
if not isinstance(layers, (torch.nn.ModuleList, torch.nn.Sequential)):
continue
counts = [0] * len(layers)
if not counts:
continue
component_counts[layer_name] = counts
for layer_index, layer in enumerate(layers):
def record_use(
_module,
_inputs,
*,
target_counts=counts,
target_index=layer_index,
target_component_name=component_name,
target_layer_name=layer_name,
target_layer_count=len(layers),
) -> None:
target_counts[target_index] += 1
if self._stage_name_provider is None:
return
stage_name = self._stage_name_provider()
if stage_name is None:
return
stage_counts = self._counts_by_stage.setdefault(stage_name, {})
component_stage_counts = stage_counts.setdefault(
target_component_name, {}
)
layer_stage_counts = component_stage_counts.setdefault(
target_layer_name, [0] * target_layer_count
)
layer_stage_counts[target_index] += 1
self._handles.append(layer.register_forward_pre_hook(record_use))
if component_counts:
self._counts[component_name] = component_counts
def finish(self) -> dict[str, dict[str, tuple[int, ...]]]:
counts, _ = self.finish_with_stages()
return counts
def finish_with_stages(
self,
) -> tuple[
dict[str, dict[str, tuple[int, ...]]],
dict[str, dict[str, dict[str, tuple[int, ...]]]],
]:
for handle in self._handles:
handle.remove()
self._handles.clear()
counts = {
component_name: {
layer_name: tuple(counts)
for layer_name, counts in component_counts.items()
}
for component_name, component_counts in self._counts.items()
}
counts_by_stage = {
stage_name: {
component_name: {
layer_name: tuple(counts)
for layer_name, counts in component_counts.items()
}
for component_name, component_counts in stage_counts.items()
}
for stage_name, stage_counts in self._counts_by_stage.items()
}
return counts, counts_by_stage
@@ -16,15 +16,7 @@ from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.disaggregation.scheduler_mixin import (
SchedulerDisaggMixin,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
GetDisaggStatsReq,
ListLorasReq,
MergeLoraWeightsReq,
@@ -33,6 +25,14 @@ from sglang.multimodal_gen.runtime.entrypoints.utils import (
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.ipc_array import (
is_local_endpoint,
spill_large_arrays_to_file_refs,
@@ -341,6 +341,7 @@ class Req:
self.suppress_logs = True
self.metrics.suppress_stage_breakdown = True
self.extra["cache_dit_num_inference_steps"] = self.num_inference_steps
self.extra["warmup_target_num_inference_steps"] = self.num_inference_steps
self.num_inference_steps = warmup_steps
def copy_as_warmup(self, warmup_steps: int = 1) -> Req:
@@ -348,6 +349,34 @@ class Req:
req.set_as_warmup(warmup_steps)
return req
def record_stage_iterations(
self,
measured_iterations: int,
target_iterations: int | None = None,
) -> None:
"""Record a stage loop against its full default-request work.
Most stages declare the count as a formula of the step count
(``PipelineStage.default_workload_iterations``) and never call this.
It is for loops whose length is only known inside them (chunked or
block-wise schedules); ``target_iterations`` defaults to scaling the
measured count from the probe's steps to the default workload's.
"""
if not self.is_warmup or self.metrics is None:
return
measured = max(1, int(measured_iterations))
if target_iterations is None:
measured_request_steps = max(1, int(self.num_inference_steps))
target_request_steps = int(
self.extra.get(
"warmup_target_num_inference_steps", measured_request_steps
)
)
target_iterations = (
measured * max(1, target_request_steps) + measured_request_steps - 1
) // measured_request_steps
self.metrics.record_stage_iterations(measured, target_iterations)
def validate(self):
"""Initialize dependent fields after dataclass initialization."""
if getattr(self.sampling_params, "data_type", None) == DataType.ACTION:
@@ -55,6 +55,31 @@ class StageVerificationError(Exception):
pass
def record_default_workload_iterations(stage, batch) -> None:
"""Record how often a stage's repeated unit ran, once per warmup request.
A stage whose loop length is a formula of the request's step count
declares it in ``default_workload_iterations``; the same formula at the
default workload's step count is the target. A stage that recorded
explicitly inside its loop (a count only known there) is left alone.
"""
metrics = batch.metrics
if metrics is None or metrics.active_stage_name is None:
return
if metrics.active_stage_name in metrics.stage_iterations:
return
measured = stage.default_workload_iterations(batch, int(batch.num_inference_steps))
if measured is None:
return
target_steps = int(
batch.extra.get("warmup_target_num_inference_steps", batch.num_inference_steps)
)
target = stage.default_workload_iterations(batch, target_steps)
metrics.record_stage_iterations(
max(1, int(measured)), max(1, int(measured if target is None else target))
)
class PipelineStage(StageDedupMixin, ABC):
"""
Abstract base class for all pipeline stages.
@@ -163,6 +188,14 @@ class PipelineStage(StageDedupMixin, ABC):
def set_profile_stage_name(self, stage_name: str) -> None:
self._profile_stage_name = stage_name
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
"""How many times this stage's repeated unit runs for a request with
``num_inference_steps`` steps; ``None`` means the stage is not repeated
(or records its count itself with ``batch.record_stage_iterations``)."""
return None
def _component_stage_name(self, stage_name: str | None = None) -> str:
return stage_name or self._registered_stage_name or self.__class__.__name__
@@ -383,7 +416,14 @@ class PipelineStage(StageDedupMixin, ABC):
# Execute the actual stage logic with unified profiling.
previous_batch_is_warmup = self._current_batch_is_warmup
metrics = batch.metrics
warmup_metrics = metrics if batch.is_warmup else None
previous_active_stage = (
warmup_metrics.active_stage_name if warmup_metrics is not None else None
)
self._current_batch_is_warmup = batch.is_warmup
if warmup_metrics is not None:
warmup_metrics.active_stage_name = self._component_stage_name()
try:
with StageProfiler(
stage_name,
@@ -394,7 +434,11 @@ class PipelineStage(StageDedupMixin, ABC):
perf_dump_path_provided=batch.perf_dump_path is not None,
):
result = self.forward(batch, server_args)
if warmup_metrics is not None:
record_default_workload_iterations(self, batch)
finally:
if warmup_metrics is not None:
warmup_metrics.active_stage_name = previous_active_stage
self._current_batch_is_warmup = previous_batch_is_warmup
self._current_use_nvtx = False
@@ -145,6 +145,12 @@ class CausalDMDRealtimeCacheContext:
class CausalDMDDenoisingStage(DenoisingStage):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
# blocks x fixed DMD steps, known only once the block sizes are laid out
return None
"""
Denoising stage for causal diffusion.
"""
@@ -218,9 +224,8 @@ class CausalDMDDenoisingStage(DenoisingStage):
(scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32))
)
timesteps = scheduler_timesteps[1000 - timesteps]
timesteps = timesteps.to(device)
logger.info("Using timesteps: %s", timesteps)
return timesteps
return timesteps.to(device)
def _prepare_causal_dmd_image_kwargs(
self,
@@ -1254,6 +1259,9 @@ class CausalDMDDenoisingStage(DenoisingStage):
block_sizes = [1] + [self.num_frames_per_block] * num_blocks
start_index = 0
total_iterations = len(block_sizes) * len(timesteps)
batch.record_stage_iterations(total_iterations, total_iterations)
def prepare_context_input(current_latents):
return current_latents
@@ -334,6 +334,11 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
def role_affinity(self):
return RoleType.DENOISER
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
return num_inference_steps
def __init__(
self, transformer, scheduler, pipeline=None, transformer_2=None, vae=None
) -> None:
@@ -25,6 +25,12 @@ logger = init_logger(__name__)
class DmdDenoisingStage(DenoisingStage):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
# a fixed distilled schedule: the same count at any requested step count
return len(self.server_args.pipeline_config.dmd_denoising_steps)
"""
Denoising stage for DMD.
"""
@@ -1063,6 +1063,11 @@ class Cosmos3TimestepPreparationStage(PipelineStage):
class Cosmos3DenoisingStage(PipelineStage, RolloutDenoisingMixin):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
return num_inference_steps
"""Cosmos3 denoise loop, including CFG and the parallelism modes.
The UND pathway runs once and its K/V is cached per cache_key (``cond`` /
@@ -776,5 +776,9 @@ class HeliosChunkedDenoisingStage(PipelineStage):
# separately to avoid temporal artifacts at chunk boundaries.
batch.latent_chunks = chunk_latents_list
batch.latents = history_latents[:, :, -total_generated_latent_frames:]
batch.record_stage_iterations(
global_step_offset,
global_step_offset if is_enable_stage2 else None,
)
return batch
@@ -691,6 +691,7 @@ class LingBotWorldCausalDMDDenoisingStage(CausalDMDDenoisingStage):
"Ensure ImageVAEEncodingStage runs before this stage."
)
ctx = self._prepare_causal_dmd_forward_context(batch, server_args)
batch.record_stage_iterations(len(ctx.timesteps), len(ctx.timesteps))
latents = ctx.latents
cache_ctx = self._prepare_realtime_causal_caches(batch, server_args, ctx)
@@ -421,6 +421,9 @@ class LongLive2CausalDenoisingStage(CausalDMDDenoisingStage):
num_blocks = (t - 1) // self.num_frames_per_block
block_sizes = [1] + [self.num_frames_per_block] * num_blocks
total_iterations = len(block_sizes) * len(timesteps)
batch.record_stage_iterations(total_iterations, total_iterations)
start_index = 0
self._validate_block_prompt_count(batch, block_sizes)
@@ -108,6 +108,12 @@ class LTX2AVDenoisingStage(LTX2DenoisingStage):
class LTX2RefinementStage(LTX2AVDenoisingStage):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
# the refiner runs its distilled sigma schedule regardless of the request's steps
return max(1, len(self.distilled_sigmas) - 1)
"""Stage-2 refinement wrapper that re-noises distilled LTX latents once."""
def __init__(
@@ -443,6 +443,12 @@ def _precompute_rope_cache(
class MiniMaxH3DenoisingStage(DenoisingStage):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
# one denoise per sigma interval: steps - 1
return max(1, num_inference_steps - 1)
def __init__(self, transformer, pipeline=None) -> None:
super().__init__(
transformer=transformer,
@@ -152,6 +152,11 @@ class MOVATimestepPreparationStage(PipelineStage):
class MOVADenoisingStage(PipelineStage):
def default_workload_iterations(
self, batch: Req, num_inference_steps: int
) -> int | None:
return num_inference_steps
"""Run MOVA dual-tower denoising loop."""
def __init__(self, video_dit, video_dit_2, audio_dit, dual_tower_bridge, scheduler):
@@ -725,6 +725,8 @@ class SanaWMLTX2RefinerStage(PipelineStage):
return batch
batch_size = int(batch.latents.shape[0])
total_iterations = batch_size * (len(STAGE_2_DISTILLED_SIGMA_VALUES) - 1)
batch.record_stage_iterations(total_iterations, total_iterations)
prompts = self._prompts_for_batch(batch, batch_size)
fps = float(getattr(batch, "fps", 16) or 16)
@@ -630,6 +630,8 @@ class SanaWMStreamingDenoisingStage(CausalDMDDenoisingStage):
len(explicit_sigmas),
do_cfg,
)
total_iterations = num_chunks * len(explicit_sigmas)
batch.record_stage_iterations(total_iterations, total_iterations)
for chunk_idx in self.progress_bar(range(num_chunks), batch=batch):
chunk_kv, sink_num = self._accumulate_kv_cache(
@@ -671,6 +671,8 @@ class SanaWMStreamingRefinerStage(SanaWMLTX2RefinerStage):
)
return batch
n_blocks = math.ceil(n_active / self.block_size)
total_iterations = n_blocks * (len(STAGE_2_DISTILLED_SIGMA_VALUES) - 1)
batch.record_stage_iterations(total_iterations, total_iterations)
self.log_info(
"SANA-WM streaming refiner: latent=%s, sink=%d, block=%d, blocks=%d, kv_max=%d, seed=%d",
tuple(latents.shape),
@@ -7,6 +7,13 @@ from typing import Any, Optional
import zmq
import zmq.asyncio
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
@@ -15,13 +22,6 @@ from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.ipc_array import materialize_file_refs
from sglang.multimodal_gen.runtime.pipelines_core import Req
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
@@ -10,6 +10,10 @@ from sglang.multimodal_gen import envs
from sglang.multimodal_gen.configs.pipeline_configs.model_deployment_config import (
ModelDeploymentConfig,
)
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
has_realtime_model_adapter,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload_components import (
LAYERWISE_OFFLOAD_ALL_COMPONENTS,
LAYERWISE_OFFLOAD_DIT_GROUP,
@@ -26,6 +30,49 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
def auto_residency_args_skip_reason(server_args: ServerArgs) -> str | None:
"""Return why args cannot use warmup-calibrated residency."""
if envs.SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY:
return "disabled via SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY"
if server_args.performance_mode != "auto":
return f"performance_mode={server_args.performance_mode}"
if (
server_args.pipeline_class_name == "LTX2TwoStagePipeline"
and server_args.ltx2_two_stage_device_mode is None
):
return "legacy LTX-2 two-stage placement"
if server_args.ltx2_two_stage_device_mode == "original":
return "LTX-2 original two-stage placement"
if (
server_args.warmup_mode != "server"
or server_args.disagg_role != RoleType.MONOLITHIC
):
return "no synthetic server warmup to calibrate from"
task_type = server_args.pipeline_config.task_type
if not (task_type.is_visual_gen() or task_type.is_mesh_gen()):
return "no synthetic server warmup to calibrate from"
if not server_args.pipeline_config.supports_auto_residency:
return "pipeline does not support post-warmup residency changes"
if has_realtime_model_adapter(server_args):
return "realtime serving has no representative synthetic warmup"
if server_args.backend == "diffusers":
return "diffusers backend"
if server_args.enable_breakable_cuda_graph:
return "breakable CUDA graph captures during warmup"
if server_args.enable_torch_compile:
# Compile warmup temporarily evicts resident auxiliaries and may
# layerwise-offload the DiT, so its peak is not a serving peak.
return "torch.compile warmup uses a stripped memory layout"
if envs.SGLANG_CACHE_DIT_ENABLED:
return "cache-dit enabled"
if server_args.batching_max_size > 1:
return "dynamic batching enabled"
if not current_platform.is_cuda():
return "requires CUDA"
return None
PERFORMANCE_MODES = ("manual", "auto", "speed", "memory")
DEFAULT_LAYERWISE_COMPONENT_ARG_NAMES = (
@@ -454,6 +454,10 @@ class ServerArgs(DisaggServerArgsMixin):
warmup_resolutions: list[str] = None
warmup_num_frames: int | None = None
warmup_steps: int = 1
# JSON overrides for the representative request shape used by synthetic
# warmup and automatic residency planning. Execution remains bounded by
# warmup_steps and the server warmup frame/area caps.
warmup_sampling_params: dict[str, Any] | str | None = None
disable_autocast: bool | None = None
@@ -2384,6 +2388,18 @@ class ServerArgs(DisaggServerArgsMixin):
default=ServerArgs.warmup_steps,
help="The number of warmup steps to perform for each resolution.",
)
parser.add_argument(
"--warmup-sampling-params",
type=str,
default=ServerArgs.warmup_sampling_params,
help=(
"JSON object overriding model sampling defaults for synthetic "
"warmup and auto residency planning, for example "
'\'{"width":832,"height":480,"num_frames":9,'
'"num_inference_steps":4}\'. Warmup still applies its '
"bounded execution caps."
),
)
# component residency and legacy offload controls
parser.add_argument(
"--component-residency",
@@ -7,15 +7,22 @@ from typing import Any, Awaitable, Callable
from tqdm.auto import tqdm
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
has_realtime_model_adapter,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
Req,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.server_args.auto_tune import (
auto_residency_args_skip_reason,
)
from sglang.multimodal_gen.runtime.utils.image_io import save_base64_image_to_path
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.warmup_request_builder import (
build_warmup_reqs,
lighten_warmup_req,
should_include_warmup_image,
supports_synthetic_warmup,
)
@@ -74,15 +81,7 @@ def should_run_server_warmup(server_args: ServerArgs) -> bool:
def is_realtime_serving(server_args: ServerArgs) -> bool:
"""Synthetic warmup has no realtime session state."""
try:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
get_realtime_model_adapter,
)
get_realtime_model_adapter(server_args)
return True
except Exception:
return False
return has_realtime_model_adapter(server_args)
def should_run_synthetic_server_warmup(server_args: ServerArgs) -> bool:
@@ -101,11 +100,69 @@ def should_run_explicit_client_warmup(server_args: ServerArgs) -> bool:
)
def auto_residency_skip_reason(server_args: ServerArgs) -> str | None:
"""Final gate for warmup-calibrated residency placement.
Only rules out paths the planner was not designed for; the workers
re-check per component (explicit placement, FSDP modules, custom
strategies, missing sizes) and per measurement.
"""
args_reason = auto_residency_args_skip_reason(server_args)
if args_reason is not None:
return args_reason
if not should_run_synthetic_server_warmup(server_args):
return "no synthetic server warmup to calibrate from"
return None
# Enough to clear a probe that overshot the card, few enough that a failure
# which is not about probe size gives up quickly instead of walking the
# workload down to nothing.
MAX_WARMUP_DEGRADE_ATTEMPTS = 3
_OUT_OF_MEMORY_MARKERS = (
"out of memory",
"outofmemory",
"cudaerrormemoryallocation",
"cublas_status_alloc_failed",
"cannot allocate memory",
"unable to allocate",
)
def _is_out_of_memory(error: Any) -> bool:
text = str(error).lower()
return any(marker in text for marker in _OUT_OF_MEMORY_MARKERS)
def _degrade_after_oom(server_args: ServerArgs, req: Req) -> Req | None:
"""Next warmup probe to try after `req` ran the card out of memory.
Only memory failures are worth retrying smaller; anything else fails the
same way at every size and should surface instead of being shrunk away.
"""
lighter = lighten_warmup_req(server_args, req)
if lighter is None:
return None
logger.warning(
"%s ran out of memory; retrying warmup at %s",
format_warmup_req(req),
format_warmup_req(lighter),
)
return lighter
def format_warmup_req(req_or_group: Any) -> str:
req = get_first_generation_req(req_or_group)
prefix = (
"server warmup req" if is_server_based_warmup(req_or_group) else "warmup req"
)
if req is not None and req.extra.get("auto_residency_full_shape_probe"):
prefix = "auto residency probe"
else:
prefix = (
"server warmup req"
if is_server_based_warmup(req_or_group)
else "warmup req"
)
if req is None:
return prefix
@@ -131,6 +188,8 @@ def build_client_warmup_reqs(
server_args: ServerArgs,
*,
warmup_input_path: str | None = None,
rewarm: bool = False,
step_limit: int | None = None,
) -> list[Req]:
warmup_reqs = build_warmup_reqs(
server_args,
@@ -143,6 +202,12 @@ def build_client_warmup_reqs(
for req in warmup_reqs:
if req.is_warmup:
req.extra["warmup_total"] = warmup_total
if step_limit is not None:
req.num_inference_steps = min(req.num_inference_steps, step_limit)
if rewarm:
# a repeat pass after an auto-residency change: keep it out of
# the scheduler's warmup progress accounting (already at N/N)
req.extra["server_warmup_rewarm"] = True
return warmup_reqs
@@ -151,6 +216,8 @@ async def run_async_client_warmup(
forward: Callable[[Req], Awaitable[OutputBatch]],
*,
fail_open: bool = False,
rewarm: bool = False,
step_limit: int | None = None,
) -> None:
try:
warmup_input_path = None
@@ -158,9 +225,20 @@ async def run_async_client_warmup(
warmup_input_path = prepare_warmup_image_path(server_args)
for req in build_client_warmup_reqs(
server_args, warmup_input_path=warmup_input_path
server_args,
warmup_input_path=warmup_input_path,
rewarm=rewarm,
step_limit=step_limit,
):
response = await forward(req)
for _ in range(MAX_WARMUP_DEGRADE_ATTEMPTS):
if response.error is None or not _is_out_of_memory(response.error):
break
lighter = _degrade_after_oom(server_args, req)
if lighter is None:
break
req = lighter
response = await forward(req)
if response.error is not None:
raise RuntimeError(response.error)
except Exception:
@@ -184,6 +262,14 @@ def run_sync_client_warmup(
server_args, warmup_input_path=warmup_input_path
):
response = forward(req)
for _ in range(MAX_WARMUP_DEGRADE_ATTEMPTS):
if response.error is None or not _is_out_of_memory(response.error):
break
lighter = _degrade_after_oom(server_args, req)
if lighter is None:
break
req = lighter
response = forward(req)
if response.error is not None:
raise RuntimeError(response.error)
@@ -275,6 +361,18 @@ class SchedulerWarmupMixin:
if not is_warmup:
return
req = get_first_generation_req(req_or_group)
if req is not None and req.extra.get("server_warmup_rewarm"):
# auto-residency re-warm passes repeat already-counted requests;
# advancing the bar again would log N+1/N in CI
if output_batch.error is not None:
logger.warning(
"%s processing failed: %s",
self._format_warmup_req(req_or_group),
output_batch.error,
)
return
server_based_warmup = is_server_based_warmup(req_or_group)
self._warmup_processed += 1
self._advance_warmup_progress_bar(req_or_group, output_batch)
@@ -58,6 +58,9 @@ class RequestMetrics:
self.request_id = request_id
self.stages: Dict[str, float] = {}
self.steps: list[float] = []
self.steps_by_stage: Dict[str, list[float]] = {}
self.stage_iterations: Dict[str, tuple[int, int]] = {}
self.active_stage_name: str | None = None
self.total_duration_ms: float = 0.0
self.suppress_stage_breakdown: bool = False
# memory tracking: {checkpoint_name: MemorySnapshot}
@@ -77,7 +80,26 @@ class RequestMetrics:
"""Records the duration of a denoising step in execution order."""
if self.suppress_stage_breakdown:
return
self.steps.append(duration_s * 1000)
duration_ms = duration_s * 1000
self.steps.append(duration_ms)
if self.active_stage_name is not None:
self.steps_by_stage.setdefault(self.active_stage_name, []).append(
duration_ms
)
def record_stage_iterations(
self, measured_iterations: int, target_iterations: int
) -> None:
"""Record calibration and default-workload iterations for this stage."""
if self.suppress_stage_breakdown or self.active_stage_name is None:
return
measured = max(1, int(measured_iterations))
target = max(1, int(target_iterations))
previous = self.stage_iterations.get(self.active_stage_name, (0, 0))
self.stage_iterations[self.active_stage_name] = (
previous[0] + measured,
previous[1] + target,
)
def record_memory_snapshot(self, checkpoint_name: str, snapshot: MemorySnapshot):
if self.suppress_stage_breakdown:
@@ -6,6 +6,12 @@ first real request, without copying user traffic. It starts from the model's
sampling defaults, then keeps startup bounded by choosing common low-cost
resolution/frame buckets and trimming the denoising step count.
When warmup-calibrated auto residency is active, warmup uses the full default
serving shape but keeps the trimmed step count. Memory depends on the
activation shape rather than the number of repeated denoising steps, so this
directly measures placement headroom without running a full generation or an
extra stateful pipeline request.
Image models may run a tiny second step because first/last step paths often
initialize different kernels or scheduler state. Video models cap frames and
steps to keep startup bounded. Explicit warmup resolutions share this builder;
@@ -13,17 +19,26 @@ callers send them through the scheduler client so warmup exercises the same
request transport path as real generation.
"""
import json
from copy import copy
from dataclasses import fields, replace
from typing import Any
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
align_num_frames_for_num_gpus,
resolve_sequence_shard,
)
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 (
ServerArgs,
is_ltx2_two_stage_pipeline_name,
)
from sglang.multimodal_gen.runtime.server_args.auto_tune import (
auto_residency_args_skip_reason,
)
from sglang.multimodal_gen.runtime.utils.common import parse_size
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
@@ -42,6 +57,11 @@ SERVER_WARMUP_MAX_VIDEO_FRAMES = 17
SERVER_WARMUP_LTX2_TWO_STAGE_MAX_VIDEO_FRAMES = 25
SERVER_WARMUP_IMAGE_STEPS = 2
SERVER_WARMUP_VIDEO_STEPS = 2
# Two-step schedulers can have one compile-heavy step and one lower-order
# boundary step, leaving no representative steady-state timing sample. Auto
# residency extrapolates this timing to the default request, so collect four
# steps while retaining the shorter warmup for every non-planning path.
AUTO_RESIDENCY_TIMING_STEPS = 4
def get_model_sampling_defaults(server_args: ServerArgs) -> SamplingParams:
@@ -50,13 +70,44 @@ def get_model_sampling_defaults(server_args: ServerArgs) -> SamplingParams:
config_classes = get_pipeline_config_classes(pipeline_class_name)
if config_classes is not None:
_, sampling_params_cls = config_classes
return sampling_params_cls()
defaults = sampling_params_cls()
return _apply_warmup_sampling_overrides(server_args, defaults)
return SamplingParams.from_pretrained(
defaults = SamplingParams.from_pretrained(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
return _apply_warmup_sampling_overrides(server_args, defaults)
def _apply_warmup_sampling_overrides(
server_args: ServerArgs, defaults: SamplingParams
) -> SamplingParams:
value = server_args.warmup_sampling_params
if value is None:
return defaults
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as error:
raise ValueError(
"--warmup-sampling-params must be a JSON object"
) from error
if not isinstance(value, dict):
raise ValueError("--warmup-sampling-params must be a JSON object")
field_names = {item.name for item in fields(defaults)}
unknown = value.keys() - field_names
if unknown:
raise ValueError(
f"invalid --warmup-sampling-params fields: {', '.join(sorted(unknown))}"
)
updated = copy(defaults)
for name, field_value in value.items():
# Some model contracts intentionally expose fixed dataclass fields
# with init=False; a warmup workload still needs to mirror the request.
object.__setattr__(updated, name, field_value)
return updated
def _resolve_default_warmup_resolution(
@@ -221,6 +272,72 @@ def _fit_resolution_to_area(
)
def _halve_num_frames(server_args: ServerArgs, num_frames: int) -> int:
"""Halve the latent frame count, keeping the model's frame arithmetic."""
if num_frames <= 1:
return num_frames
ratio = (
server_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
if not ratio or ratio <= 1:
return max(1, num_frames // 2)
latent_frames = (num_frames - 1) // ratio + 1
# round up: halving 5 latent frames should land on 3 (9 frames), not 2 (5)
return ((latent_frames + 1) // 2 - 1) * ratio + 1
def _lighter_valid_num_frames(server_args: ServerArgs, num_frames: int) -> int:
"""Largest frame count at or below half that the model's frame contract accepts.
``adjust_num_frames`` rounds up (LongLive2 maps 17 frames to 29), so the
halved count is walked down until it is a fixed point of the contract.
"""
halved = _halve_num_frames(server_args, num_frames)
adjust = getattr(server_args.pipeline_config, "adjust_num_frames", None)
for candidate in range(halved, 0, -1):
adjusted = adjust(candidate) if callable(adjust) else candidate
if not isinstance(adjusted, int) or isinstance(adjusted, bool):
adjusted = candidate
if adjusted == candidate:
return candidate if candidate < num_frames else num_frames
return num_frames
def lighten_warmup_req(server_args: ServerArgs, req: Req) -> Req | None:
"""Roughly halve a warmup probe, or None once it cannot shrink further.
Warmup peak memory tracks width * height * num_frames, so a card that could
not hold the full probe usually holds half of it while still walking the
same code path. Frames go first: they drive video activation size, and
cutting them leaves the spatial kernels at their serving shape.
"""
params = req.sampling_params
if params is None:
return None
num_frames = params.num_frames or 1
lighter_frames = _lighter_valid_num_frames(server_args, num_frames)
if lighter_frames < num_frames:
return _replace_warmup_workload(req, num_frames=lighter_frames)
width = params.width
height = params.height
if not width or not height:
return None
alignment = _warmup_resolution_alignment(server_args)
lighter = _fit_resolution_to_area(width, height, width * height // 2, alignment)
# below the alignment floor the fit rounds back up; only take a real cut
if lighter[0] * lighter[1] >= width * height:
return None
return _replace_warmup_workload(req, width=lighter[0], height=lighter[1])
def _replace_warmup_workload(req: Req, **changes: int) -> Req:
lighter = copy(req)
lighter.sampling_params = replace(req.sampling_params, **changes)
return lighter
def _is_resolution_aligned(resolution: tuple[int, int], alignment: int) -> bool:
width, height = resolution
return width % alignment == 0 and height % alignment == 0
@@ -268,7 +385,87 @@ def _resolve_warmup_num_frames(
)
warmup_num_frames = min(num_frames, frame_budget)
return server_args.pipeline_config.adjust_num_frames(warmup_num_frames)
return _apply_warmup_frame_contract(
server_args, sampling_defaults, num_frames=warmup_num_frames
)
def _apply_warmup_frame_contract(
server_args: ServerArgs, sampling_defaults: SamplingParams, *, num_frames: int
) -> int:
"""Re-apply the real-request frame contract to a capped warmup frame count.
Warmup requests skip ``SamplingParams._adjust``, so the cap must run the
model frame contract itself -- without this, e.g. LongLive2's capped 17
frames map to 5 latent frames (not divisible by its 8-frame causal block)
and every server warmup fails silently under fail-open. Pipelines that
align frames to ``num_gpus`` (rather than sharding the sequence dim) get
the same latent alignment real requests get.
"""
num_frames = server_args.pipeline_config.adjust_num_frames(num_frames)
if (
sampling_defaults.adjust_frames
and not resolve_sequence_shard(
server_args.pipeline_config, sampling_defaults.enable_sequence_shard
)
and server_args.num_gpus > 1
):
num_frames = align_num_frames_for_num_gpus(
num_frames,
num_gpus=server_args.num_gpus,
vae_config=server_args.pipeline_config.vae_config,
round_down=sampling_defaults.num_frames_round_down,
)
return num_frames
def resolve_default_workload_shape(
server_args: ServerArgs,
sampling_defaults: SamplingParams,
) -> tuple[int | None, int | None, int]:
"""Resolve the serving shape used by warmup-based memory planning."""
width = sampling_defaults.width
height = sampling_defaults.height
if (width is None or height is None) and sampling_defaults.supported_resolutions:
width, height = max(
sampling_defaults.supported_resolutions,
key=lambda size: size[0] * size[1],
)
num_frames = sampling_defaults.num_frames or 1
if num_frames > 1:
num_frames = _apply_warmup_frame_contract(
server_args, sampling_defaults, num_frames=num_frames
)
return width, height, num_frames
def _resolve_auto_residency_warmup_shape(
server_args: ServerArgs,
sampling_defaults: SamplingParams,
*,
warmup_shape: tuple[int, int, int | None],
server_based_warmup: bool,
) -> tuple[int, int, int] | None:
"""Return the full serving shape when bounded warmup is smaller.
The probe still runs only the bounded warmup step count. Denoising steps
repeat the same activation shape, so one full-shape forward measures the
placement constraints directly without paying for a full generation or
extrapolating a small-shape allocator peak.
"""
if not server_based_warmup:
return None
if auto_residency_args_skip_reason(server_args) is not None:
return None
width, height, num_frames = resolve_default_workload_shape(
server_args, sampling_defaults
)
if width is None or height is None:
return None
target = (width, height, num_frames)
if target == warmup_shape:
return None
return target
def _effective_cfg_scale(sampling_defaults: SamplingParams) -> float | None:
@@ -381,11 +578,42 @@ def build_warmup_reqs(
sampling_defaults,
server_based_warmup=server_based_warmup,
)
auto_residency_warmup_shape = (
_resolve_auto_residency_warmup_shape(
server_args,
sampling_defaults,
warmup_shape=(width, height, warmup_num_frames),
server_based_warmup=server_based_warmup,
)
if warmup_resolutions is None
else None
)
collect_auto_residency_metrics = (
warmup_resolutions is None
and server_based_warmup
and auto_residency_args_skip_reason(server_args) is None
)
if collect_auto_residency_metrics and sampling_defaults.num_inference_steps:
warmup_steps = min(
int(sampling_defaults.num_inference_steps),
max(warmup_steps, AUTO_RESIDENCY_TIMING_STEPS),
)
shapes = [
(width, height, warmup_num_frames, False) for width, height in resolutions
]
if auto_residency_warmup_shape is not None:
# The bounded warmup runs first: its measurement lets the worker size
# the full-shape probe to the memory the card actually has left. The
# bounded shape then runs once more so the allocator pool and kernel
# caches serving starts from are shaped by a serving-sized request,
# not by the probe (the worker drops the probe's pool before it).
shapes.append((*auto_residency_warmup_shape, True))
shapes.append(shapes[0])
# build warmup reqs
warmup_reqs = []
include_warmup_image = should_include_warmup_image(server_args, server_based_warmup)
for width, height in resolutions:
for width, height, num_frames, is_probe in shapes:
req_kwargs = dict(
data_type=task_type.data_type(),
width=width,
@@ -399,7 +627,7 @@ def build_warmup_reqs(
guidance_scale_2=sampling_defaults.guidance_scale_2,
true_cfg_scale=sampling_defaults.true_cfg_scale,
num_inference_steps=sampling_defaults.num_inference_steps,
num_frames=warmup_num_frames,
num_frames=num_frames,
)
if include_warmup_image:
if warmup_input_path is None:
@@ -407,7 +635,13 @@ def build_warmup_reqs(
"Warmup image path is required for image-input model"
)
req_kwargs["prompt"] = DEFAULT_PLACEHOLDER_PROMPT
req_kwargs["image_path"] = [warmup_input_path]
default_image_path = sampling_defaults.image_path
image_count = (
len(default_image_path)
if isinstance(default_image_path, (list, tuple))
else 1
)
req_kwargs["image_path"] = [warmup_input_path] * max(1, image_count)
if server_args.enable_cfg_parallel:
if not req_kwargs.get("negative_prompt"):
req_kwargs["negative_prompt"] = DEFAULT_PLACEHOLDER_PROMPT
@@ -441,6 +675,13 @@ def build_warmup_reqs(
req.extra["return_warmup_result"] = True
if server_based_warmup:
req.extra["server_based_warmup"] = True
if collect_auto_residency_metrics:
# Stage timers already synchronize around warmup stages. Keep
# their values for residency planning instead of discarding
# the measurements after paying that cost.
req.metrics.suppress_stage_breakdown = False
if is_probe:
req.extra["auto_residency_full_shape_probe"] = True
warmup_reqs.append(req)
return warmup_reqs
@@ -1568,6 +1568,7 @@
"expected_median_denoise_ms": 1010.9,
"load_peak_vram_mb": 27320.0,
"runtime_peak_vram_mb": 35926.0,
"warmup_peak_vram_mb": 56748.0,
"load_peak_allocated_mb": 26241.0,
"runtime_peak_allocated_mb": 31922.0,
"estimated_full_test_time_s": 77.0
@@ -1701,6 +1702,7 @@
"expected_median_denoise_ms": 656.68,
"load_peak_vram_mb": 35350.0,
"runtime_peak_vram_mb": 42674.0,
"warmup_peak_vram_mb": 45782.0,
"load_peak_allocated_mb": 34033.0,
"runtime_peak_allocated_mb": 38569.0,
"estimated_full_test_time_s": 243.0
@@ -1833,6 +1835,7 @@
"expected_median_denoise_ms": 2020.71,
"load_peak_vram_mb": 6440.0,
"runtime_peak_vram_mb": 21524.0,
"warmup_peak_vram_mb": 26902.0,
"load_peak_allocated_mb": 6175.0,
"runtime_peak_allocated_mb": 15472.0,
"estimated_full_test_time_s": 204.3
@@ -2812,7 +2815,7 @@
"expected_avg_denoise_ms": 477.49,
"expected_median_denoise_ms": 56.74,
"load_peak_vram_mb": 34022.0,
"runtime_peak_vram_mb": 61190.0,
"runtime_peak_vram_mb": 62706.0,
"load_peak_allocated_mb": 33866.0,
"runtime_peak_allocated_mb": 57075.0,
"estimated_full_test_time_s": 153.1
@@ -465,6 +465,7 @@ class DiffusionServerBase:
summary,
expected_load_peak_vram_mb,
expected_runtime_peak_vram_mb,
scenario.warmup_peak_vram_mb,
expected_load_peak_allocated_mb=scenario.load_peak_allocated_mb,
expected_runtime_peak_allocated_mb=(
scenario.runtime_peak_allocated_mb
@@ -539,10 +540,12 @@ class DiffusionServerBase:
if os.environ.get("SGLANG_GEN_BASELINE", "0") == "1":
logger.info(
"%s realtime peak VRAM baseline: load=%.0fMiB, runtime=%.0fMiB",
"%s realtime peak VRAM baseline: load=%.0fMiB, runtime=%.0fMiB, "
"warmup=%.0fMiB",
case.id,
summary.load_peak_vram_mb,
summary.runtime_peak_vram_mb,
summary.warmup_peak_vram_mb,
)
return
@@ -559,6 +562,7 @@ class DiffusionServerBase:
summary,
scenario.load_peak_vram_mb,
scenario.runtime_peak_vram_mb,
scenario.warmup_peak_vram_mb,
expected_load_peak_allocated_mb=scenario.load_peak_allocated_mb,
expected_runtime_peak_allocated_mb=scenario.runtime_peak_allocated_mb,
)
@@ -579,6 +583,7 @@ class DiffusionServerBase:
"median_denoise_ms": summary.median_denoise_ms,
"load_peak_vram_mb": summary.load_peak_vram_mb,
"runtime_peak_vram_mb": summary.runtime_peak_vram_mb,
"warmup_peak_vram_mb": summary.warmup_peak_vram_mb,
"load_peak_allocated_mb": summary.load_peak_allocated_mb,
"runtime_peak_allocated_mb": summary.runtime_peak_allocated_mb,
"stage_metrics": summary.stage_metrics,
@@ -680,6 +685,7 @@ class DiffusionServerBase:
{
"load_peak_vram_mb": round(summary.load_peak_vram_mb, 2),
"runtime_peak_vram_mb": round(summary.runtime_peak_vram_mb, 2),
"warmup_peak_vram_mb": round(summary.warmup_peak_vram_mb, 2),
"load_peak_allocated_mb": round(summary.load_peak_allocated_mb, 2),
"runtime_peak_allocated_mb": round(
summary.runtime_peak_allocated_mb, 2
@@ -405,7 +405,7 @@ class ServerManager:
"--log-level=debug",
]
if self.extra_args.strip():
command.extend(self.extra_args.strip().split())
command.extend(shlex.split(self.extra_args))
access_log_exclude_flag = "--uvicorn-access-log-exclude-prefixes"
if not any(arg.startswith(access_log_exclude_flag) for arg in command):
command.extend(["--uvicorn-access-log-exclude-prefixes", "/health"])
@@ -590,6 +590,7 @@ class PerformanceValidator:
summary: PerformanceSummary,
expected_load_peak_vram_mb: float,
expected_runtime_peak_vram_mb: float,
expected_warmup_peak_vram_mb: float | None = None,
expected_load_peak_allocated_mb: float | None = None,
expected_runtime_peak_allocated_mb: float | None = None,
) -> None:
@@ -611,6 +612,16 @@ class PerformanceValidator:
expected_allocated=expected_runtime_peak_allocated_mb,
tolerance=self.tolerances.runtime_peak_vram,
)
# the full-shape warmup probe keeps its own budget, separate from serving
if expected_warmup_peak_vram_mb is not None and summary.warmup_peak_vram_mb > 0:
self._assert_le(
"Warmup Peak VRAM",
summary.warmup_peak_vram_mb,
expected_warmup_peak_vram_mb,
self.tolerances.runtime_peak_vram,
min_abs_tolerance=128.0,
unit=" MiB",
)
def _assert_peak_vram(
self,
@@ -117,6 +117,9 @@ class ScenarioConfig:
estimated_full_test_time_s: float | None = None
load_peak_vram_mb: float | None = None
runtime_peak_vram_mb: float | None = None
# Peak of the warmup calibration probe (the default workload's full shape
# under the load-safe placement); None skips the check until a baseline exists.
warmup_peak_vram_mb: float | None = None
# Allocated peaks; when present they are the enforced VRAM figure and the
# reserved peaks above are reported only (reserved tracks pool history).
load_peak_allocated_mb: float | None = None
@@ -140,6 +143,7 @@ class ScenarioConfig:
estimated_full_test_time_s=optional_float("estimated_full_test_time_s"),
load_peak_vram_mb=optional_float("load_peak_vram_mb"),
runtime_peak_vram_mb=optional_float("runtime_peak_vram_mb"),
warmup_peak_vram_mb=optional_float("warmup_peak_vram_mb"),
load_peak_allocated_mb=optional_float("load_peak_allocated_mb"),
runtime_peak_allocated_mb=optional_float("runtime_peak_allocated_mb"),
load_peak_host_anon_mb=optional_float("load_peak_host_anon_mb"),
@@ -451,6 +455,7 @@ class PerformanceSummary:
all_denoise_steps: dict[int, float]
load_peak_vram_mb: float = 0.0
runtime_peak_vram_mb: float = 0.0
warmup_peak_vram_mb: float = 0.0
load_peak_allocated_mb: float = 0.0
runtime_peak_allocated_mb: float = 0.0
load_peak_host_anon_mb: float = 0.0
@@ -490,6 +495,9 @@ class PerformanceSummary:
runtime_peak_vram_mb = float(
record.memory_snapshots.get("runtime_peak", {}).get("peak_reserved_mb", 0.0)
)
warmup_peak_vram_mb = float(
record.memory_snapshots.get("warmup_peak", {}).get("peak_reserved_mb", 0.0)
)
load_peak_allocated_mb = float(
record.memory_snapshots.get("load_peak", {}).get("peak_allocated_mb", 0.0)
)
@@ -517,6 +525,7 @@ class PerformanceSummary:
all_denoise_steps=per_step,
load_peak_vram_mb=load_peak_vram_mb,
runtime_peak_vram_mb=runtime_peak_vram_mb,
warmup_peak_vram_mb=warmup_peak_vram_mb,
load_peak_allocated_mb=load_peak_allocated_mb,
runtime_peak_allocated_mb=runtime_peak_allocated_mb,
load_peak_host_anon_mb=load_peak_host_anon_mb,
@@ -35,6 +35,7 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_a
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
get_realtime_model_adapter,
has_realtime_model_adapter,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world.lingbot_world_causal_denoising import (
@@ -976,6 +977,7 @@ def test_realtime_input_validation_reuses_generator_across_chunks():
def test_realtime_registry_resolves_lingbot_adapter():
server_args = SimpleNamespace(pipeline_config=LingBotWorldCausalDMDConfig())
assert has_realtime_model_adapter(server_args)
adapter = get_realtime_model_adapter(server_args)
assert isinstance(adapter, lingbot_realtime.LingBotWorldRealtimeAdapter)
@@ -0,0 +1,921 @@
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for warmup-calibrated auto residency adjustment."""
from types import SimpleNamespace
import pytest
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
from sglang.multimodal_gen.configs.pipeline_configs.longlive2 import LongLive2T2VConfig
from sglang.multimodal_gen.runtime.managers.memory_managers.auto_residency import (
ACTIVATION_EXTRAPOLATION_MARGIN,
GIB_BYTES,
DefaultWorkload,
WarmupMemoryRecord,
estimate_default_workload_peak_bytes,
estimate_default_workload_timing,
estimate_layerwise_layer_uses,
estimate_workload_phase_peaks,
resolve_measured_default_workload,
)
from sglang.multimodal_gen.runtime.warmup_request_builder import (
SERVER_WARMUP_MAX_VIDEO_FRAMES,
_resolve_auto_residency_warmup_shape,
_resolve_warmup_num_frames,
)
def _record(
*,
width=832,
height=480,
num_frames=17,
baseline_gib=10,
peak_gib=12,
peak_reserved_gib=0,
succeeded=True,
num_inference_steps=1,
total_duration_ms=0.0,
stage_duration_ms=None,
step_duration_ms=(),
step_duration_ms_by_stage=None,
stage_iterations=None,
phase_active_components=None,
phase_used_components=None,
phase_full_weight_transition_components=None,
layerwise_layer_uses=None,
layerwise_layer_uses_by_stage=None,
) -> WarmupMemoryRecord:
return WarmupMemoryRecord(
width=width,
height=height,
num_frames=num_frames,
baseline_allocated_bytes=baseline_gib * GIB_BYTES,
peak_allocated_bytes=peak_gib * GIB_BYTES,
succeeded=succeeded,
peak_reserved_bytes=peak_reserved_gib * GIB_BYTES,
num_inference_steps=num_inference_steps,
total_duration_ms=total_duration_ms,
stage_duration_ms=stage_duration_ms or {},
step_duration_ms=step_duration_ms,
step_duration_ms_by_stage=step_duration_ms_by_stage or {},
stage_iterations=stage_iterations or {},
phase_active_components=phase_active_components or {},
phase_used_components=phase_used_components or {},
phase_full_weight_transition_components=(
phase_full_weight_transition_components or {}
),
layerwise_layer_uses=layerwise_layer_uses or {},
layerwise_layer_uses_by_stage=layerwise_layer_uses_by_stage or {},
)
class TestEstimateDefaultWorkloadTiming:
def test_scales_only_denoising_with_target_steps(self):
record = _record(
num_inference_steps=2,
total_duration_ms=1_200,
stage_duration_ms={
"TextEncodingStage": 100,
"DenoisingStage": 1_000,
},
phase_active_components={
"0:TextEncodingStage:use:text_encoder": ("text_encoder",),
"1:DenoisingStage:use:transformer": ("transformer",),
},
)
request_ns, stage_ns, component_stages = estimate_default_workload_timing(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=40,
)
assert stage_ns == {
"TextEncodingStage": 100_000_000,
"DenoisingStage": 20_000_000_000,
}
assert request_ns == 20_200_000_000
assert component_stages == {
"text_encoder": ("TextEncodingStage",),
"transformer": ("DenoisingStage",),
}
def test_uses_steady_step_instead_of_scaling_first_step_setup(self):
record = _record(
num_inference_steps=2,
total_duration_ms=720,
stage_duration_ms={
"TextEncodingStage": 100,
"DenoisingStage": 620,
},
step_duration_ms=(500, 100),
phase_active_components={
"0:TextEncodingStage:use:text_encoder": ("text_encoder",),
"1:DenoisingStage:use:transformer": ("transformer",),
},
)
request_ns, stage_ns, _ = estimate_default_workload_timing(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=10,
)
assert stage_ns["DenoisingStage"] == 1_020_000_000
assert request_ns == 1_120_000_000
def test_scales_standard_and_nonstandard_denoising_stages_together(self):
record = _record(
num_inference_steps=2,
total_duration_ms=1_200,
stage_duration_ms={
"TextEncodingStage": 100,
"DenoisingStage": 500,
"CustomDenoisingStage": 600,
},
phase_active_components={
"0:TextEncodingStage:use:text_encoder": ("text_encoder",),
"1:DenoisingStage:use:transformer": ("transformer",),
"2:CustomDenoisingStage:use:custom_refiner": ("custom_refiner",),
},
)
request_ns, stage_ns, _ = estimate_default_workload_timing(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=10,
)
assert stage_ns == {
"TextEncodingStage": 100_000_000,
"DenoisingStage": 2_500_000_000,
"CustomDenoisingStage": 3_000_000_000,
}
assert request_ns == 5_600_000_000
def test_uses_each_stage_iteration_target(self):
record = _record(
num_inference_steps=4,
total_duration_ms=1_100,
stage_duration_ms={
"ShapeDenoisingStage": 500,
"PaintStage": 600,
},
step_duration_ms_by_stage={
"ShapeDenoisingStage": (200, 100, 100, 100),
},
stage_iterations={
"ShapeDenoisingStage": (4, 50),
"PaintStage": (4, 30),
},
phase_active_components={
"0:ShapeDenoisingStage:use:hy3dshape_model": ("hy3dshape_model",),
"1:PaintStage:use:paint_transformer": ("paint_transformer",),
},
)
request_ns, stage_ns, _ = estimate_default_workload_timing(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=50,
)
assert stage_ns == {
"ShapeDenoisingStage": 5_000_000_000,
"PaintStage": 4_500_000_000,
}
assert request_ns == 9_500_000_000
class TestEstimateLayerwiseLayerUses:
def test_scales_repeated_groups_but_not_one_shot_groups(self):
record = _record(
num_inference_steps=2,
layerwise_layer_uses={
"transformer": {
"token_refiner.blocks": (1, 1),
"blocks": (2, 2, 2),
},
"vae": {
"encoder.down_blocks": (0, 0),
"decoder.up_blocks": (1, 1),
},
},
)
uses = estimate_layerwise_layer_uses(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=10,
)
assert uses["transformer"] == {
"token_refiner.blocks": (1, 1),
"blocks": (10, 10, 10),
}
assert uses["vae"] == {
"encoder.down_blocks": (0, 0),
"decoder.up_blocks": (1, 1),
}
def test_stage_role_scales_nonstandard_denoiser_but_not_encoder_repeats(self):
record = _record(
num_inference_steps=2,
phase_used_components={
"0:TextEncodingStage:use:custom_encoder": ("custom_encoder",),
"1:CustomDenoisingStage:use:custom_refiner": ("custom_refiner",),
},
layerwise_layer_uses={
"custom_encoder": {"layers": (2, 2)},
"custom_refiner": {"blocks": (2, 2)},
},
)
uses = estimate_layerwise_layer_uses(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=10,
)
assert uses["custom_encoder"]["layers"] == (2, 2)
assert uses["custom_refiner"]["blocks"] == (10, 10)
def test_scales_each_stage_calls_with_its_own_iteration_target(self):
record = _record(
num_inference_steps=4,
phase_used_components={
"0:ShapeStage:use:transformer": ("transformer",),
"1:PaintStage:use:transformer": ("transformer",),
},
stage_iterations={
"ShapeStage": (4, 50),
"PaintStage": (4, 30),
},
layerwise_layer_uses={
"transformer": {"blocks": (9, 9), "one_shot": (2, 2)}
},
layerwise_layer_uses_by_stage={
"ShapeStage": {"transformer": {"blocks": (4, 4), "one_shot": (1, 1)}},
"PaintStage": {"transformer": {"blocks": (4, 4), "one_shot": (1, 1)}},
},
)
uses = estimate_layerwise_layer_uses(
records=[record],
target_units=record.workload_units(),
target_num_inference_steps=50,
)
# one untracked one-shot call + 50 shape calls + 30 paint calls
assert uses["transformer"]["blocks"] == (81, 81)
assert uses["transformer"]["one_shot"] == (2, 2)
class TestEstimateDefaultWorkloadPeak:
def test_same_shape_uses_measured_peak(self):
record = _record()
estimate = estimate_default_workload_peak_bytes(
records=[record], target_units=record.workload_units()
)
assert estimate == record.peak_allocated_bytes
def test_unknown_target_disables_estimation(self):
# An unknown target would silently equate the capped warmup peak with
# the serving peak and promote with no margin at all.
record = _record()
estimate = estimate_default_workload_peak_bytes(
records=[record], target_units=None
)
assert estimate is None
def test_single_capped_record_scales_only_the_activation_part(self):
# Warmup capped to 832x480x17; Wan-class default is 704x1280x121.
record = _record()
target_units = 704 * 1280 * 121
ratio = target_units / record.workload_units()
assert ratio > 10 # the cap ratio this formula exists for
estimate = estimate_default_workload_peak_bytes(
records=[record], target_units=target_units
)
activation = record.peak_allocated_bytes - record.baseline_allocated_bytes
expected = record.baseline_allocated_bytes + int(
activation * ratio * ACTIVATION_EXTRAPOLATION_MARGIN
)
assert estimate == expected
# Scaling the whole peak would inflate the estimate by the resident
# weights times the cap ratio and adjustment would never trigger.
naive = int(record.peak_allocated_bytes * ratio)
assert estimate < naive
def test_two_point_fit_separates_constant_from_linear(self):
# Two calibration sizes let the estimator measure the slope instead
# of assuming everything above the baseline scales. Under offload the
# baseline is nearly empty, so the single-point formula would scale
# the whole peak (~x13 here); the fit extrapolates only the measured
# linear part.
small = _record(num_frames=9, baseline_gib=1, peak_gib=14)
large = _record(num_frames=17, baseline_gib=1, peak_gib=16)
target_units = 1280 * 720 * 81
estimate = estimate_default_workload_peak_bytes(
records=[small, large], target_units=target_units
)
slope = (large.peak_allocated_bytes - small.peak_allocated_bytes) / (
large.workload_units() - small.workload_units()
)
constant = large.peak_allocated_bytes - slope * large.workload_units()
expected = int(
constant + slope * target_units * ACTIVATION_EXTRAPOLATION_MARGIN
)
assert estimate == expected
single_point = estimate_default_workload_peak_bytes(
records=[large], target_units=target_units
)
assert estimate < single_point
def test_negative_slope_falls_back_to_single_point_formula(self):
small = _record(num_frames=9, peak_gib=16)
large = _record(num_frames=17, peak_gib=14)
target_units = 1280 * 720 * 81
estimate = estimate_default_workload_peak_bytes(
records=[small, large], target_units=target_units
)
fallback = max(
estimate_default_workload_peak_bytes(
records=[record], target_units=target_units
)
for record in (small, large)
)
assert estimate == fallback
def test_weight_floor_prevents_scaling_constant_component_memory(self):
small = WarmupMemoryRecord(
width=832,
height=480,
num_frames=9,
baseline_allocated_bytes=GIB_BYTES,
peak_allocated_bytes=int(29.5 * GIB_BYTES),
succeeded=True,
)
large = WarmupMemoryRecord(
width=832,
height=480,
num_frames=17,
baseline_allocated_bytes=GIB_BYTES,
peak_allocated_bytes=int(29.4 * GIB_BYTES),
succeeded=True,
)
target_units = 1024 * 1024 * 81
estimate = estimate_default_workload_peak_bytes(
records=[small, large],
target_units=target_units,
constant_weight_bytes=int(29.4 * GIB_BYTES),
)
fallback_without_weights = estimate_default_workload_peak_bytes(
records=[small, large], target_units=target_units
)
assert estimate < 50 * GIB_BYTES
assert estimate < fallback_without_weights
def test_covering_measurement_bounds_the_target(self):
capped = _record(num_frames=17, peak_gib=12)
full = _record(width=1280, height=720, num_frames=81, peak_gib=30)
estimate = estimate_default_workload_peak_bytes(
records=[capped, full], target_units=1280 * 720 * 81
)
assert estimate == full.peak_allocated_bytes
def test_multiple_records_take_the_max(self):
low = _record(peak_gib=12)
high = _record(peak_gib=20)
estimate = estimate_default_workload_peak_bytes(
records=[low, high], target_units=low.workload_units()
)
assert estimate == high.peak_allocated_bytes
def test_failure_at_the_target_size_disables_estimation(self):
good = _record(num_frames=9, peak_gib=8)
failed = _record(num_frames=17, succeeded=False)
assert (
estimate_default_workload_peak_bytes(
records=[good, failed], target_units=failed.workload_units()
)
is None
)
def test_failure_below_the_target_size_disables_estimation(self):
good = _record(num_frames=9, peak_gib=8)
failed = _record(num_frames=17, succeeded=False)
assert (
estimate_default_workload_peak_bytes(
records=[good, failed], target_units=failed.workload_units() * 2
)
is None
)
def test_failure_above_the_target_size_is_dropped(self):
good = _record(num_frames=9, peak_gib=8)
failed = _record(num_frames=81, succeeded=False)
estimate = estimate_default_workload_peak_bytes(
records=[good, failed], target_units=good.workload_units()
)
assert estimate == good.peak_allocated_bytes
def test_no_records_disables_estimation(self):
assert (
estimate_default_workload_peak_bytes(records=[], target_units=None) is None
)
def test_phase_estimation_preserves_active_component_membership(self):
small = _record(num_frames=9, peak_gib=30)
large = _record(num_frames=17, peak_gib=32)
small = WarmupMemoryRecord(
width=small.width,
height=small.height,
num_frames=small.num_frames,
baseline_allocated_bytes=small.baseline_allocated_bytes,
peak_allocated_bytes=small.peak_allocated_bytes,
succeeded=small.succeeded,
phase_peak_allocated_bytes={"denoise": 30 * GIB_BYTES},
phase_active_components={"denoise": ("transformer",)},
)
large = WarmupMemoryRecord(
width=large.width,
height=large.height,
num_frames=large.num_frames,
baseline_allocated_bytes=large.baseline_allocated_bytes,
peak_allocated_bytes=large.peak_allocated_bytes,
succeeded=large.succeeded,
phase_peak_allocated_bytes={"denoise": 32 * GIB_BYTES},
phase_active_components={"denoise": ("transformer",)},
)
peaks, active, used, _ = estimate_workload_phase_peaks(
records=[small, large],
target_units=832 * 480 * 81,
component_weight_bytes={"transformer": 28 * GIB_BYTES},
)
assert peaks["denoise"] >= 32 * GIB_BYTES
assert active == {"denoise": ("transformer",)}
assert used == active
def test_phase_estimation_uses_allocated_peak(self):
record = WarmupMemoryRecord(
width=1024,
height=1024,
num_frames=1,
baseline_allocated_bytes=5 * GIB_BYTES,
peak_allocated_bytes=12 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"denoise": 11 * GIB_BYTES},
phase_active_components={"denoise": ("transformer",)},
)
peaks, _, _, _ = estimate_workload_phase_peaks(
records=[record],
target_units=record.workload_units(),
component_weight_bytes={"transformer": 10 * GIB_BYTES},
)
assert peaks["denoise"] == 11 * GIB_BYTES
def test_phase_estimation_preserves_full_weight_transition_components(self):
record = WarmupMemoryRecord(
width=1024,
height=1024,
num_frames=1,
baseline_allocated_bytes=2 * GIB_BYTES,
peak_allocated_bytes=4 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"lora_switch": 4 * GIB_BYTES},
phase_full_weight_transition_components={"lora_switch": ("transformer",)},
)
peaks, _, _, transitions = estimate_workload_phase_peaks(
records=[record],
target_units=record.workload_units(),
component_weight_bytes={"transformer": 2 * GIB_BYTES},
)
assert peaks == {"lora_switch": 4 * GIB_BYTES}
assert transitions == {"lora_switch": ("transformer",)}
def test_phase_estimation_prefers_target_layout_over_smaller_warmup(self):
small = WarmupMemoryRecord(
width=256,
height=256,
num_frames=9,
baseline_allocated_bytes=2 * GIB_BYTES,
peak_allocated_bytes=30 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"denoise": 30 * GIB_BYTES},
phase_active_components={"denoise": ()},
)
target = WarmupMemoryRecord(
width=768,
height=512,
num_frames=25,
baseline_allocated_bytes=2 * GIB_BYTES,
peak_allocated_bytes=48 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"denoise": 48 * GIB_BYTES},
phase_active_components={"denoise": ("transformer",)},
)
peaks, active, used, _ = estimate_workload_phase_peaks(
records=[small, target],
target_units=target.workload_units(),
component_weight_bytes={"transformer": 40 * GIB_BYTES},
)
assert peaks == {"denoise": 48 * GIB_BYTES}
assert active == {"denoise": ("transformer",)}
assert used == active
def test_phase_estimation_keeps_distinct_active_layouts_separate(self):
transformer_phase = WarmupMemoryRecord(
width=768,
height=512,
num_frames=25,
baseline_allocated_bytes=2 * GIB_BYTES,
peak_allocated_bytes=48 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"denoise": 48 * GIB_BYTES},
phase_active_components={"denoise": ("transformer",)},
)
encoder_phase = WarmupMemoryRecord(
width=768,
height=512,
num_frames=25,
baseline_allocated_bytes=2 * GIB_BYTES,
peak_allocated_bytes=20 * GIB_BYTES,
succeeded=True,
phase_peak_allocated_bytes={"denoise": 20 * GIB_BYTES},
phase_active_components={"denoise": ("text_encoder",)},
)
peaks, active, used, _ = estimate_workload_phase_peaks(
records=[transformer_phase, encoder_phase],
target_units=transformer_phase.workload_units(),
component_weight_bytes={
"transformer": 40 * GIB_BYTES,
"text_encoder": 10 * GIB_BYTES,
},
)
assert peaks == {
"denoise:layout:0": 20 * GIB_BYTES,
"denoise:layout:1": 48 * GIB_BYTES,
}
assert active == {
"denoise:layout:0": ("text_encoder",),
"denoise:layout:1": ("transformer",),
}
assert used == active
class TestResolveMeasuredDefaultWorkload:
def test_uses_effective_warmup_resolution_for_implicit_image_size(self):
workload = DefaultWorkload(
width=None,
height=None,
num_frames=1,
num_inference_steps=40,
)
resolved = resolve_measured_default_workload(
workload,
[
_record(width=512, height=512),
_record(width=1024, height=1024),
],
)
assert resolved == DefaultWorkload(
width=1024,
height=1024,
num_frames=1,
num_inference_steps=40,
)
def test_keeps_default_frames_when_warmup_caps_video(self):
workload = DefaultWorkload(
width=None,
height=None,
num_frames=81,
num_inference_steps=30,
)
resolved = resolve_measured_default_workload(
workload, [_record(width=832, height=480, num_frames=17)]
)
assert resolved.num_frames == 81
def test_does_not_replace_explicit_default_shape(self):
workload = DefaultWorkload(
width=1280,
height=720,
num_frames=81,
num_inference_steps=30,
)
assert (
resolve_measured_default_workload(
workload, [_record(width=512, height=512)]
)
is workload
)
class TestWarmupFrameAdjustment:
def _server_args(self, *, bcg: bool = False, num_gpus: int = 1) -> SimpleNamespace:
return SimpleNamespace(
pipeline_config=LongLive2T2VConfig(),
pipeline_class_name=None,
enable_breakable_cuda_graph=bcg,
num_gpus=num_gpus,
)
def _defaults(self) -> SimpleNamespace:
return SimpleNamespace(
num_frames=61,
adjust_frames=True,
enable_sequence_shard=None,
num_frames_round_down=False,
)
def test_capped_frames_keep_the_model_frame_contract(self):
# LongLive2 default 61 frames is capped to 17, whose 5 latent frames
# break the 8-frame causal block; the builder must re-align to 29.
assert SERVER_WARMUP_MAX_VIDEO_FRAMES == 17
num_frames = _resolve_warmup_num_frames(
self._server_args(), self._defaults(), server_based_warmup=True
)
assert num_frames == 29
def test_bcg_keeps_full_serving_frames(self):
num_frames = _resolve_warmup_num_frames(
self._server_args(bcg=True), self._defaults(), server_based_warmup=True
)
assert num_frames == 61
def test_non_server_warmup_keeps_default_frames(self):
num_frames = _resolve_warmup_num_frames(
self._server_args(), self._defaults(), server_based_warmup=False
)
assert num_frames == 61
def test_capped_frames_get_the_gpu_alignment_real_requests_get(self):
# a frame-aligning pipeline (no sequence shard) on multiple GPUs:
# 17 frames -> 5 latent frames -> ceil to 6 latents on 2 GPUs -> 21
args = SimpleNamespace(
pipeline_config=SimpleNamespace(
task_type=ModelTaskType.T2V,
adjust_num_frames=lambda n: n,
vae_config=SimpleNamespace(
use_temporal_scaling_frames=True,
arch_config=SimpleNamespace(temporal_compression_ratio=4),
),
),
pipeline_class_name=None,
enable_breakable_cuda_graph=False,
num_gpus=2,
)
defaults = SimpleNamespace(
num_frames=81,
adjust_frames=True,
enable_sequence_shard=None,
num_frames_round_down=False,
)
num_frames = _resolve_warmup_num_frames(
args, defaults, server_based_warmup=True
)
assert num_frames == 21
class TestAutoResidencyWarmupShape:
def _patch_gate(self, monkeypatch, reason: str | None = None) -> None:
monkeypatch.setattr(
"sglang.multimodal_gen.runtime.warmup_request_builder.auto_residency_args_skip_reason",
lambda _args: reason,
)
def _wan_like_args(self) -> SimpleNamespace:
return SimpleNamespace(
pipeline_config=SimpleNamespace(
task_type=ModelTaskType.T2V,
adjust_num_frames=lambda n: n,
),
num_gpus=1,
)
def _defaults(
self,
num_frames: int,
*,
width: int | None = 1280,
height: int | None = 720,
supported_resolutions=None,
) -> SimpleNamespace:
return SimpleNamespace(
width=width,
height=height,
num_frames=num_frames,
supported_resolutions=supported_resolutions,
adjust_frames=True,
enable_sequence_shard=None,
num_frames_round_down=False,
)
def test_capped_video_gets_a_full_shape_probe(self, monkeypatch):
self._patch_gate(monkeypatch)
probe = _resolve_auto_residency_warmup_shape(
self._wan_like_args(),
self._defaults(81),
warmup_shape=(832, 480, 17),
server_based_warmup=True,
)
assert probe == (1280, 720, 81)
def test_matching_warmup_needs_no_probe(self, monkeypatch):
self._patch_gate(monkeypatch)
probe = _resolve_auto_residency_warmup_shape(
self._wan_like_args(),
self._defaults(17, width=832, height=480),
warmup_shape=(832, 480, 17),
server_based_warmup=True,
)
assert probe is None
def test_unknown_target_resolution_skips_probe(self, monkeypatch):
self._patch_gate(monkeypatch)
probe = _resolve_auto_residency_warmup_shape(
self._wan_like_args(),
self._defaults(81, width=None, height=None),
warmup_shape=(832, 480, 17),
server_based_warmup=True,
)
assert probe is None
def test_skip_gate_disables_probe(self, monkeypatch):
# Full-shape calibration must share the adjustment's own gate (kill
# switch, quantized, manual, ...).
self._patch_gate(monkeypatch, reason="performance_mode=manual")
probe = _resolve_auto_residency_warmup_shape(
self._wan_like_args(),
self._defaults(81),
warmup_shape=(832, 480, 17),
server_based_warmup=True,
)
assert probe is None
def test_supported_resolution_fills_missing_target_size(self, monkeypatch):
self._patch_gate(monkeypatch)
probe = _resolve_auto_residency_warmup_shape(
self._wan_like_args(),
self._defaults(
81,
width=None,
height=None,
supported_resolutions=[(832, 480), (1024, 1024)],
),
warmup_shape=(832, 480, 17),
server_based_warmup=True,
)
assert probe == (1024, 1024, 81)
class TestAutoResidencySkipReason:
def _base_args(self, **overrides) -> SimpleNamespace:
args = SimpleNamespace(
performance_mode="auto",
warmup_mode="server",
warmup_resolutions=None,
disagg_role="monolithic",
backend="sglang",
enable_breakable_cuda_graph=False,
enable_torch_compile=False,
batching_max_size=1,
dp_size=1,
ulysses_degree=1,
use_fsdp_inference=False,
quantization=None,
component_quantizations={},
transformer_weights_path=None,
nunchaku_config=None,
direct_gpu_weight_loading=False,
ltx2_two_stage_device_mode=None,
pipeline_class_name=None,
pipeline_config=SimpleNamespace(
task_type=ModelTaskType.T2V,
supports_auto_residency=True,
),
)
for key, value in overrides.items():
setattr(args, key, value)
return args
def _skip_reason(self, args):
from sglang.multimodal_gen.runtime.server_warmup import (
auto_residency_skip_reason,
)
return auto_residency_skip_reason(args)
def test_env_kill_switch(self, monkeypatch):
monkeypatch.setenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", "1")
reason = self._skip_reason(self._base_args())
assert (
reason is not None and "SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY" in reason
)
def test_manual_performance_mode(self, monkeypatch):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
reason = self._skip_reason(self._base_args(performance_mode="manual"))
assert reason == "performance_mode=manual"
@pytest.mark.parametrize(
"overrides, expected_fragment",
[
({"warmup_mode": "request"}, "server warmup"),
({"disagg_role": "denoiser"}, "server warmup"),
({"backend": "diffusers"}, "diffusers"),
(
{"ltx2_two_stage_device_mode": "original"},
"LTX-2 original two-stage placement",
),
(
{"pipeline_class_name": "LTX2TwoStagePipeline"},
"legacy LTX-2 two-stage placement",
),
({"enable_breakable_cuda_graph": True}, "CUDA graph"),
# compile warmup strips the memory layout (layerwise DiT +
# resident aux components on CPU): its peaks are not serving peaks
({"enable_torch_compile": True}, "stripped memory layout"),
({"batching_max_size": 4}, "batching"),
(
{
"pipeline_config": SimpleNamespace(
task_type=ModelTaskType.T2V,
supports_auto_residency=False,
)
},
"post-warmup residency changes",
),
],
)
def test_excluded_paths(self, monkeypatch, overrides, expected_fragment):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
monkeypatch.delenv("SGLANG_CACHE_DIT_ENABLED", raising=False)
reason = self._skip_reason(self._base_args(**overrides))
assert reason is not None and expected_fragment in reason
@pytest.mark.parametrize(
"overrides",
[
{"quantization": "fp8"},
{"component_quantizations": {"image_encoder": "fp8"}},
{"transformer_weights_path": "/x.safetensors"},
{"direct_gpu_weight_loading": True},
],
)
def test_fixed_loading_paths_still_calibrate_other_components(
self, monkeypatch, overrides
):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
monkeypatch.delenv("SGLANG_CACHE_DIT_ENABLED", raising=False)
reason = self._skip_reason(self._base_args(**overrides))
assert reason is None or reason == "requires CUDA"
def test_cache_dit_excluded(self, monkeypatch):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
monkeypatch.setenv("SGLANG_CACHE_DIT_ENABLED", "true")
reason = self._skip_reason(self._base_args())
assert reason is not None and "cache-dit" in reason
@pytest.mark.parametrize(
"overrides",
[
{"dp_size": 2},
{"ulysses_degree": 2},
{"use_fsdp_inference": True},
],
)
def test_parallel_paths_reach_platform_gate(self, monkeypatch, overrides):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
monkeypatch.delenv("SGLANG_CACHE_DIT_ENABLED", raising=False)
reason = self._skip_reason(self._base_args(**overrides))
assert reason is None or reason == "requires CUDA"
def test_eligible_path_reaches_platform_gate(self, monkeypatch):
monkeypatch.delenv("SGLANG_DIFFUSION_DISABLE_AUTO_RESIDENCY", raising=False)
monkeypatch.delenv("SGLANG_CACHE_DIT_ENABLED", raising=False)
reason = self._skip_reason(self._base_args())
# on a CUDA host everything passes; CPU CI stops at the platform gate
assert reason is None or reason == "requires CUDA"
@@ -28,12 +28,13 @@ from sglang.multimodal_gen.configs.pipeline_configs.longlive2 import (
LongLive2T2VConfig,
)
from sglang.multimodal_gen.configs.sample.longlive2 import LongLive2SamplingParams
from sglang.multimodal_gen.configs.sample.minimax_h3 import MiniMaxH3SamplingParams
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import (
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
SetLoraReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.managers.scheduler import Scheduler
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
@@ -53,6 +54,7 @@ from sglang.multimodal_gen.runtime.server_warmup import (
from sglang.multimodal_gen.runtime.warmup_request_builder import (
DEFAULT_PLACEHOLDER_PROMPT,
SERVER_WARMUP_IMAGE_FALLBACK_RESOLUTION,
_apply_warmup_sampling_overrides,
_resolve_warmup_num_frames,
build_warmup_reqs,
should_include_warmup_image,
@@ -108,6 +110,51 @@ def _make_validation_server_args(enable_cfg_parallel: bool) -> MagicMock:
class TestWarmupReqCfgParallel(unittest.TestCase):
"""Warmup request construction and req-based warmup guards."""
def test_sampling_workload_override_accepts_json(self):
defaults = SamplingParams(
width=1024,
height=1024,
num_frames=81,
num_inference_steps=35,
)
server_args = SimpleNamespace(
warmup_sampling_params=(
'{"width":832,"height":480,"num_frames":9,"num_inference_steps":4}'
)
)
overridden = _apply_warmup_sampling_overrides(server_args, defaults)
self.assertEqual(
(
overridden.width,
overridden.height,
overridden.num_frames,
overridden.num_inference_steps,
),
(832, 480, 9, 4),
)
self.assertEqual((defaults.width, defaults.height), (1024, 1024))
def test_sampling_workload_override_rejects_unknown_field(self):
server_args = SimpleNamespace(
warmup_sampling_params={"not_a_sampling_field": 1}
)
with self.assertRaisesRegex(ValueError, "invalid --warmup-sampling-params"):
_apply_warmup_sampling_overrides(server_args, SamplingParams())
def test_sampling_workload_override_supports_fixed_model_fields(self):
defaults = MiniMaxH3SamplingParams()
server_args = SimpleNamespace(
warmup_sampling_params={"num_frames": 49, "fps": 12}
)
overridden = _apply_warmup_sampling_overrides(server_args, defaults)
self.assertEqual((overridden.num_frames, overridden.fps), (49, 12))
self.assertEqual((defaults.num_frames, defaults.fps), (1, 24))
def test_warmup_req_cfg_parallel_sets_do_cfg(self):
server_args = _make_bare_scheduler(enable_cfg_parallel=True).server_args
sampling_defaults = SamplingParams()
@@ -270,6 +317,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_config.task_type = ModelTaskType.T2V
server_args.pipeline_config.adjust_num_frames.side_effect = lambda value: value
@@ -334,6 +382,68 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
self.assertTrue(req.extra["return_warmup_result"])
self.assertTrue(req.extra["server_based_warmup"])
def test_auto_residency_uses_one_full_serving_shape_probe(self):
server_args = SimpleNamespace(
warmup_steps=1,
enable_cfg_parallel=False,
enable_torch_compile=False,
enable_breakable_cuda_graph=False,
pipeline_class_name=None,
num_gpus=1,
pipeline_config=SimpleNamespace(
task_type=ModelTaskType.T2V,
adjust_num_frames=lambda value: value,
vae_stride=None,
vae_scale_factor=None,
vae_config=SimpleNamespace(arch_config=None),
),
is_arg_explicitly_set=lambda _name: False,
)
sampling_defaults = SamplingParams(
width=1280,
height=720,
num_frames=81,
num_inference_steps=35,
adjust_frames=False,
supported_resolutions=[(1280, 720), (832, 480)],
)
with (
patch(
"sglang.multimodal_gen.runtime.warmup_request_builder.get_model_sampling_defaults",
return_value=sampling_defaults,
),
patch(
"sglang.multimodal_gen.runtime.warmup_request_builder.auto_residency_args_skip_reason",
return_value=None,
),
):
reqs = build_warmup_reqs(
server_args,
warmup_resolutions=None,
server_based_warmup=True,
)
# the bounded warmup runs first so the worker can size the probe, and
# once more after it so serving starts from a serving-shaped pool
self.assertEqual(len(reqs), 3)
self.assertFalse(reqs[0].extra.get("auto_residency_full_shape_probe"))
self.assertFalse(reqs[2].extra.get("auto_residency_full_shape_probe"))
self.assertEqual(
(reqs[2].width, reqs[2].height, reqs[2].num_frames),
(reqs[0].width, reqs[0].height, reqs[0].num_frames),
)
self.assertEqual(
(reqs[1].width, reqs[1].height, reqs[1].num_frames),
(1280, 720, 81),
)
self.assertTrue(reqs[1].extra["auto_residency_full_shape_probe"])
self.assertFalse(reqs[1].metrics.suppress_stage_breakdown)
self.assertEqual(reqs[1].num_inference_steps, 4)
self.assertIn(
"auto residency probe (1280x720x81f, 4/35 steps)",
format_warmup_req(reqs[1]),
)
def test_server_based_warmup_uses_model_default_resolution(self):
server_args = MagicMock()
server_args.warmup_steps = 1
@@ -362,6 +472,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_config.task_type = ModelTaskType.T2V
server_args.pipeline_config.adjust_num_frames.side_effect = lambda value: value
@@ -474,6 +585,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_config.task_type = ModelTaskType.T2V
server_args.pipeline_config.adjust_num_frames.side_effect = lambda value: value
@@ -503,6 +615,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
pipeline_config=pipeline_config,
enable_breakable_cuda_graph=False,
pipeline_class_name=None,
num_gpus=1,
)
num_frames = _resolve_warmup_num_frames(
@@ -545,6 +658,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_config.task_type = ModelTaskType.T2V
server_args.pipeline_config.adjust_num_frames.side_effect = lambda value: value
@@ -581,10 +695,15 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_class_name = "LTX2TwoStageHQPipeline"
server_args.pipeline_config.task_type = ModelTaskType.T2V
server_args.pipeline_config.vae_scale_factor = 32
server_args.pipeline_config.vae_config = SimpleNamespace(
use_temporal_scaling_frames=True,
arch_config=SimpleNamespace(temporal_compression_ratio=8),
)
server_args.pipeline_config.adjust_num_frames.return_value = 25
server_args.num_gpus = 2
@@ -725,6 +844,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.is_arg_explicitly_set.return_value = False
server_args.pipeline_config = SimpleNamespace(
task_type=ModelTaskType.I2M,
supports_auto_residency=True,
vae_stride=None,
vae_scale_factor=None,
vae_config=None,
@@ -772,6 +892,33 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
self.assertEqual(reqs[0].image_path, ["/tmp/warmup.png"])
def test_server_based_warmup_keeps_image_input_count(self):
server_args = MagicMock()
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.pipeline_config.task_type = ModelTaskType.TI2I
with patch(
"sglang.multimodal_gen.runtime.warmup_request_builder.get_model_sampling_defaults",
return_value=SamplingParams(
width=512,
height=512,
image_path=["first.png", "second.png"],
),
):
reqs = build_warmup_reqs(
server_args,
warmup_resolutions=None,
warmup_input_path="/tmp/warmup.png",
server_based_warmup=True,
)
self.assertEqual(
reqs[0].image_path,
["/tmp/warmup.png", "/tmp/warmup.png"],
)
def test_server_based_warmup_keeps_required_image_input(self):
server_args = MagicMock()
server_args.warmup_steps = 1
@@ -797,6 +944,7 @@ class TestWarmupReqCfgParallel(unittest.TestCase):
server_args.warmup_steps = 1
server_args.enable_cfg_parallel = False
server_args.enable_torch_compile = False
server_args.num_gpus = 1
server_args.pipeline_config.task_type = ModelTaskType.TI2V
with patch(
@@ -8,6 +8,7 @@ from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager im
ComponentResidencyManager,
ComponentUse,
ResidencyState,
WarmupPhasePeak,
)
from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency import (
ComponentResidencyError,
@@ -22,9 +23,19 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.image_encoding import (
from sglang.multimodal_gen.runtime.pipelines_core.stages.realtime.text_encoding import (
RealtimeTextEncodingStage,
)
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.server_args import ServerArgs
def _server_args(*, supports_auto_residency=True):
return SimpleNamespace(
enable_layerwise_nvtx_marker=False,
pipeline_config=SimpleNamespace(
supports_auto_residency=supports_auto_residency,
),
)
def test_component_offload_releases_preferred_component_after_request():
strategy = ComponentOffloadStrategy()
strategy.finish_use = Mock()
@@ -143,7 +154,7 @@ def test_group_warmup_state_requires_every_batch_to_be_warmup():
_stage_name_mapping={},
component_residency_strategies={},
)
server_args = SimpleNamespace(enable_layerwise_nvtx_marker=False)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
manager.begin_request(
@@ -166,6 +177,246 @@ class _Stage:
return self.uses
def test_warmup_records_use_and_transition_peaks(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
max_memory_allocated=lambda: 7,
memory_allocated=lambda: 2,
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
use = ComponentUse("denoise", "transformer")
stage = _Stage(use)
module = torch.nn.Linear(2, 2)
pipeline = SimpleNamespace(
modules={"transformer": module},
_stage_name_mapping={"denoise": stage},
component_residency_strategies={},
)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
manager.strategy_for = Mock(return_value=Mock())
manager.refresh_pipeline(pipeline)
manager.begin_request([stage], SimpleNamespace(is_warmup=True), server_args)
manager.before_stage(stage, 0, SimpleNamespace(is_warmup=True), server_args)
manager.begin_stage()
manager.end_stage()
manager.finish_request()
peaks = manager.take_warmup_phase_peaks()
inactive_peak = WarmupPhasePeak((), 7)
transformer_peak = WarmupPhasePeak(
("transformer",), 7, used_components=("transformer",)
)
assert peaks["request:before-stage"] == inactive_peak
assert peaks["0:denoise:setup"] == inactive_peak
assert peaks["0:denoise:transition:idle->transformer"] == transformer_peak
assert peaks["0:denoise:use:transformer"] == transformer_peak
assert peaks["0:denoise:transition:transformer->idle"] == transformer_peak
assert peaks["0:denoise:between"] == inactive_peak
# A non-preferred component is being released during cleanup, so it is no
# longer part of the placement that follows this transition.
assert peaks["request:cleanup:transformer"] == inactive_peak
assert peaks["idle"] == WarmupPhasePeak(
active_components=(),
allocated_bytes=2,
)
def test_warmup_skips_memory_tracking_for_unsupported_pipeline(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
stage = _Stage()
pipeline = SimpleNamespace(
modules={},
_stage_name_mapping={"stage": stage},
component_residency_strategies={},
)
server_args = _server_args(supports_auto_residency=False)
manager = ComponentResidencyManager(pipeline, server_args)
manager.begin_request([stage], SimpleNamespace(is_warmup=True), server_args)
manager.before_stage(stage, 0, SimpleNamespace(is_warmup=True), server_args)
manager.finish_request()
assert manager._track_warmup_memory is False
assert manager.take_warmup_phase_peaks() == {}
device_module.reset_peak_memory_stats.assert_not_called()
def test_warmup_records_full_weight_transition_without_preparing(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
max_memory_allocated=lambda: 7,
memory_allocated=lambda: 2,
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
stage = _Stage()
module = torch.nn.Linear(2, 2)
pipeline = SimpleNamespace(
modules={"transformer": module},
_stage_name_mapping={"lora_switch": stage},
component_residency_strategies={},
)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
manager.strategy_for = Mock()
manager.refresh_pipeline(pipeline)
manager.begin_request([stage], SimpleNamespace(is_warmup=True), server_args)
manager.before_stage(stage, 0, SimpleNamespace(is_warmup=True), server_args)
with manager.full_weight_transition(("transformer",)):
pass
assert manager._warmup_phase_peaks[
"0:lora_switch:full-weight-transition:transformer"
] == WarmupPhasePeak(
(),
7,
full_weight_transition_components=("transformer",),
)
assert manager._warmup_phase_key == "0:lora_switch:setup"
manager.strategy_for.assert_not_called()
def test_warmup_records_same_component_dtype_prepare_as_transition(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
max_memory_allocated=lambda: 7,
memory_allocated=lambda: 2,
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
first = ComponentUse("stage", "transformer", target_dtype=torch.float16)
second = ComponentUse("stage", "transformer", target_dtype=torch.bfloat16)
stage = _Stage(first, second)
module = torch.nn.Linear(2, 2)
pipeline = SimpleNamespace(
modules={"transformer": module},
_stage_name_mapping={"stage": stage},
component_residency_strategies={},
)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
strategy = Mock()
manager.strategy_for = Mock(return_value=strategy)
manager.refresh_pipeline(pipeline)
manager.begin_request([stage], SimpleNamespace(is_warmup=True), server_args)
manager.before_stage(stage, 0, SimpleNamespace(is_warmup=True), server_args)
manager.begin_use(first, module=module)
manager.begin_use(second, module=module)
manager._record_warmup_phase_peak()
assert manager._warmup_phase_peaks[
"0:stage:transition:transformer->transformer"
] == WarmupPhasePeak(("transformer",), 7, used_components=("transformer",))
assert strategy.prepare_for_use.call_count == 2
def test_warmup_attributes_prefetch_peak_to_prefetched_component(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
max_memory_allocated=lambda: 7,
memory_allocated=lambda: 2,
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
encoder_use = ComponentUse("encode", "text_encoder")
transformer_use = ComponentUse("denoise", "transformer", memory_intensive=True)
encode_stage = _Stage(encoder_use)
denoise_stage = _Stage(transformer_use)
modules = {
"text_encoder": torch.nn.Linear(2, 2),
"transformer": torch.nn.Linear(2, 2),
}
pipeline = SimpleNamespace(
modules=modules,
_stage_name_mapping={
"encode": encode_stage,
"denoise": denoise_stage,
},
component_residency_strategies={},
)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
strategy = Mock()
strategy.prefetch_for_use.return_value = True
manager.strategy_for = Mock(return_value=strategy)
manager.refresh_pipeline(pipeline)
manager.begin_request(
[encode_stage, denoise_stage],
SimpleNamespace(is_warmup=True),
server_args,
)
manager.before_stage(encode_stage, 0, SimpleNamespace(is_warmup=True), server_args)
manager.begin_stage()
manager.end_stage()
manager.before_stage(denoise_stage, 1, SimpleNamespace(is_warmup=True), server_args)
assert manager._warmup_phase_peaks[
"0:encode:prefetch:transformer"
] == WarmupPhasePeak(("transformer",), 7, used_components=("transformer",))
assert manager._warmup_phase_peaks["0:encode:between"] == WarmupPhasePeak((), 7)
def test_warmup_splits_sequential_component_transition(monkeypatch):
device_module = SimpleNamespace(
is_available=lambda: True,
reset_peak_memory_stats=Mock(),
max_memory_allocated=lambda: 7,
memory_allocated=lambda: 2,
)
monkeypatch.setattr(torch, "get_device_module", lambda: device_module)
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
first = ComponentUse("stage", "text_encoder")
second = ComponentUse("stage", "transformer")
stage = _Stage(first, second)
modules = {
"text_encoder": torch.nn.Linear(2, 2),
"transformer": torch.nn.Linear(2, 2),
}
pipeline = SimpleNamespace(
modules=modules,
_stage_name_mapping={"stage": stage},
component_residency_strategies={},
)
server_args = _server_args()
manager = ComponentResidencyManager(pipeline, server_args)
manager.strategy_for = Mock(return_value=Mock())
manager.refresh_pipeline(pipeline)
manager.begin_request([stage], SimpleNamespace(is_warmup=True), server_args)
manager.before_stage(stage, 0, SimpleNamespace(is_warmup=True), server_args)
manager.begin_use(first)
manager.begin_use(second)
manager._record_warmup_phase_peak()
assert manager._warmup_phase_peaks["0:stage:transition:text_encoder->idle"] == (
WarmupPhasePeak(("text_encoder",), 7, used_components=("text_encoder",))
)
assert manager._warmup_phase_peaks["0:stage:transition:idle->transformer"] == (
WarmupPhasePeak(("transformer",), 7, used_components=("transformer",))
)
def _manager_for_stage(stage, modules):
pipeline = SimpleNamespace(
modules=modules,
@@ -3,8 +3,8 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch
from sglang.multimodal_gen.runtime.entrypoints import diffusion_generator as dg
from sglang.multimodal_gen.runtime.entrypoints.control_requests import ShutdownReq
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
class _FakeProcess:
@@ -6,7 +6,10 @@ and a realtime session always lands on the same replica it started on.
import itertools
from sglang.multimodal_gen.runtime.entrypoints.utils import SetLoraReq, ShutdownReq
from sglang.multimodal_gen.runtime.entrypoints.control_requests import (
SetLoraReq,
ShutdownReq,
)
from sglang.multimodal_gen.runtime.pipelines_core import Req
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import (
@@ -56,3 +56,38 @@ def test_peer_cuda_device_uses_the_ulysses_group_mapping():
patch(f"{_IPC}.dist.all_gather_object", side_effect=gather),
):
assert _peer_cuda_device(group, rank=1, device=3) == 2
def test_drop_staging_clears_cached_buffers_and_nothing_else():
from collections import OrderedDict
from sglang.multimodal_gen.runtime.distributed.device_communicators.ipc_a2a import (
IpcA2AState,
)
state = IpcA2AState()
state.inited = True
state.calls = 7
state.staging = OrderedDict(
{(4, 4, "bf16"): ("local", "peer"), (8, 8, "bf16"): ("l", "p")}
)
state.drop_staging()
assert state.staging == OrderedDict()
assert state.inited is True and state.calls == 7
state.drop_staging() # idempotent on an empty cache
def test_drop_a2a_staging_buffers_clears_the_ulysses_cache():
import torch
from sglang.multimodal_gen.runtime.layers import usp
usp._A2A_STAGING_BUFFERS[("qkv", torch.float16, 0)] = torch.empty(
8, dtype=torch.float16
)
with patch.object(torch.cuda, "is_available", return_value=False):
usp.drop_a2a_staging_buffers()
usp.drop_a2a_staging_buffers() # idempotent on an empty cache
assert usp._A2A_STAGING_BUFFERS == {}
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
from sglang.multimodal_gen.runtime import launch_server as ls
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.entrypoints.utils import ShutdownReq
from sglang.multimodal_gen.runtime.entrypoints.control_requests import ShutdownReq
class _FakeProcess:
@@ -1,11 +1,16 @@
import json
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
import torch
import sglang.multimodal_gen.runtime.managers.gpu_worker as gpu_worker_module
import sglang.multimodal_gen.runtime.managers.memory_managers.component_manager as component_manager_module
from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
WarmupPhasePeak,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.utils.perf_logger import (
@@ -23,6 +28,16 @@ from sglang.multimodal_gen.test.server.testcase_configs import (
)
@pytest.fixture(autouse=True)
def _isolate_global_component_residency_manager(monkeypatch):
# Worker paths exercised here create the process-global residency manager
# for their fake pipeline; leaving it behind hands its modules to later
# tests (the worker prefers the global manager's placement modules).
monkeypatch.setattr(
component_manager_module, "_GLOBAL_COMPONENT_RESIDENCY_MANAGER", None
)
def _perf_record(memory_snapshots: dict[str, dict]) -> RequestPerfRecord:
return RequestPerfRecord(
request_id="request",
@@ -35,6 +50,27 @@ def _perf_record(memory_snapshots: dict[str, dict]) -> RequestPerfRecord:
)
def test_request_metrics_attributes_steps_and_iterations_to_active_stage():
metrics = RequestMetrics("request")
metrics.active_stage_name = "ShapeStage"
metrics.record_step(0.1)
metrics.record_stage_iterations(4, 50)
metrics.active_stage_name = "PaintStage"
metrics.record_step(0.2)
metrics.record_stage_iterations(4, 30)
assert metrics.steps == [100.0, 200.0]
assert metrics.steps_by_stage == {
"ShapeStage": [100.0],
"PaintStage": [200.0],
}
assert metrics.stage_iterations == {
"ShapeStage": (4, 50),
"PaintStage": (4, 30),
}
def test_performance_summary_separates_load_and_runtime_peaks():
summary = PerformanceSummary.from_req_perf_record(
_perf_record(
@@ -62,13 +98,14 @@ def test_worker_records_replica_load_and_runtime_peaks():
worker.is_output_rank = True
worker._load_peak_reserved_mb = 4096.0
worker._runtime_peak_reserved_mb = 0.0
worker._warmup_peak_reserved_mb = 0.0
worker._load_peak_allocated_mb = 3000.0
worker._runtime_peak_allocated_mb = 0.0
output = OutputBatch()
metrics = RequestMetrics("request")
replica_group = Mock()
replica_group.all_reduce.return_value = torch.tensor(
[5120.0, 3584.0, 3500.0, 2560.0], dtype=torch.float64
[5120.0, 3584.0, 6144.0, 3500.0, 2560.0], dtype=torch.float64
)
snapshots = [
MemorySnapshot(0.0, 0.0, 2048.0, 3072.0),
@@ -97,6 +134,88 @@ def test_worker_records_replica_load_and_runtime_peaks():
assert metrics.memory_snapshots["runtime_peak"].peak_reserved_mb == 3584.0
assert metrics.memory_snapshots["load_peak"].peak_allocated_mb == 3500.0
assert metrics.memory_snapshots["runtime_peak"].peak_allocated_mb == 2560.0
assert metrics.memory_snapshots["warmup_peak"].peak_reserved_mb == 6144.0
def test_server_warmup_preserves_peak_after_managed_stage_timeline():
worker = GPUWorker.__new__(GPUWorker)
worker._auto_residency_warmup_records = []
residency_manager = Mock()
residency_manager.take_warmup_phase_peaks.return_value = {
"0:denoise:use:transformer": WarmupPhasePeak(("transformer",), 8)
}
residency_manager.current_device_components.return_value = ("transformer",)
device_module = Mock()
device_module.max_memory_allocated.return_value = 9
device_module.max_memory_reserved.return_value = 12
req = SimpleNamespace(
width=64,
height=64,
num_frames=1,
num_inference_steps=1,
metrics=None,
)
with (
patch.object(torch, "get_device_module", return_value=device_module),
patch.object(
gpu_worker_module,
"peek_global_component_residency_manager",
return_value=residency_manager,
),
):
worker._record_server_warmup_memory(
req=req,
workload=(128, 96, 9, 2),
baseline_allocated_bytes=3,
succeeded=True,
)
record = worker._auto_residency_warmup_records[0]
assert record.peak_allocated_bytes == 9
assert record.peak_reserved_bytes == 12
assert (record.width, record.height, record.num_frames) == (128, 96, 9)
assert record.num_inference_steps == 2
assert record.phase_peak_allocated_bytes["request:untracked"] == 9
assert record.phase_active_components["request:untracked"] == ("transformer",)
def test_server_warmup_does_not_treat_allocator_cache_as_untracked_live_memory():
worker = GPUWorker.__new__(GPUWorker)
worker._auto_residency_warmup_records = []
residency_manager = Mock()
residency_manager.take_warmup_phase_peaks.return_value = {
"0:denoise:use:transformer": WarmupPhasePeak(("transformer",), 8)
}
residency_manager.current_device_components.return_value = ("transformer",)
device_module = Mock()
device_module.max_memory_allocated.return_value = 8
device_module.max_memory_reserved.return_value = 12
req = SimpleNamespace(
width=64,
height=64,
num_frames=1,
num_inference_steps=1,
metrics=None,
)
with (
patch.object(torch, "get_device_module", return_value=device_module),
patch.object(
gpu_worker_module,
"peek_global_component_residency_manager",
return_value=residency_manager,
),
):
worker._record_server_warmup_memory(
req=req,
workload=(64, 64, 1, 1),
baseline_allocated_bytes=3,
succeeded=True,
)
record = worker._auto_residency_warmup_records[0]
assert "request:untracked" not in record.phase_peak_allocated_bytes
def test_baseline_config_loads_per_scenario_peak_vram(tmp_path):
@@ -249,6 +368,16 @@ def test_peak_vram_validation_enforces_allocated_when_baselined():
with pytest.raises(AssertionError, match="Runtime Peak VRAM"):
validator.validate_peak_vram(reserved_drift, 10_000.0, 10_000.0)
reserved_drift.warmup_peak_vram_mb = 12_000.0
with pytest.raises(AssertionError, match="Warmup Peak VRAM"):
validator.validate_peak_vram(
reserved_drift,
10_000.0,
10_000.0,
expected_warmup_peak_vram_mb=10_000.0,
expected_runtime_peak_allocated_mb=8_000.0,
)
@pytest.mark.parametrize(
("load_peak_vram_mb", "runtime_peak_vram_mb", "message"),
@@ -308,3 +437,138 @@ def test_results_json_merges_retry_sessions(tmp_path):
results = json.loads(path.read_text(encoding="utf-8"))
assert {item["test_name"] for item in results} == {"first", "second"}
def _warmup_batch_for_iterations(steps: int, target_steps: int):
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestMetrics
metrics = RequestMetrics(request_id="probe")
metrics.suppress_stage_breakdown = False
metrics.active_stage_name = "DenoisingStage"
return SimpleNamespace(
metrics=metrics,
num_inference_steps=steps,
extra={"warmup_target_num_inference_steps": target_steps},
is_warmup=True,
)
def test_stage_formula_records_probe_and_default_iterations():
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import (
record_default_workload_iterations,
)
batch = _warmup_batch_for_iterations(steps=4, target_steps=50)
stage = SimpleNamespace(default_workload_iterations=lambda batch, steps: steps - 1)
record_default_workload_iterations(stage, batch)
assert batch.metrics.stage_iterations == {"DenoisingStage": (3, 49)}
def test_fixed_schedule_formula_records_the_same_count_twice():
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import (
record_default_workload_iterations,
)
batch = _warmup_batch_for_iterations(steps=4, target_steps=50)
stage = SimpleNamespace(default_workload_iterations=lambda batch, steps: 8)
record_default_workload_iterations(stage, batch)
assert batch.metrics.stage_iterations == {"DenoisingStage": (8, 8)}
def test_explicit_loop_record_wins_over_the_formula():
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import (
record_default_workload_iterations,
)
batch = _warmup_batch_for_iterations(steps=4, target_steps=50)
batch.metrics.record_stage_iterations(12, 12)
stage = SimpleNamespace(default_workload_iterations=lambda batch, steps: steps)
record_default_workload_iterations(stage, batch)
assert batch.metrics.stage_iterations == {"DenoisingStage": (12, 12)}
def test_stage_without_a_formula_records_nothing():
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import (
record_default_workload_iterations,
)
batch = _warmup_batch_for_iterations(steps=4, target_steps=50)
stage = SimpleNamespace(default_workload_iterations=lambda batch, steps: None)
record_default_workload_iterations(stage, batch)
assert batch.metrics.stage_iterations == {}
def test_warmup_peak_stays_out_of_the_runtime_peak():
from unittest import mock
worker = gpu_worker_module.GPUWorker.__new__(gpu_worker_module.GPUWorker)
worker._runtime_peak_reserved_mb = 0.0
worker._runtime_peak_allocated_mb = 0.0
worker._warmup_peak_reserved_mb = 0.0
worker.is_output_rank = False
with (
mock.patch.object(
gpu_worker_module.current_platform, "is_cpu", return_value=False
),
mock.patch.object(
gpu_worker_module,
"capture_memory_snapshot",
side_effect=[
MemorySnapshot(0.0, 0.0, 35000.0, 40000.0),
MemorySnapshot(0.0, 0.0, 15000.0, 20000.0),
],
),
):
worker._record_output_peak_memory(SimpleNamespace(), is_warmup=True)
worker._record_output_peak_memory(SimpleNamespace(), is_warmup=False)
assert worker._warmup_peak_reserved_mb == 40000.0
assert worker._runtime_peak_reserved_mb == 20000.0
assert worker._runtime_peak_allocated_mb == 15000.0
def _warmup_req(*, probe: bool = False) -> SimpleNamespace:
extra = {"auto_residency_full_shape_probe": True} if probe else {}
return SimpleNamespace(is_warmup=True, extra=extra)
def test_worker_releases_the_probe_pool_before_the_next_request(monkeypatch):
import sglang.multimodal_gen.runtime.distributed.device_communicators.ipc_a2a as ipc_a2a_module
calls = []
fake_device = SimpleNamespace(empty_cache=lambda: calls.append("empty_cache"))
monkeypatch.setattr(
gpu_worker_module.torch, "get_device_module", lambda: fake_device
)
monkeypatch.setattr(type(current_platform), "is_cpu", lambda self: False)
monkeypatch.setattr(type(current_platform), "is_mps", lambda self: False)
monkeypatch.setattr(
ipc_a2a_module.IPC_A2A, "drop_staging", lambda: calls.append("drop_staging")
)
worker = GPUWorker.__new__(GPUWorker)
worker._release_warmup_pool_before_serving = False
worker._release_warmup_pool(_warmup_req())
worker._release_warmup_pool(_warmup_req(probe=True))
assert calls == []
# the bounded re-warm after the probe regrows the pool from empty, and the
# IPC staging buffers sized for the probe's messages go with it
worker._release_warmup_pool(_warmup_req())
assert calls == ["drop_staging", "empty_cache"]
worker._release_warmup_pool(SimpleNamespace(is_warmup=False, extra={}))
assert calls == ["drop_staging", "empty_cache"]
def test_worker_keeps_the_pool_when_no_probe_ran(monkeypatch):
calls = []
fake_device = SimpleNamespace(empty_cache=lambda: calls.append("empty_cache"))
monkeypatch.setattr(
gpu_worker_module.torch, "get_device_module", lambda: fake_device
)
worker = GPUWorker.__new__(GPUWorker)
worker._release_warmup_pool_before_serving = False
worker._release_warmup_pool(_warmup_req())
worker._release_warmup_pool(SimpleNamespace(is_warmup=False, extra={}))
assert calls == []
@@ -0,0 +1,179 @@
"""A warmup probe that does not fit is retried smaller instead of abandoned."""
from types import SimpleNamespace
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.warmup_request_builder import lighten_warmup_req
def _server_args(temporal_compression_ratio: int = 4) -> SimpleNamespace:
arch_config = SimpleNamespace(
temporal_compression_ratio=temporal_compression_ratio,
vae_scale_factor=8,
spatial_compression_ratio=8,
)
return SimpleNamespace(
pipeline_class_name=None,
pipeline_config=SimpleNamespace(
vae_config=SimpleNamespace(arch_config=arch_config),
vae_scale_factor=8,
),
)
def _req(width: int, height: int, num_frames: int) -> Req:
return Req(
sampling_params=SamplingParams(
width=width, height=height, num_frames=num_frames
)
)
class TestLightenWarmupReq:
def test_video_probe_halves_latent_frames_first(self):
lighter = lighten_warmup_req(_server_args(), _req(832, 480, 17))
assert (lighter.width, lighter.height) == (832, 480)
assert lighter.num_frames == 9
def test_frames_step_down_to_a_single_frame(self):
server_args = _server_args()
frames = []
req = _req(832, 480, 17)
for _ in range(4):
req = lighten_warmup_req(server_args, req)
if req is None:
break
frames.append(req.num_frames)
assert frames[:3] == [9, 5, 1]
def test_image_probe_halves_the_area(self):
lighter = lighten_warmup_req(_server_args(), _req(1024, 1024, 1))
assert lighter.num_frames == 1
assert lighter.width * lighter.height <= 1024 * 1024 // 2
assert lighter.width % 16 == 0 and lighter.height % 16 == 0
def test_the_original_request_is_left_alone(self):
req = _req(832, 480, 17)
lighten_warmup_req(_server_args(), req)
assert req.num_frames == 17
def test_a_probe_at_the_floor_cannot_shrink(self):
assert lighten_warmup_req(_server_args(), _req(16, 16, 1)) is None
def test_frames_follow_the_model_frame_contract(self):
# LongLive2-style contract: latent frames come in causal blocks of 8,
# so with a temporal ratio of 4 only 29, 61, 93, ... frames are valid.
server_args = _server_args()
def adjust_num_frames(num_frames: int) -> int:
latent = (num_frames - 1) // 4 + 1
if latent % 8 == 0:
return num_frames
return (max(8, latent // 8 * 8) - 1) * 4 + 1
server_args.pipeline_config.adjust_num_frames = adjust_num_frames
lighter = lighten_warmup_req(server_args, _req(960, 928, 61))
assert lighter.num_frames == 29
assert (lighter.width, lighter.height) == (960, 928)
# At the smallest valid frame count the probe shrinks the area instead.
floor = lighten_warmup_req(server_args, lighter)
assert floor.num_frames == 29
assert floor.width * floor.height <= 960 * 928 // 2
def _record(width: int, height: int, num_frames: int, *, peak_gib: float):
from sglang.multimodal_gen.runtime.managers.memory_managers.auto_residency import (
WarmupMemoryRecord,
)
return WarmupMemoryRecord(
width=width,
height=height,
num_frames=num_frames,
baseline_allocated_bytes=2 << 30,
peak_allocated_bytes=int(peak_gib * (1 << 30)),
succeeded=True,
)
class TestFitAutoResidencyProbe:
def test_probe_shrinks_until_its_extrapolated_peak_fits(self):
from sglang.multimodal_gen.runtime.managers.gpu_worker import (
fit_auto_residency_probe,
)
fitted, estimate, steps = fit_auto_residency_probe(
_req(1280, 720, 81),
records=[_record(832, 480, 81, peak_gib=20.0)],
free_bytes=40 << 30,
total_bytes=80 << 30,
server_args=_server_args(),
)
assert steps >= 1
assert (fitted.width, fitted.height) == (1280, 720)
assert fitted.num_frames < 81
assert estimate is not None and estimate <= 40 << 30
def test_probe_that_fits_runs_at_full_shape(self):
from sglang.multimodal_gen.runtime.managers.gpu_worker import (
fit_auto_residency_probe,
)
fitted, _, steps = fit_auto_residency_probe(
_req(1280, 720, 81),
records=[_record(832, 480, 81, peak_gib=20.0)],
free_bytes=79 << 30,
total_bytes=80 << 30,
server_args=_server_args(),
)
assert steps == 0
assert fitted.num_frames == 81
def test_probe_never_shrinks_below_the_bounded_warmup_shape(self):
from sglang.multimodal_gen.runtime.managers.gpu_worker import (
fit_auto_residency_probe,
)
# Nothing fits the extrapolation, but the bounded 832x480x17f warmup
# already ran, so the ladder (81 -> 41 -> 21 -> 9 frames) stops at the
# first shape at or below it instead of reaching a 16x16x1f probe.
fitted, _, steps = fit_auto_residency_probe(
_req(832, 480, 81),
records=[_record(832, 480, 17, peak_gib=30.0)],
free_bytes=8 << 30,
total_bytes=80 << 30,
server_args=_server_args(),
)
assert steps >= 1
assert (fitted.width, fitted.height) == (832, 480)
assert fitted.num_frames == 9
def test_without_a_trusted_estimate_the_probe_runs_as_requested(self):
from sglang.multimodal_gen.runtime.managers.gpu_worker import (
fit_auto_residency_probe,
)
fitted, estimate, steps = fit_auto_residency_probe(
_req(1280, 720, 81),
records=[],
free_bytes=1 << 30,
total_bytes=80 << 30,
server_args=_server_args(),
)
assert (steps, estimate) == (0, None)
assert fitted.num_frames == 81
class TestOutOfMemoryClassification:
def test_allocation_failures_from_libraries_count_as_out_of_memory(self):
from sglang.multimodal_gen.runtime.server_warmup import _is_out_of_memory
assert _is_out_of_memory("CUDA error: out of memory")
assert _is_out_of_memory("cuBLAS error: CUBLAS_STATUS_ALLOC_FAILED")
assert _is_out_of_memory(
"RuntimeError: cudaErrorMemoryAllocation: out of memory"
)
assert not _is_out_of_memory("shape mismatch in attention")