[MLX] Upgrade to Torch 2.13/MLX 0.32+ and redesign the Torch-MLX tensor bridge (#32984)

Co-authored-by: Alex Nails <alex.nails@radixark.ai>
This commit is contained in:
R0CKSTAR
2026-08-21 18:51:42 -07:00
committed by GitHub
co-authored by Alex Nails
parent 3b5909de0e
commit d90318b3e2
36 changed files with 1695 additions and 343 deletions
+5 -4
View File
@@ -167,14 +167,15 @@ diffusion_musa = [
"vsa==0.0.4",
]
# https://docs.sglang.io/platforms/mps.md
# https://docs.sglang.io/hardware-platforms/apple_metal
srt_mps = [
"mlx",
"mlx>=0.32.0",
"mlx-lm",
"sglang[runtime_common]",
"torch==2.11.0",
"torch==2.13.0",
"torchaudio==2.11.0",
"torchvision",
"torchcodec==0.15.0",
"torchvision==0.28.0",
]
diffusion_mps = [
+40
View File
@@ -431,9 +431,44 @@ def install_platform_stubs() -> None:
pass
class _KernelInterface(_StubBase):
pass
jit_mod.JITFunction = _JITFunction
jit_mod.KernelInterface = _KernelInterface
runtime.jit = jit_mod
# Torch 2.13 imports these as classes while initializing Inductor, even on
# MPS where no Triton kernel is compiled. Define them explicitly so the
# catch-all meta-path finder does not materialize class names as modules.
autotuner = _make_mock("triton.runtime.autotuner")
class _OutOfResources(Exception):
pass
class _PTXASError(Exception):
pass
autotuner.OutOfResources = _OutOfResources
autotuner.PTXASError = _PTXASError
runtime.autotuner = autotuner
compiler_root = _make_mock("triton.compiler")
class _CompiledKernel(_StubBase):
pass
compiler_root.CompiledKernel = _CompiledKernel
compiler_impl = _make_mock("triton.compiler.compiler")
class _ASTSource(_StubBase):
pass
compiler_impl.ASTSource = _ASTSource
compiler_impl.triton_key = lambda: "triton-stub"
compiler_root.compiler = compiler_impl
triton.compiler = compiler_root
# triton.runtime.driver
driver = _make_mock("triton.runtime.driver")
runtime.driver = driver
@@ -452,6 +487,11 @@ def install_platform_stubs() -> None:
backends = _make_mock("triton.backends")
triton.backends = backends
compiler = _make_mock("triton.backends.compiler")
class _GPUTarget(_StubBase):
pass
compiler.GPUTarget = _GPUTarget
backends.compiler = compiler
mps = torch.mps
+1 -1
View File
@@ -71,6 +71,7 @@ from sglang.srt.distributed.parallel_state import (
)
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.entrypoints.engine import _set_envs_and_config
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.layers.dp_attention import compute_dp_attention_world_info
from sglang.srt.layers.moe import initialize_moe_config
from sglang.srt.layers.quantization.fp4_utils import initialize_fp4_gemm_config
@@ -96,7 +97,6 @@ from sglang.srt.utils import (
suppress_other_loggers,
)
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.srt.utils.tensor_bridge import use_mlx
def start_profile(
@@ -21,7 +21,7 @@ free to move; the facade is not. `test_import_surface.py` enforces this, with
a small allowlist for tests that deliberately exercise one backend.
Resolution is lazy (PEP 562): the backends have disjoint heavy dependencies
(Triton, CUTLASS/CuTe-DSL, FlyDSL on ROCm, MLX on Apple), so an eager
(Triton, CUTLASS/CuTe-DSL, and FlyDSL on ROCm), so an eager
re-export would make all of them import-time requirements everywhere.
## Layout
@@ -18,7 +18,7 @@ numerics and platform plumbing, ``sites`` the request-scoped mount policy, and
``README.md``: several norms look interchangeable and are not.
Resolution is lazy (PEP 562). The backends have disjoint, heavy dependencies
-- Triton, CUTLASS/CuTe-DSL, FlyDSL (ROCm), MLX (Apple) -- so an eager
-- Triton, CUTLASS/CuTe-DSL, and FlyDSL (ROCm) -- so an eager
re-export would turn every one of them into a hard import-time requirement on
every platform. ``_EXPORTS`` maps a symbol to its module and the import
happens on first attribute access.
@@ -1,122 +0,0 @@
"""MPS (Apple Silicon) fallbacks for Triton diffusion kernels.
Triton is not available on macOS / Metal, so these pure-PyTorch (and
optionally MLX-accelerated) implementations replace the Triton kernels
at import time when the live platform is MPS (see ``common.platform``).
MLX acceleration (opt-in via ``SGLANG_USE_MLX=1``):
Norm ops use ``mx.fast.rms_norm`` / ``mx.fast.layer_norm`` — single fused
Metal kernels that are 1.4x2.9x faster than the multi-step PyTorch MPS
decomposition for medium-to-large tensors.
"""
from typing import Optional
import torch
from torch import Tensor
from sglang.srt.utils.tensor_bridge import mlx_to_torch, torch_to_mlx, use_mlx
from .fallback_torch import (
apply_rotary_embedding_native as apply_rotary_embedding_native,
)
from .fallback_torch import (
fuse_scale_shift_kernel_native as fuse_scale_shift_kernel_native,
)
from .fallback_torch import (
norm_infer_native,
rms_norm_fn_native,
triton_one_pass_rms_norm_native,
)
_use_mlx = use_mlx()
if _use_mlx:
import mlx.core as mx
# MLX-accelerated norm ops (1.4x2.9x faster than torch native on MPS)
# Uses mx.fast.rms_norm / mx.fast.layer_norm — single fused Metal kernels
# instead of 7+ separate PyTorch MPS kernel launches.
if _use_mlx:
def norm_infer_native( # noqa: F811
x: Tensor,
weight: Optional[Tensor],
bias: Optional[Tensor],
eps: float,
is_rms_norm: bool = False,
out: Optional[Tensor] = None,
) -> Tensor:
"""MLX-accelerated norm_infer (layer norm / rms norm inference)."""
device = x.device
orig_dtype = x.dtype
x_mx = torch_to_mlx(x)
if is_rms_norm:
w_mx = (
torch_to_mlx(weight) if weight is not None else mx.ones(x_mx.shape[-1])
)
result_mx = mx.fast.rms_norm(x_mx, w_mx, eps)
else:
w_mx = torch_to_mlx(weight) if weight is not None else None
b_mx = torch_to_mlx(bias) if bias is not None else None
result_mx = mx.fast.layer_norm(x_mx, w_mx, b_mx, eps)
result = mlx_to_torch(result_mx, device).to(orig_dtype)
if out is not None:
out.copy_(result)
return out
return result
def triton_one_pass_rms_norm_native( # noqa: F811
x: torch.Tensor, w: torch.Tensor, eps: float = 1e-6
) -> torch.Tensor:
"""MLX-accelerated triton_one_pass_rms_norm."""
device = x.device
orig_dtype = x.dtype
x_mx = torch_to_mlx(x)
w_mx = torch_to_mlx(w)
result_mx = mx.fast.rms_norm(x_mx, w_mx, eps)
return mlx_to_torch(result_mx, device).to(orig_dtype)
def rms_norm_fn_native( # noqa: F811
x,
weight,
bias,
residual=None,
x1=None,
weight1=None,
bias1=None,
eps=1e-6,
dropout_p=0.0,
rowscale=None,
prenorm=False,
residual_in_fp32=False,
zero_centered_weight=False,
return_dropout_mask=False,
out_dtype=None,
out=None,
residual_out=None,
):
"""MLX-accelerated rms_norm_fn (inference only, no dropout/x1 support)."""
device = x.device
orig_dtype = x.dtype
if residual is not None:
x = x.float() + residual.float()
residual_out_val = x.to(torch.float32 if residual_in_fp32 else orig_dtype)
else:
residual_out_val = None
if weight is not None and zero_centered_weight:
w = weight.float() + 1.0
else:
w = weight
x_mx = torch_to_mlx(x)
w_mx = torch_to_mlx(w) if w is not None else mx.ones(x_mx.shape[-1])
result_mx = mx.fast.rms_norm(x_mx, w_mx, eps)
x_hat = mlx_to_torch(result_mx, device)
if bias is not None:
x_hat = x_hat + bias.to(x_hat.device, x_hat.dtype)
final_dtype = out_dtype if out_dtype is not None else orig_dtype
y = x_hat.to(final_dtype)
if residual is not None and residual_out_val is not None:
return y, residual_out_val
return y
@@ -8,6 +8,7 @@ implementations replace the Triton kernels
from typing import Optional
import torch
import torch.nn.functional as F
from torch import Tensor
@@ -69,20 +70,36 @@ def norm_infer_native(
out: Optional[Tensor] = None,
) -> Tensor:
"""Native fallback for norm_infer (layer norm / rms norm inference)."""
orig_dtype = x.dtype
x = x.contiguous().float()
normalized_shape = (x.shape[-1],)
if is_rms_norm:
variance = x.pow(2).mean(dim=-1, keepdim=True)
x_hat = x * torch.rsqrt(variance + eps)
# ``F.rms_norm`` returns in the input dtype before a separately added
# bias is applied. Promote the whole branch when a bias is present or
# parameters have a different dtype, retaining the old
# fp32-accumulate-then-cast contract. A bias triggers promotion even
# when its dtype matches the input; otherwise the normalized value is
# rounded before the affine add. Keep the native fast path for the
# common bias-free, same-dtype case.
needs_fp32 = bias is not None or (
weight is not None and weight.dtype != x.dtype
)
if needs_fp32:
result = F.rms_norm(
x.float(),
normalized_shape,
weight.float() if weight is not None else None,
eps,
)
if bias is not None:
result = result + bias.float()
else:
result = F.rms_norm(x, normalized_shape, weight, eps)
if bias is not None:
result = result + bias
else:
mean = x.mean(dim=-1, keepdim=True)
variance = (x - mean).pow(2).mean(dim=-1, keepdim=True)
x_hat = (x - mean) * torch.rsqrt(variance + eps)
if weight is not None:
x_hat = x_hat * weight.float()
if bias is not None:
x_hat = x_hat + bias.float()
result = x_hat.to(orig_dtype)
result = F.layer_norm(x, normalized_shape, weight, bias, eps)
# Match the original fallback and Triton kernel contract even when a
# higher-precision weight or bias promotes PyTorch's intermediate result.
result = result.to(x.dtype)
if out is not None:
out.copy_(result)
return out
@@ -93,12 +110,7 @@ def triton_one_pass_rms_norm_native(
x: torch.Tensor, w: torch.Tensor, eps: float = 1e-6
) -> torch.Tensor:
"""Native fallback for triton_one_pass_rms_norm."""
shape = x.shape
orig_dtype = x.dtype
x = x.contiguous().float()
variance = x.pow(2).mean(dim=-1, keepdim=True)
x_hat = x * torch.rsqrt(variance + eps)
return (x_hat * w.float()).to(orig_dtype).view(shape)
return F.rms_norm(x, (x.shape[-1],), w, eps)
def rms_norm_fn_native(
@@ -130,13 +142,13 @@ def rms_norm_fn_native(
residual_out_val = x.to(torch.float32 if residual_in_fp32 else orig_dtype)
else:
residual_out_val = None
variance = x.pow(2).mean(dim=-1, keepdim=True)
x_hat = x * torch.rsqrt(variance + eps)
if weight is not None:
w = weight.float()
if zero_centered_weight:
w = w + 1.0
x_hat = x_hat * w
else:
w = None
x_hat = F.rms_norm(x, (x.shape[-1],), w, eps)
if bias is not None:
x_hat = x_hat + bias.float()
final_dtype = out_dtype if out_dtype is not None else orig_dtype
@@ -1,8 +1,8 @@
"""Platform predicates and the import-time fallback selector.
Several diffusion Triton kernels have no Triton on the live device (Ascend
NPU, Apple MPS, MUSA, CPU) and must resolve to a pure-``torch`` — or
MLX-accelerated — implementation. That choice is made once at import time,
NPU, Apple MPS, MUSA, CPU) and must resolve to a pure-``torch`` implementation.
That choice is made once at import time,
which used to mean a hand-rolled four-branch ``if`` block repeated in every
such module, each importing ``current_platform`` directly.
@@ -75,8 +75,8 @@ def lazy_fallback(kind: str, name: str) -> Callable:
"""Name a fallback without importing its module.
``select_impl`` is handed every candidate at once, so a plain import here
would pull in *all* fallback modules on every platform -- including MLX on
CUDA hosts. The returned shim imports ``common.fallback_<kind>`` on its
would pull in *all* fallback modules on every platform. The returned shim
imports ``common.fallback_<kind>`` on its
first call instead, which for the unselected candidates never happens.
"""
@@ -736,7 +736,7 @@ def fuse_residual_layernorm_scale_shift_gate_select01_kernel(
fuse_scale_shift_kernel = select_impl(
fuse_scale_shift_kernel,
npu=lazy_fallback("npu", "fuse_scale_shift_native"),
mps=lazy_fallback("mps", "fuse_scale_shift_kernel_native"),
mps=lazy_fallback("torch", "fuse_scale_shift_kernel_native"),
musa=lazy_fallback("torch", "fuse_scale_shift_kernel_native"),
cpu=lazy_fallback("torch", "fuse_scale_shift_kernel_native"),
)
@@ -649,11 +649,11 @@ def norm_infer(
norm_infer = select_impl(
norm_infer,
mps=lazy_fallback("mps", "norm_infer_native"),
mps=lazy_fallback("torch", "norm_infer_native"),
cpu=lazy_fallback("torch", "norm_infer_native"),
)
rms_norm_fn = select_impl(
rms_norm_fn,
mps=lazy_fallback("mps", "rms_norm_fn_native"),
mps=lazy_fallback("torch", "rms_norm_fn_native"),
cpu=lazy_fallback("torch", "rms_norm_fn_native"),
)
@@ -72,6 +72,6 @@ def triton_one_pass_rms_norm(x: torch.Tensor, w: torch.Tensor, eps: float = 1e-6
triton_one_pass_rms_norm = select_impl(
triton_one_pass_rms_norm,
# MPS keeps the api-logging wrapper the Triton entry point carries.
mps=debug_kernel_api(lazy_fallback("mps", "triton_one_pass_rms_norm_native")),
mps=debug_kernel_api(lazy_fallback("torch", "triton_one_pass_rms_norm_native")),
cpu=lazy_fallback("torch", "triton_one_pass_rms_norm_native"),
)
@@ -128,6 +128,6 @@ def apply_rotary_embedding(
apply_rotary_embedding = select_impl(
apply_rotary_embedding,
npu=lazy_fallback("npu", "apply_rotary_embedding_native"),
mps=lazy_fallback("mps", "apply_rotary_embedding_native"),
mps=lazy_fallback("torch", "apply_rotary_embedding_native"),
cpu=lazy_fallback("torch", "apply_rotary_embedding_native"),
)
+1 -1
View File
@@ -30,7 +30,7 @@ SGLang Diffusion supports Moore Threads GPUs (MTGPU) through the MUSA software s
### Apple MPS Support
SGLang Diffusion supports Apple Silicon (M-series) via the MPS backend. Since Triton is Linux-only, all Triton kernels are replaced with PyTorch-native fallbacks on MPS. Norm operations can be optionally accelerated with MLX fused Metal kernels (`SGLANG_USE_MLX=1`). See the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation) for setup instructions.
SGLang Diffusion supports Apple Silicon (M-series) via the MPS backend. Since Triton is Linux-only, Triton kernels are replaced with PyTorch-native fallbacks on MPS. See the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation) for setup instructions.
## Getting Started
@@ -152,8 +152,11 @@ class TransformerLoader(ComponentLoader):
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
if (
current_platform.is_mps()
and server_args.should_configure_layerwise_offload_for_lazy_component(
component_name
)
):
logger.info(
"Loading %s on CPU first for MPS layerwise offload", component_name
@@ -150,8 +150,11 @@ class VAELoader(ComponentLoader):
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
if (
current_platform.is_mps()
and server_args.should_configure_layerwise_offload_for_lazy_component(
component_name
)
):
logger.info(
"Loading %s on CPU first for MPS layerwise offload", component_name
@@ -0,0 +1,37 @@
"""Import smoke tests for the diffusion Torch path."""
import os
import subprocess
import sys
import unittest
class TestDiffusionImportIsolation(unittest.TestCase):
def test_disabled_backend_does_not_import_mlx(self):
"""Diffusion modules must remain usable without the optional MLX path."""
script = """
import sys
from sglang.kernels.ops.diffusion import norm_infer
from sglang.multimodal_gen.runtime.layers.layernorm import RMSNorm
assert norm_infer is not None and RMSNorm is not None
assert not any(name == "mlx" or name.startswith("mlx.") for name in sys.modules)
"""
env = os.environ.copy()
env.pop("SGLANG_USE_MLX", None)
completed = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=30,
check=False,
env=env,
)
self.assertEqual(
completed.returncode,
0,
msg=f"stdout={completed.stdout}\nstderr={completed.stderr}",
)
if __name__ == "__main__":
unittest.main()
@@ -67,6 +67,7 @@ from sglang.multimodal_gen.runtime.layers.quantization.modelopt_quant import (
)
from sglang.multimodal_gen.runtime.loader.component_loaders import transformer_loader
from sglang.multimodal_gen.runtime.loader.component_loaders.transformer_loader import (
TransformerLoader,
_default_quantized_attention_backend,
_resolve_checkpoint_load_device,
_warn_if_expected_param_dtype_missing,
@@ -121,6 +122,29 @@ def _make_quant_config(name: str, **attrs):
class TestTransformerQuantHelpers(unittest.TestCase):
def test_mps_layerwise_load_uses_residency_api(self):
server_args = SimpleNamespace(
should_configure_layerwise_offload_for_lazy_component=lambda name: (
name == "transformer"
)
)
with patch.object(
transformer_loader.current_platform, "is_mps", return_value=True
):
self.assertEqual(
TransformerLoader().customized_load_kwargs_for_component(
server_args, "transformer"
),
{"cpu_offload_flag": True},
)
self.assertEqual(
TransformerLoader().customized_load_kwargs_for_component(
server_args, "audio_dit"
),
{},
)
def _make_server_args(self, **overrides):
defaults = dict(
transformer_weights_path=None,
@@ -38,6 +38,7 @@ class _FakeServerArgs:
self.model_paths = {}
self.revision = "test-revision"
self.trust_remote_code = True
self.layerwise_components = set()
def resolve_component_attention_backend(self, _component_name):
return None, None
@@ -45,6 +46,9 @@ class _FakeServerArgs:
def should_start_component_on_cpu(self, _component_name):
return False
def should_configure_layerwise_offload_for_lazy_component(self, component_name):
return component_name in self.layerwise_components
class TestKeepCheckpointMapped(unittest.TestCase):
"""The mapping is for hosts that cannot afford the whole deployment."""
@@ -92,6 +96,21 @@ class TestMatchCheckpointDtypes(unittest.TestCase):
class TestVAELoader(unittest.TestCase):
def test_mps_layerwise_load_uses_residency_api(self):
loader = vae_loader.VAELoader()
server_args = _FakeServerArgs(QwenImagePipelineConfig())
server_args.layerwise_components.add("vae")
with patch.object(vae_loader.current_platform, "is_mps", return_value=True):
self.assertEqual(
loader.customized_load_kwargs_for_component(server_args, "vae"),
{"cpu_offload_flag": True},
)
self.assertEqual(
loader.customized_load_kwargs_for_component(server_args, "audio_vae"),
{},
)
def test_quantized_vae_admission_leaves_plain_configs_unchanged(self):
_require_native_loader_for_quantized_vae(
{"_class_name": "AutoencoderKL"}, "vae"
+1 -1
View File
@@ -37,6 +37,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from sglang.srt.arg_groups.arg_utils import resolvable_fields
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.model_executor.cuda_graph_config import Backend
from sglang.srt.utils.common import (
cpu_has_amx_support,
@@ -62,7 +63,6 @@ from sglang.srt.utils.common import (
is_xpu,
xpu_has_xmx_support,
)
from sglang.srt.utils.tensor_bridge import use_mlx
logger = logging.getLogger(__name__)
@@ -12,8 +12,8 @@ from typing import Any, Callable, Optional
import torch
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.managers.io_struct import ProfileReqOutput
from sglang.srt.utils.tensor_bridge import use_mlx
logger = logging.getLogger(__name__)
@@ -0,0 +1,69 @@
"""Runtime gate for the opt-in MLX backend on Apple silicon."""
from functools import lru_cache
import torch
from packaging.version import InvalidVersion, Version
from sglang.srt.environ import envs
_MIN_MLX_VERSION = Version("0.32.0")
_SUPPORTED_TORCH_SERIES = (2, 13)
def _is_stable_series(raw_version: object, series: tuple[int, int]) -> bool:
try:
version = Version(str(raw_version))
except InvalidVersion:
return False
return not version.is_prerelease and (version.major, version.minor) == series
def _is_stable_at_least(raw_version: object, minimum: Version) -> bool:
try:
version = Version(str(raw_version))
except InvalidVersion:
return False
return not version.is_prerelease and version >= minimum
@lru_cache(maxsize=1)
def _validate_runtime() -> None:
try:
import mlx.core as mx
except ImportError:
raise RuntimeError(
"SGLANG_USE_MLX requires stable Torch 2.13.x and MLX >= 0.32.0, "
"but MLX is not installed; reinstall with "
"the srt_mps extra"
) from None
mlx_version = getattr(mx, "__version__", None)
torch_version = getattr(torch, "__version__", None)
if not _is_stable_series(
torch_version, _SUPPORTED_TORCH_SERIES
) or not _is_stable_at_least(mlx_version, _MIN_MLX_VERSION):
raise RuntimeError(
"SGLANG_USE_MLX requires stable Torch 2.13.x and MLX >= 0.32.0; "
"found "
f"Torch {torch_version or 'unknown'} + MLX {mlx_version or 'unknown'}; "
"reinstall with the srt_mps extra"
)
mps_backend = getattr(torch.backends, "mps", None)
is_mps_available = getattr(mps_backend, "is_available", None)
if not callable(is_mps_available) or not is_mps_available():
raise RuntimeError("SGLANG_USE_MLX requires an available PyTorch MPS device")
metal = getattr(mx, "metal", None)
is_available = getattr(metal, "is_available", None)
if not callable(is_available) or not is_available():
raise RuntimeError("SGLANG_USE_MLX requires an available MLX Metal device")
@lru_cache(maxsize=1)
def use_mlx() -> bool:
"""Return whether the validated MLX backend was explicitly enabled."""
enabled = bool(envs.SGLANG_USE_MLX.get())
if enabled:
_validate_runtime()
return enabled
+1 -1
View File
@@ -103,6 +103,7 @@ from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.dllm.mixin.scheduler import SchedulerDllmMixin
from sglang.srt.environ import envs
from sglang.srt.eplb.expert_distribution import get_global_expert_distribution_recorder
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.layers.dp_attention import compute_dp_attention_world_info
from sglang.srt.layers.moe import initialize_moe_config
from sglang.srt.layers.quantization.fp4_utils import initialize_fp4_gemm_config
@@ -326,7 +327,6 @@ from sglang.srt.utils.hf_transformers_utils import (
from sglang.srt.utils.msgspec_utils import msgspec_to_builtins
from sglang.srt.utils.numa_utils import get_numa_node_if_available, numa_bind_to_node
from sglang.srt.utils.nvtx_utils import scheduler_nvtx_method
from sglang.srt.utils.tensor_bridge import use_mlx
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
from sglang.utils import TypeBasedDispatcher, get_exception_traceback
@@ -32,6 +32,7 @@ from sglang.srt.configs.hybrid_arch import (
)
from sglang.srt.configs.model_config import ModelImpl, is_deepseek_dsa
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.managers.mm_schedule import init_mm_embedding_cache
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool
@@ -46,7 +47,6 @@ from sglang.srt.runtime_context import (
get_parallel,
get_schedule,
)
from sglang.srt.utils.tensor_bridge import use_mlx
if TYPE_CHECKING:
+1 -1
View File
@@ -15,10 +15,10 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Optional
from sglang.srt.environ import envs
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.runtime_context import get_disagg, get_memory
from sglang.srt.utils.tensor_bridge import use_mlx
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
+12 -1
View File
@@ -55,6 +55,7 @@ from sglang.srt.distributed.device_communicators.mooncake_transfer_engine import
)
from sglang.srt.environ import envs
from sglang.srt.function_call.function_call_parser import FunctionCallParser
from sglang.srt.hardware_backend.mlx.runtime import use_mlx
from sglang.srt.lora.lora_registry import LoRARef
from sglang.srt.model_executor.cuda_graph_config import (
ALLOWED_BACKENDS_PER_PHASE,
@@ -102,7 +103,6 @@ from sglang.srt.utils.common import (
from sglang.srt.utils.hf_transformers_utils import check_gguf_file
from sglang.srt.utils.network import NetworkAddress, get_free_port, wait_port_available
from sglang.srt.utils.runai_utils import ObjectStorageModel, is_runai_obj_uri
from sglang.srt.utils.tensor_bridge import use_mlx
from sglang.utils import is_in_ci
logger = logging.getLogger(__name__)
@@ -3665,6 +3665,10 @@ class ServerArgs:
self._handle_media_url_security()
self._handle_hicache_ratio_default()
self._validate_prefill_decode_interval()
# Reject an explicitly enabled but incompatible hardware runtime before
# model path resolution, downloads, or the dummy-model short circuit.
self._handle_hardware_runtime_validation()
if self.model_path.lower() in ["none", "dummy"]:
return
@@ -4401,6 +4405,13 @@ class ServerArgs:
)
self.sampling_backend = "pytorch"
def _handle_hardware_runtime_validation(self):
# This is intentionally independent of self.device: setting
# SGLANG_USE_MLX opts into the MLX backend and must fail immediately if
# the environment cannot honor that request. With the flag unset,
# use_mlx() remains lazy and does not import MLX.
use_mlx()
def _handle_npu_backends(self):
if self.device == "npu":
from sglang.srt.hardware_backend.npu.utils import set_default_server_args
+368 -158
View File
@@ -1,78 +1,57 @@
# Copied and adapted from: https://github.com/vllm-project/vllm-metal
# SPDX-License-Identifier: Apache-2.0
"""Tensor bridge between MLX and PyTorch.
"""Tensor bridge between MLX and PyTorch on Apple silicon.
Provides zero-copy conversion when possible using Apple Silicon's unified memory.
The MLX backend requires MLX >= 0.32 and PyTorch >= 2.13. Ordinary
``torch_to_mlx`` conversion creates an independent MLX allocation. The
zero-copy ``mlx_call`` helper is available for a complete MLX operation and
keeps all borrowed DLPack inputs alive until the result has been evaluated.
This lifetime boundary matters because MLX may donate a borrowed input buffer
to a lazy operation.
Bridge entry points are serialized because Torch and MLX use different stream
abstractions over the same Metal command queues. The lock covers producer
fencing, MLX evaluation, and DLPack import. It cannot cover arbitrary MPS
work outside the function, so callers must serialize any overlapping use or
mutation of source and returned MPS tensors as well.
"""
from __future__ import annotations
import logging
from functools import lru_cache
from typing import TYPE_CHECKING, Literal
from functools import lru_cache, wraps
from threading import RLock
from typing import TYPE_CHECKING, Any, Callable, Literal
import torch
from sglang.srt.environ import envs
if TYPE_CHECKING:
import mlx.core as mx
logger = logging.getLogger(__name__)
_MLX_AVAILABLE: bool = False
try:
import mlx.core as mx # noqa: F811
_MLX_AVAILABLE = True
except ImportError:
pass
_BRIDGE_LOCK = RLock()
def is_mlx_available() -> bool:
"""Return True when the ``mlx`` package can be imported."""
return _MLX_AVAILABLE
def _serialized_bridge(function: Callable[..., Any]) -> Callable[..., Any]:
"""Serialize one complete Torch/MLX crossing, including result export."""
@wraps(function)
def wrapper(*args: Any, **kwargs: Any) -> Any:
with _BRIDGE_LOCK:
return function(*args, **kwargs)
return wrapper
@lru_cache(maxsize=1)
def use_mlx() -> bool:
"""Return True when the user opted-in via ``SGLANG_USE_MLX=1`` **and** MLX is importable."""
return bool(envs.SGLANG_USE_MLX.get()) and _MLX_AVAILABLE
def _mlx_core():
try:
import mlx.core as mx
except ImportError:
raise RuntimeError("The MLX tensor bridge requires MLX >= 0.32.0") from None
return mx
# MPS has a 4GB (2^32 bytes) limit for MPSTemporaryNDArray allocations.
# Metal may allocate multiple temporary buffers internally, so we use a
# conservative threshold of 1GB to avoid hitting the limit.
# See: https://github.com/anthropics/vllm-metal/issues/43
_MPS_SAFE_SIZE_BYTES = 1 << 30 # 1GB
# MLX to PyTorch dtype mapping
# TODO(perf): float64 is CPU-only in MLX (see ml-explore/mlx#1843).
# When the target device is GPU/MPS we should auto-downcast float64 → float32
# to avoid a runtime error; when the target is CPU we can keep float64.
# For now float64 is omitted from the mapping so it hits the ValueError
# fallback in mlx_to_torch().
MLX_TO_TORCH_DTYPE = (
{
mx.float32: torch.float32,
mx.float16: torch.float16,
mx.bfloat16: torch.bfloat16,
mx.int32: torch.int32,
mx.int64: torch.int64,
mx.int16: torch.int16,
mx.int8: torch.int8,
mx.uint8: torch.uint8,
mx.bool_: torch.bool,
}
if _MLX_AVAILABLE
else {}
)
# PyTorch to MLX dtype mapping
TORCH_TO_MLX_DTYPE = {v: k for k, v in MLX_TO_TORCH_DTYPE.items()}
def get_torch_device() -> torch.device:
def _get_torch_device() -> torch.device:
"""Get the PyTorch device for Metal/MPS.
Returns:
@@ -83,146 +62,377 @@ def get_torch_device() -> torch.device:
return torch.device("cpu")
def _get_tensor_size_bytes(array: mx.array) -> int:
"""Calculate the size of an MLX array in bytes.
def _torch_to_mlx(
tensor: torch.Tensor,
*,
copy: bool,
synchronize: bool = True,
) -> mx.array:
"""Convert one tensor, optionally borrowing its MPS allocation."""
mx = _mlx_core()
tensor = tensor.detach()
Args:
array: MLX array
if tensor.device.type == "mps":
if synchronize:
# Torch and MLX do not share stream state on Metal.
torch.mps.synchronize()
return mx.asarray(tensor, copy=copy)
if tensor.device.type == "cpu":
# CPU tensors always get MLX-owned storage. In particular, do not
# expose a NumPy/memoryview alias whose lifetime is controlled by the
# caller.
if tensor.dtype == torch.complex128:
raise ValueError(
"MLX 0.32 does not support complex128; convert the Torch tensor "
"to complex64 explicitly"
)
# MLX 0.32 does not support float64 on its default Metal stream. Keep
# the dtype by constructing this uncommon CPU value on the CPU stream
# instead of silently downcasting it to float32.
if tensor.dtype == torch.float64:
with mx.stream(mx.cpu):
return mx.array(tensor, dtype=mx.float64)
return mx.array(tensor)
raise ValueError(
f"The MLX tensor bridge supports CPU and MPS tensors, got {tensor.device}"
)
Returns:
Size in bytes
class MlxTensorView:
"""A lifetime-bound, zero-copy MLX view of a Torch MPS tensor.
The view deliberately retains a detached Torch tensor *and* the imported
MLX array. Holding only the array is insufficient: a later parameter
replacement or garbage collection could invalidate the borrowed storage
while MLX still has a lazy graph referring to it. This class is intended
for immutable inference weights; construct a new view after replacing the
source storage.
"""
return array.size * array.dtype.size
__slots__ = ("torch_tensor", "array")
def __init__(self, tensor: torch.Tensor, *, synchronize: bool = True):
with _BRIDGE_LOCK:
owner = tensor.detach()
if owner.device.type != "mps":
raise ValueError(
f"MlxTensorView requires a Torch MPS tensor, got {owner.device}"
)
if synchronize:
torch.mps.synchronize()
self.torch_tensor = owner
self.array = _torch_to_mlx(owner, copy=False, synchronize=False)
@classmethod
def _from_synchronized(cls, tensor: torch.Tensor) -> MlxTensorView:
view = object.__new__(cls)
owner = tensor.detach()
if owner.device.type != "mps":
raise ValueError(
f"MlxTensorView requires a Torch MPS tensor, got {owner.device}"
)
view.torch_tensor = owner
view.array = _torch_to_mlx(owner, copy=False, synchronize=False)
return view
def matches(self, tensor: torch.Tensor) -> bool:
"""Return whether ``tensor`` still refers to this borrowed storage."""
owner = tensor.detach()
return (
owner.device == self.torch_tensor.device
and owner.dtype == self.torch_tensor.dtype
and owner.shape == self.torch_tensor.shape
and owner.stride() == self.torch_tensor.stride()
and owner.data_ptr() == self.torch_tensor.data_ptr()
)
def _is_safe_for_mps(array: mx.array) -> bool:
"""Check if an array is safe to transfer to MPS without hitting size limits.
@_serialized_bridge
def borrow_torch_tensors(
*tensors: torch.Tensor, synchronize: bool = True
) -> tuple[MlxTensorView, ...]:
"""Borrow one or more Torch MPS tensors, optionally synchronizing once.
MPS has a 4GB limit for MPSTemporaryNDArray, but Metal may allocate
multiple temporary buffers internally. We use a conservative threshold.
Args:
array: MLX array to check
Returns:
True if safe to transfer to MPS, False if should stay on CPU
The returned views own the Torch tensor references for their entire
lifetime. No data copy is made. Set ``synchronize=False`` only when a
surrounding operation (such as :func:`mlx_call`) performs the producer
barrier immediately before consuming the views. This helper is
intentionally separate from :func:`torch_to_mlx`, whose contract is an
independent MLX copy.
"""
return _get_tensor_size_bytes(array) < _MPS_SAFE_SIZE_BYTES
detached = tuple(tensor.detach() for tensor in tensors)
if any(tensor.device.type != "mps" for tensor in detached):
devices = ", ".join(str(tensor.device) for tensor in detached)
raise ValueError(f"borrow_torch_tensors requires MPS tensors, got {devices}")
if synchronize and detached:
torch.mps.synchronize()
return tuple(MlxTensorView._from_synchronized(tensor) for tensor in detached)
@_serialized_bridge
def torch_to_mlx(tensor: torch.Tensor) -> mx.array:
"""Convert PyTorch tensor to MLX array.
"""Convert a PyTorch tensor to an independent MLX array.
Uses numpy as an intermediate to enable zero-copy on unified memory.
MPS inputs are copied inside the unified Metal device. Use ``mlx_call``
when a complete operation needs zero-copy MPS input imports; it owns the
borrowed MLX arrays for the complete lazy operation.
Args:
tensor: PyTorch tensor (can be on any device)
tensor: PyTorch CPU or MPS tensor.
Returns:
MLX array with the same data
"""
# Move to CPU if on MPS for numpy conversion
if tensor.device.type != "cpu":
tensor = tensor.cpu()
tensor = tensor.detach()
# Note: numpy does not support bfloat16.
if tensor.dtype == torch.bfloat16:
return mx.array(tensor)
return mx.array(tensor.numpy())
array = _torch_to_mlx(tensor, copy=True)
if tensor.device.type == "mps":
# Materialize the owned copy before the caller may mutate or release
# the Torch source.
_mlx_core().eval(array)
return array
# TODO(perf): accept a list/batch of arrays and convert them in one pass
# to reduce the Python ↔ MLX round-trip overhead.
@_serialized_bridge
def mlx_call(
operation: Callable[..., mx.array],
*tensors: torch.Tensor | MlxTensorView,
device: torch.device | Literal["mps", "cpu"] | None = None,
) -> torch.Tensor:
"""Run one MLX operation with zero-copy Torch MPS input imports.
The imported MLX arrays remain strongly referenced until
:func:`mlx_to_torch` evaluates and exports ``operation``'s result. Keep
the operation inside this call; returning a lazy MLX result for later use
or stashing a borrowed input through a callback side effect would escape
the borrow scope. The caller must also serialize any overlapping MPS work
outside this function, including use or mutation of source and returned
tensors. The operation may allocate its own output normally.
"""
mx = _mlx_core()
target_device = _get_torch_device() if device is None else torch.device(device)
if target_device.type not in {"cpu", "mps"}:
raise ValueError(
f"The MLX tensor bridge supports CPU and MPS targets, got {target_device}"
)
detached = tuple(
tensor.detach() for tensor in tensors if isinstance(tensor, torch.Tensor)
)
if any(tensor.device.type == "mps" for tensor in detached) or any(
isinstance(tensor, MlxTensorView) for tensor in tensors
):
torch.mps.synchronize()
borrowed: tuple[Any, ...] = tuple(
(
tensor.array
if isinstance(tensor, MlxTensorView)
else _torch_to_mlx(tensor.detach(), copy=False, synchronize=False)
)
for tensor in tensors
)
# MLX does not support float64 on the Metal stream. Keep an explicitly
# requested CPU call on the CPU stream when a borrowed input carries that
# dtype; otherwise even constructing the lazy result would fail before the
# export preparation below can move it.
if target_device.type == "cpu" and any(
array.dtype == mx.float64 for array in borrowed
):
with mx.stream(mx.cpu):
result = operation(*borrowed)
else:
result = operation(*borrowed)
output = mlx_to_torch(result, device=target_device)
# Keep the imported MLX objects (and any MlxTensorView Torch owners) alive
# through lazy result evaluation and DLPack export.
_ = borrowed
return output
def _prepare_mlx_export(
array: mx.array,
target_device: torch.device,
mx: Any,
) -> mx.array:
"""Prepare one lazy MLX result for the requested Torch target.
This intentionally does not evaluate the result. Callers which export
several results should prepare every result first and then issue one
shared ``mx.eval`` boundary.
"""
if target_device.type not in {"cpu", "mps"}:
raise ValueError(
f"The MLX tensor bridge supports CPU and MPS targets, got {target_device}"
)
if target_device.type == "mps" and array.dtype == mx.float64:
raise ValueError(
"MLX float64 arrays cannot be exported to a Torch MPS tensor; "
"use float32/bfloat16 or request device='cpu'"
)
return array
def _has_negative_stride(array: mx.array) -> bool:
"""Return whether an evaluated MLX array has a DLPack-incompatible view."""
# PyTorch's DLPack importer aborts the process for negative strides. MLX
# exposes the evaluated layout through the buffer protocol, so inspect it
# before handing the capsule to PyTorch.
with memoryview(array) as view:
return any(stride < 0 for stride in (view.strides or ()))
def _export_evaluated_mlx(
array: mx.array,
target_device: torch.device,
mx: Any,
*,
materialize_negative: bool = True,
) -> torch.Tensor:
"""Export an already-evaluated MLX result through one DLPack capsule.
Negative-stride results are materialized here as a safety fallback. The
normal (contiguous/positive-stride) path performs no copy and no extra
evaluation; :func:`mlx_call_multi` batches any required materialization
evaluations for all outputs together.
"""
if materialize_negative and _has_negative_stride(array):
materialize_stream = mx.cpu if target_device.type == "cpu" else mx.gpu
array = mx.contiguous(array, stream=materialize_stream)
mx.eval(array)
if target_device.type == "cpu":
# MLX owns CPU-accessible unified memory. Request a CPU DLPack view
# explicitly rather than importing on MPS and copying back.
dlpack = array.__dlpack__(dl_device=(1, 0), copy=False)
return torch.utils.dlpack.from_dlpack(dlpack)
return torch.utils.dlpack.from_dlpack(array)
@_serialized_bridge
def mlx_call_multi(
operation: Callable[..., tuple[mx.array, ...]],
*tensors: torch.Tensor | MlxTensorView,
device: torch.device | Literal["mps", "cpu"] | None = None,
) -> tuple[torch.Tensor, ...]:
"""Run one MLX operation and export all of its outputs as Torch tensors.
``operation`` must return a non-empty flat ``tuple`` or ``list`` of MLX
arrays. All Torch/MPS inputs are fenced once before import, and all
ordinary outputs are evaluated with one ``mx.eval(*outputs)`` call before
being exported through DLPack. The imported arrays and detached Torch
owners remain local until every output capsule has been consumed, which is
required when MLX lazily donates a borrowed input buffer. Callers must
serialize any overlapping MPS work outside this function, including use
or mutation of source and returned tensors.
CPU targets retain the same float64 and negative-stride safeguards as
:func:`mlx_to_torch`. A negative-stride output necessarily needs one
additional materialization evaluation; contiguous MPS model outputs take
the single-evaluation, zero-copy path.
"""
mx = _mlx_core()
target_device = _get_torch_device() if device is None else torch.device(device)
if target_device.type not in {"cpu", "mps"}:
raise ValueError(
f"The MLX tensor bridge supports CPU and MPS targets, got {target_device}"
)
detached = tuple(
tensor.detach() for tensor in tensors if isinstance(tensor, torch.Tensor)
)
needs_mps_fence = any(tensor.device.type == "mps" for tensor in detached) or any(
isinstance(tensor, MlxTensorView) for tensor in tensors
)
if needs_mps_fence:
torch.mps.synchronize()
borrowed: tuple[Any, ...] = tuple(
(
tensor.array
if isinstance(tensor, MlxTensorView)
else _torch_to_mlx(tensor.detach(), copy=False, synchronize=False)
)
for tensor in tensors
)
if target_device.type == "cpu" and any(
array.dtype == mx.float64 for array in borrowed
):
with mx.stream(mx.cpu):
result = operation(*borrowed)
else:
result = operation(*borrowed)
if not isinstance(result, (tuple, list)) or not result:
raise TypeError(
"mlx_call_multi operation must return a non-empty tuple or list of MLX arrays"
)
arrays = tuple(result)
if any(not isinstance(array, mx.array) for array in arrays):
raise TypeError("mlx_call_multi outputs must be MLX arrays")
# Prepare all outputs before crossing the one shared MLX evaluation
# boundary. This is the key difference from calling mlx_to_torch in a
# loop, which would fence/evaluate every result separately.
arrays = tuple(_prepare_mlx_export(array, target_device, mx) for array in arrays)
mx.eval(*arrays)
# DLPack cannot represent negative strides. Materialize all such outputs
# together so even this safety path has one additional evaluation boundary
# rather than one boundary per result.
negative = tuple(_has_negative_stride(array) for array in arrays)
if any(negative):
materialized = []
for array, needs_materialization in zip(arrays, negative):
if needs_materialization:
stream = mx.cpu if target_device.type == "cpu" else mx.gpu
array = mx.contiguous(array, stream=stream)
materialized.append(array)
arrays = tuple(materialized)
mx.eval(*(array for array, needs in zip(arrays, negative) if needs))
outputs = tuple(
_export_evaluated_mlx(array, target_device, mx, materialize_negative=False)
for array in arrays
)
# Keep both borrowed MLX views and their Torch owners alive through the
# final DLPack import. (The local remains live until function return.)
_ = borrowed
return outputs
@_serialized_bridge
def mlx_to_torch(
array: mx.array,
device: torch.device | Literal["mps", "cpu"] | None = None,
already_contiguous: bool = False,
) -> torch.Tensor:
"""Convert MLX array to PyTorch tensor.
Uses numpy as an intermediate to enable zero-copy on unified memory.
MLX arrays with PyTorch-compatible strides share their unified-memory
allocation through DLPack, including explicit CPU views. Negative-stride
views are materialized because PyTorch's DLPack importer cannot represent
them safely. MLX is evaluated before the handoff because the frameworks do
not share stream state. Only CPU and MPS targets are supported; other
target devices are rejected.
Args:
array: MLX array
device: Target PyTorch device (default: MPS if available)
already_contiguous: Skip contiguity check if array is known contiguous
Returns:
PyTorch tensor with the same data
"""
if device is None:
device = get_torch_device()
elif isinstance(device, str):
device = torch.device(device)
# Use memoryview for zero-copy conversion (bypasses numpy for bfloat16)
# reference: https://github.com/ml-explore/mlx/issues/403
torch_dtype = MLX_TO_TORCH_DTYPE.get(array.dtype)
if torch_dtype is not None:
if already_contiguous:
# Fast path: skip contiguity check, single eval
mx.eval(array)
buffer = memoryview(array)
else:
# MLX views / non-contiguous arrays expose a non-contiguous buffer (or
# sometimes no usable buffer), which `torch.frombuffer` can't consume.
# Make contiguous first, then eval once
array = mx.contiguous(array)
mx.eval(array)
buffer = memoryview(array)
tensor = torch.frombuffer(buffer, dtype=torch_dtype).reshape(array.shape)
else:
# Fallback to numpy path for unsupported dtypes
raise ValueError(f"Unsupported MLX dtype: {array.dtype}")
# Move to target device, but check for MPS size limits first
if device.type == "mps":
if _is_safe_for_mps(array):
tensor = tensor.to(device)
else:
# Large tensor - keep on CPU to avoid MPS 4GB limit crash
# See: https://github.com/anthropics/vllm-metal/issues/43
logger.debug(
"Tensor too large for MPS (%d bytes > %d limit), keeping on CPU",
_get_tensor_size_bytes(array),
_MPS_SAFE_SIZE_BYTES,
)
elif device.type != "cpu":
tensor = tensor.to(device)
return tensor
def sync_mlx() -> None:
"""Synchronize MLX operations.
Call this before converting MLX arrays to ensure all operations complete.
"""
# Prefer an explicit MLX barrier when available; otherwise force evaluation.
# `mx.eval([])` is a no-op, so we evaluate a tiny scalar as a safe fallback.
try:
mx.synchronize()
except (AttributeError, TypeError):
mx.eval(mx.array(0, dtype=mx.int32))
def sync_torch() -> None:
"""Synchronize PyTorch MPS operations.
Call this before converting PyTorch tensors to ensure all operations complete.
"""
if torch.backends.mps.is_available():
torch.mps.synchronize()
mx = _mlx_core()
target_device = _get_torch_device() if device is None else torch.device(device)
array = _prepare_mlx_export(array, target_device, mx)
mx.eval(array)
return _export_evaluated_mlx(array, target_device, mx)
__all__ = [
"is_mlx_available",
"use_mlx",
"MlxTensorView",
"borrow_torch_tensors",
"mlx_call",
"mlx_call_multi",
"mlx_to_torch",
"torch_to_mlx",
"get_torch_device",
]