[diffusion] optimization: reduce minimax h3 mps memory pressure (#33880)

This commit is contained in:
Mick
2026-08-19 13:35:01 +08:00
committed by GitHub
parent 3391ab3712
commit 593b1a9b8a
26 changed files with 757 additions and 98 deletions
@@ -25,6 +25,9 @@ from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend i
AttentionRequirements, AttentionRequirements,
) )
from sglang.multimodal_gen.runtime.layers.attention.selector import get_attn_backend from sglang.multimodal_gen.runtime.layers.attention.selector import get_attn_backend
from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency import (
LAYERWISE_OFFLOAD,
)
from sglang.multimodal_gen.runtime.platforms import ( from sglang.multimodal_gen.runtime.platforms import (
AttentionBackendEnum, AttentionBackendEnum,
current_platform, current_platform,
@@ -186,6 +189,29 @@ class MiniMaxH3PipelineConfig(PipelineConfig):
def validate_server_args(self, server_args) -> None: def validate_server_args(self, server_args) -> None:
# Reject known-inexact VAE modes before any large component download. # Reject known-inexact VAE modes before any large component download.
self.vae_config.resolved_parallel_decode_mode() self.vae_config.resolved_parallel_decode_mode()
if current_platform.is_mps():
required_components = (
"transformer",
"text_encoder",
"video_vae",
"audio_vae",
)
missing_components = [
component
for component in required_components
if server_args.residency_mode(component) != LAYERWISE_OFFLOAD
]
if missing_components:
raise ValueError(
"MiniMax-H3 on MPS requires synchronous layerwise offload for "
f"{missing_components}; pass --layerwise-offload-components "
"transformer text_encoder video_vae audio_vae"
)
if server_args.enable_torch_compile:
raise ValueError(
"MiniMax-H3 MPS execution does not support torch.compile; "
"pass --enable-torch-compile false"
)
component_backends = server_args.component_attention_backends or {} component_backends = server_args.component_attention_backends or {}
attention_backend = component_backends.get( attention_backend = component_backends.get(
"transformer", self._server_arg_value(server_args.attention_backend) "transformer", self._server_arg_value(server_args.attention_backend)
@@ -24,6 +24,8 @@ _PYTORCH_DEFAULT_CUDA_SDP_BACKENDS = [
SDPBackend.MATH, SDPBackend.MATH,
] ]
_MPS_VARLEN_QUERY_CHUNK_SIZE = 128
class SDPABackend(AttentionBackend): class SDPABackend(AttentionBackend):
@@ -128,13 +130,31 @@ class SDPAImpl(AttentionImpl):
for start, stop in zip(bounds[:-1], bounds[1:]): for start, stop in zip(bounds[:-1], bounds[1:]):
if start == stop: if start == stop:
continue continue
segment = self.forward( if query.device.type != "mps":
query[start:stop].unsqueeze(0), segment = self.forward(
key[start:stop].unsqueeze(0), query[start:stop].unsqueeze(0),
value[start:stop].unsqueeze(0), key[start:stop].unsqueeze(0),
None, value[start:stop].unsqueeze(0),
) None,
output[start:stop].copy_(segment[0]) )
output[start:stop].copy_(segment[0])
continue
# mps SDPA materializes a quadratic temporary for a varlen segment
# chunking query rows keeps every row's complete K/V context intact
keys = key[start:stop].unsqueeze(0)
values = value[start:stop].unsqueeze(0)
for query_start in range(start, stop, _MPS_VARLEN_QUERY_CHUNK_SIZE):
query_stop = min(query_start + _MPS_VARLEN_QUERY_CHUNK_SIZE, stop)
segment = self.forward(
query[query_start:query_stop].unsqueeze(0),
keys,
values,
None,
)
output[query_start:query_stop].copy_(segment[0])
torch.mps.synchronize()
torch.mps.empty_cache()
return output return output
@@ -154,11 +154,11 @@ class UnquantizedLinearMethod(LinearMethodBase):
def apply( def apply(
self, layer: torch.nn.Module, x: torch.Tensor, bias: torch.Tensor | None = None self, layer: torch.nn.Module, x: torch.Tensor, bias: torch.Tensor | None = None
) -> torch.Tensor: ) -> torch.Tensor:
if x.device.type == "mps" and ( if x.device.type == "mps":
x.dtype != torch.float32 if x.dtype == layer.weight.dtype and (
or layer.weight.dtype != torch.float32 bias is None or bias.dtype == x.dtype
or (bias is not None and bias.dtype != torch.float32) ):
): return F.linear(x, layer.weight, bias)
return F.linear( return F.linear(
x.to(torch.float32), x.to(torch.float32),
layer.weight.to(torch.float32), layer.weight.to(torch.float32),
@@ -530,7 +530,7 @@ class TextEncoderLoader(ComponentLoader):
) )
component_starts_on_cpu = False component_starts_on_cpu = False
if component_starts_on_cpu and not current_platform.is_mps(): if component_starts_on_cpu:
model_device = torch.device("cpu") model_device = torch.device("cpu")
else: else:
model_device = local_torch_device model_device = local_torch_device
@@ -559,6 +559,12 @@ class TextEncoderLoader(ComponentLoader):
) )
model.bind_encoder_tp_group(encoder_tp_group) model.bind_encoder_tp_group(encoder_tp_group)
if current_platform.is_mps() and component_starts_on_cpu:
# the h3 encoder is layered immediately after this loader returns
# compatible CPU safetensors stay mapped instead of copying the
# full Qwen checkpoint into unified memory
model._mps_zero_copy_weight_loading = True
weights_to_load = {name for name, _ in model.named_parameters()} weights_to_load = {name for name, _ in model.named_parameters()}
loaded_weights = model.load_weights( loaded_weights = model.load_weights(
self._get_all_weights( self._get_all_weights(
@@ -581,7 +587,10 @@ class TextEncoderLoader(ComponentLoader):
if component_starts_on_cpu: if component_starts_on_cpu:
if current_platform.is_mps(): if current_platform.is_mps():
model = model.to(local_torch_device) logger.info(
"Keeping %s on CPU for MPS layerwise offload",
model.__class__.__name__,
)
else: else:
model = model.to("cpu") model = model.to("cpu")
else: else:
@@ -136,6 +136,18 @@ class TransformerLoader(ComponentLoader):
] ]
expected_library = "diffusers" expected_library = "diffusers"
def customized_load_kwargs_for_component(
self, server_args: ServerArgs, component_name: str
) -> dict[str, bool]:
if current_platform.is_mps() and self._is_component_set_as_layerwise_load(
server_args, component_name
):
logger.info(
"Loading %s on CPU first for MPS layerwise offload", component_name
)
return {"cpu_offload_flag": True}
return {}
def should_raise_customized_load_error( def should_raise_customized_load_error(
self, server_args: ServerArgs, component_name: str self, server_args: ServerArgs, component_name: str
) -> bool: ) -> bool:
@@ -151,7 +163,11 @@ class TransformerLoader(ComponentLoader):
) )
def load_customized( def load_customized(
self, component_model_path: str, server_args: ServerArgs, component_name: str self,
component_model_path: str,
server_args: ServerArgs,
component_name: str,
cpu_offload_flag: bool = False,
): ):
"""Load the transformer based on the model path, and inference args.""" """Load the transformer based on the model path, and inference args."""
component_server_args = _server_args_for_transformer_component( component_server_args = _server_args_for_transformer_component(
@@ -196,8 +212,9 @@ class TransformerLoader(ComponentLoader):
) )
# Quantization adapters may require resident weights, so placement must # Quantization adapters may require resident weights, so placement must
# be resolved after they have validated the component configuration. # be resolved after they have validated the component configuration.
component_starts_on_cpu = server_args.should_start_component_on_cpu( component_starts_on_cpu = (
component_name server_args.should_start_component_on_cpu(component_name)
or cpu_offload_flag
) )
use_fsdp = server_args.should_use_fsdp_for_component(component_name) use_fsdp = server_args.should_use_fsdp_for_component(component_name)
@@ -259,10 +276,14 @@ class TransformerLoader(ComponentLoader):
logger.debug("quantization config: %s", init_params["quant_config"]) logger.debug("quantization config: %s", init_params["quant_config"])
local_torch_device = get_local_torch_device() local_torch_device = get_local_torch_device()
checkpoint_load_device = _resolve_checkpoint_load_device( checkpoint_load_device = (
local_torch_device, torch.device("cpu")
component_starts_on_cpu=component_starts_on_cpu, if cpu_offload_flag
runtime_quant_config=quant_spec.runtime_quant_config, else _resolve_checkpoint_load_device(
local_torch_device,
component_starts_on_cpu=component_starts_on_cpu,
runtime_quant_config=quant_spec.runtime_quant_config,
)
) )
direct_gpu_weight_loading = bool( direct_gpu_weight_loading = bool(
component_server_args.direct_gpu_weight_loading component_server_args.direct_gpu_weight_loading
@@ -276,6 +297,9 @@ class TransformerLoader(ComponentLoader):
needs_device_weight_postprocess=quant_spec.needs_device_weight_postprocess, needs_device_weight_postprocess=quant_spec.needs_device_weight_postprocess,
component_starts_on_cpu=component_starts_on_cpu, component_starts_on_cpu=component_starts_on_cpu,
load_full_state_dict_on_device=direct_gpu_weight_loading, load_full_state_dict_on_device=direct_gpu_weight_loading,
mps_layerwise_cpu_staging=bool(
cpu_offload_flag and current_platform.is_mps()
),
) )
if direct_gpu_weight_loading: if direct_gpu_weight_loading:
logger.warning( logger.warning(
@@ -98,8 +98,24 @@ class VAELoader(ComponentLoader):
component_names = ["vae", "audio_vae", "video_vae"] component_names = ["vae", "audio_vae", "video_vae"]
expected_library = "diffusers" expected_library = "diffusers"
def customized_load_kwargs_for_component(
self, server_args: ServerArgs, component_name: str
) -> dict[str, bool]:
if current_platform.is_mps() and self._is_component_set_as_layerwise_load(
server_args, component_name
):
logger.info(
"Loading %s on CPU first for MPS layerwise offload", component_name
)
return {"cpu_offload_flag": True}
return {}
def load_customized( def load_customized(
self, component_model_path: str, server_args: ServerArgs, component_name: str self,
component_model_path: str,
server_args: ServerArgs,
component_name: str,
cpu_offload_flag: bool = False,
): ):
"""Load the VAE based on the model path, and inference args.""" """Load the VAE based on the model path, and inference args."""
config = get_diffusers_component_config(component_path=component_model_path) config = get_diffusers_component_config(component_path=component_model_path)
@@ -133,8 +149,9 @@ class VAELoader(ComponentLoader):
# NOTE: some post init logics are only available after updated with config # NOTE: some post init logics are only available after updated with config
vae_config.post_init() vae_config.post_init()
component_starts_on_cpu = server_args.should_start_component_on_cpu( component_starts_on_cpu = (
component_name server_args.should_start_component_on_cpu(component_name)
or cpu_offload_flag
) )
target_device = self.target_device(component_starts_on_cpu) target_device = self.target_device(component_starts_on_cpu)
@@ -190,7 +207,11 @@ class VAELoader(ComponentLoader):
loaded.update(safetensors_load_file(sf_path)) loaded.update(safetensors_load_file(sf_path))
_backfill_ltx2_audio_vae_latent_stats(loaded, component_name) _backfill_ltx2_audio_vae_latent_stats(loaded, component_name)
strict_load = native_only strict_load = native_only
vae.load_state_dict(loaded, strict=strict_load) vae.load_state_dict(
loaded,
strict=strict_load,
assign=bool(cpu_offload_flag and current_platform.is_mps()),
)
if not strict_load: if not strict_load:
state_keys = set(vae.state_dict().keys()) state_keys = set(vae.state_dict().keys())
@@ -295,6 +295,15 @@ def maybe_load_fsdp_model(
logger.info("Disabling FSDP for MPS platform as it's not compatible") logger.info("Disabling FSDP for MPS platform as it's not compatible")
weight_load_plan = weight_load_plan or WeightLoadPlan(checkpoint_load_device=device) weight_load_plan = weight_load_plan or WeightLoadPlan(checkpoint_load_device=device)
mps_zero_copy_weight_loading = bool(
current_platform.is_mps()
and weight_load_plan.mps_layerwise_cpu_staging
and weight_load_plan.checkpoint_load_device.type == "cpu"
)
if mps_zero_copy_weight_loading:
# layerwise offload replaces block parameters with mps placeholders after
# load, so compatible checkpoint tensors stay file-backed on CPU
model._mps_zero_copy_weight_loading = True
defer_cpu_placement = bool( defer_cpu_placement = bool(
component_starts_on_cpu component_starts_on_cpu
and weight_load_plan.defer_cpu_placement and weight_load_plan.defer_cpu_placement
@@ -408,6 +417,7 @@ def maybe_load_fsdp_model(
strict=strict, strict=strict,
cpu_offload=load_on_cpu, cpu_offload=load_on_cpu,
param_names_mapping=param_names_mapping_fn, param_names_mapping=param_names_mapping_fn,
mps_zero_copy_weight_loading=mps_zero_copy_weight_loading,
preconverted_state_dict=preconverted_state_dict, preconverted_state_dict=preconverted_state_dict,
) )
if bnb_quant_states: if bnb_quant_states:
@@ -531,6 +541,7 @@ def load_model_from_full_model_state_dict(
strict: bool = False, strict: bool = False,
cpu_offload: bool = False, cpu_offload: bool = False,
param_names_mapping: Callable[[str], tuple[str, Any, Any]] | None = None, param_names_mapping: Callable[[str], tuple[str, Any, Any]] | None = None,
mps_zero_copy_weight_loading: bool = False,
preconverted_state_dict: ( preconverted_state_dict: (
tuple[ tuple[
dict[ dict[
@@ -555,6 +566,7 @@ def load_model_from_full_model_state_dict(
strict (bool): flag to check if to load the model in strict mode strict (bool): flag to check if to load the model in strict mode
cpu_offload (bool): flag to check if FSDP offload is enabled cpu_offload (bool): flag to check if FSDP offload is enabled
param_names_mapping (Optional[Callable[[str], str]]): a function that maps full param name to sharded param name param_names_mapping (Optional[Callable[[str], str]]): a function that maps full param name to sharded param name
mps_zero_copy_weight_loading (bool): retain compatible CPU checkpoint tensors for MPS layerwise offload
Returns: Returns:
``NamedTuple`` with ``missing_keys`` and ``unexpected_keys`` fields: ``NamedTuple`` with ``missing_keys`` and ``unexpected_keys`` fields:
* **missing_keys** is a list of str containing the missing keys * **missing_keys** is a list of str containing the missing keys
@@ -696,7 +708,17 @@ def load_model_from_full_model_state_dict(
if actual_param is not None if actual_param is not None
else None else None
) )
if weight_loader is not None: use_checkpoint_tensor_directly = bool(
mps_zero_copy_weight_loading
and actual_param is not None
and not getattr(actual_param, "mps_zero_copy_unsafe", False)
and tuple(meta_sharded_param.shape) == tuple(full_tensor.shape)
and full_tensor.device.type == "cpu"
and full_tensor.dtype == target_dtype
)
if use_checkpoint_tensor_directly:
sharded_tensor = full_tensor
elif weight_loader is not None:
assert actual_param is not None assert actual_param is not None
if _can_assign_cpu_tensor_without_copy( if _can_assign_cpu_tensor_without_copy(
actual_param, actual_param,
@@ -11,6 +11,9 @@ class WeightLoadPlan:
checkpoint_load_device: torch.device checkpoint_load_device: torch.device
# Device required while running process_weights_after_loading; None means unchanged. # Device required while running process_weights_after_loading; None means unchanged.
weight_postprocess_device: torch.device | None = None weight_postprocess_device: torch.device | None = None
# mps layerwise loading retains compatible safetensors tensors as CPU-backed
# parameters instead of materializing a second unified-memory copy
mps_layerwise_cpu_staging: bool = False
# Delay final CPU placement until after device-side weight postprocessing. # Delay final CPU placement until after device-side weight postprocessing.
defer_cpu_placement: bool = False defer_cpu_placement: bool = False
# keep the complete mapped checkpoint state dict on the load device # keep the complete mapped checkpoint state dict on the load device
@@ -24,6 +27,7 @@ class WeightLoadPlan:
needs_device_weight_postprocess: bool, needs_device_weight_postprocess: bool,
component_starts_on_cpu: bool, component_starts_on_cpu: bool,
load_full_state_dict_on_device: bool = False, load_full_state_dict_on_device: bool = False,
mps_layerwise_cpu_staging: bool = False,
) -> "WeightLoadPlan": ) -> "WeightLoadPlan":
# if on-device weight postprocessing is required, load directly to device to speedup loading # if on-device weight postprocessing is required, load directly to device to speedup loading
weight_postprocess_device = ( weight_postprocess_device = (
@@ -36,4 +40,5 @@ class WeightLoadPlan:
needs_device_weight_postprocess and component_starts_on_cpu needs_device_weight_postprocess and component_starts_on_cpu
), ),
load_full_state_dict_on_device=load_full_state_dict_on_device, load_full_state_dict_on_device=load_full_state_dict_on_device,
mps_layerwise_cpu_staging=mps_layerwise_cpu_staging,
) )
@@ -65,9 +65,7 @@ from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.post_training.gpu_worker_post_training_mixin import ( from sglang.multimodal_gen.runtime.post_training.gpu_worker_post_training_mixin import (
GPUWorkerPostTrainingMixin, GPUWorkerPostTrainingMixin,
) )
from sglang.multimodal_gen.runtime.realtime.session import ( from sglang.multimodal_gen.runtime.realtime.session import RealtimeSessionCache
RealtimeSessionCache,
)
from sglang.multimodal_gen.runtime.server_args import PortArgs, ServerArgs from sglang.multimodal_gen.runtime.server_args import PortArgs, ServerArgs
from sglang.multimodal_gen.runtime.utils.common import set_cuda_arch, set_musa_arch from sglang.multimodal_gen.runtime.utils.common import set_cuda_arch, set_musa_arch
from sglang.multimodal_gen.runtime.utils.logging_utils import ( from sglang.multimodal_gen.runtime.utils.logging_utils import (
@@ -228,7 +226,8 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
def init_device_and_model(self) -> None: def init_device_and_model(self) -> None:
"""Initialize the device and load the model.""" """Initialize the device and load the model."""
current_platform.set_device(current_platform.get_device(self.local_rank)) if not current_platform.is_mps():
current_platform.set_device(current_platform.get_device(self.local_rank))
# num_gpus is the total world size across every node; the co-located, # num_gpus is the total world size across every node; the co-located,
# CPU-contending worker count on THIS host is num_gpus // nnodes. # CPU-contending worker count on THIS host is num_gpus // nnodes.
local_num_gpus = self.server_args.num_gpus // self.server_args.nnodes local_num_gpus = self.server_args.num_gpus // self.server_args.nnodes
@@ -316,9 +315,8 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
if output_batch.metrics: if output_batch.metrics:
output_batch.metrics.record_memory_snapshot("mem_analysis", final_snapshot) output_batch.metrics.record_memory_snapshot("mem_analysis", final_snapshot)
# for details on max_memory_reserved: https://docs.pytorch.org/docs/stable/generated/torch.cuda.memory.max_memory_reserved.html peak_reserved_bytes = final_snapshot.peak_reserved_mb * (1024**2)
peak_reserved_bytes = torch.get_device_module().max_memory_reserved() peak_allocated_bytes = final_snapshot.peak_allocated_mb * (1024**2)
peak_allocated_bytes = torch.get_device_module().max_memory_allocated()
output_batch.peak_memory_mb = peak_reserved_bytes / (1024**2) output_batch.peak_memory_mb = peak_reserved_bytes / (1024**2)
peak_reserved_gb = peak_reserved_bytes / (1024**3) peak_reserved_gb = peak_reserved_bytes / (1024**3)
@@ -466,7 +464,11 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
output_batch = None output_batch = None
forward_failed = False forward_failed = False
try: try:
if self.is_output_rank and not current_platform.is_cpu(): if (
self.is_output_rank
and not current_platform.is_cpu()
and not current_platform.is_mps()
):
torch.get_device_module().reset_peak_memory_stats() torch.get_device_module().reset_peak_memory_stats()
start_time = ( start_time = (
@@ -690,8 +692,7 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
def _record_output_peak_memory(self, output_batch: OutputBatch) -> None: def _record_output_peak_memory(self, output_batch: OutputBatch) -> None:
if not self.is_output_rank or current_platform.is_cpu(): if not self.is_output_rank or current_platform.is_cpu():
return return
peak_reserved_bytes = torch.get_device_module().max_memory_reserved() output_batch.peak_memory_mb = capture_memory_snapshot().peak_reserved_mb
output_batch.peak_memory_mb = peak_reserved_bytes / (1024**2)
def _forward_group(self, batch: list[Req]) -> OutputBatch: def _forward_group(self, batch: list[Req]) -> OutputBatch:
assert self.pipeline is not None assert self.pipeline is not None
@@ -202,6 +202,14 @@ class LayerwiseOffloadStrategy(ComponentResidencyStrategy):
state: ResidencyState, state: ResidencyState,
) -> None: ) -> None:
if isinstance(module, LayerwiseOffloadableModuleMixin): if isinstance(module, LayerwiseOffloadableModuleMixin):
# MPS layerwise components retain checkpoint-backed CPU weights and
# synchronously materialize one layer at a time. Moving the whole
# module here would defeat that bounded-residency contract.
if current_platform.is_mps():
if module.mps_stream_non_layer_weights:
return
_module_to_local_device(module, dtype=use.target_dtype)
return
module.prepare_for_next_req() module.prepare_for_next_req()
def finish_use( def finish_use(
@@ -214,6 +222,10 @@ class LayerwiseOffloadStrategy(ComponentResidencyStrategy):
return return
for manager in module.layerwise_offload_managers: for manager in module.layerwise_offload_managers:
manager.release_all() manager.release_all()
if current_platform.is_mps():
torch.mps.synchronize()
module.restore_mps_cpu_non_layer_weights()
torch.mps.empty_cache()
def finish_request( def finish_request(
self, self,
@@ -1,6 +1,7 @@
import bisect import bisect
import re import re
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from contextlib import nullcontext
from typing import Any, Dict, List, Set, Tuple from typing import Any, Dict, List, Set, Tuple
import torch import torch
@@ -84,7 +85,8 @@ class LayerwiseOffloadManager:
"""A lightweight layerwise CPU offload manager. """A lightweight layerwise CPU offload manager.
This utility offloads per-layer parameters/buffers from GPU to CPU, and This utility offloads per-layer parameters/buffers from GPU to CPU, and
supports async H2D prefetch using a dedicated CUDA stream. supports async H2D prefetch using a dedicated CUDA stream. MPS uses
synchronous per-layer transfers from the checkpoint-backed CPU tensors.
Typical usage: Typical usage:
- Construct the manager with the target model and the list-like module - Construct the manager with the target model and the list-like module
@@ -104,13 +106,23 @@ class LayerwiseOffloadManager:
pin_cpu_memory: bool = True, pin_cpu_memory: bool = True,
prefetch_size: int = 1, prefetch_size: int = 1,
resident_layers: int = 0, resident_layers: int = 0,
initialize: bool = True,
residency_policy: str = RESIDENCY_POLICY_LEADING, residency_policy: str = RESIDENCY_POLICY_LEADING,
) -> None: ) -> None:
self.model = model self.model = model
self.layers_attr_str = layers_attr_str self.layers_attr_str = layers_attr_str
self.num_layers = num_layers self.num_layers = num_layers
self.pin_cpu_memory = pin_cpu_memory self._synchronous_mps = current_platform.is_mps()
self.prefetch_size = min(max(1, prefetch_size), self.num_layers) # mps shares physical memory with the CPU and has no pinned host memory
# or CUDA-style copy streams
self.pin_cpu_memory = bool(pin_cpu_memory and not self._synchronous_mps)
# an explicit MPS zero avoids staging the next layer alongside the
# active one; MPS has no transfer overlap to recover from that cost
self.prefetch_size = (
0
if current_platform.is_mps() and prefetch_size == 0
else min(max(1, prefetch_size), self.num_layers)
)
# Layers held on GPU across denoise steps, instead of being re-streamed # Layers held on GPU across denoise steps, instead of being re-streamed
# every step. `residency_policy` picks *which* layers those are; see # every step. `residency_policy` picks *which* layers those are; see
# compute_streamed_layers for why the choice is not cosmetic. # compute_streamed_layers for why the choice is not cosmetic.
@@ -134,10 +146,17 @@ class LayerwiseOffloadManager:
self.enabled = bool(enabled and torch.get_device_module().is_available()) self.enabled = bool(enabled and torch.get_device_module().is_available())
if not self.enabled: if not self.enabled:
return return
self.device = torch.device( self.device = (
current_platform.device_type, torch.get_device_module().current_device() current_platform.get_local_torch_device()
if current_platform.is_mps()
else torch.device(
current_platform.device_type,
torch.get_device_module().current_device(),
)
)
self.copy_stream = (
None if self._synchronous_mps else torch.get_device_module().Stream()
) )
self.copy_stream = torch.get_device_module().Stream()
# ``named_parameters()`` is relative to ``model``, just like the path in # ``named_parameters()`` is relative to ``model``, just like the path in
# ``layers_attr_str``. Anchor the match so a manager for top-level # ``layers_attr_str``. Anchor the match so a manager for top-level
@@ -153,6 +172,9 @@ class LayerwiseOffloadManager:
# layer_idx -> {name: pinned_cpu_tensor_with_original_stride} # layer_idx -> {name: pinned_cpu_tensor_with_original_stride}
# stores tensors whose original non-contiguous stride/layout must be preserved # stores tensors whose original non-contiguous stride/layout must be preserved
self._strided_cpu_weights: Dict[int, Dict[str, torch.Tensor]] = {} self._strided_cpu_weights: Dict[int, Dict[str, torch.Tensor]] = {}
# mps keeps the original CPU tensor for each layer instead of building a
# second flattened host copy
self._mps_cpu_weights: Dict[int, Dict[str, torch.Tensor]] = {}
# layer_idx -> {name: {dtype, offset, numel, shape}} # layer_idx -> {name: {dtype, offset, numel, shape}}
# stores the offset and numel of each weight from a same layer, of same dtype # stores the offset and numel of each weight from a same layer, of same dtype
self._weight_metadata: Dict[int, Dict[str, Dict[str, Any]]] = {} self._weight_metadata: Dict[int, Dict[str, Dict[str, Any]]] = {}
@@ -168,6 +190,10 @@ class LayerwiseOffloadManager:
# Store forward hooks for removal # Store forward hooks for removal
self._forward_hooks: List[Any] = [] self._forward_hooks: List[Any] = []
if initialize:
self._initialize()
def initialize(self) -> None:
self._initialize() self._initialize()
def _match_layer_idx(self, name: str) -> int | None: def _match_layer_idx(self, name: str) -> int | None:
@@ -229,6 +255,10 @@ class LayerwiseOffloadManager:
self._named_parameters = dict(self.model.named_parameters()) self._named_parameters = dict(self.model.named_parameters())
self._named_buffers = dict(self.model.named_buffers()) self._named_buffers = dict(self.model.named_buffers())
if self._synchronous_mps:
self._initialize_mps_cpu_weights()
return
# 1. collect and group layer parameters by dtype. Keep buffers resident: # 1. collect and group layer parameters by dtype. Keep buffers resident:
# shared buffers such as RoPE caches may be referenced by many layers. # shared buffers such as RoPE caches may be referenced by many layers.
layer_groups: Dict[int, Dict[torch.dtype, List[Tuple[str, torch.Tensor]]]] = {} layer_groups: Dict[int, Dict[torch.dtype, List[Tuple[str, torch.Tensor]]]] = {}
@@ -361,6 +391,33 @@ class LayerwiseOffloadManager:
return list(range(count)) return list(range(count))
return self._next_streamed(after=-1, count=count) return self._next_streamed(after=-1, count=count)
@torch.compiler.disable
def _initialize_mps_cpu_weights(self) -> None:
for name, tensor in self._named_parameters.items():
layer_idx = self._match_layer_idx(name)
if layer_idx is None or layer_idx >= self.num_layers:
continue
local_tensor = self._to_local_tensor(tensor).detach()
cpu_tensor = (
local_tensor
if local_tensor.device.type == "cpu"
else local_tensor.to("cpu")
)
self._mps_cpu_weights.setdefault(layer_idx, {})[name] = cpu_tensor
self._weight_metadata.setdefault(layer_idx, {})[name] = {
"dtype": cpu_tensor.dtype,
}
tensor.data = self._get_shared_empty_tensor_for_target(
tensor, cpu_tensor.dtype
)
torch.mps.empty_cache()
self.register_forward_hooks()
self._configured = True
logger.info(
f"Initialized synchronous MPS layerwise offload with {self.num_layers} layers"
)
def prepare_for_next_req(self, non_blocking=True): def prepare_for_next_req(self, non_blocking=True):
""" """
Prepare for the next round of denoising loop with prefetching the necessary layers Prepare for the next round of denoising loop with prefetching the necessary layers
@@ -435,22 +492,41 @@ class LayerwiseOffloadManager:
""" """
idempotent idempotent
""" """
if not self.enabled or self.device is None or self.copy_stream is None: if not self.enabled or self.device is None:
return return
if layer_idx < 0 or layer_idx >= self.num_layers: if layer_idx < 0 or layer_idx >= self.num_layers:
return return
if layer_idx in self._gpu_layers: if layer_idx in self._gpu_layers:
return return
if self._synchronous_mps:
cpu_weights = self._mps_cpu_weights.get(layer_idx)
if not cpu_weights:
return
with torch.inference_mode(False), torch.no_grad():
for name, cpu_tensor in cpu_weights.items():
target = self.get_target_with_name(name)
target.data = self._wrap_for_target(
target,
cpu_tensor.to(device=self.device, non_blocking=False),
)
self._gpu_layers.add(layer_idx)
return
if layer_idx not in self._consolidated_cpu_weights: if layer_idx not in self._consolidated_cpu_weights:
return return
self.copy_stream.wait_stream(torch.get_device_module().current_stream()) if self.copy_stream is not None:
self.copy_stream.wait_stream(torch.get_device_module().current_stream())
stream_context = torch.get_device_module().stream(self.copy_stream)
else:
# the device has no CUDA-like stream or pinned-memory support
non_blocking = False
stream_context = nullcontext()
# create gpu buffer and load from CPU buffer # create gpu buffer and load from CPU buffer
gpu_buffers: Dict[torch.dtype, torch.Tensor] = {} gpu_buffers: Dict[torch.dtype, torch.Tensor] = {}
with ( with (
torch.inference_mode(False), torch.inference_mode(False),
torch.no_grad(), torch.no_grad(),
torch.get_device_module().stream(self.copy_stream), stream_context,
): ):
for dtype, cpu_buffer in self._consolidated_cpu_weights[layer_idx].items(): for dtype, cpu_buffer in self._consolidated_cpu_weights[layer_idx].items():
gpu_buffer = torch.empty( gpu_buffer = torch.empty(
@@ -488,10 +564,11 @@ class LayerwiseOffloadManager:
].view(meta["shape"]) ].view(meta["shape"])
target.data = self._wrap_for_target(target, local_tensor) target.data = self._wrap_for_target(target, local_tensor)
# record the prefetch event of this layer after all copies are enqueued if self.copy_stream is not None:
event = torch.get_device_module().Event() # record after all copies so the consumer waits for every weight copy
event.record(self.copy_stream) event = torch.get_device_module().Event()
self._prefetch_events[layer_idx] = event event.record(self.copy_stream)
self._prefetch_events[layer_idx] = event
self._gpu_layers.add(layer_idx) self._gpu_layers.add(layer_idx)
@@ -524,6 +601,11 @@ class LayerwiseOffloadManager:
) )
self._gpu_layers.discard(layer_idx) self._gpu_layers.discard(layer_idx)
if self._synchronous_mps:
# mps dispatch is asynchronous, so a tensor rebinding alone leaves
# prior layer allocations live until the command buffer drains
torch.mps.synchronize()
torch.mps.empty_cache()
@torch.compiler.disable @torch.compiler.disable
def release_all(self) -> None: def release_all(self) -> None:
@@ -554,6 +636,10 @@ class LayerwiseOffloadManager:
"""Sync a layer's weights from GPU back to CPU.""" """Sync a layer's weights from GPU back to CPU."""
if not self.enabled or layer_idx not in self._gpu_layers: if not self.enabled or layer_idx not in self._gpu_layers:
return return
if self._synchronous_mps:
# inference does not mutate parameters; retain the original mapped
# tensor instead of materializing a CPU copy after every layer
return
if layer_idx not in self._consolidated_cpu_weights: if layer_idx not in self._consolidated_cpu_weights:
return return
@@ -616,6 +702,33 @@ class LayerwiseOffloadManager:
return None return None
updated_names: Set[str] = set() updated_names: Set[str] = set()
if self._synchronous_mps:
for name, loaded_weight in weight_dict.items():
layer_idx = self._match_layer_idx(name)
if layer_idx is None:
continue
cpu_tensor = self._mps_cpu_weights.get(layer_idx, {}).get(name)
if cpu_tensor is None:
continue
if tuple(cpu_tensor.shape) != tuple(loaded_weight.shape):
raise ValueError(
f"Shape mismatch for {name}: "
f"expected={tuple(cpu_tensor.shape)}, "
f"loaded={tuple(loaded_weight.shape)}"
)
replacement = loaded_weight.to(
device="cpu", dtype=cpu_tensor.dtype
).detach()
self._mps_cpu_weights[layer_idx][name] = replacement
if layer_idx in self._gpu_layers:
target = self.get_target_with_name(name)
target.data = self._wrap_for_target(
target,
replacement.to(device=target.device, dtype=target.dtype),
)
updated_names.add(name)
return updated_names
for name, loaded_weight in weight_dict.items(): for name, loaded_weight in weight_dict.items():
layer_idx = self._match_layer_idx(name) layer_idx = self._match_layer_idx(name)
if layer_idx is None: if layer_idx is None:
@@ -665,6 +778,11 @@ class LayerwiseOffloadManager:
when offload is enabled, this method returns the real weights and when offload is enabled, this method returns the real weights and
can be used for checksum computation. can be used for checksum computation.
""" """
if self._synchronous_mps:
for layer_idx in sorted(self._mps_cpu_weights):
yield from self._mps_cpu_weights[layer_idx].items()
return
for layer_idx in sorted(self._weight_metadata): for layer_idx in sorted(self._weight_metadata):
for name, meta in self._weight_metadata[layer_idx].items(): for name, meta in self._weight_metadata[layer_idx].items():
if meta.get("preserve_strides", False): if meta.get("preserve_strides", False):
@@ -696,7 +814,7 @@ class LayerwiseOffloadManager:
if i not in self._gpu_layers: if i not in self._gpu_layers:
# LTX audio VAE traverses decoder.up in reverse order # LTX audio VAE traverses decoder.up in reverse order
self.prefetch_layer(i, non_blocking=False) self.prefetch_layer(i, non_blocking=False)
if i in self._prefetch_events: if i in self._prefetch_events and self.copy_stream is not None:
torch.get_device_module().current_stream().wait_event( torch.get_device_module().current_stream().wait_event(
self._prefetch_events[i] self._prefetch_events[i]
) )
@@ -716,7 +834,7 @@ class LayerwiseOffloadManager:
): ):
self.prefetch_layer(layer_to_prefetch, non_blocking=True) self.prefetch_layer(layer_to_prefetch, non_blocking=True)
# trigger batch prefetch (i + prefetch_size ~ i + 2 * prefetch_size) if needed # trigger batch prefetch (i + prefetch_size ~ i + 2 * prefetch_size) if needed
elif i % self.prefetch_size == 0: elif self.prefetch_size and i % self.prefetch_size == 0:
for j in range(i + self.prefetch_size, i + 2 * self.prefetch_size): for j in range(i + self.prefetch_size, i + 2 * self.prefetch_size):
layer_to_prefetch = j % self.num_layers layer_to_prefetch = j % self.num_layers
self.prefetch_layer(layer_to_prefetch, non_blocking=True) self.prefetch_layer(layer_to_prefetch, non_blocking=True)
@@ -750,11 +868,106 @@ class LayerwiseOffloadableModuleMixin:
# whether the current module is selected by the `dit` group # whether the current module is selected by the `dit` group
layerwise_offload_dit_group_enabled: bool = True layerwise_offload_dit_group_enabled: bool = True
# H3 has a large packed-sequence working set on MPS, so its non-block
# weights are materialized only for the subphase that consumes them
mps_stream_non_layer_weights: bool = False
# The list of names of this module's layer/block ModuleList or Sequential attributes. # The list of names of this module's layer/block ModuleList or Sequential attributes.
layer_names: List[str] = [] layer_names: List[str] = []
layerwise_offload_managers: list[LayerwiseOffloadManager] = [] layerwise_offload_managers: list[LayerwiseOffloadManager] = []
def _capture_mps_cpu_non_layer_weights(self) -> None:
managed_names = {
name
for manager in self.layerwise_offload_managers
for weights in manager._mps_cpu_weights.values()
for name in weights
}
self._mps_cpu_non_layer_parameters = {
name: parameter.detach()
for name, parameter in self.named_parameters()
if name not in managed_names
}
self._mps_cpu_buffers = {
name: buffer.detach() for name, buffer in self.named_buffers()
}
if not self.mps_stream_non_layer_weights:
return
parameters = dict(self.named_parameters())
for name, tensor in self._mps_cpu_non_layer_parameters.items():
parameters[name].data = torch.empty(
(1,),
dtype=tensor.dtype,
device=current_platform.get_local_torch_device(),
)
buffers = dict(self.named_buffers())
for name, tensor in self._mps_cpu_buffers.items():
buffers[name].data = torch.empty(
(1,),
dtype=tensor.dtype,
device=current_platform.get_local_torch_device(),
)
@staticmethod
def _matches_mps_weight_prefix(name: str, prefixes: tuple[str, ...]) -> bool:
return any(
name == prefix or name.startswith(f"{prefix}.") for prefix in prefixes
)
def materialize_mps_non_layer_weights(self, *prefixes: str) -> None:
if not current_platform.is_mps() or not self.mps_stream_non_layer_weights:
return
selected_prefixes = tuple(prefixes)
with torch.inference_mode(False), torch.no_grad():
parameters = dict(self.named_parameters())
for name, tensor in self._mps_cpu_non_layer_parameters.items():
if self._matches_mps_weight_prefix(name, selected_prefixes):
parameters[name].data = tensor.to(
current_platform.get_local_torch_device()
)
buffers = dict(self.named_buffers())
for name, tensor in self._mps_cpu_buffers.items():
if self._matches_mps_weight_prefix(name, selected_prefixes):
buffers[name].data = tensor.to(
current_platform.get_local_torch_device()
)
def release_mps_non_layer_weights(self, *prefixes: str) -> None:
if not current_platform.is_mps() or not self.mps_stream_non_layer_weights:
return
selected_prefixes = tuple(prefixes)
with torch.inference_mode(False), torch.no_grad():
parameters = dict(self.named_parameters())
for name, tensor in self._mps_cpu_non_layer_parameters.items():
if self._matches_mps_weight_prefix(name, selected_prefixes):
parameters[name].data = torch.empty(
(1,),
dtype=tensor.dtype,
device=current_platform.get_local_torch_device(),
)
buffers = dict(self.named_buffers())
for name, tensor in self._mps_cpu_buffers.items():
if self._matches_mps_weight_prefix(name, selected_prefixes):
buffers[name].data = torch.empty(
(1,),
dtype=tensor.dtype,
device=current_platform.get_local_torch_device(),
)
torch.mps.synchronize()
torch.mps.empty_cache()
def restore_mps_cpu_non_layer_weights(self) -> None:
if not current_platform.is_mps():
return
with torch.inference_mode(False), torch.no_grad():
parameters = dict(self.named_parameters())
for name, tensor in self._mps_cpu_non_layer_parameters.items():
parameters[name].data = tensor
buffers = dict(self.named_buffers())
for name, tensor in self._mps_cpu_buffers.items():
buffers[name].data = tensor
def configure_layerwise_offload(self, server_args: ServerArgs): def configure_layerwise_offload(self, server_args: ServerArgs):
self.layerwise_offload_managers = [] self.layerwise_offload_managers = []
named_modules = dict(self.named_modules()) named_modules = dict(self.named_modules())
@@ -774,7 +987,9 @@ class LayerwiseOffloadableModuleMixin:
prefetch_value = ( prefetch_value = (
server_args.dit_offload_prefetch_size if dit_tuning_enabled else 0.0 server_args.dit_offload_prefetch_size if dit_tuning_enabled else 0.0
) )
if prefetch_value < 1.0: if current_platform.is_mps() and prefetch_value == 0.0:
prefetch_size = 0
elif prefetch_value < 1.0:
prefetch_size = 1 + int(round(prefetch_value * (num_layers - 1))) prefetch_size = 1 + int(round(prefetch_value * (num_layers - 1)))
else: else:
prefetch_size = int(prefetch_value) prefetch_size = int(prefetch_value)
@@ -797,6 +1012,7 @@ class LayerwiseOffloadableModuleMixin:
pin_cpu_memory=server_args.pin_cpu_memory, pin_cpu_memory=server_args.pin_cpu_memory,
prefetch_size=prefetch_size, prefetch_size=prefetch_size,
resident_layers=resident_layers, resident_layers=resident_layers,
initialize=not current_platform.is_mps(),
residency_policy=( residency_policy=(
server_args.dit_layerwise_residency_policy server_args.dit_layerwise_residency_policy
if dit_tuning_enabled if dit_tuning_enabled
@@ -806,6 +1022,11 @@ class LayerwiseOffloadableModuleMixin:
self.layerwise_offload_managers.append(manager) self.layerwise_offload_managers.append(manager)
configured_layer_names.append(layer_name) configured_layer_names.append(layer_name)
if current_platform.is_mps():
for manager in self.layerwise_offload_managers:
manager.initialize()
self._capture_mps_cpu_non_layer_weights()
if configured_layer_names: if configured_layer_names:
logger.debug( logger.debug(
"Enabled layerwise offload for %s on modules: %s", "Enabled layerwise offload for %s on modules: %s",
@@ -77,6 +77,19 @@ logger = init_logger(__name__)
_ARCH_DEFAULTS = MiniMaxH3DiTArchConfig() _ARCH_DEFAULTS = MiniMaxH3DiTArchConfig()
_BF16_DTYPE = torch.bfloat16 _BF16_DTYPE = torch.bfloat16
_FP32_DTYPE = torch.float32 _FP32_DTYPE = torch.float32
_MPS_MLP_TOKEN_CHUNK_SIZE = 128
# keep MPS activation chunks below the allocator high-watermark; CUDA keeps
# its fused full-sequence projection
_MPS_QKV_PROJECTION_TOKEN_CHUNK_SIZE = 128
_MPS_ATTENTION_QUERY_TOKEN_CHUNK_SIZE = 128
_MPS_EMBED_WEIGHT_PREFIXES = (
"condition_proj",
"video_patch_proj",
"audio_patch_proj",
"time_embedder",
"token_refiner.final_norm",
)
_MINIMAX_H3_FP32_PARAM_NAMES_IN_MODEL_ORDER = ( _MINIMAX_H3_FP32_PARAM_NAMES_IN_MODEL_ORDER = (
"video_patch_proj.weight", "video_patch_proj.weight",
@@ -390,6 +403,18 @@ def _apply_rope_qk(
return q, k return q, k
def _apply_rope(
x: torch.Tensor,
cos_sin_cache: torch.Tensor,
) -> torch.Tensor:
"""Apply the eager (non-CUDA) H3 RoPE path to one Q or K tensor."""
half = cos_sin_cache.shape[-1] // 2
cos_half, sin_half = cos_sin_cache.split(half, dim=-1)
cos = torch.cat((cos_half, cos_half), dim=-1).unsqueeze(1)
sin = torch.cat((sin_half, sin_half), dim=-1).unsqueeze(1)
return _apply_rope_cos_sin(x, cos, sin)
class MiniMaxH3TimeEmbedder(nn.Module): class MiniMaxH3TimeEmbedder(nn.Module):
def __init__( def __init__(
self, self,
@@ -597,6 +622,9 @@ class MiniMaxH3Attention(nn.Module):
def _install_qkv_weight_loader(self, arch: MiniMaxH3DiTArchConfig) -> None: def _install_qkv_weight_loader(self, arch: MiniMaxH3DiTArchConfig) -> None:
weight = self.qkv_proj.weight weight = self.qkv_proj.weight
# h3 checkpoints interleave each attention head's Q, K, and V rows
# this parameter needs reordering before the native QKV projection
weight.mps_zero_copy_unsafe = True
base_loader = weight.weight_loader base_loader = weight.weight_loader
def _reorder_checkpoint_weight(loaded_weight: torch.Tensor) -> torch.Tensor: def _reorder_checkpoint_weight(loaded_weight: torch.Tensor) -> torch.Tensor:
@@ -630,6 +658,107 @@ class MiniMaxH3Attention(nn.Module):
# rank-local FSDP must reorder grouped QKV before selecting each shard # rank-local FSDP must reorder grouped QKV before selecting each shard
weight.rank_local_weight_transform = _reorder_checkpoint_weight weight.rank_local_weight_transform = _reorder_checkpoint_weight
def _forward_mps_streamed_attention(
self,
x: torch.Tensor,
*,
rope_cache: tuple[torch.Tensor, torch.Tensor] | None,
cu_seqlens: torch.Tensor,
cu_seqlens_host: tuple[int, ...] | None,
max_seqlen: int,
) -> torch.Tensor:
"""Run MPS attention without materializing the full QKV activation.
H3's fused QKV output alone is roughly 1.45 GiB at 768px. MPS shares
unified memory with the host, so retaining it alongside the packed
residual and SDPA workspace can evict the OS. Build normalized K/V
once, then project Q a small chunk at a time and immediately consume it
through attention and the output projection. The formula, weights,
and complete K/V context are unchanged; this is intentionally limited
to single-device MPS where Ulysses collectives are not active.
"""
total = x.shape[0]
key = torch.empty(
(total, self.num_heads, self.head_dim), dtype=x.dtype, device=x.device
)
value = torch.empty_like(key)
cos_sin_cache = None if rope_cache is None else rope_cache[0]
# Do not retain Q while producing the K/V cache. Dropping the chunk
# before the next transfer keeps only two full-width attention tensors.
for start in range(0, total, _MPS_QKV_PROJECTION_TOKEN_CHUNK_SIZE):
stop = min(start + _MPS_QKV_PROJECTION_TOKEN_CHUNK_SIZE, total)
qkv, _ = self.qkv_proj(x[start:stop])
q_chunk, k_chunk, v_chunk = qkv.split(self.local_inner_dim, dim=-1)
del q_chunk
k_chunk = self.k_norm(k_chunk.view(-1, self.num_heads, self.head_dim))
if cos_sin_cache is not None:
k_chunk = _apply_rope(k_chunk, cos_sin_cache[start:stop])
key[start:stop].copy_(k_chunk)
value[start:stop].copy_(v_chunk.view(-1, self.num_heads, self.head_dim))
del qkv, k_chunk, v_chunk
torch.mps.synchronize()
torch.mps.empty_cache()
if self._attention_impl is None:
self._set_attention_backend(
get_attn_backend(
self.head_dim,
x.dtype,
attention_requirements=AttentionRequirements(packed_varlen=True),
)
)
bounds = (
cu_seqlens_host
if cu_seqlens_host is not None
else tuple(int(item) for item in cu_seqlens.tolist())
)
out = torch.empty_like(x)
for sequence_start, sequence_stop in zip(bounds[:-1], bounds[1:]):
if sequence_start == sequence_stop:
continue
keys = key[sequence_start:sequence_stop].unsqueeze(0)
values = value[sequence_start:sequence_stop].unsqueeze(0)
for start in range(
sequence_start,
sequence_stop,
_MPS_QKV_PROJECTION_TOKEN_CHUNK_SIZE,
):
stop = min(start + _MPS_QKV_PROJECTION_TOKEN_CHUNK_SIZE, sequence_stop)
qkv, _ = self.qkv_proj(x[start:stop])
q_chunk, k_chunk, v_chunk = qkv.split(self.local_inner_dim, dim=-1)
del k_chunk, v_chunk
for query_start in range(
start, stop, _MPS_ATTENTION_QUERY_TOKEN_CHUNK_SIZE
):
query_stop = min(
query_start + _MPS_ATTENTION_QUERY_TOKEN_CHUNK_SIZE, stop
)
q = self.q_norm(
q_chunk[query_start - start : query_stop - start].view(
-1, self.num_heads, self.head_dim
)
)
if cos_sin_cache is not None:
q = _apply_rope(q, cos_sin_cache[query_start:query_stop])
attention_out = self._attention_impl.forward(
q.unsqueeze(0), keys, values, None
)[0]
projected, _ = self.out_proj(
attention_out.reshape(
query_stop - query_start, self.local_inner_dim
)
)
out[query_start:query_stop].copy_(projected)
del q, attention_out, projected
del qkv, q_chunk
torch.mps.synchronize()
torch.mps.empty_cache()
del key, value
torch.mps.empty_cache()
return out
def forward( def forward(
self, self,
x: torch.Tensor, x: torch.Tensor,
@@ -652,6 +781,15 @@ class MiniMaxH3Attention(nn.Module):
so cu_seqlens retains global packed-document semantics. The inverse so cu_seqlens retains global packed-document semantics. The inverse
all-to-all restores the row shard before the output projection. all-to-all restores the row shard before the output projection.
""" """
if x.device.type == "mps" and not ulysses_active:
return self._forward_mps_streamed_attention(
x,
rope_cache=rope_cache,
cu_seqlens=cu_seqlens,
cu_seqlens_host=cu_seqlens_host,
max_seqlen=max_seqlen,
)
total = x.shape[0] total = x.shape[0]
qkv, _ = self.qkv_proj(x) qkv, _ = self.qkv_proj(x)
q, k, v = qkv.split(self.local_inner_dim, dim=-1) q, k, v = qkv.split(self.local_inner_dim, dim=-1)
@@ -747,6 +885,18 @@ class MiniMaxH3MLP(nn.Module):
self.reuse_fc1_activation = quant_config is None self.reuse_fc1_activation = quant_config is None
def forward(self, x: torch.Tensor) -> torch.Tensor: def forward(self, x: torch.Tensor) -> torch.Tensor:
if x.device.type == "mps":
out = torch.empty_like(x)
for start in range(0, x.shape[0], _MPS_MLP_TOKEN_CHUNK_SIZE):
stop = min(start + _MPS_MLP_TOKEN_CHUNK_SIZE, x.shape[0])
hidden, _ = self.fc1(x[start:stop])
hidden = _silu_mul(hidden, reuse_input=self.reuse_fc1_activation)
chunk, _ = self.fc2(hidden)
out[start:stop].copy_(chunk)
del hidden, chunk
torch.mps.synchronize()
torch.mps.empty_cache()
return out
hidden, _ = self.fc1(x) hidden, _ = self.fc1(x)
hidden = _silu_mul(hidden, reuse_input=self.reuse_fc1_activation) hidden = _silu_mul(hidden, reuse_input=self.reuse_fc1_activation)
out, _ = self.fc2(hidden) out, _ = self.fc2(hidden)
@@ -1355,6 +1505,38 @@ class MiniMaxH3FinalLayer(nn.Module):
raise ValueError("MiniMax H3 AdaLN cache parameters are required") raise ValueError("MiniMax H3 AdaLN cache parameters are required")
adaln_params = self.adaln_proj(adaln_input) adaln_params = self.adaln_proj(adaln_input)
shift, scale = adaln_params shift, scale = adaln_params
if x.device.type == "mps":
video = audio = None
for start in range(0, x.shape[0], _MPS_MLP_TOKEN_CHUNK_SIZE):
stop = min(start + _MPS_MLP_TOKEN_CHUNK_SIZE, x.shape[0])
h = self.norm(x[start:stop])
h = _modulate_scale_shift(
h,
shift,
scale,
inverse_indices[start:stop],
dtype=_BF16_DTYPE,
).to(_FP32_DTYPE)
video_chunk, _ = self.video_out(h)
audio_chunk, _ = self.audio_out(h)
if video is None:
video = torch.empty(
(x.shape[0], video_chunk.shape[-1]),
dtype=video_chunk.dtype,
device=x.device,
)
audio = torch.empty(
(x.shape[0], audio_chunk.shape[-1]),
dtype=audio_chunk.dtype,
device=x.device,
)
video[start:stop].copy_(video_chunk)
audio[start:stop].copy_(audio_chunk)
del h, video_chunk, audio_chunk
torch.mps.synchronize()
torch.mps.empty_cache()
assert video is not None and audio is not None
return video, audio
h = self.norm(x) h = self.norm(x)
h = _modulate_scale_shift(h, shift, scale, inverse_indices, dtype=_BF16_DTYPE) h = _modulate_scale_shift(h, shift, scale, inverse_indices, dtype=_BF16_DTYPE)
# Preserve full precision through both final output projections. # Preserve full precision through both final output projections.
@@ -1371,6 +1553,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
# parameters mix fp32 (patch projections, timestep embedder, and output # parameters mix fp32 (patch projections, timestep embedder, and output
# heads) with bf16 blocks; FSDP must gather in each parameter's own dtype # heads) with bf16 blocks; FSDP must gather in each parameter's own dtype
_fsdp_mixed_dtype_params = True _fsdp_mixed_dtype_params = True
mps_stream_non_layer_weights = True
_compile_conditions = [is_block] _compile_conditions = [is_block]
param_names_mapping = _ARCH_DEFAULTS.param_names_mapping param_names_mapping = _ARCH_DEFAULTS.param_names_mapping
reverse_param_names_mapping = _ARCH_DEFAULTS.reverse_param_names_mapping reverse_param_names_mapping = _ARCH_DEFAULTS.reverse_param_names_mapping
@@ -1551,7 +1734,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
for index in range(arch.num_layers) for index in range(arch.num_layers)
] ]
) )
self.layer_names = ["blocks"] self.layer_names = ["token_refiner.blocks", "blocks"]
self.final_layer = MiniMaxH3FinalLayer( self.final_layer = MiniMaxH3FinalLayer(
arch, arch,
quant_config, quant_config,
@@ -1655,6 +1838,9 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
device: torch.device, device: torch.device,
) -> torch.Tensor: ) -> torch.Tensor:
"""Project and refine request-static text conditioning once.""" """Project and refine request-static text conditioning once."""
self.materialize_mps_non_layer_weights(
"condition_proj", "token_refiner.final_norm"
)
text_len = int(refiner_cu_seqlens[1].item()) text_len = int(refiner_cu_seqlens[1].item())
if text_len <= 0 or text_len > int(prompt_embeds.shape[0]): if text_len <= 0 or text_len > int(prompt_embeds.shape[0]):
raise ValueError( raise ValueError(
@@ -1670,12 +1856,14 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
) )
) )
text_embed, _ = self.condition_proj(text_rows) text_embed, _ = self.condition_proj(text_rows)
return self.token_refiner( refined = self.token_refiner(
text_embed, text_embed,
cu_seqlens=true_refiner_cu, cu_seqlens=true_refiner_cu,
cu_seqlens_host=(0, text_len, text_len), cu_seqlens_host=(0, text_len, text_len),
max_seqlen=text_len, max_seqlen=text_len,
) )
self.release_mps_non_layer_weights("condition_proj", "token_refiner.final_norm")
return refined
def build_rope_cache( def build_rope_cache(
self, self,
@@ -1690,6 +1878,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
chunk) -- see forward()'s row_start derivation for the identity chunk) -- see forward()'s row_start derivation for the identity
this must stay in sync with. this must stay in sync with.
""" """
self.materialize_mps_non_layer_weights("rope")
if img_position_ids.dim() != 3 or img_position_ids.shape[0] != 1: if img_position_ids.dim() != 3 or img_position_ids.shape[0] != 1:
raise ValueError( raise ValueError(
"img_position_ids must be [1, S, 3], got " "img_position_ids must be [1, S, 3], got "
@@ -1711,7 +1900,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
rope_freqs = self.rope( rope_freqs = self.rope(
img_position_ids[:, row_start : row_start + local_seq_len] img_position_ids[:, row_start : row_start + local_seq_len]
).to(device) ).to(device)
return ( result = (
_rope_cos_sin_cache(rope_freqs, dtype=_BF16_DTYPE), _rope_cos_sin_cache(rope_freqs, dtype=_BF16_DTYPE),
torch.arange( torch.arange(
local_seq_len, local_seq_len,
@@ -1719,6 +1908,8 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
dtype=torch.long, dtype=torch.long,
), ),
) )
self.release_mps_non_layer_weights("rope")
return result
@eager_on_graph(True) @eager_on_graph(True)
def _embed( def _embed(
@@ -1994,6 +2185,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
# request-static cache once; direct model callers use this fallback. # request-static cache once; direct model callers use this fallback.
rope_cache = kwargs.get("rope_cache") rope_cache = kwargs.get("rope_cache")
if rope_cache is None: if rope_cache is None:
self.materialize_mps_non_layer_weights("rope")
rope_freqs = self.rope(img_position_ids[:, row_start:row_stop]).to(device) rope_freqs = self.rope(img_position_ids[:, row_start:row_stop]).to(device)
rope_cache = ( rope_cache = (
_rope_cos_sin_cache(rope_freqs, dtype=_BF16_DTYPE), _rope_cos_sin_cache(rope_freqs, dtype=_BF16_DTYPE),
@@ -2003,6 +2195,8 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
dtype=torch.long, dtype=torch.long,
), ),
) )
self.release_mps_non_layer_weights("rope")
self.materialize_mps_non_layer_weights(*_MPS_EMBED_WEIGHT_PREFIXES)
img_pos = img_pos.to(device) img_pos = img_pos.to(device)
audio_pos = audio_pos.to(device) audio_pos = audio_pos.to(device)
text_pos = text_pos.to(device) text_pos = text_pos.to(device)
@@ -2023,6 +2217,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
refined_prompt_embeds_length=kwargs.get("refined_prompt_embeds_length"), refined_prompt_embeds_length=kwargs.get("refined_prompt_embeds_length"),
local_embedding_layout=kwargs.get("local_embedding_layout"), local_embedding_layout=kwargs.get("local_embedding_layout"),
) )
self.release_mps_non_layer_weights(*_MPS_EMBED_WEIGHT_PREFIXES)
# request-step AdaLN input shared by all blocks # request-step AdaLN input shared by all blocks
adaln_input = nn.functional.silu(t_emb).to(_BF16_DTYPE) adaln_input = nn.functional.silu(t_emb).to(_BF16_DTYPE)
inverse_indices = inverse_indices.to(device) inverse_indices = inverse_indices.to(device)
@@ -2091,6 +2286,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
None if block_adaln_params is None else block_adaln_params[index] None if block_adaln_params is None else block_adaln_params[index]
), ),
) )
self.materialize_mps_non_layer_weights("final_layer")
video_logits, audio_logits = self.final_layer( video_logits, audio_logits = self.final_layer(
hidden, hidden,
adaln_input=adaln_input, adaln_input=adaln_input,
@@ -2104,6 +2300,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
) )
), ),
) )
self.release_mps_non_layer_weights("final_layer")
if sp_ws > 1: if sp_ws > 1:
from sglang.multimodal_gen.runtime.distributed.parallel_state import ( from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_sp_group, get_sp_group,
@@ -41,9 +41,10 @@ class MiniMaxH3Qwen3VLEncoder(TextEncoder):
eight otherwise-idle ranks during encoding. eight otherwise-idle ranks during encoding.
""" """
layer_names = [*TextEncoder.layer_names, "model.visual.blocks"]
supports_dp_encode = True supports_dp_encode = True
# The inherited text-layer list covers Qwen's language stack; reference
# modes also execute the embedded visual tower.
layer_names = [*TextEncoder.layer_names, "model.visual.blocks"]
supported_checkpoint_quantization_methods = frozenset({"fp8"}) supported_checkpoint_quantization_methods = frozenset({"fp8"})
@staticmethod @staticmethod
@@ -191,7 +192,18 @@ class MiniMaxH3Qwen3VLEncoder(TextEncoder):
) )
weight_loader = getattr(param, "weight_loader", default_weight_loader) weight_loader = getattr(param, "weight_loader", default_weight_loader)
try: try:
weight_loader(param, loaded_weight.to(param.dtype)) can_keep_checkpoint_tensor = bool(
getattr(self, "_mps_zero_copy_weight_loading", False)
and weight_loader is default_weight_loader
and param.device.type == "cpu"
and loaded_weight.device.type == "cpu"
and loaded_weight.dtype == param.dtype
and tuple(loaded_weight.shape) == tuple(param.shape)
)
if can_keep_checkpoint_tensor:
param.data = loaded_weight
else:
weight_loader(param, loaded_weight.to(param.dtype))
except Exception as exc: except Exception as exc:
raise RuntimeError( raise RuntimeError(
"Failed to load MiniMax H3 Qwen3-VL weight " "Failed to load MiniMax H3 Qwen3-VL weight "
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# Transformer building blocks for the MiniMax H3 visual VAE ViT decoder. # Transformer building blocks for the MiniMax H3 visual VAE ViT decoder.
import math import math
from contextlib import nullcontext
from typing import Optional from typing import Optional
import torch import torch
@@ -171,7 +172,10 @@ class RotaryEmbeddingND(nn.Module):
if D != self.n_dim: if D != self.n_dim:
raise ValueError(f"Expected {self.n_dim} dimensions, got {D}") raise ValueError(f"Expected {self.n_dim} dimensions, got {D}")
with torch.autocast("cuda", enabled=False): autocast_context = (
torch.autocast("cuda", enabled=False) if img_ids.is_cuda else nullcontext()
)
with autocast_context:
angles = ( angles = (
self.angle_scale self.angle_scale
* img_ids[:, :, :, None] * img_ids[:, :, :, None]
@@ -1,5 +1,7 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# ViT3D decoder for the MiniMax H3 visual VAE (inference-only bundle). # ViT3D decoder for the MiniMax H3 visual VAE (inference-only bundle).
from contextlib import nullcontext
import torch import torch
import torch.distributed as dist import torch.distributed as dist
import torch.nn as nn import torch.nn as nn
@@ -22,6 +24,10 @@ def _linear_with_module_dtype(linear, tensor, out_dtype=None):
return output return output
def _cuda_autocast_disabled(tensor: torch.Tensor):
return torch.autocast("cuda", enabled=False) if tensor.is_cuda else nullcontext()
def _pack_tensors_3d(tensors, patch_size, patch_size_t): def _pack_tensors_3d(tensors, patch_size, patch_size_t):
batch_size, num_channels_tensors, temporal, height, width = tensors.shape batch_size, num_channels_tensors, temporal, height, width = tensors.shape
@@ -264,7 +270,7 @@ class ViT3DDecoder(ViTBase):
hidden_states = _pack_tensors_3d(x, 1, 1) hidden_states = _pack_tensors_3d(x, 1, 1)
latent_size = (latent_T, latent_H, latent_W) latent_size = (latent_T, latent_H, latent_W)
with torch.autocast("cuda", enabled=False): with _cuda_autocast_disabled(hidden_states):
hidden_states = _linear_with_module_dtype( hidden_states = _linear_with_module_dtype(
self.x_embedder, hidden_states, hidden_states.dtype self.x_embedder, hidden_states, hidden_states.dtype
) )
@@ -339,7 +345,7 @@ class ViT3DDecoder(ViTBase):
hidden_states = self.apply_mask_postprocess(hidden_states, num_patches) hidden_states = self.apply_mask_postprocess(hidden_states, num_patches)
with torch.autocast("cuda", enabled=False): with _cuda_autocast_disabled(hidden_states):
output = _linear_with_module_dtype( output = _linear_with_module_dtype(
self.proj_out, hidden_states, hidden_states.dtype self.proj_out, hidden_states, hidden_states.dtype
) )
@@ -1636,7 +1636,11 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
# deallocate transformer if on mps # deallocate transformer if on mps
pipeline = self.pipeline() if self.pipeline else None pipeline = self.pipeline() if self.pipeline else None
if torch.backends.mps.is_available() and not is_warmup: if (
torch.backends.mps.is_available()
and not is_warmup
and not is_layerwise_offloaded_module(self.transformer)
):
logger.info( logger.info(
"Memory before deallocating transformer: %s", "Memory before deallocating transformer: %s",
torch.mps.current_allocated_memory(), torch.mps.current_allocated_memory(),
@@ -70,6 +70,8 @@ class _AudioVAEDeterminismContext:
_saved: tuple | None = None _saved: tuple | None = None
def __enter__(self): def __enter__(self):
if not torch.cuda.is_available():
return self
if _AudioVAEDeterminismContext._depth == 0: if _AudioVAEDeterminismContext._depth == 0:
b = torch.backends b = torch.backends
_AudioVAEDeterminismContext._saved = ( _AudioVAEDeterminismContext._saved = (
@@ -94,6 +96,8 @@ class _AudioVAEDeterminismContext:
return self return self
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
if not torch.cuda.is_available():
return
_AudioVAEDeterminismContext._depth -= 1 _AudioVAEDeterminismContext._depth -= 1
if _AudioVAEDeterminismContext._depth == 0: if _AudioVAEDeterminismContext._depth == 0:
b = torch.backends b = torch.backends
@@ -147,6 +147,11 @@ class MiniMaxH3PartitionAdmissionStage(PipelineStage):
if not isinstance(task, str) or not task.strip(): if not isinstance(task, str) or not task.strip():
raise ValueError("MiniMax H3 request task must be a non-empty string") raise ValueError("MiniMax H3 request task must be a non-empty string")
self.metadata.canonical_task(task) self.metadata.canonical_task(task)
if batch.num_inference_steps < 2:
raise ValueError(
"MiniMax H3 requires num_inference_steps >= 2 because its "
"video/audio sigma schedules include both interval endpoints"
)
quality = getattr(batch.sampling_params, "quality", "lossless") quality = getattr(batch.sampling_params, "quality", "lossless")
if quality not in QUALITY_LEVELS: if quality not in QUALITY_LEVELS:
raise ValueError( raise ValueError(
@@ -3,6 +3,7 @@ from __future__ import annotations
import functools import functools
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import nullcontext
import torch import torch
@@ -296,11 +297,16 @@ class MiniMaxH3DecodingStage(DecodingStage):
audio_latent.device.type == "cuda" audio_latent.device.type == "cuda"
and autocast_enabled(audio_vae_dtype, server_args.disable_autocast) and autocast_enabled(audio_vae_dtype, server_args.disable_autocast)
) )
with torch.autocast( autocast_context = (
device_type=audio_latent.device.type, torch.autocast(
dtype=audio_vae_dtype, device_type="cuda",
enabled=audio_autocast_enabled, dtype=audio_vae_dtype,
): enabled=audio_autocast_enabled,
)
if audio_latent.is_cuda
else nullcontext()
)
with autocast_context:
audio_decode = self._get_vae_decode_fn( audio_decode = self._get_vae_decode_fn(
audio_vae, audio_vae,
server_args, server_args,
@@ -352,11 +358,16 @@ class MiniMaxH3DecodingStage(DecodingStage):
) )
if visual_autocast_enabled: if visual_autocast_enabled:
selected_video_vae.prepare_decoder_autocast_weights(video_vae_dtype) selected_video_vae.prepare_decoder_autocast_weights(video_vae_dtype)
with torch.autocast( autocast_context = (
device_type=visual_latent.device.type, torch.autocast(
dtype=video_vae_dtype, device_type="cuda",
enabled=visual_autocast_enabled, dtype=video_vae_dtype,
): enabled=visual_autocast_enabled,
)
if visual_latent.is_cuda
else nullcontext()
)
with autocast_context:
video_decode = self._get_vae_decode_fn( video_decode = self._get_vae_decode_fn(
selected_video_vae, selected_video_vae,
server_args, server_args,
@@ -35,6 +35,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import ( from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
VerificationResult, VerificationResult,
) )
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.server_args import ServerArgs from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.nvtx_pytorch_hooks import maybe_nvtx_range from sglang.multimodal_gen.runtime.utils.nvtx_pytorch_hooks import maybe_nvtx_range
@@ -605,9 +606,9 @@ class MiniMaxH3DenoisingStage(DenoisingStage):
ctx = _resolve_full_loop_context(batch) ctx = _resolve_full_loop_context(batch)
if not torch.cuda.is_available(): if not (current_platform.is_cuda() or current_platform.is_mps()):
raise RuntimeError("MiniMax H3 full-loop denoise requires CUDA") raise RuntimeError("MiniMax H3 full-loop denoise requires CUDA or MPS")
device = torch.device("cuda") device = current_platform.get_local_torch_device()
sigmas_video = [float(v) for v in ctx.sigmas["video"]] sigmas_video = [float(v) for v in ctx.sigmas["video"]]
self._maybe_enable_cache_dit_and_torch_compile( self._maybe_enable_cache_dit_and_torch_compile(
len(sigmas_video) - 1, len(sigmas_video) - 1,
@@ -18,6 +18,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.m
from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import ( from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
TextEncodingStage, TextEncodingStage,
) )
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.server_args import ServerArgs from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
@@ -55,6 +56,8 @@ class MiniMaxH3TextEncodingStage(TextEncodingStage):
try: try:
self._encode_from_plan(batch, plan) self._encode_from_plan(batch, plan)
self._publish_native_text_conditioning(batch) self._publish_native_text_conditioning(batch)
if current_platform.is_mps():
self._finish_active_component_use()
except Exception: except Exception:
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import ( from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
minimax_h3_cleanup_temp_dirs, minimax_h3_cleanup_temp_dirs,
@@ -46,15 +46,15 @@ class MpsPlatform(Platform):
@classmethod @classmethod
def get_device_capability(cls, device_id: int = 0) -> DeviceCapability | None: def get_device_capability(cls, device_id: int = 0) -> DeviceCapability | None:
raise NotImplementedError return None
@classmethod @classmethod
def get_device_name(cls, device_id: int = 0) -> str: def get_device_name(cls, device_id: int = 0) -> str:
raise NotImplementedError return "Apple Silicon MPS"
@classmethod @classmethod
def get_device_uuid(cls, device_id: int = 0) -> str: def get_device_uuid(cls, device_id: int = 0) -> str:
raise NotImplementedError return "mps"
@classmethod @classmethod
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
@@ -1310,31 +1310,17 @@ class ServerArgs(DisaggServerArgsMixin):
def _adjust_platform_specific(self): def _adjust_platform_specific(self):
if current_platform.is_mps(): if current_platform.is_mps():
if self.num_gpus != 1:
raise ValueError("MPS currently supports only --num-gpus 1")
if self.component_residency is not None and any( if self.component_residency is not None and any(
mode != RESIDENT for mode in self.component_residency.values() mode not in (RESIDENT, LAYERWISE_OFFLOAD)
for mode in self.component_residency.values()
): ):
raise ValueError( raise ValueError(
"--component-residency offload modes require CUDA; " "MPS supports only resident or layerwise-offload component "
"MPS supports only resident components" "residency"
) )
self.use_fsdp_inference = False self.use_fsdp_inference = False
self.dit_layerwise_offload = False
self.layerwise_offload_components = None
if (
self.dit_cpu_offload
or self.text_encoder_cpu_offload
or self.image_encoder_cpu_offload
or self.vae_cpu_offload
):
logger.warning(
"Disabling component CPU offload on MPS because the component "
"residency offload strategy is only validated on CUDA."
)
self.dit_cpu_offload = False
self.text_encoder_cpu_offload = False
self.image_encoder_cpu_offload = False
self.vae_cpu_offload = False
self.cpu_offload_components = None
def is_arg_explicitly_set(self, arg_name: str) -> bool: def is_arg_explicitly_set(self, arg_name: str) -> bool:
return arg_name in self._explicit_arg_names return arg_name in self._explicit_arg_names
@@ -925,6 +925,7 @@ def maybe_download_model(
local_path = snapshot_download( local_path = snapshot_download(
repo_id=model_name_or_path, repo_id=model_name_or_path,
ignore_patterns=["*.onnx", "*.msgpack"], ignore_patterns=["*.onnx", "*.msgpack"],
allow_patterns=allow_patterns,
local_dir=local_dir, local_dir=local_dir,
local_files_only=True, local_files_only=True,
max_workers=8, max_workers=8,
@@ -1034,6 +1035,7 @@ def maybe_download_model(
local_path = snapshot_download( local_path = snapshot_download(
repo_id=model_name_or_path, repo_id=model_name_or_path,
ignore_patterns=["*.onnx", "*.msgpack"], ignore_patterns=["*.onnx", "*.msgpack"],
allow_patterns=allow_patterns,
local_dir=local_dir, local_dir=local_dir,
max_workers=8, max_workers=8,
force_download=True, force_download=True,
@@ -1084,6 +1086,28 @@ def maybe_download_model(
wait_time, wait_time,
) )
time.sleep(wait_time) time.sleep(wait_time)
except RuntimeError as e:
if "client has been closed" not in str(e).lower():
raise ValueError(
f"Could not find model at {model_name_or_path} and failed to download from {_model_hub_name()}: {e}"
) from e
if attempt == MAX_RETRIES - 1:
raise ValueError(
f"Could not find model at {model_name_or_path} and failed to download from {_model_hub_name()} "
f"after {MAX_RETRIES} attempts due to network error: {e}"
) from e
from huggingface_hub.utils._http import close_session
close_session()
wait_time = 2**attempt
logger.warning(
"Download failed (attempt %d/%d) because the Hugging Face client was closed. "
"Retrying in %d seconds...",
attempt + 1,
MAX_RETRIES,
wait_time,
)
time.sleep(wait_time)
except Exception as e: except Exception as e:
raise ValueError( raise ValueError(
f"Could not find model at {model_name_or_path} and failed to download from {_model_hub_name()}: {e}" f"Could not find model at {model_name_or_path} and failed to download from {_model_hub_name()}: {e}"
@@ -134,6 +134,16 @@ def capture_memory_snapshot() -> MemorySnapshot:
peak_reserved_mb=0.0, peak_reserved_mb=0.0,
) )
if current_platform.is_mps():
allocated = torch.mps.current_allocated_memory()
reserved = torch.mps.driver_allocated_memory()
return MemorySnapshot(
allocated_mb=allocated / (1024**2),
reserved_mb=reserved / (1024**2),
peak_allocated_mb=allocated / (1024**2),
peak_reserved_mb=reserved / (1024**2),
)
allocated = torch.get_device_module().memory_allocated() allocated = torch.get_device_module().memory_allocated()
reserved = torch.get_device_module().memory_reserved() reserved = torch.get_device_module().memory_reserved()
peak_allocated = torch.get_device_module().max_memory_allocated() peak_allocated = torch.get_device_module().max_memory_allocated()
@@ -19,6 +19,10 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import ( from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionRequirements, AttentionRequirements,
) )
from sglang.multimodal_gen.runtime.managers.memory_managers.component_residency import (
LAYERWISE_OFFLOAD,
RESIDENT,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.release_metadata import ( from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.release_metadata import (
MiniMaxH3PartitionAdmissionStage, MiniMaxH3PartitionAdmissionStage,
MiniMaxH3ReleaseMetadata, MiniMaxH3ReleaseMetadata,
@@ -369,3 +373,30 @@ def test_validate_server_args_requires_packed_varlen_backend():
): ):
with pytest.raises(ValueError, match="does not implement packed varlen"): with pytest.raises(ValueError, match="does not implement packed varlen"):
MiniMaxH3PipelineConfig.validate_server_args(config, server_args) MiniMaxH3PipelineConfig.validate_server_args(config, server_args)
def test_mps_admission_requires_layerwise_residency_for_every_h3_component():
config = SimpleNamespace(
vae_config=SimpleNamespace(resolved_parallel_decode_mode=lambda: None),
dit_config=SimpleNamespace(arch_config=SimpleNamespace(attention_head_dim=128)),
_server_arg_value=MiniMaxH3PipelineConfig._server_arg_value,
)
modes = {
"transformer": LAYERWISE_OFFLOAD,
"text_encoder": LAYERWISE_OFFLOAD,
"video_vae": LAYERWISE_OFFLOAD,
"audio_vae": LAYERWISE_OFFLOAD,
}
server_args = SimpleNamespace(
component_attention_backends={},
attention_backend=None,
enable_torch_compile=False,
residency_mode=modes.get,
)
with patch.object(current_platform, "is_mps", return_value=True):
MiniMaxH3PipelineConfig.validate_server_args(config, server_args)
modes["audio_vae"] = RESIDENT
with pytest.raises(ValueError, match="audio_vae"):
MiniMaxH3PipelineConfig.validate_server_args(config, server_args)