chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,3 @@
from sglang.srt.distributed.communication_op import *
from sglang.srt.distributed.parallel_state import *
from sglang.srt.distributed.utils import *
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/communication_op.py
from typing import Any, Dict, Optional, Tuple, Union
import torch
import torch.distributed
from .parallel_state import (
get_attn_tp_group,
get_moe_ep_group,
get_moe_tp_group,
get_tp_group,
)
def tensor_model_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across model parallel group."""
return get_tp_group().all_reduce(input_)
def tensor_model_parallel_quant_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across model parallel group."""
return get_tp_group().quant_all_reduce(input_)
def tensor_model_parallel_fused_allreduce_rmsnorm(
input_: torch.Tensor,
residual_inp_: torch.Tensor,
weight_: torch.Tensor,
eps: float,
) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
"""Fused TP all-reduce + RMSNorm.
Policy and backend selection are owned by GroupCoordinator:
it may dispatch to communicator-native fused APIs, custom fused kernels,
or return None so callers can run generic fallback paths.
"""
return get_tp_group().fused_allreduce_rmsnorm(input_, residual_inp_, weight_, eps)
def tensor_model_parallel_all_gather(
input_: torch.Tensor, dim: int = -1
) -> torch.Tensor:
"""All-gather the input tensor across model parallel group."""
return get_tp_group().all_gather(input_, dim)
def tensor_model_parallel_gather(
input_: torch.Tensor, dst: int = 0, dim: int = -1
) -> Optional[torch.Tensor]:
"""Gather the input tensor across model parallel group."""
return get_tp_group().gather(input_, dst, dim)
def broadcast_tensor_dict(
tensor_dict: Optional[Dict[Any, Union[torch.Tensor, Any]]] = None, src: int = 0
):
if not torch.distributed.is_initialized():
return tensor_dict
return get_tp_group().broadcast_tensor_dict(tensor_dict, src)
def attention_tensor_model_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across attention parallel group."""
return get_attn_tp_group().all_reduce(input_)
def attention_tensor_model_parallel_quant_all_reduce(
input_: torch.Tensor,
) -> torch.Tensor:
"""All-reduce the input tensor across attention parallel group."""
return get_attn_tp_group().quant_all_reduce(input_)
def moe_tensor_model_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across moe parallel group."""
return get_moe_tp_group().all_reduce(input_)
def moe_expert_parallel_all_reduce(input_: torch.Tensor) -> torch.Tensor:
"""All-reduce the input tensor across moe expert parallel group."""
return get_moe_ep_group().all_reduce(input_)
@@ -0,0 +1,15 @@
from enum import IntEnum, unique
@unique
class P2PTag(IntEnum):
"""
Tags reserved for point-to-point communication protocols.
Communications introduced outside existing scheduler loops need explicit
tags to avoid being consumed by unrelated send/recv paths.
"""
DEFAULT = 0
HIRADIX_PP_SYNC = int.from_bytes(b"PpHi", byteorder="big")
GRAMMAR_PP_SYNC = int.from_bytes(b"PpGr", byteorder="big")
@@ -0,0 +1,16 @@
MiB = 1024 * 1024
TORCH_SYMM_MEM_ALL_REDUCE_MAX_SIZES = {
9: {
2: 64 * MiB, # 64 MB
4: 64 * MiB, # 64 MB
6: 128 * MiB, # 128 MB
8: 128 * MiB, # 128 MB
},
10: {
2: 64 * MiB, # 64 MB
4: 64 * MiB, # 64 MB
6: 128 * MiB, # 128 MB
8: 128 * MiB, # 128 MB
},
}
@@ -0,0 +1,189 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/cuda_wrapper.py
"""This file is a pure Python wrapper for the cudart library.
It avoids the need to compile a separate shared library, and is
convenient for use when we just need to call a few functions.
"""
import ctypes
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
# this line makes it possible to directly load `libcudart.so` using `ctypes`
import torch # noqa
from sglang.srt.utils import is_musa
_is_musa = is_musa()
logger = logging.getLogger(__name__)
# === export types and functions from cudart to Python ===
# for the original cudart definition, please check
# https://docs.nvidia.com/cuda/cuda-runtime-api/index.html
cudaError_t = ctypes.c_int
cudaMemcpyKind = ctypes.c_int
class cudaIpcMemHandle_t(ctypes.Structure):
_fields_ = [("internal", ctypes.c_byte * 128)]
@dataclass
class Function:
name: str
restype: Any
argtypes: List[Any]
def find_loaded_library(lib_name) -> Optional[str]:
"""
According to according to https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html,
the file `/proc/self/maps` contains the memory maps of the process, which includes the
shared libraries loaded by the process. We can use this file to find the path of the
a loaded library.
""" # noqa
found = False
with open("/proc/self/maps") as f:
for line in f:
if lib_name in line:
found = True
break
if not found:
# the library is not loaded in the current process
return None
# if lib_name is libcudart, we need to match a line with:
# address /path/to/libcudart-hash.so.11.0
start = line.index("/")
path = line[start:].strip()
filename = path.split("/")[-1]
assert filename.rpartition(".so")[0].startswith(
lib_name
), f"Unexpected filename: {filename} for library {lib_name}"
return path
class CudaRTLibrary:
exported_functions = [
# cudaError_t cudaSetDevice ( int device )
Function("cudaSetDevice", cudaError_t, [ctypes.c_int]),
# cudaError_t cudaDeviceSynchronize ( void )
Function("cudaDeviceSynchronize", cudaError_t, []),
# cudaError_t cudaDeviceReset ( void )
Function("cudaDeviceReset", cudaError_t, []),
# const char* cudaGetErrorString ( cudaError_t error )
Function("cudaGetErrorString", ctypes.c_char_p, [cudaError_t]),
# cudaError_t cudaMalloc ( void** devPtr, size_t size )
Function(
"cudaMalloc",
cudaError_t,
[ctypes.POINTER(ctypes.c_void_p), ctypes.c_size_t],
),
# cudaError_t cudaFree ( void* devPtr )
Function("cudaFree", cudaError_t, [ctypes.c_void_p]),
# cudaError_t cudaMemset ( void* devPtr, int value, size_t count )
Function(
"cudaMemset", cudaError_t, [ctypes.c_void_p, ctypes.c_int, ctypes.c_size_t]
),
# cudaError_t cudaMemcpy ( void* dst, const void* src, size_t count, cudaMemcpyKind kind ) # noqa
Function(
"cudaMemcpy",
cudaError_t,
[ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, cudaMemcpyKind],
),
# cudaError_t cudaIpcGetMemHandle ( cudaIpcMemHandle_t* handle, void* devPtr ) # noqa
Function(
"cudaIpcGetMemHandle",
cudaError_t,
[ctypes.POINTER(cudaIpcMemHandle_t), ctypes.c_void_p],
),
# cudaError_t cudaIpcOpenMemHandle ( void** devPtr, cudaIpcMemHandle_t handle, unsigned int flags ) # noqa
Function(
"cudaIpcOpenMemHandle",
cudaError_t,
[ctypes.POINTER(ctypes.c_void_p), cudaIpcMemHandle_t, ctypes.c_uint],
),
]
# class attribute to store the mapping from the path to the library
# to avoid loading the same library multiple times
path_to_library_cache: Dict[str, Any] = {}
# class attribute to store the mapping from library path
# to the corresponding dictionary
path_to_dict_mapping: Dict[str, Dict[str, Any]] = {}
def __init__(self, so_file: Optional[str] = None):
if so_file is None:
so_file = find_loaded_library("libcudart" if not _is_musa else "libmusart")
assert so_file is not None, "libcudart is not loaded in the current process"
if so_file not in CudaRTLibrary.path_to_library_cache:
lib = ctypes.CDLL(so_file)
CudaRTLibrary.path_to_library_cache[so_file] = lib
self.lib = CudaRTLibrary.path_to_library_cache[so_file]
if so_file not in CudaRTLibrary.path_to_dict_mapping:
_funcs = {}
for func in CudaRTLibrary.exported_functions:
f = getattr(self.lib, func.name)
f.restype = func.restype
f.argtypes = func.argtypes
_funcs[func.name] = f
CudaRTLibrary.path_to_dict_mapping[so_file] = _funcs
self.funcs = CudaRTLibrary.path_to_dict_mapping[so_file]
def CUDART_CHECK(self, result: cudaError_t) -> None:
if result != 0:
error_str = self.cudaGetErrorString(result)
raise RuntimeError(f"CUDART error: {error_str}")
def cudaGetErrorString(self, error: cudaError_t) -> str:
return self.funcs["cudaGetErrorString"](error).decode("utf-8")
def cudaSetDevice(self, device: int) -> None:
self.CUDART_CHECK(self.funcs["cudaSetDevice"](device))
def cudaDeviceSynchronize(self) -> None:
self.CUDART_CHECK(self.funcs["cudaDeviceSynchronize"]())
def cudaDeviceReset(self) -> None:
self.CUDART_CHECK(self.funcs["cudaDeviceReset"]())
def cudaMalloc(self, size: int) -> ctypes.c_void_p:
devPtr = ctypes.c_void_p()
self.CUDART_CHECK(self.funcs["cudaMalloc"](ctypes.byref(devPtr), size))
return devPtr
def cudaFree(self, devPtr: ctypes.c_void_p) -> None:
self.CUDART_CHECK(self.funcs["cudaFree"](devPtr))
def cudaMemset(self, devPtr: ctypes.c_void_p, value: int, count: int) -> None:
self.CUDART_CHECK(self.funcs["cudaMemset"](devPtr, value, count))
def cudaMemcpy(
self, dst: ctypes.c_void_p, src: ctypes.c_void_p, count: int
) -> None:
cudaMemcpyDefault = 4
kind = cudaMemcpyDefault
self.CUDART_CHECK(self.funcs["cudaMemcpy"](dst, src, count, kind))
def cudaIpcGetMemHandle(self, devPtr: ctypes.c_void_p) -> cudaIpcMemHandle_t:
handle = cudaIpcMemHandle_t()
self.CUDART_CHECK(
self.funcs["cudaIpcGetMemHandle"](ctypes.byref(handle), devPtr)
)
return handle
def cudaIpcOpenMemHandle(self, handle: cudaIpcMemHandle_t) -> ctypes.c_void_p:
cudaIpcMemLazyEnablePeerAccess = 1
devPtr = ctypes.c_void_p()
self.CUDART_CHECK(
self.funcs["cudaIpcOpenMemHandle"](
ctypes.byref(devPtr), handle, cudaIpcMemLazyEnablePeerAccess
)
)
return devPtr
@@ -0,0 +1,421 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/custom_all_reduce.py
import ctypes
import logging
from contextlib import contextmanager
from functools import partial
from typing import Any, List, Optional, Union
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
import sglang.srt.distributed.device_communicators.custom_all_reduce_ops as ops
from sglang.srt.distributed.device_communicators.cuda_wrapper import CudaRTLibrary
from sglang.srt.distributed.device_communicators.custom_all_reduce_utils import (
can_use_custom_all_reduce_with_nvlink,
is_weak_contiguous,
)
from sglang.srt.environ import envs
from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph import (
is_in_tc_piecewise_cuda_graph,
)
from sglang.srt.utils import (
get_bool_env_var,
is_cuda,
is_hip,
is_musa,
log_info_on_rank0,
)
_is_cuda = is_cuda()
_is_hip = is_hip()
_is_musa = is_musa()
logger = logging.getLogger(__name__)
class CustomAllreduce:
_SUPPORTED_WORLD_SIZES = [2, 4, 6, 8]
_MAX_CAR_SIZE = 8192 * 1024
if _is_hip:
# crossover is at 16MB buffer size for ROCm
_MAX_CAR_SIZE = 2 * 8192 * 1024
if _is_musa:
# crossover is at 128MB buffer size for MUSA
_MAX_CAR_SIZE = 16 * 8196 * 1024
# max_size: max supported allreduce size
def __init__(
self,
group: ProcessGroup,
device: Union[int, str, torch.device],
max_size=_MAX_CAR_SIZE,
) -> None:
"""
Args:
group: the process group to work on. If None, it will use the
default process group.
device: the device to bind the CustomAllreduce to. If None,
it will be bind to f"cuda:{local_rank}".
It is the caller's responsibility to make sure each communicator
is bind to a unique device, and all communicators in this group
are in the same node.
"""
self._IS_CAPTURING = False
self.disabled = True # This can be modified in-place by context manager in piecewise cuda graph runner
self.original_disabled = True # To store the original state
self.use_amd_deterministic_impl = _use_amd_deterministic_impl()
if not ops.IS_CUSTOM_AR_AVAILABLE:
# disable because of missing custom allreduce library
# e.g. in a non-cuda environment
return
rank = dist.get_rank(group=group)
world_size = dist.get_world_size(group=group)
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
# now `device` is a `torch.device` object
assert isinstance(device, torch.device)
self.device = device
full_nvlink = can_use_custom_all_reduce_with_nvlink(
group=group,
device=device,
supported_world_size=self._SUPPORTED_WORLD_SIZES,
cls_name="CustomAllreduce",
)
if full_nvlink is None:
return # fail to get nvlink status
self.group = group
self.max_size = max_size
self.rank = rank
self.world_size = world_size
self.full_nvlink = full_nvlink
if not _is_hip:
# Buffers memory are owned by this Python class and passed to C++.
# Meta data composes of two parts: meta data for synchronization and a
# temporary buffer for storing intermediate allreduce results.
self.meta_ptrs = self.create_shared_buffer(
ops.meta_size() + max_size, group=group
)
# This is a pre-registered IPC buffer. In eager mode, input tensors
# are first copied into this buffer before allreduce is performed
self.buffer_ptrs = self.create_shared_buffer(max_size, group=group)
# This is a buffer for storing the tuples of pointers pointing to
# IPC buffers from all ranks. Each registered tuple has size of
# 8*world_size bytes where world_size is at most 8. Allocating 8MB
# is enough for 131072 such tuples. The largest model I've seen only
# needs less than 10000 of registered tuples.
self.rank_data = torch.empty(
max_size, dtype=torch.uint8, device=self.device
)
self._ptr = ops.init_custom_ar(
self.meta_ptrs, self.rank_data, rank, self.full_nvlink
)
ops.register_buffer(self._ptr, self.buffer_ptrs)
else:
# meta data buffers need to be "uncached" for signal on MI200
self.meta = ops.allocate_meta_buffer(ops.meta_size() + max_size)
self.buffer = torch.empty(max_size, dtype=torch.uint8, device=self.device)
handle = ops.get_meta_buffer_ipc_handle(self.meta)
shard_data = (
bytes(handle), # ipc handle to base ptr
0, # offset of base ptr
)
handles, offsets = self._gather_ipc_meta(shard_data)
self.rank_data = torch.empty(
max_size, dtype=torch.uint8, device=self.device
)
self._ptr = ops.init_custom_ar(
self.meta, self.rank_data, handles, offsets, rank, self.full_nvlink
)
self.register_buffer(self.buffer)
self.disabled = False
self.original_disabled = False # Ensure original_disabled == disabled
self.tms_cudagraph = envs.SGLANG_MEMORY_SAVER_CUDA_GRAPH.get()
@staticmethod
def create_shared_buffer(
size_in_bytes: int, group: Optional[ProcessGroup] = None
) -> List[int]:
"""
Creates a shared buffer and returns a list of pointers
representing the buffer on all processes in the group.
"""
lib = CudaRTLibrary()
pointer = lib.cudaMalloc(size_in_bytes)
if _is_musa:
lib.cudaMemset(pointer, 0, size_in_bytes)
handle = lib.cudaIpcGetMemHandle(pointer)
world_size = dist.get_world_size(group=group)
rank = dist.get_rank(group=group)
handles = [None] * world_size
dist.all_gather_object(handles, handle, group=group)
pointers: List[int] = []
for i, h in enumerate(handles):
if i == rank:
pointers.append(pointer.value) # type: ignore
else:
pointers.append(lib.cudaIpcOpenMemHandle(h).value) # type: ignore
return pointers
@staticmethod
def free_shared_buffer(
pointers: List[int], group: Optional[ProcessGroup] = None
) -> None:
rank = dist.get_rank(group=group)
lib = CudaRTLibrary()
lib.cudaFree(ctypes.c_void_p(pointers[rank]))
@contextmanager
def capture(self):
"""
The main responsibility of this context manager is the
`register_graph_buffers` call at the end of the context.
It records all the buffer addresses used in the CUDA graph.
"""
try:
self._IS_CAPTURING = True
yield
finally:
self._IS_CAPTURING = False
if not self.disabled:
self.register_graph_buffers()
def _get_ipc_meta(self, inp: torch.Tensor):
# _share_cuda_() doesn't accept meta buffer not allocated from
# PyTorch cache allocator, use direct HIP call to get IPC handle
handle = ops.get_meta_buffer_ipc_handle(inp)
shard_data = (
bytes(handle), # ipc handle to base ptr
0, # offset of base ptr
)
return self._gather_ipc_meta(shard_data)
def _gather_ipc_meta(self, shard_data):
# Note: don't use `[[None]] * self.world_size` here
# because it will create a list of the same reference
all_data: List[Optional[Any]] = [[None] for i in range(self.world_size)]
all_data[self.rank][0] = shard_data
ranks = dist.get_process_group_ranks(group=self.group)
ranks.sort()
for i, rank in enumerate(ranks):
dist.broadcast_object_list(
all_data[i], src=rank, group=self.group, device="cpu"
)
# we cannot directly use `dist.all_gather_object` here
# because it is incompatible with `gloo` backend under inference mode.
# see https://github.com/pytorch/pytorch/issues/126032 for details.
handles = []
offsets = []
for i in range(len(all_data)):
handles.append(all_data[i][0][0]) # type: ignore
offsets.append(all_data[i][0][1]) # type: ignore
return handles, offsets
def register_buffer(self, inp: torch.Tensor):
handles, offsets = self._get_ipc_meta(inp)
ops.register_buffer(self._ptr, inp, handles, offsets)
def register_graph_buffers(self):
if _is_hip:
handle, offset = ops.get_graph_buffer_ipc_meta(self._ptr)
handles, offsets = self._gather_ipc_meta((bytes(handle), offset))
log_info_on_rank0(logger, f"Registering {len(offset)} cuda graph addresses")
ops.register_graph_buffers(self._ptr, handles, offsets)
else:
handle, offset = ops.get_graph_buffer_ipc_meta(self._ptr)
log_info_on_rank0(logger, f"Registering {len(offset)} cuda graph addresses")
# We cannot directly use `dist.all_gather_object` here
# because it is incompatible with `gloo` backend under inference mode.
# see https://github.com/pytorch/pytorch/issues/126032 for details.
all_data = [
[None, None] for _ in range(dist.get_world_size(group=self.group))
]
all_data[self.rank] = [handle, offset]
ranks = sorted(dist.get_process_group_ranks(group=self.group))
for i, rank in enumerate(ranks):
dist.broadcast_object_list(
all_data[i], src=rank, group=self.group, device="cpu"
)
# Unpack list of tuples to tuple of lists.
handles = [d[0] for d in all_data] # type: ignore
offsets = [d[1] for d in all_data] # type: ignore
ops.register_graph_buffers(self._ptr, handles, offsets)
def should_custom_ar(self, inp: torch.Tensor):
if self.disabled:
return False
inp_size = inp.numel() * inp.element_size()
# custom allreduce requires input byte size to be multiples of 16
if inp_size % 16 != 0:
return False
if not is_weak_contiguous(inp):
return False
# for 4 or more non NVLink-capable GPUs, custom allreduce provides
# little performance improvement over NCCL.
if not _is_hip:
if self.world_size == 2 or self.full_nvlink:
return inp_size <= self.max_size
return False
if _is_hip:
if self.use_amd_deterministic_impl:
return True
if self.full_nvlink:
return inp_size <= self.max_size
return False
return False
def _all_reduce_impl(self, inp: torch.Tensor, registered: bool):
out = torch.empty_like(inp)
if not _is_hip: # CUDA-like
if registered:
ops.all_reduce(self._ptr, inp, out, 0, 0)
else:
ops.all_reduce(
self._ptr, inp, out, self.buffer_ptrs[self.rank], self.max_size
)
elif self.use_amd_deterministic_impl:
inp_size = inp.numel() * inp.element_size()
if inp_size < self.max_size:
reg_buffer = self.buffer.view(inp.dtype)[: inp.numel()]
ops.deterministic_all_reduce_unreg(self._ptr, inp, reg_buffer, out)
else:
self.register_buffer(inp)
ops.deterministic_all_reduce_reg(self._ptr, inp, out)
else: # normal AMD ROCm path
if registered:
ops.all_reduce_reg(self._ptr, inp, out)
else:
ops.all_reduce_unreg(self._ptr, inp, self.buffer, out)
return out
def custom_all_reduce(self, input: torch.Tensor) -> Optional[torch.Tensor]:
"""The main allreduce API that provides support for cuda graph."""
# When custom allreduce is disabled, this will be None.
if self.disabled or not self.should_custom_ar(input):
return None
if self._IS_CAPTURING:
if torch.cuda.is_current_stream_capturing():
return self._all_reduce_impl(input, registered=not self.tms_cudagraph)
else:
# Could be warmup OR piecewise cuda graph split op execution.
# In piecewise cuda graph, split ops run eagerly outside the graph
# but _IS_CAPTURING is still True. We need to do real all-reduce.
if is_in_tc_piecewise_cuda_graph():
# Split op execution - do real all-reduce
return self._all_reduce_impl(input, registered=False)
else:
# True warmup - mimic the allocation pattern since custom
# allreduce is out-of-place.
return torch.zeros_like(input)
else:
return self._all_reduce_impl(input, registered=False)
def close(self):
if not self.disabled and self._ptr:
if ops is not None:
ops.dispose(self._ptr)
if _is_cuda:
self.free_shared_buffer(self.meta_ptrs)
self.free_shared_buffer(self.buffer_ptrs)
self._ptr = 0
def __del__(self):
self.close()
def dispatch_custom_allreduce(
group: ProcessGroup,
device: torch.device,
):
"""Return the CustomAllreduce class to use (aiter on ROCm if enabled).
On AMD with 1-stage AR enabled, use sglang's CustomAllreduce.
Otherwise use AiterCustomAllreduce if available.
On CUDA, the JIT-compiled v2 implementation is used by default.
Set SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2=0 to fall back to the legacy CustomAllreduce.
Note: ServerArgs._handle_environment_variables forces this env to "0" when
nnodes > 1 since custom AR is intra-node only.
"""
if _is_cuda and envs.SGLANG_OPT_USE_CUSTOM_ALL_REDUCE_V2.get():
from .custom_all_reduce_v2 import (
CustomAllReduceV2,
can_use_custom_all_reduce_v2,
)
if can_use_custom_all_reduce_v2(group=group, device=device):
logger.debug("[AR] Using CustomAllReduceV2 (JIT-compiled)")
return CustomAllReduceV2
if _is_cuda or _is_musa:
return CustomAllreduce
assert _is_hip
if envs.SGLANG_USE_1STAGE_ALLREDUCE.is_set():
if envs.SGLANG_USE_1STAGE_ALLREDUCE.get():
logger.debug(
"[AR] All-reduce: 1-stage kernel (SGLANG_USE_1STAGE_ALLREDUCE=1)"
)
else:
logger.debug("[AR] All-reduce: default (SGLANG_USE_1STAGE_ALLREDUCE=0)")
elif envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.get():
logger.debug(
"[AR] All-reduce: 1-stage kernel (deterministic inference enabled)"
)
else:
logger.debug("[AR] All-reduce: default")
# On AMD with 1-stage AR, use sglang's CustomAllreduce
# (AiterCustomAllreduce doesn't have deterministic_all_reduce method)
if _use_amd_deterministic_impl():
return CustomAllreduce
if get_bool_env_var("SGLANG_USE_AITER_AR", default="true"):
try:
from aiter.dist.device_communicators.custom_all_reduce import (
CustomAllreduce as AiterCustomAllreduce,
)
logger.info("[AR] Using AiterCustomAllreduce (AMD default)")
tms_cudagraph = envs.SGLANG_MEMORY_SAVER_CUDA_GRAPH.get()
return partial(
AiterCustomAllreduce,
enable_register_for_capturing=not tms_cudagraph,
)
except ImportError as e:
logger.warning(
"[AR] Aiter custom all-reduce not available; "
"falling back to sglang CustomAllreduce. Details: %s",
e,
)
return CustomAllreduce
return CustomAllreduce
def _use_amd_deterministic_impl() -> bool:
if not _is_hip: # CUDA is always deterministic
return False
if envs.SGLANG_USE_1STAGE_ALLREDUCE.is_set():
return envs.SGLANG_USE_1STAGE_ALLREDUCE.get()
else:
return envs.SGLANG_ENABLE_DETERMINISTIC_INFERENCE.get()
@@ -0,0 +1,166 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/_custom_ops.py
import logging
from typing import List, Optional, Tuple
import torch
from sglang.srt.utils import is_cuda, is_hip, is_musa
logger = logging.getLogger(__name__)
_is_cuda = is_cuda()
_is_hip = is_hip()
_is_musa = is_musa()
IS_CUSTOM_AR_AVAILABLE = _is_cuda or _is_hip or _is_musa
IS_QUICK_AR_AVAILABLE = _is_hip
try:
import sgl_kernel.allreduce as _custom_ar
except ImportError as e:
if _is_cuda or _is_hip:
logger.warning("Failed to import from custom_ar with %r", e)
IS_CUSTOM_AR_AVAILABLE = False
IS_QUICK_AR_AVAILABLE = False
# region IS_CUSTOM_AR_AVAILABLE
if not IS_CUSTOM_AR_AVAILABLE:
pass
elif _is_cuda or _is_musa:
# CUDA custom allreduce
def init_custom_ar(
ipc_tensors: List[torch.Tensor],
rank_data: torch.Tensor,
rank: int,
full_nvlink: bool,
) -> int:
return _custom_ar.init_custom_ar(ipc_tensors, rank_data, rank, full_nvlink)
def all_reduce(
fa: int,
inp: torch.Tensor,
out: torch.Tensor,
reg_buffer: int,
reg_buffer_sz_bytes: int,
) -> None:
_custom_ar.all_reduce(fa, inp, out, reg_buffer, reg_buffer_sz_bytes)
def dispose(fa: int) -> None:
_custom_ar.dispose(fa)
def meta_size() -> int:
return _custom_ar.meta_size()
def register_buffer(fa: int, ipc_tensors: List[int]) -> None:
return _custom_ar.register_buffer(fa, ipc_tensors)
def get_graph_buffer_ipc_meta(fa: int) -> Tuple[List[int], List[int]]:
return _custom_ar.get_graph_buffer_ipc_meta(fa)
def register_graph_buffers(
fa: int, handles: List[List[int]], offsets: List[List[int]]
) -> None:
_custom_ar.register_graph_buffers(fa, handles, offsets)
elif _is_hip:
# ROCM custom allreduce
def init_custom_ar(
meta: torch.Tensor,
rank_data: torch.Tensor,
handles: List[str],
offsets: List[int],
rank: int,
full_nvlink: bool,
) -> int:
return _custom_ar.init_custom_ar(
meta, rank_data, handles, offsets, rank, full_nvlink
)
def all_reduce_reg(fa: int, inp: torch.Tensor, out: torch.Tensor) -> None:
_custom_ar.all_reduce_reg(fa, inp, out)
def all_reduce_unreg(
fa: int, inp: torch.Tensor, reg_buffer: torch.Tensor, out: torch.Tensor
) -> None:
_custom_ar.all_reduce_unreg(fa, inp, reg_buffer, out)
def deterministic_all_reduce_reg(
fa: int, inp: torch.Tensor, out: torch.Tensor
) -> None:
_custom_ar.deterministic_all_reduce_reg(fa, inp, out)
def deterministic_all_reduce_unreg(
fa: int, inp: torch.Tensor, reg_buffer: torch.Tensor, out: torch.Tensor
) -> None:
_custom_ar.deterministic_all_reduce_unreg(fa, inp, reg_buffer, out)
def dispose(fa: int) -> None:
_custom_ar.dispose(fa)
def meta_size() -> int:
return _custom_ar.meta_size()
def register_buffer(
fa: int, t: torch.Tensor, handles: List[str], offsets: List[int]
) -> None:
return _custom_ar.register_buffer(fa, t, handles, offsets)
def get_graph_buffer_ipc_meta(fa: int) -> Tuple[torch.Tensor, List[int]]:
return _custom_ar.get_graph_buffer_ipc_meta(fa)
def register_graph_buffers(
fa: int, handles: List[str], offsets: List[List[int]]
) -> None:
_custom_ar.register_graph_buffers(fa, handles, offsets)
def allocate_meta_buffer(size: int) -> torch.Tensor:
return _custom_ar.allocate_meta_buffer(size)
def get_meta_buffer_ipc_handle(inp: torch.Tensor) -> torch.Tensor:
return _custom_ar.get_meta_buffer_ipc_handle(inp)
# endregion
# region IS_QUICK_AR_AVAILABLE
if not IS_QUICK_AR_AVAILABLE:
pass
elif _is_hip:
# ROCM custom quick allreduce
def init_custom_qr(
rank: int, world_size: int, qr_max_size: Optional[int] = None
) -> int:
return _custom_ar.init_custom_qr(world_size, rank, qr_max_size)
def qr_get_handle(fa: int) -> torch.Tensor:
return _custom_ar.qr_get_handle(fa)
def qr_open_handles(fa: int, handles: list[torch.Tensor]) -> None:
_custom_ar.qr_open_handles(fa, handles)
def qr_all_reduce(
fa: int,
inp: torch.Tensor,
out: torch.Tensor,
quant_level: int,
cast_bf2half: bool,
) -> None:
_custom_ar.qr_all_reduce(fa, inp, out, quant_level, cast_bf2half)
def qr_destroy(fa: int) -> None:
_custom_ar.qr_destroy(fa)
def qr_max_size() -> int:
return _custom_ar.qr_max_size()
# endregion
@@ -0,0 +1,492 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/custom_all_reduce_utils.py
import ctypes
import json
import logging
import os
import pickle
import subprocess
import sys
import tempfile
from functools import wraps
from itertools import product
from typing import Callable, Dict, List, Optional, Sequence, TypeVar
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from typing_extensions import ParamSpec
from sglang.srt.distributed.device_communicators.cuda_wrapper import CudaRTLibrary
from sglang.srt.distributed.parallel_state import in_the_same_node_as
from sglang.srt.environ import envs as sglang_envs
from sglang.srt.utils import is_cuda, is_hip, is_musa
logger = logging.getLogger(__name__)
_is_cuda = is_cuda()
_is_hip = is_hip()
_is_musa = is_musa()
if _is_cuda:
try:
import pynvml
except ImportError as e:
logger.warning("Failed to import pynvml with %r", e)
if _is_musa:
try:
import pymtml as pynvml
except ImportError as e:
logger.warning("Failed to import pymtml with %r", e)
if _is_hip:
try:
from amdsmi import (
AmdSmiException,
amdsmi_get_processor_handles,
amdsmi_init,
amdsmi_shut_down,
amdsmi_topo_get_link_type,
)
except ImportError as e:
logger.warning("Failed to import amdsmi with %r", e)
_P = ParamSpec("_P")
_R = TypeVar("_R")
def update_environment_variables(envs: Dict[str, str]):
for k, v in envs.items():
if k in os.environ and os.environ[k] != v:
logger.warning(
"Overwriting environment variable %s " "from '%s' to '%s'",
k,
os.environ[k],
v,
)
os.environ[k] = v
def producer(
batch_src: Sequence[int],
producer_queue,
consumer_queue,
result_queue,
cuda_visible_devices: Optional[str] = None,
):
if cuda_visible_devices is not None:
update_environment_variables({"CUDA_VISIBLE_DEVICES": cuda_visible_devices})
lib = CudaRTLibrary()
for i in batch_src:
lib.cudaSetDevice(i)
pointer = lib.cudaMalloc(1024)
lib.cudaMemset(pointer, 1, 1024)
lib.cudaDeviceSynchronize()
handle = lib.cudaIpcGetMemHandle(pointer)
producer_queue.put(handle)
open_success = consumer_queue.get()
if open_success:
# use two queues to simulate barrier
producer_queue.put(0)
consumer_queue.get()
# check if the memory is modified
host_data = (ctypes.c_char * 1024)()
lib.cudaMemcpy(host_data, pointer, 1024) # type: ignore
for i in range(1024):
if ord(host_data[i]) != 2:
open_success = False
break
result_queue.put(open_success)
lib.cudaDeviceReset()
def consumer(
batch_tgt: Sequence[int],
producer_queue,
consumer_queue,
result_queue,
cuda_visible_devices: Optional[str] = None,
):
if cuda_visible_devices is not None:
update_environment_variables({"CUDA_VISIBLE_DEVICES": cuda_visible_devices})
lib = CudaRTLibrary()
for j in batch_tgt:
lib.cudaSetDevice(j)
handle = producer_queue.get()
open_success = False
try:
pointer = lib.cudaIpcOpenMemHandle(handle) # type: ignore
open_success = True
except RuntimeError:
# cannot error out here, because the producer process
# is still waiting for the response.
pass
consumer_queue.put(open_success)
if open_success:
# modify the memory
lib.cudaMemset(pointer, 2, 1024)
lib.cudaDeviceSynchronize()
# use two queues to simulate barrier
producer_queue.get()
consumer_queue.put(0)
# check if the memory is modified
host_data = (ctypes.c_char * 1024)()
lib.cudaMemcpy(host_data, pointer, 1024) # type: ignore
for i in range(1024):
if ord(host_data[i]) != 2:
open_success = False
break
result_queue.put(open_success)
lib.cudaDeviceReset()
def can_actually_p2p(
batch_src: Sequence[int],
batch_tgt: Sequence[int],
) -> Sequence[bool]:
"""
Usually, checking if P2P access is enabled can be done by
`torch.cuda.can_device_access_peer(src, tgt)`. However, sometimes
the driver might be broken, and `torch.cuda.can_device_access_peer(src, tgt)`
returns `True` even if P2P access is not actually possible.
See https://github.com/vllm-project/vllm/issues/2728 and
https://forums.developer.nvidia.com/t/direct-gpu-gpu-communication-does-not-seem-to-work-properly/283264/10
Therefore, we have to perform a real P2P access to check if it is actually
possible.
Note on p2p and cuda IPC:
Usually, one process uses one GPU:
GPU src --> cuda context src --> tensor src --> process src
We need to combine p2p and cuda IPC, so that:
GPU src --> cuda context src --> tensor src --> process src
|shared|
GPU tgt --> cuda context tgt --> tensor tgt --> process tgt
That is to say, process src creates a tensor in GPU src, passes IPC handle to
process tgt, and process tgt accesses the tensor in GPU tgt. Any operation on the
tensor in process tgt will be reflected in the tensor in process src, because
they are the same memory segment.
It is important to note that process tgt accesses the tensor in GPU tgt, not
GPU src. That's why we need p2p access.
The most time-consuming part is the process creation. To avoid creating
processes for every pair of GPUs, we use batched testing. We create two
processes for testing all pairs of GPUs in batch. The trick is to reset
the device after each test (which is not available in PyTorch).
""" # noqa
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", None)
# pass the CUDA_VISIBLE_DEVICES to the child process
# to make sure they see the same set of GPUs
# make sure the processes are spawned
smp = mp.get_context("spawn")
producer_queue = smp.Queue()
consumer_queue = smp.Queue()
result_queue = smp.Queue()
p_src = smp.Process(
target=producer,
args=(
batch_src,
producer_queue,
consumer_queue,
result_queue,
cuda_visible_devices,
),
)
p_tgt = smp.Process(
target=consumer,
args=(
batch_tgt,
producer_queue,
consumer_queue,
result_queue,
cuda_visible_devices,
),
)
p_src.start()
p_tgt.start()
p_src.join()
p_tgt.join()
assert p_src.exitcode == 0 and p_tgt.exitcode == 0
result: List[bool] = []
for src, tgt in zip(batch_src, batch_tgt):
a = result_queue.get()
b = result_queue.get()
if a != b:
logger.warning(
"Two processes do not agree on the P2P access"
" status on %d -> %d, treat as disabled.",
src,
tgt,
)
result.append(False)
else:
result.append(a)
return result
# why do we need this cache?
# we are testing peer-to-peer (p2p) access between GPUs,across processes.
# if we test it every time, it will be very slow, because we need to create
# N * N * 2 processes, where N is the world size. This is very slow.
# to reduce the time, we use a cache file to store the p2p access status.
# the cache file is generated by the master process if it does not exist.
# then all the processes can read the cache file to check the p2p access status.
# Note that the cache file is suffixed by the CUDA_VISIBLE_DEVICES, so that we
# can have different cache files for different CUDA_VISIBLE_DEVICES settings,
# e.g. used by different vllm engines. The device id in the cache file is a
# **local** device id, i.e. from 0 to num_dev-1, where num_dev is the number
# of visible devices in the vllm engine.
_gpu_p2p_access_cache: Optional[Dict[str, bool]] = None
def gpu_p2p_access_check(src: int, tgt: int) -> bool:
"""Check if GPU src can access GPU tgt."""
# if the cache variable is already calculated,
# read from the cache instead of checking it again
global _gpu_p2p_access_cache
if _gpu_p2p_access_cache is not None:
return _gpu_p2p_access_cache[f"{src}->{tgt}"]
is_distributed = dist.is_initialized()
num_dev = torch.cuda.device_count()
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", None)
if cuda_visible_devices is None:
cuda_visible_devices = ",".join(str(i) for i in range(num_dev))
# VLLM_CACHE_ROOT -> SGLANG_CACHE_ROOT
# "~/.cache/vllm" -> envs.SGLANG_CACHE_DIR
SGLANG_CACHE_ROOT = os.path.expanduser(sglang_envs.SGLANG_CACHE_DIR.get())
path = os.path.join(
SGLANG_CACHE_ROOT, f"gpu_p2p_access_cache_for_{cuda_visible_devices}.json"
)
cache_dir = os.path.dirname(path)
try:
os.makedirs(cache_dir, exist_ok=True)
except (FileExistsError, NotADirectoryError):
if not os.path.isdir(cache_dir):
# Path exists as a file (stale cache/lock). Remove and retry.
try:
os.remove(cache_dir)
except OSError:
pass
os.makedirs(cache_dir, exist_ok=True)
from sglang.srt.distributed.parallel_state import get_world_group
if (not is_distributed or get_world_group().local_rank == 0) and (
not os.path.exists(path)
):
# only the local master process (with local_rank == 0) can
# enter this block to calculate the cache
logger.info("generating GPU P2P access cache in %s", path)
cache: Dict[str, bool] = {}
ids = list(range(num_dev))
# batch of all pairs of GPUs
batch_src, batch_tgt = zip(*list(product(ids, ids)))
# NOTE: we use `subprocess` rather than `multiprocessing` here
# because the caller might not have `if __name__ == "__main__":`,
# in that case we cannot use spawn method in multiprocessing.
# However, `can_actually_p2p` requires spawn method.
# The fix is, we use `subprocess` to call the function,
# where we have `if __name__ == "__main__":` in this file.
# use a temporary file to store the result
# we don't use the output of the subprocess directly,
# because the subprocess might produce logging output
with tempfile.NamedTemporaryFile() as output_file:
input_bytes = pickle.dumps((batch_src, batch_tgt, output_file.name))
returned = subprocess.run(
[sys.executable, __file__], input=input_bytes, capture_output=True
)
# check if the subprocess is successful
try:
returned.check_returncode()
except Exception as e:
# wrap raised exception to provide more information
raise RuntimeError(
f"Error happened when batch testing "
f"peer-to-peer access from {batch_src} to {batch_tgt}:\n"
f"{returned.stderr.decode()}"
) from e
with open(output_file.name, "rb") as f:
result = pickle.load(f)
for _i, _j, r in zip(batch_src, batch_tgt, result):
cache[f"{_i}->{_j}"] = r
with open(path, "w") as f:
json.dump(cache, f, indent=4)
if is_distributed:
get_world_group().barrier()
logger.info("reading GPU P2P access cache from %s", path)
with open(path) as f:
cache = json.load(f)
_gpu_p2p_access_cache = cache
return _gpu_p2p_access_cache[f"{src}->{tgt}"]
def with_nvml_context(fn: Callable[_P, _R]) -> Callable[_P, _R]:
@wraps(fn)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
if _is_hip:
try:
amdsmi_init()
return fn(*args, **kwargs)
finally:
amdsmi_shut_down()
else:
pynvml.nvmlInit()
try:
return fn(*args, **kwargs)
finally:
pynvml.nvmlShutdown()
return wrapper
@with_nvml_context
def is_full_nvlink(physical_device_ids: List[int], world_size: int) -> bool:
if _is_hip:
"""
query if the set of gpus are fully connected by xgmi (1 hop)
"""
handles = [amdsmi_get_processor_handles()[i] for i in physical_device_ids]
for i, handle in enumerate(handles):
for j, peer_handle in enumerate(handles):
if i < j:
try:
link_type = amdsmi_topo_get_link_type(handle, peer_handle)
# type is 2 for XGMI
if link_type["hops"] != 1 or link_type["type"] != 2:
return False
except AmdSmiException as error:
logger.error("AMD 1 hop XGMI detection failed.", exc_info=error)
return False
return True
else:
"""
query if the set of gpus are fully connected by nvlink (1 hop)
"""
handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in physical_device_ids]
for i, handle in enumerate(handles):
for j, peer_handle in enumerate(handles):
if i < j:
try:
p2p_status = pynvml.nvmlDeviceGetP2PStatus(
handle, peer_handle, pynvml.NVML_P2P_CAPS_INDEX_NVLINK
)
if p2p_status != pynvml.NVML_P2P_STATUS_OK:
return False
except pynvml.NVMLError:
logger.exception(
"NVLink detection failed. This is normal if your"
" machine has no NVLink equipped."
)
return False
return True
def is_weak_contiguous(inp: torch.Tensor):
return inp.is_contiguous() or (
inp.storage().nbytes() - inp.storage_offset() * inp.element_size()
== inp.numel() * inp.element_size()
)
def can_p2p(rank: int, world_size: int) -> bool:
# SGLANG_SKIP_P2P_CHECK can be set to False in sglang
SGLANG_SKIP_P2P_CHECK = os.getenv("SGLANG_SKIP_P2P_CHECK", "0") == "1"
for i in range(world_size):
if i == rank:
continue
if SGLANG_SKIP_P2P_CHECK:
logger.info("Skipping P2P check and trusting the driver's P2P report.")
return torch.cuda.can_device_access_peer(rank, i)
if not gpu_p2p_access_check(rank, i):
return False
return True
def can_use_custom_all_reduce_with_nvlink(
group: torch.distributed.ProcessGroup,
device: torch.device,
supported_world_size: List[int],
cls_name: str,
) -> Optional[bool]: # None if fail; otherwise return whether NVLink is available
assert (
dist.get_backend(group) != dist.Backend.NCCL
), f"{cls_name} should be attached to a non-NCCL group."
rank = dist.get_rank(group=group)
world_size = dist.get_world_size(group=group)
# No need to initialize custom allreduce for single GPU case.
if world_size == 1:
return
# No need to initialize custom allreduce for multi-node case.
if not all(in_the_same_node_as(group, source_rank=0)):
logger.warning(
f"{cls_name} is disabled because this process group" " spans across nodes."
)
return
# For not supported world size, we disable custom allreduce.
if world_size not in supported_world_size:
logger.warning(
f"{cls_name} is disabled due to an unsupported world"
f" size: {world_size}. Supported world sizes: {supported_world_size}. "
"To silence this warning, specify disable_custom_all_reduce=True explicitly.",
)
return
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", None)
if cuda_visible_devices:
device_ids = list(map(int, cuda_visible_devices.split(",")))
else:
device_ids = list(range(torch.cuda.device_count()))
physical_device_id = device_ids[device.index]
tensor = torch.tensor([physical_device_id], dtype=torch.int, device="cpu")
gather_list = [
torch.tensor([0], dtype=torch.int, device="cpu") for _ in range(world_size)
]
dist.all_gather(gather_list, tensor, group=group)
physical_device_ids = [int(t) for t in gather_list]
full_nvlink = is_full_nvlink(physical_device_ids, world_size)
# test nvlink first, this will filter out most of the cases
# where custom allreduce is not supported
# this checks hardware and driver support for NVLink
if world_size > 2 and not full_nvlink:
logger.warning(
f"{cls_name} is disabled because it's not supported on"
" more than two PCIe-only GPUs. To silence this warning, "
"specify disable_custom_all_reduce=True explicitly."
)
return
# test P2P capability, this checks software/cudaruntime support
# this is expensive to compute at the first time
# then we cache the result
# On AMD GPU, p2p is always enabled between XGMI connected GPUs
if not _is_hip and not can_p2p(rank, world_size):
logger.warning(
f"{cls_name} is disabled because your platform lacks "
"GPU P2P capability or P2P test failed. To silence this "
"warning, specify disable_custom_all_reduce=True explicitly."
)
return
return full_nvlink
if __name__ == "__main__":
batch_src, batch_tgt, output_file = pickle.loads(sys.stdin.buffer.read())
result = can_actually_p2p(batch_src, batch_tgt)
with open(output_file, "wb") as f:
f.write(pickle.dumps(result))
@@ -0,0 +1,240 @@
import logging
from contextlib import contextmanager
from dataclasses import dataclass, replace
from typing import Dict, List, Optional, TypeVar
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.jit_kernel.all_reduce import AllReduceAlgo, get_custom_all_reduce_cls
from sglang.srt.distributed.device_communicators.custom_all_reduce_utils import (
can_use_custom_all_reduce_with_nvlink,
is_weak_contiguous,
)
from sglang.srt.distributed.device_communicators.vmm_utils import (
VmmGraphInputManager,
is_vmm_pointer,
)
from sglang.srt.environ import envs
from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph import (
is_in_tc_piecewise_cuda_graph,
)
from sglang.srt.utils import is_sm100_supported
logger = logging.getLogger(__name__)
T = TypeVar("T")
INF = 1 << 60
@dataclass(frozen=True)
class ModeConfig:
one_shot_push_threshold: int # below this, use one-shot push
one_shot_pull_threshold: int # below this, use one-shot pull
class CustomAllReduceV2:
def __init__(
self,
group: ProcessGroup,
device: torch.device,
max_pull_size: Optional[int] = None,
max_push_size: Optional[int] = None,
max_pull_blocks: Optional[int] = None,
max_push_blocks: Optional[int] = None,
) -> None:
_maybe_init_config()
self.disabled = True
if not can_use_custom_all_reduce_v2(group=group, device=device):
return
self.group = group
self.rank = dist.get_rank(group=self.group)
self.world_size = dist.get_world_size(group=self.group)
if max_pull_size is None: # default to 16MB
max_pull_size = 16 * 1024 * 1024
if max_push_size is None: # default to recommended size
config = THRESHOLD_2_SHOT_MAP[self.world_size]
max_push_size = config.one_shot_push_threshold
self.max_pull_size = max_pull_size
self.max_push_size = max_push_size
self.max_size = max(max_pull_size, max_push_size)
self.override_shot(None) # set default config based on world size
self.override_algo: Optional[AllReduceAlgo] = None
self.tms_cudagraph = envs.SGLANG_MEMORY_SAVER_CUDA_GRAPH.get()
self.obj = get_custom_all_reduce_cls()(
rank=self.rank,
world_size=self.world_size,
pull_buffer_bytes=self.max_pull_size,
push_buffer_bytes=self.max_push_size,
graph_input_count=131072,
max_pull_blocks=max_pull_blocks,
max_push_blocks=max_push_blocks,
)
self._vmm_graph_input_manager = VmmGraphInputManager(
obj=self.obj,
group=self.group,
rank=self.rank,
world_size=self.world_size,
)
self._post_init_obj()
self.disabled = False
def override_shot(self, shot: int | None):
if shot is None:
config = THRESHOLD_2_SHOT_MAP[self.world_size]
else:
assert shot in (1, 2)
threshold = INF if shot == 1 else 0
config = replace(self.config, one_shot_pull_threshold=threshold)
# need to clip the config thresholds to max sizes to avoid invalid config
push_threshold = min(config.one_shot_push_threshold, self.max_push_size)
pull_threshold = min(config.one_shot_pull_threshold, self.max_pull_size)
self.config: ModeConfig = replace(
config,
one_shot_push_threshold=push_threshold,
one_shot_pull_threshold=pull_threshold,
)
@contextmanager
def capture(self):
if self.disabled:
yield
return
try:
self.obj.set_cuda_graph_capture(not self.tms_cudagraph)
yield
finally:
self.obj.set_cuda_graph_capture(False)
assert (
not torch.cuda.is_current_stream_capturing()
), "Cannot register graph inputs while capturing CUDA graph"
raw_ptrs = self.obj.get_graph_capture_ptrs()
if raw_ptrs and is_vmm_pointer(raw_ptrs[0]):
self._vmm_graph_input_manager.register_graph_inputs()
else:
self._register_graph_inputs_ipc()
def _register_graph_inputs_ipc(self):
"""Register graph capture inputs via cudaIpcGetMemHandle.
This is the fast path for cudaMalloc-backed allocations. Fails
on VMM pointers (expandable_segments).
"""
pairs = self.obj.share_graph_inputs()
handles = [handle for _, handle in pairs]
offsets = [offset for offset, _ in pairs]
handles_all = self._share_list(handles)
offsets_all = self._share_list(offsets)
result = [list(zip(o, h)) for o, h in zip(offsets_all, handles_all)]
self.obj.register_inputs(result)
def should_custom_ar(self, inp: torch.Tensor) -> bool:
"""Check if the input tensor is suitable for custom all-reduce."""
if self.disabled:
return False
inp_size = inp.numel() * inp.element_size()
# custom allreduce requires input byte size to be multiples of 16
if inp_size % 16 != 0:
return False
if not is_weak_contiguous(inp):
return False
return inp_size <= self.max_size
def custom_all_reduce(self, input: torch.Tensor) -> torch.Tensor:
if is_in_tc_piecewise_cuda_graph(): # disable inplace optimization
try:
self.obj.set_cuda_graph_capture(False)
return self._all_reduce(input)
finally:
self.obj.set_cuda_graph_capture(not self.tms_cudagraph)
return self._all_reduce(input)
def close(self):
if not self.disabled and hasattr(self, "obj"):
self.obj.free(self.group)
if hasattr(self, "_vmm_graph_input_manager"):
self._vmm_graph_input_manager.close()
def _all_reduce(self, input: torch.Tensor) -> torch.Tensor:
"""Perform the actual all-reduce via JIT kernel."""
algo = self._determine_algo(input)
return torch.from_dlpack(self.obj.all_reduce(input, algo))
def _determine_algo(self, input: torch.Tensor) -> AllReduceAlgo:
if self.override_algo is not None:
return self.override_algo
input_bytes = input.numel() * input.element_size()
if input_bytes <= self.config.one_shot_push_threshold:
return AllReduceAlgo.ONE_SHOT_PUSH
if input_bytes <= self.config.one_shot_pull_threshold:
return AllReduceAlgo.ONE_SHOT_PULL
else:
return AllReduceAlgo.TWO_SHOT_PULL
def _post_init_obj(self):
handles = [self.obj.share_storage()]
result = self._share_list(handles)
assert all(len(r) == 1 for r in result)
result = [h[0] for h in result]
self.obj.post_init(result)
def _share_list(self, input: List[T]) -> List[List[T]]:
input_tensor = torch.tensor(input, dtype=torch.int64, device="cpu")
gather_list = [torch.empty_like(input_tensor) for _ in range(self.world_size)]
dist.all_gather(gather_list, input_tensor, group=self.group)
return [g.tolist() for g in gather_list]
def __del__(self):
self.close()
def _maybe_init_config():
global THRESHOLD_2_SHOT_MAP
if THRESHOLD_2_SHOT_MAP:
return
KB, MB = 1024, 1024 * 1024
if is_sm100_supported():
# NOTE: This result is based on benchmarks on B200 GPUs
THRESHOLD_2_SHOT_MAP = {
2: ModeConfig(4 * MB, INF),
3: ModeConfig(4 * MB, 4 * MB),
4: ModeConfig(2 * MB, 2 * MB),
5: ModeConfig(2 * MB, 2 * MB),
6: ModeConfig(1 * MB, 1 * MB),
7: ModeConfig(896 * KB, 896 * KB),
8: ModeConfig(720 * KB, 720 * KB),
}
else:
# NOTE: This result is based on benchmarks on H200 GPUs
THRESHOLD_2_SHOT_MAP = {
2: ModeConfig(2 * MB, INF),
3: ModeConfig(512 * KB, 512 * KB),
4: ModeConfig(384 * KB, 256 * KB),
5: ModeConfig(256 * KB, 256 * KB),
6: ModeConfig(192 * KB, 192 * KB),
7: ModeConfig(192 * KB, 192 * KB),
8: ModeConfig(160 * KB, 160 * KB),
}
# TODO: tune on more GPUs, e.g A100
def can_use_custom_all_reduce_v2(
group: ProcessGroup,
device: torch.device,
) -> bool:
# call _maybe_init_config() to ensure THRESHOLD_2_SHOT_MAP is initialized, since can_use_custom_all_reduce_v2 can be called before CustomAllReduceV2 is initialized
_maybe_init_config()
full_nvlink = can_use_custom_all_reduce_with_nvlink(
group=group,
device=device,
supported_world_size=list(THRESHOLD_2_SHOT_MAP.keys()),
cls_name="CustomAllReduceV2",
)
return full_nvlink is True
THRESHOLD_2_SHOT_MAP: Dict[int, ModeConfig] = {}
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/hpu_communicator.py
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.srt.utils import is_hpu
if is_hpu():
import habana_frameworks.torch as htorch # noqa: F401
class HpuCommunicator:
def __init__(self, group: ProcessGroup):
if not is_hpu():
self.disabled = True
return
self.disabled = False
self.group = group
self.world_size = dist.get_world_size(self.group)
def all_reduce(self, x: torch.Tensor) -> torch.Tensor:
# FIXME(kzawora): this is a workaround for a bug in Habana PT bridge
# occurring when PT_HPU_ENABLE_LAZY_COLLECTIVES=true env var is used
# (which is required for tensor parallel HPUGraph inference)
htorch.core.mark_step()
dist.all_reduce(x, group=self.group)
return x
def all_gather(self, x: torch.Tensor, dim: int = -1) -> torch.Tensor:
world_size = self.world_size
if dim < 0:
# Convert negative dim to positive.
dim += x.dim()
input_size = x.size()
# Allocate output tensor.
output_tensor = torch.empty(
(world_size,) + input_size, dtype=x.dtype, device=x.device
)
# All-gather.
htorch.core.mark_step()
dist.all_gather_into_tensor(output_tensor, x, group=self.group)
# Reshape
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim] + (world_size * input_size[dim],) + input_size[dim + 1 :]
)
return output_tensor
@@ -0,0 +1,297 @@
import json
import logging
import os
from typing import Dict, List, Optional, Union
from sglang.srt.environ import envs
from sglang.srt.utils.network import NetworkAddress, get_free_port
logger = logging.getLogger(__name__)
# Module-level shared engine instance, set by init_mooncake_transfer_engine().
_mooncake_transfer_engine: Optional["MooncakeTransferEngine"] = None
def parse_ib_device_config(
ib_device_str: Optional[str],
) -> Optional[Union[str, Dict[int, str]]]:
"""Parse IB device config from a shared string, JSON mapping, or JSON file."""
if ib_device_str is None or not ib_device_str.strip():
return None
normalized_input = ib_device_str.strip()
if not normalized_input.endswith(".json") and not normalized_input.startswith("{"):
return normalized_input
if normalized_input.endswith(".json"):
if not os.path.isfile(normalized_input):
raise RuntimeError(f"File {normalized_input} does not exist.")
try:
with open(normalized_input, "r", encoding="utf-8") as file:
mapping = json.load(file)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Failed to parse JSON content from file {normalized_input}"
) from exc
except (IOError, OSError) as exc:
raise RuntimeError(
f"Failed to read JSON file {normalized_input}: {exc}"
) from exc
else:
try:
mapping = json.loads(normalized_input)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON mapping: {normalized_input}") from exc
if not isinstance(mapping, dict):
raise ValueError(
"Invalid format: expected a mapping from GPU id to IB device string"
)
normalized_mapping: Dict[int, str] = {}
for gpu_key, ib_devices in mapping.items():
normalized_key = int(gpu_key) if str(gpu_key).isdigit() else None
if normalized_key is None or not isinstance(ib_devices, str):
raise ValueError(
"Invalid format: keys must be integers (or string "
"representations of integers) and values must be strings"
)
normalized_mapping[normalized_key] = ib_devices.strip()
if not normalized_mapping:
raise ValueError("No valid GPU mappings found in JSON")
return normalized_mapping
def get_ib_devices_for_gpu(ib_device_str: Optional[str], gpu_id: int) -> Optional[str]:
"""
Parse IB device string and get IB devices for a specific GPU ID.
Supports all the following formats:
1. Old format: "ib0, ib1, ib2"
2. New format: {0: "ib0, ib1", 1: "ib2, ib3", 2: "ib4"}
3. JSON file: path to a JSON file containing the mapping
Args:
ib_device_str: The original IB device string or path to JSON file
gpu_id: The GPU ID to get devices for
Returns:
IB devices string for the GPU, or None if not available
"""
parsed_config = parse_ib_device_config(ib_device_str)
if parsed_config is None:
return None
if isinstance(parsed_config, str):
return parsed_config
if gpu_id in parsed_config:
return parsed_config[gpu_id]
raise ValueError(
f"No IB devices configured for GPU {gpu_id}. "
f"Available GPUs: {list(parsed_config.keys())}"
)
class MooncakeTransferEngine:
"""Shared Mooncake transfer engine for RDMA/transfer operations."""
def __init__(
self,
hostname: str,
gpu_id: Optional[int] = None,
ib_device: Optional[str] = None,
):
try:
from mooncake.engine import TransferEngine
except ImportError as e:
raise ImportError(
"Please install mooncake by following the instructions at "
"https://kvcache-ai.github.io/Mooncake/getting_started/build.html "
"to run SGLang with MooncakeTransferEngine."
) from e
self.engine = TransferEngine()
self.hostname = hostname
self.gpu_id = gpu_id if gpu_id is not None else 0
# MC_FORCE_TCP=1 makes mooncake install TcpTransport instead of RDMA,
# in which case RDMA HCA selection is irrelevant; pass empty device.
if os.environ.get("MC_FORCE_TCP") == "1":
self.ib_device = ""
else:
self.ib_device = get_ib_devices_for_gpu(ib_device, self.gpu_id)
self.initialize(
hostname=self.hostname,
device_name=self.ib_device,
)
self.session_id = NetworkAddress(
self.hostname, self.engine.get_rpc_port()
).to_host_port_str()
def register(self, ptr, length):
try:
ret_value = self.engine.register_memory(ptr, length)
except Exception:
# Mark register as failed
ret_value = -1
if ret_value != 0:
logger.debug("Mooncake memory registration %s failed.", ptr)
def deregister(self, ptr):
try:
ret_value = self.engine.unregister_memory(ptr)
except Exception:
# Mark deregister as failed
ret_value = -1
if ret_value != 0:
logger.debug("Mooncake memory deregistration %s failed.", ptr)
def batch_register(self, ptrs: List[int], lengths: List[int]) -> int:
"""Batch register multiple memory regions."""
try:
ret_value = self.engine.batch_register_memory(ptrs, lengths)
except Exception:
# Mark batch register as failed
ret_value = -1
if not hasattr(self.engine, "batch_register_memory"):
raise RuntimeError(
"Mooncake's batch register requires a newer version of "
"mooncake-transfer-engine. Please upgrade Mooncake."
)
if ret_value != 0:
logger.debug("Mooncake batch memory registration failed.")
return ret_value
def batch_deregister(self, ptrs: List[int]) -> int:
"""Batch deregister multiple memory regions."""
try:
ret_value = self.engine.batch_unregister_memory(ptrs)
except Exception:
# Mark batch deregister as failed
ret_value = -1
if ret_value != 0:
logger.debug("Mooncake batch memory deregistration failed.")
return ret_value
def initialize(
self,
hostname: str,
device_name: Optional[str],
) -> None:
"""Initialize the mooncake instance."""
if envs.ENABLE_ASCEND_TRANSFER_WITH_MOONCAKE.get():
npu_phy_id = envs.ASCEND_NPU_PHY_ID.get()
suffix = self.gpu_id if npu_phy_id == -1 else npu_phy_id
hostname += f":{get_free_port()}:npu_{suffix}"
protocol = "ascend"
else:
# MOONCAKE_PROTOCOL selects the transport (rdma | efa | tcp | ...).
# Default is "rdma"; set MOONCAKE_PROTOCOL=efa on AWS EFA hardware.
protocol = envs.MOONCAKE_PROTOCOL.get()
ret_value = self.engine.initialize(
hostname,
"P2PHANDSHAKE",
protocol,
device_name if device_name is not None else "",
)
if ret_value != 0:
logger.error("Mooncake Transfer Engine initialization failed.")
raise RuntimeError("Mooncake Transfer Engine initialization failed.")
def transfer_sync(
self, session_id: str, buffer: int, peer_buffer_address: int, length: int
) -> int:
"""Synchronously transfer data to the specified address."""
try:
ret = self.engine.transfer_sync_write(
session_id, buffer, peer_buffer_address, length
)
except Exception:
ret = -1
if ret < 0:
logger.debug(
"Failed to transfer data from %s to %s - %s.",
buffer,
session_id,
peer_buffer_address,
)
return ret
def batch_transfer_sync(
self,
session_id: str,
buffers: List[int],
peer_buffer_addresses: List[int],
lengths: List[int],
) -> int:
"""Synchronously transfer data to the specified addresses in batches."""
try:
ret = self.engine.batch_transfer_sync_write(
session_id, buffers, peer_buffer_addresses, lengths
)
except Exception:
ret = -1
if not hasattr(self.engine, "batch_transfer_sync_write"):
raise RuntimeError(
"Mooncake's batch transfer requires mooncake-transfer-engine "
">= 0.3.4.post2. Please upgrade Mooncake by "
"'pip install mooncake-transfer-engine --upgrade'"
)
if ret < 0:
logger.debug(
"Failed to batch transfer data. Buffers: %s, Session: %s, "
"Peer addresses: %s",
buffers,
session_id,
peer_buffer_addresses,
)
return ret
def get_session_id(self):
return self.session_id
def send_probe(self, peer_session_id: str) -> int:
return self.engine.send_probe(peer_session_id)
def get_engine(self):
return self.engine.get_engine()
def get_ib_device(self):
return self.ib_device
def init_mooncake_transfer_engine(
hostname: str,
gpu_id: Optional[int] = None,
ib_device: Optional[str] = None,
) -> MooncakeTransferEngine:
"""
Initialize the shared MooncakeTransferEngine. Note: if already
initialized with the same (hostname, gpu_id, ib_device), returns existing
instance. Call from parallel_state when model parallel is set up and
mooncake transfer is needed.
"""
global _mooncake_transfer_engine
if _mooncake_transfer_engine is not None:
return _mooncake_transfer_engine
_mooncake_transfer_engine = MooncakeTransferEngine(
hostname=hostname, gpu_id=gpu_id, ib_device=ib_device
)
return _mooncake_transfer_engine
def get_mooncake_transfer_engine() -> Optional[MooncakeTransferEngine]:
"""Return the shared MooncakeTransferEngine if initialized, else None."""
return _mooncake_transfer_engine
@@ -0,0 +1,71 @@
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.srt.utils import is_npu
_is_npu = is_npu()
if _is_npu:
from torch_npu import npu_dynamic_quant
class NpuCommunicator:
def __init__(self, group: ProcessGroup):
if not _is_npu:
self.disabled = True
return
self.disabled = False
self.group = group
self.world_size = dist.get_world_size(self.group)
def all_reduce(self, x: torch.Tensor) -> torch.Tensor:
dist.all_reduce(x, group=self.group)
return x
def quant_all_reduce(self, x: torch.Tensor) -> torch.Tensor:
"""
Note:
All reduce is split into All gather + reduce.
All gather is performed in low precision, but reduce in full precision.
"""
world_size = self.world_size
input_size = x.size()
output_size = (input_size[0] * world_size,) + input_size[1:]
x_q, scale = npu_dynamic_quant(x, dst_type=torch.int8)
# Allocate output tensor.
output_tensor = torch.empty(output_size, dtype=x_q.dtype, device=x.device)
output_scale = torch.empty(
output_size[:1], dtype=scale.dtype, device=scale.device
)
# All-gather.
dist.all_gather_into_tensor(output_tensor, x_q, group=self.group)
dist.all_gather_into_tensor(output_scale, scale, group=self.group)
output_tensor = output_tensor.to(x.dtype) * output_scale.unsqueeze(-1).to(
x.dtype
)
# Reshape
output_tensor = output_tensor.reshape((world_size,) + input_size)
return output_tensor.sum(dim=0)
def all_gather(self, x: torch.Tensor, dim: int = -1) -> torch.Tensor:
world_size = self.world_size
if dim < 0:
# Convert negative dim to positive.
dim += x.dim()
input_size = x.size()
output_size = (input_size[0] * world_size,) + input_size[1:]
# Allocate output tensor.
output_tensor = torch.empty(output_size, dtype=x.dtype, device=x.device)
# All-gather.
dist.all_gather_into_tensor(output_tensor, x, group=self.group)
# Reshape
output_tensor = output_tensor.reshape((world_size,) + input_size)
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim] + (world_size * input_size[dim],) + input_size[dim + 1 :]
)
return output_tensor
@@ -0,0 +1,394 @@
import importlib
import logging
from contextlib import contextmanager
from typing import Optional, Union
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup, ReduceOp
from sglang.srt.compilation.compile_phase import (
get_pcg_capture_stream,
is_in_torch_compile_warmup,
)
from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph import (
is_in_tc_piecewise_cuda_graph,
)
from sglang.srt.runtime_context import get_server_args
logger = logging.getLogger(__name__)
class PyMscclppCommunicator:
_SUPPORTED_WORLD_SIZES = [8, 16, 32]
_SUPPORTED_DTYPE = [torch.float, torch.float16, torch.bfloat16]
def _is_symm_mem_enabled(self) -> bool:
try:
return get_server_args().enable_symm_mem
except ValueError:
return False
def _is_weak_contiguous(self, inp: torch.Tensor):
return inp.is_contiguous() or (
inp.storage().nbytes() - inp.storage_offset() * inp.element_size()
== inp.numel() * inp.element_size()
)
def _get_tuned_config(self, size):
if size <= 512:
target_size = 512
elif size > 256 * 1024 * 1024:
target_size = 256 * 1024 * 1024
else:
target_size = 1 << (size - 1).bit_length()
return self.best_configs.get(target_size)
def _create_dsl_algorithms(self):
dsl_algos_config = []
n_nodes = self.world_size // self.nranks_per_node
if n_nodes == 2 or n_nodes == 4:
for tbg in [1, 2, 4, 8]:
for num_threads_per_block in [256, 512, 768, 1024]:
spec = self.mscclpp.language.AlgoSpec(
name=f"allreduce_{n_nodes}node_{tbg}TBG_{num_threads_per_block}TPB",
collective=self.mscclpp.language.collectives.AllReduce(
self.world_size, 1, True
),
nranks_per_node=self.nranks_per_node,
world_size=self.world_size,
in_place=True,
instances=1,
protocol="LL",
auto_sync=False,
num_threads_per_block=num_threads_per_block,
reuse_resources=True,
use_double_scratch_buffer=True,
min_message_size=tbg * (1 << 10),
max_message_size=8 << 20,
tags={"default": 1},
)
algo = self.mscclpp.compile(
self.def_algo.allreduce_multi_nodes,
spec,
self.rank,
thread_block_group_size=tbg,
)
dsl_algos_config.append((algo, [0], [0]))
return dsl_algos_config
def _create_native_algorithms(self):
navitve_algorithms_config = []
dlpack = self.mscclpp.RawGpuBuffer(1 << 27).to_dlpack(
data_type=str(torch.float16)
)
self.scratch_buffer = torch.utils.dlpack.from_dlpack(dlpack)
self.flag_buffer = torch.ones(128, dtype=torch.uint32, device="cuda")
algos = self.mscclpp_ext.AlgorithmCollectionBuilder().build_default_algorithms(
scratch_buffer=self.scratch_buffer.data_ptr(),
scratch_buffer_size=self.scratch_buffer.nbytes,
rank=self.rank,
)
for algo in algos:
if algo.name == "default_allreduce_nvls_packet":
algo.set_message_size_range(0, 512 << 10)
navitve_algorithms_config.append(
(algo, [4, 8, 12, 16], [256, 512, 768, 1024])
)
if algo.name == "default_allreduce_packet":
algo.set_message_size_range(0, 2 << 20)
navitve_algorithms_config.append(
(algo, [14, 21, 28, 42, 56], [256, 512, 768, 1024])
)
if algo.name == "default_allreduce_rsag_zero_copy":
algo.set_message_size_range(512 << 10, 4 << 30)
navitve_algorithms_config.append(
(algo, [32, 48, 64, 128], [256, 512, 768, 1024])
)
if (
self.symm_mem_enabled
and algo.name == "default_allreduce_nvls_zero_copy"
):
algo.set_message_size_range(512 << 10, 4 << 30)
navitve_algorithms_config.append(
(algo, [4, 8, 12, 16, 32], [256, 512, 768, 1024])
)
return navitve_algorithms_config
def _create_algorithms(self):
if self.world_size == 8:
self.algos_config = self._create_native_algorithms()
self._tune(5, 10, 20, self.algos_config)
elif self.world_size == 16 or self.world_size == 32:
self.dsl_algos_config = self._create_dsl_algorithms()
self._tune(5, 10, 20, self.dsl_algos_config)
def _get_time(
self,
algo,
tune_tensor,
size,
nb,
nt,
n_warmup,
n_graph_launches,
n_ops_per_graph,
):
# Check if the algorithm can run with the given configuration
if self._run_algo(algo, tune_tensor, size, nb, nt, True) != 0:
return float("inf")
# Warmup iterations to stabilize performance
for _ in range(n_warmup):
self._run_algo(algo, tune_tensor, size, nb, nt, True)
# Warmup on capture stream
capture_stream = torch.cuda.Stream()
capture_stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(capture_stream):
self._run_algo(algo, tune_tensor, size, nb, nt, True)
capture_stream.synchronize()
# Capture the algorithm execution in a CUDA graph
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g, stream=capture_stream):
for _ in range(n_ops_per_graph):
self._run_algo(algo, tune_tensor, size, nb, nt, True)
# Measure the execution time of the captured graph
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record(capture_stream)
with torch.cuda.stream(capture_stream):
for _ in range(n_graph_launches):
g.replay()
end_event.record(capture_stream)
end_event.synchronize()
elapsed = start_event.elapsed_time(end_event)
# Synchronize timing results across all ranks to ensure consistent algorithm selection
# replicate n times such due to algo limitations
time_tensor = torch.full(
(self.world_size,), elapsed, dtype=torch.float64, device="cuda"
).to(dtype=torch.float32)
torch.cuda.current_stream().wait_stream(capture_stream)
if self.rank == 0:
avg_time = time_tensor[self.rank].item() / self.world_size
tensor = torch.tensor([avg_time])
else:
tensor = torch.empty(1)
dist.broadcast(tensor, src=0, group=self.group)
avg_time = tensor.item()
return avg_time
def _tune(self, n_warmup, n_graph_launches, n_ops_per_graph, algos_config):
sizes = [1 << i for i in range(9, 24)]
dlpack = self.mscclpp.RawGpuBuffer(1 << 27).to_dlpack(
data_type=str(torch.float16)
)
tune_tensor = torch.utils.dlpack.from_dlpack(dlpack)
for size in sizes:
best_time = float("inf")
best_config = None
for i in range(len(algos_config)):
algo, candidates_nblocks, candidates_nthreads = algos_config[i]
if (
size >= algo.message_size_range[0]
and size <= algo.message_size_range[1]
):
for nb in candidates_nblocks:
for nt in candidates_nthreads:
avg_time = self._get_time(
algo,
tune_tensor,
size,
nb,
nt,
n_warmup,
n_graph_launches,
n_ops_per_graph,
)
if avg_time < best_time:
best_time = avg_time
best_config = (algo, nb, nt)
if best_config:
self.best_configs[size] = best_config
torch.cuda.synchronize()
for algo, _, _ in algos_config:
algo.reset()
def _run_algo(self, algo, tensor, size, nblocks, nthreads, sym_mem_enabled=False):
return algo.execute(
comm=self.comm.communicator,
executor=self.executor,
input_buffer=tensor.data_ptr(),
output_buffer=tensor.data_ptr(),
input_size=size,
output_size=size,
dtype=self.dtype_to_mscclpp_dtype(tensor.dtype),
op=self.mscclpp.ReduceOp.SUM,
stream=torch.cuda.current_stream().cuda_stream,
nblocks=nblocks,
nthreads_per_block=nthreads,
symmetric_memory=sym_mem_enabled,
)
def __init__(
self,
group: ProcessGroup,
device: Union[int, str, torch.device],
) -> None:
"""Args:
group: the process group to work on. If None, it will use the
default process group.
device: the device to bind the CustomAllreduce to. If None,
it will be bind to f"cuda:{local_rank}".
It is the caller's responsibility to make sure each communicator
is bind to a unique device, and all communicators in this group
are in the same node.
"""
self._IS_CAPTURING = False
self.disabled = True
try:
self.mscclpp = importlib.import_module("mscclpp")
self.mscclpp_ext = importlib.import_module("mscclpp.ext")
self.def_algo = importlib.import_module("mscclpp.default_algos")
except ImportError:
self.available = False
self.mscclpp = None
return
self.available = True
self.group = group
assert (
dist.get_backend(group) != dist.Backend.NCCL
), "CustomAllreduce should be attached to a non-NCCL group."
rank = dist.get_rank(group=self.group)
world_size = dist.get_world_size(group=self.group)
if world_size == 1:
# No need to initialize mscclpp for single GPU case.
return
if world_size not in PyMscclppCommunicator._SUPPORTED_WORLD_SIZES:
logger.warning(
"PyMscclpp is disabled due to an unsupported world"
" size: %d. Supported world sizes: %s. To silence this "
"warning, specify disable_mscclpp=True explicitly.",
world_size,
str(PyMscclppCommunicator._SUPPORTED_WORLD_SIZES),
)
return
self.ranks = torch.distributed.get_process_group_ranks(group)
self.nranks_per_node = torch.cuda.device_count()
# for now mscclpp with stride in the communicator is not tested
if not (abs(self.ranks[-1] - self.ranks[0]) == world_size - 1):
logger.warning(
"PyMscclpp is disabled due to an unsupported group %s."
"Please ensure all ranks in the group are consecutive."
"To silence this warning, specify disable_mscclpp=True explicitly.",
str(self.ranks),
)
return
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
# now `device` is a `torch.device` object
assert isinstance(device, torch.device)
self.device = device
self.rank = rank
self.world_size = world_size
self.comm = self.mscclpp.CommGroup(
torch_group=self.group, rank=rank, size=world_size
)
self.executor = self.mscclpp.Executor(self.comm.communicator)
self.symm_mem_enabled = self._is_symm_mem_enabled()
self.best_configs = {}
self._create_algorithms()
def destroy(self):
self.algos_config = None
self.best_configs = None
self.executor = None
self.scratch_buffer = None
self.flag_buffer = None
self.comm = None
def should_mscclpp_allreduce(
self, inp: torch.Tensor, op: ReduceOp = ReduceOp.SUM
) -> bool:
if (
self.disabled
or self.world_size not in PyMscclppCommunicator._SUPPORTED_WORLD_SIZES
):
return False
if inp.dtype not in PyMscclppCommunicator._SUPPORTED_DTYPE:
return False
if not self._is_weak_contiguous(inp):
return False
if op is not ReduceOp.SUM:
return False
if self._get_tuned_config(inp.numel() * inp.element_size()) is None:
return False
# mscclpp must not be used during any piecewise CUDA graph phase
# (compile, capture, or replay) as it changes the allreduce dispatch
# path and triggers recompilation.
if (
is_in_tc_piecewise_cuda_graph()
or is_in_torch_compile_warmup()
or get_pcg_capture_stream() is not None
):
return False
return True
def dtype_to_mscclpp_dtype(self, dtype: torch.dtype):
if dtype == torch.float16:
return self.mscclpp.DataType.float16
elif dtype == torch.float32:
return self.mscclpp.DataType.float32
elif dtype == torch.int32:
return self.mscclpp.DataType.int32
elif dtype == torch.bfloat16:
return self.mscclpp.DataType.bfloat16
else:
raise ValueError(f"Unknown data type: {dtype}")
def all_reduce(
self,
tensor: torch.Tensor,
op: ReduceOp = ReduceOp.SUM,
stream: torch.cuda.Stream = None,
):
assert op == torch.distributed.ReduceOp.SUM
nbytes = tensor.numel() * tensor.element_size()
algo, nblocks, nthreads = self._get_tuned_config(nbytes)
self._run_algo(algo, tensor, nbytes, nblocks, nthreads, self.symm_mem_enabled)
return tensor
@contextmanager
def change_state(
self,
enable: Optional[bool] = None,
):
if enable is None or self.available is False:
# guess a default value when not specified
# DO: Decided if raise an exception here or not
enable = self.available
old_disable = self.disabled
self.disabled = not enable
yield
self.disabled = old_disable
@@ -0,0 +1,385 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/pynccl.py
import logging
from contextlib import contextmanager
from typing import Optional, Union
# ===================== import region =====================
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup, ReduceOp
from sglang.srt.distributed.device_communicators.pynccl_wrapper import (
NCCLLibrary,
buffer_type,
cudaStream_t,
ncclComm_t,
ncclDataTypeEnum,
ncclRedOpTypeEnum,
ncclUniqueId,
)
from sglang.srt.distributed.utils import StatelessProcessGroup
from sglang.srt.utils.common import get_current_device_stream_fast
logger = logging.getLogger(__name__)
class PyNcclCommunicator:
def __init__(
self,
group: Union[ProcessGroup, StatelessProcessGroup],
device: Union[int, str, torch.device],
library_path: Optional[str] = None,
):
"""
Args:
group: the process group to work on. If None, it will use the
default process group.
device: the device to bind the PyNcclCommunicator to. If None,
it will be bind to f"cuda:{local_rank}".
library_path: the path to the NCCL library. If None, it will
use the default library path.
It is the caller's responsibility to make sure each communicator
is bind to a unique device.
"""
if not isinstance(group, StatelessProcessGroup):
assert dist.is_initialized()
assert (
dist.get_backend(group) != dist.Backend.NCCL
), "PyNcclCommunicator should be attached to a non-NCCL group."
# note: this rank is the rank in the group
self.rank = dist.get_rank(group)
self.world_size = dist.get_world_size(group)
else:
self.rank = group.rank
self.world_size = group.world_size
self.group = group
# if world_size == 1, no need to create communicator
if self.world_size == 1:
self.available = False
self.disabled = True
return
try:
self.nccl = NCCLLibrary(library_path)
except Exception:
# disable because of missing NCCL library
# e.g. in a non-GPU environment
self.available = False
self.disabled = True
return
self.available = True
self.disabled = False
self.nccl_version = self.nccl.ncclGetRawVersion()
if self.rank == 0:
logger.info("sglang is using nccl==%s", self.nccl.ncclGetVersion())
if self.rank == 0:
# get the unique id from NCCL
self.unique_id = self.nccl.ncclGetUniqueId()
else:
# construct an empty unique id
self.unique_id = ncclUniqueId()
if not isinstance(group, StatelessProcessGroup):
tensor = torch.ByteTensor(list(self.unique_id.internal))
ranks = dist.get_process_group_ranks(group)
# arg `src` in `broadcast` is the global rank
dist.broadcast(tensor, src=ranks[0], group=group)
byte_list = tensor.tolist()
for i, byte in enumerate(byte_list):
self.unique_id.internal[i] = byte
else:
self.unique_id = group.broadcast_obj(self.unique_id, src=0)
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
# now `device` is a `torch.device` object
assert isinstance(device, torch.device)
self.device = device
# nccl communicator and stream will use this device
# `torch.cuda.device` is a context manager that changes the
# current cuda device to the specified one
with torch.cuda.device(device):
self.comm: ncclComm_t = self.nccl.ncclCommInitRank(
self.world_size, self.unique_id, self.rank
)
warmup_stream = torch.cuda.Stream()
# A small all_reduce for warmup.
with torch.cuda.stream(warmup_stream):
data = torch.zeros(1, device=device)
self.all_reduce(data)
warmup_stream.synchronize()
del data
# by default it is disabled, e.g. in profiling models and prefill phase.
# to use it, use under `with obj.change_state(enable=True)`, usually
# when we are using CUDA graph.
self.disabled = True
def _resolve_stream(self) -> torch.cuda.Stream:
"""Return the current device stream used for NCCL calls."""
return get_current_device_stream_fast()
def all_reduce(self, tensor: torch.Tensor, op: ReduceOp = ReduceOp.SUM):
if self.disabled:
return
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
stream = self._resolve_stream()
self.nccl.ncclAllReduce(
buffer_type(tensor.data_ptr()),
buffer_type(tensor.data_ptr()),
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def outplace_all_reduce(
self,
in_tensor: torch.Tensor,
out_tensor: Optional[torch.Tensor] = None,
op: ReduceOp = ReduceOp.SUM,
) -> Optional[torch.Tensor]:
if self.disabled:
return None
assert in_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {in_tensor.device}"
)
if out_tensor is None:
out_tensor = torch.empty_like(in_tensor)
stream = self._resolve_stream()
self.nccl.ncclAllReduce(
buffer_type(in_tensor.data_ptr()), # sendbuff
buffer_type(out_tensor.data_ptr()), # recvbuff - DIFFERENT pointer
in_tensor.numel(),
ncclDataTypeEnum.from_torch(in_tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
self.comm,
cudaStream_t(stream.cuda_stream),
)
return out_tensor
def all_gather(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
sizes: Optional[list[int]] = None,
):
if self.disabled:
return
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert input_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {input_tensor.device}"
)
stream = self._resolve_stream()
if sizes is not None:
split_offset = 0
self.nccl.ncclGroupStart()
for root, split_size in enumerate(sizes):
dst_slice = output_tensor[split_offset : split_offset + split_size]
self.nccl.ncclBroadcast(
buffer_type(input_tensor.data_ptr()),
buffer_type(dst_slice.data_ptr()),
dst_slice.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
root,
self.comm,
cudaStream_t(stream.cuda_stream),
)
split_offset += split_size
self.nccl.ncclGroupEnd()
else:
self.nccl.ncclAllGather(
buffer_type(input_tensor.data_ptr()),
buffer_type(output_tensor.data_ptr()),
input_tensor.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def cp_all_gather_into_tensor(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
stream: torch.cuda.Stream,
sizes: Optional[list[int]] = None,
):
"""
Currently, it is mainly used in context parallelism,
primarily leveraging pynccl to implement non-blocking allgather communication.
"""
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert input_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {input_tensor.device}"
)
self.nccl.ncclAllGather(
buffer_type(input_tensor.data_ptr()),
buffer_type(output_tensor.data_ptr()),
input_tensor.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def reduce_scatter(
self,
output_tensor: torch.Tensor,
input_tensor: torch.Tensor,
op: ReduceOp = ReduceOp.SUM,
sizes: Optional[list[int]] = None,
):
if self.disabled:
return
# nccl communicator created on a specific device
# will only work on tensors on the same device
# otherwise it will cause "illegal memory access"
assert input_tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {input_tensor.device}"
)
stream = self._resolve_stream()
if sizes is not None:
split_offset = 0
self.nccl.ncclGroupStart()
for root, split_size in enumerate(sizes):
chunk = input_tensor[split_offset : split_offset + split_size, ...]
self.nccl.ncclReduce(
buffer_type(chunk.data_ptr()),
buffer_type(output_tensor.data_ptr()),
chunk.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
root,
self.comm,
cudaStream_t(stream.cuda_stream),
)
split_offset += split_size
self.nccl.ncclGroupEnd()
else:
self.nccl.ncclReduceScatter(
buffer_type(input_tensor.data_ptr()),
buffer_type(output_tensor.data_ptr()),
output_tensor.numel(),
ncclDataTypeEnum.from_torch(input_tensor.dtype),
ncclRedOpTypeEnum.from_torch(op),
self.comm,
cudaStream_t(stream.cuda_stream),
)
def send(self, tensor: torch.Tensor, dst: int):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
stream = self._resolve_stream()
self.nccl.ncclSend(
buffer_type(tensor.data_ptr()),
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
dst,
self.comm,
cudaStream_t(stream.cuda_stream),
)
def recv(self, tensor: torch.Tensor, src: int):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
stream = self._resolve_stream()
self.nccl.ncclRecv(
buffer_type(tensor.data_ptr()),
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
src,
self.comm,
cudaStream_t(stream.cuda_stream),
)
def broadcast(self, tensor: torch.Tensor, src: int):
if self.disabled:
return
assert tensor.device == self.device, (
f"this nccl communicator is created to work on {self.device}, "
f"but the input tensor is on {tensor.device}"
)
stream = self._resolve_stream()
if src == self.rank:
sendbuff = buffer_type(tensor.data_ptr())
# NCCL requires the sender also to have a receive buffer
recvbuff = buffer_type(tensor.data_ptr())
else:
sendbuff = buffer_type()
recvbuff = buffer_type(tensor.data_ptr())
self.nccl.ncclBroadcast(
sendbuff,
recvbuff,
tensor.numel(),
ncclDataTypeEnum.from_torch(tensor.dtype),
src,
self.comm,
cudaStream_t(stream.cuda_stream),
)
def register_comm_window_raw(self, ptr: int, size: int):
return self.nccl.ncclCommWindowRegister(self.comm, buffer_type(ptr), size, 1)
def deregister_comm_window(self, window):
return self.nccl.ncclCommWindowDeregister(self.comm, window)
def group_start(self):
self.nccl.ncclGroupStart()
def group_end(self):
self.nccl.ncclGroupEnd()
@contextmanager
def change_state(self, enable: Optional[bool] = None):
"""
A context manager to change the enabled state of the communicator.
"""
if enable is None:
# guess a default value when not specified
enable = self.available
old_disable = self.disabled
self.disabled = not enable
try:
yield
finally:
self.disabled = old_disable
@@ -0,0 +1,406 @@
import ctypes
import logging
import os
import tempfile
import traceback
from contextlib import nullcontext
import torch
from torch.cuda.memory import (
CUDAPluggableAllocator,
_cuda_beginAllocateCurrentThreadToPool,
_cuda_endAllocateToPool,
_cuda_releasePool,
)
from sglang.srt.distributed.parallel_state import GroupCoordinator
from sglang.srt.environ import envs
from sglang.srt.runtime_context import get_server_args
from sglang.srt.utils.common import torch_release
after_2_8_0 = torch_release >= (2, 8)
# C++ source for the NCCL allocator plugin
# Key design:
# 1. nccl_alloc_plug: Allocates memory via ncclMemAlloc and TRACKS the segment
# (ptr, size). Does NOT register with any comm at allocation time.
# 2. nccl_free_plug: Frees memory via ncclMemFree and UNTRACKS the segment.
# Each segment is tracked only during its lifetime (from alloc to free).
# 3. Segment tracking uses thread-safe std::vector + unordered_map for O(1) operations.
# 4. Registration via nccl_allocator_register_segments_with_comm: Registers all
# tracked segments with a given comm, using index-based tracking to avoid
# re-registration. Registration state is maintained per-communicator in C++.
nccl_allocator_source = """
#include <cuda_runtime.h>
#include <mutex>
#include <vector>
#include <unordered_map>
#include <utility>
extern "C" {
// copy from https://github.com/NVIDIA/nccl/blob/master/src/nccl.h.in
typedef enum { ncclSuccess = 0,
ncclUnhandledCudaError = 1,
ncclSystemError = 2,
ncclInternalError = 3,
ncclInvalidArgument = 4,
ncclInvalidUsage = 5,
ncclRemoteError = 6,
ncclInProgress = 7,
ncclNumResults = 8 } ncclResult_t;
// NCCL symmetric memory window flags
#define NCCL_WIN_COLL_SYMMETRIC 0x01
typedef struct ncclComm* ncclComm_t;
typedef struct ncclWindow_vidmem* ncclWindow_t;
ncclResult_t ncclMemAlloc(void** ptr, size_t size);
ncclResult_t ncclMemFree(void *ptr);
ncclResult_t ncclCommWindowRegister(ncclComm_t comm, void* buff, size_t size, ncclWindow_t* win, int winFlags);
const char* ncclGetErrorString(ncclResult_t result);
#define NCCLCHECK(cmd) do { \
ncclResult_t res = cmd; \
if (res != ncclSuccess) { \
fprintf(stderr, "ERROR: NCCL symmetric memory allocation failed. Most likely out of device memory. '%s'\\n", \
ncclGetErrorString(res)); \
return NULL; \
} \
} while(0)
// Segment information structure
struct Segment {
void* ptr;
size_t size;
Segment(void* p, size_t s) : ptr(p), size(s) {}
};
// Thread-safe segment tracking
// Segment tracking using std::vector for FIFO order.
// g_segments is maintained in insertion order (oldest first).
static std::vector<Segment> g_segments;
static std::mutex g_segment_mutex;
// Track which segments have been registered with each communicator.
// Key: comm_ptr, Value: the next segment index to register for this comm.
static std::unordered_map<uintptr_t, size_t> g_comm_registration_index;
// Add a segment to the tracking (appends to end, maintaining FIFO order)
static void track_segment(void* ptr, size_t size) {
std::lock_guard<std::mutex> lock(g_segment_mutex);
g_segments.emplace_back(ptr, size);
}
void* nccl_alloc_plug(size_t size, int device, void* stream) {
void* ptr;
NCCLCHECK(ncclMemAlloc(&ptr, size));
// Track the segment but do NOT register with any comm
// Registration will be done at context exit via register_segments_with_comm
track_segment(ptr, size);
return ptr;
}
void nccl_free_plug(void* ptr, size_t size, int device, void* stream) {
ncclResult_t err = ncclMemFree(ptr);
// NOTE: We assume that no individual allocation will be freed until the
// entire memory pool is destroyed. If this assumption does not hold,
// we will encounter asymmetry issues between GPUs. For now, we clear
// all tracking state when the pool is destroyed.
std::lock_guard<std::mutex> lock(g_segment_mutex);
g_segments = std::vector<Segment>();
g_comm_registration_index = std::unordered_map<uintptr_t, size_t>();
}
// Register all tracked segments with a communicator.
// Uses an index-based approach to avoid re-registering already-registered segments.
// Returns 0 on success, non-zero on failure.
int nccl_allocator_register_segments_with_comm(uintptr_t comm_ptr) {
std::lock_guard<std::mutex> lock(g_segment_mutex);
ncclComm_t comm = reinterpret_cast<ncclComm_t>(comm_ptr);
// Get the starting index for this communicator
size_t start_index = g_comm_registration_index[comm_ptr];
// Register all segments from start_index to the current end
for (size_t i = start_index; i < g_segments.size(); ++i) {
const Segment& seg = g_segments[i];
ncclWindow_t win;
ncclResult_t res = ncclCommWindowRegister(comm, seg.ptr, seg.size, &win, NCCL_WIN_COLL_SYMMETRIC);
if (res != ncclSuccess) {
fprintf(stderr, "ERROR: NCCL symmetric memory registration failed. '%s'\\n", ncclGetErrorString(res));
return res;
}
}
// Update the registration index for this communicator
g_comm_registration_index[comm_ptr] = g_segments.size();
return ncclSuccess;
}
}
"""
_allocator = None
_mem_pool = None
_graph_pool_id = None
_cur_device = None
_active_symmetric_memory_context = None
# Reference to the C registration function (with arg types set)
_register_func = None
def is_symmetric_memory_enabled():
try:
return get_server_args().enable_symm_mem
except ValueError:
return False
def set_graph_pool_id(graph_pool_id):
global _graph_pool_id
_graph_pool_id = graph_pool_id
def disable_symmetric_memory_context():
if _active_symmetric_memory_context is None:
return None
saved_context = _active_symmetric_memory_context
saved_context.__exit__(None, None, None)
return saved_context
def restore_symmetric_memory_context(saved_context):
if saved_context is not None:
saved_context.__enter__()
def get_nccl_mem_pool() -> torch.cuda.MemPool:
"""
Get the shared MemPool for all groups.
All groups share the same pool to avoid memory fragmentation.
Comm registration is handled at context exit time.
"""
global _allocator, _mem_pool, _cur_device, _register_func
if _allocator is None:
import torch.utils.cpp_extension
out_dir = os.path.join(tempfile.gettempdir(), "symm_allocator")
os.makedirs(out_dir, exist_ok=True)
# Make sure to clean up leftover pytorch lock files
# from previous runs and synchronize across processes
# right after
try:
os.remove(os.path.join(out_dir, "lock"))
except FileNotFoundError:
pass
torch.distributed.barrier()
nccl_allocator_libname = "nccl_allocator"
lib_path = torch.utils.cpp_extension.load_inline(
name=nccl_allocator_libname,
cpp_sources=nccl_allocator_source,
with_cuda=True,
extra_ldflags=["-lnccl"],
verbose=True,
is_python_module=False,
build_directory=out_dir,
)
nccl_allocator_lib = ctypes.CDLL(lib_path)
_allocator = CUDAPluggableAllocator(
f"{out_dir}/{nccl_allocator_libname}.so",
"nccl_alloc_plug",
"nccl_free_plug",
).allocator()
_mem_pool = torch.cuda.MemPool(_allocator)
_cur_device = torch.cuda.current_device()
# Setup the C function for registration with correct arg types
_register_func = nccl_allocator_lib.nccl_allocator_register_segments_with_comm
_register_func.restype = ctypes.c_int
_register_func.argtypes = [ctypes.c_uint64]
return _mem_pool
class SymmetricMemoryContext:
"""
Context manager for using symmetric memory with pynccl.
To Utilize the symmetric memory feature in NCCL, the buffers need to be allocated
by `ncclMemAlloc` and registered by `ncclCommWindowRegister`. Due to this, we introduce
this context manager. All tensors created under this context will be correctly
allocated and registered with a custom allocator.
Key design:
- All groups share a single MemPool to avoid memory fragmentation.
- At allocation time, ptrs are tracked but NOT registered with any comm.
- At context exit time, nccl_allocator_register_segments_with_comm is called
to register all tracked segments with the current comm. The C++ layer
tracks per-comm registration state using index-based tracking to avoid
re-registration of already-registered segments.
"""
def __init__(
self,
group_coordinator: GroupCoordinator,
):
self.group_coordinator = group_coordinator
self._pool_id = get_nccl_mem_pool().id
self._device_index = torch.cuda.current_device()
self.is_graph_capture = torch.cuda.is_current_stream_capturing()
# Get comm ptr for tracking registrations
# Use the comm pointer value as unique identifier
self._comm_ptr = self.group_coordinator.pynccl_comm.comm.value
def __enter__(self):
assert (
self.group_coordinator.pynccl_comm is not None
), f"Symmetric memory requires pynccl to be enabled in group '{self.group_coordinator.unique_name}'"
if self.is_graph_capture:
assert (
_graph_pool_id is not None
), "graph_pool_id is not set under graph capture"
# Pause graph memory pool to use symmetric memory with cuda graph
if after_2_8_0:
torch._C._cuda_endAllocateToPool(_cur_device, _graph_pool_id)
else:
torch._C._cuda_endAllocateCurrentStreamToPool(
_cur_device, _graph_pool_id
)
_cuda_beginAllocateCurrentThreadToPool(self._device_index, self._pool_id)
global _active_symmetric_memory_context
_active_symmetric_memory_context = self
return self
def __exit__(self, exc_type, exc_val, exc_tb):
_cuda_endAllocateToPool(self._device_index, self._pool_id)
_cuda_releasePool(self._device_index, self._pool_id)
# Register all unregistered segments
# with the current comm
self._register_segments_for_comm()
if self.is_graph_capture:
if after_2_8_0:
torch._C._cuda_beginAllocateCurrentThreadToPool(
_cur_device, _graph_pool_id
)
else:
torch._C._cuda_beginAllocateToPool(_cur_device, _graph_pool_id)
global _active_symmetric_memory_context
_active_symmetric_memory_context = None
def _register_segments_for_comm(self):
"""
Register all tracked segments with the current comm.
Delegates to C++ layer which handles:
1. Tracking which segments have been registered with each comm
2. Only registering new segments (avoiding re-registration)
3. Thread-safe access to the segment registry
"""
# Call C++ API to register all segments with this comm
# C++ layer tracks per-comm registration state internally
result = _register_func(self._comm_ptr)
assert (
result == 0
), f"nccl_allocator_register_segments_with_comm failed with return code: {result}"
def use_symmetric_memory(group_coordinator: GroupCoordinator, disabled: bool = False):
disabled = (
not is_symmetric_memory_enabled()
or disabled
or group_coordinator.world_size == 1
)
return SymmetricMemoryContext(group_coordinator) if not disabled else nullcontext()
# --- Debug mode for symmetric memory validation ---
_symm_mem_logger = logging.getLogger(__name__)
_debug_seen_traces: set = set()
def is_tensor_in_symmetric_mempool(tensor: torch.Tensor) -> bool:
"""Check if a tensor's storage is allocated in the NCCL symmetric memory pool."""
if _mem_pool is None:
return False # Pool not initialized
data_ptr = tensor.untyped_storage().data_ptr()
for segment in _mem_pool.snapshot():
for block in segment["blocks"]:
if block["address"] == data_ptr:
return True
return False
def debug_check_symmetric_mempool(
group_coordinator: GroupCoordinator,
tensors: dict,
op_name: str,
) -> None:
"""
Debug check: verify that tensors passed to communication ops are allocated
in the NCCL symmetric memory pool.
Enabled by setting SGLANG_DEBUG_SYMM_MEM=1.
Only prints warnings on rank 0 and deduplicates identical stack traces.
Args:
tensors: dict mapping argument name to tensor
(e.g. {"input": t1, "output": t2})
op_name: name of the communication operation being checked
"""
if not envs.SGLANG_DEBUG_SYMM_MEM.get() or not is_symmetric_memory_enabled():
return
# Only print on rank 0
if not group_coordinator.is_first_rank:
return
bad_names = []
bad_details = []
for name, tensor in tensors.items():
if not is_tensor_in_symmetric_mempool(tensor):
bad_names.append(name)
bad_details.append(
f" - '{name}' (data_ptr=0x{tensor.storage().data_ptr():x}, "
f"shape={list(tensor.shape)}, dtype={tensor.dtype})"
)
if bad_names:
traces = traceback.format_stack()
# Skip autotune stack traces
if any("_flashinfer_autotune" in trace for trace in traces):
return
stack = "".join(traces[:-1])
trace_key = f"{op_name}:{','.join(bad_names)}:{stack}"
if trace_key not in _debug_seen_traces:
_debug_seen_traces.add(trace_key)
_symm_mem_logger.warning(
"[SymmMem Debug] %s: %d tensor(s) are NOT in the "
"NCCL symmetric memory pool:\n%s\n"
"Stack trace:\n%s",
op_name,
len(bad_names),
"\n".join(bad_details),
stack,
)
@@ -0,0 +1,567 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/pynccl.py
# This file is a pure Python wrapper for the NCCL library.
# The main purpose is to use NCCL combined with CUDA graph.
# Before writing this script, we tried the following approach:
# 1. We tried to use `cupy`, it calls NCCL correctly, but `cupy` itself
# often gets stuck when initializing the NCCL communicator.
# 2. We tried to use `torch.distributed`, but `torch.distributed.all_reduce`
# contains many other potential cuda APIs, that are not allowed during
# capturing the CUDA graph. For further details, please check
# https://discuss.pytorch.org/t/pytorch-cudagraph-with-nccl-operation-failed/ .
#
# Another rejected idea is to write a C/C++ binding for NCCL. It is usually
# doable, but we often encounter issues related with nccl versions, and need
# to switch between different versions of NCCL. See
# https://github.com/NVIDIA/nccl/issues/1234 for more details.
# A C/C++ binding is not flexible enough to handle this. It requires
# recompilation of the code every time we want to switch between different
# versions. This current implementation, with a **pure** Python wrapper, is
# more flexible. We can easily switch between different versions of NCCL by
# changing the environment variable `SGLANG_NCCL_SO_PATH`, or the `so_file`
# variable in the code.
import ctypes
import logging
import os
import platform
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import torch
from torch.distributed import ReduceOp
logger = logging.getLogger(__name__)
def find_nccl_library() -> str:
"""
We either use the library file specified by the `SGLANG_NCCL_SO_PATH`
environment variable, or we find the library file brought by PyTorch.
After importing `torch`, `libnccl.so.2`, `librccl.so.1` or `libmccl.so.2`
can be found by `ctypes` automatically.
"""
# so_file can be set to None in sglang
so_file = os.environ.get("SGLANG_NCCL_SO_PATH", None)
# manually load the nccl library
if so_file:
logger.info(
"Found nccl from environment variable SGLANG_NCCL_SO_PATH=%s", so_file
)
else:
if torch.version.cuda is not None:
so_file = "libnccl.so.2"
elif torch.version.hip is not None:
so_file = "librccl.so.1"
elif hasattr(torch.version, "musa") and torch.version.musa is not None:
so_file = "libmccl.so.2"
else:
raise ValueError("NCCL only supports CUDA, ROCm and MUSA backends.")
logger.debug("Found nccl from library %s", so_file)
return so_file
# === export types and functions from nccl to Python ===
# for the original nccl definition, please check
# https://github.com/NVIDIA/nccl/blob/master/src/nccl.h.in
ncclResult_t = ctypes.c_int
ncclComm_t = ctypes.c_void_p
ncclWindow_t = ctypes.c_void_p
class ncclUniqueId(ctypes.Structure):
_fields_ = [("internal", ctypes.c_byte * 128)]
cudaStream_t = ctypes.c_void_p
buffer_type = ctypes.c_void_p
ncclDataType_t = ctypes.c_int
class ncclDataTypeEnum:
ncclInt8 = 0
ncclChar = 0
ncclUint8 = 1
ncclInt32 = 2
ncclInt = 2
ncclUint32 = 3
ncclInt64 = 4
ncclUint64 = 5
ncclFloat16 = 6
ncclHalf = 6
ncclFloat32 = 7
ncclFloat = 7
ncclFloat64 = 8
ncclDouble = 8
ncclBfloat16 = 9
ncclNumTypes = 10
@classmethod
def from_torch(cls, dtype: torch.dtype) -> int:
if dtype == torch.int8:
return cls.ncclInt8
if dtype == torch.uint8:
return cls.ncclUint8
if dtype == torch.int32:
return cls.ncclInt32
if dtype == torch.int64:
return cls.ncclInt64
if dtype == torch.float16:
return cls.ncclFloat16
if dtype == torch.float32:
return cls.ncclFloat32
if dtype == torch.float64:
return cls.ncclFloat64
if dtype == torch.bfloat16:
return cls.ncclBfloat16
raise ValueError(f"Unsupported dtype: {dtype}")
ncclRedOp_t = ctypes.c_int
class ncclRedOpTypeEnum:
ncclSum = 0
ncclProd = 1
ncclMax = 2
ncclMin = 3
ncclAvg = 4
ncclNumOps = 5
@classmethod
def from_torch(cls, op: ReduceOp) -> int:
if op == ReduceOp.SUM:
return cls.ncclSum
if op == ReduceOp.PRODUCT:
return cls.ncclProd
if op == ReduceOp.MAX:
return cls.ncclMax
if op == ReduceOp.MIN:
return cls.ncclMin
if op == ReduceOp.AVG:
return cls.ncclAvg
raise ValueError(f"Unsupported op: {op}")
@dataclass
class Function:
name: str
restype: Any
argtypes: List[Any]
class NCCLLibrary:
exported_functions = [
# const char* ncclGetErrorString(ncclResult_t result)
Function("ncclGetErrorString", ctypes.c_char_p, [ncclResult_t]),
# ncclResult_t ncclGetVersion(int *version);
Function("ncclGetVersion", ncclResult_t, [ctypes.POINTER(ctypes.c_int)]),
# ncclResult_t ncclGetUniqueId(ncclUniqueId* uniqueId);
Function("ncclGetUniqueId", ncclResult_t, [ctypes.POINTER(ncclUniqueId)]),
# ncclResult_t ncclCommInitRank(
# ncclComm_t* comm, int nranks, ncclUniqueId commId, int rank);
# note that ncclComm_t is a pointer type, so the first argument
# is a pointer to a pointer
Function(
"ncclCommInitRank",
ncclResult_t,
[ctypes.POINTER(ncclComm_t), ctypes.c_int, ncclUniqueId, ctypes.c_int],
),
# ncclResult_t ncclAllReduce(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclRedOp_t op, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclAllReduce",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclRedOp_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclAllGather(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclAllGather",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclReduce(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclRedOp_t op, int root,
# ncclComm_t comm, cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclReduce",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclRedOp_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclReduceScatter(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, ncclRedOp_t op, ncclComm_t comm,
# cudaStream_t stream);
# note that cudaStream_t is a pointer type, so the last argument
# is a pointer
Function(
"ncclReduceScatter",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ncclRedOp_t,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclSend(
# const void* sendbuff, size_t count, ncclDataType_t datatype,
# int dest, ncclComm_t comm, cudaStream_t stream);
Function(
"ncclSend",
ncclResult_t,
[
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclRecv(
# void* recvbuff, size_t count, ncclDataType_t datatype,
# int src, ncclComm_t comm, cudaStream_t stream);
Function(
"ncclRecv",
ncclResult_t,
[
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# ncclResult_t ncclBroadcast(
# const void* sendbuff, void* recvbuff, size_t count,
# ncclDataType_t datatype, int root, ncclComm_t comm,
# cudaStream_t stream);
Function(
"ncclBroadcast",
ncclResult_t,
[
buffer_type,
buffer_type,
ctypes.c_size_t,
ncclDataType_t,
ctypes.c_int,
ncclComm_t,
cudaStream_t,
],
),
# be cautious! this is a collective call, it will block until all
# processes in the communicator have called this function.
# because Python object destruction can happen in random order,
# it is better not to call it at all.
# ncclResult_t ncclCommDestroy(ncclComm_t comm);
Function("ncclCommDestroy", ncclResult_t, [ncclComm_t]),
# ncclResult_t ncclGroupStart();
Function("ncclGroupStart", ncclResult_t, []),
# ncclResult_t ncclGroupEnd();
Function("ncclGroupEnd", ncclResult_t, []),
]
exported_functions_symm_mem = [
# ncclResult_t ncclCommWindowRegister(ncclComm_t comm, void* buff, size_t size, ncclWindow_t* win, int winFlags);
Function(
"ncclCommWindowRegister",
ncclResult_t,
[
ncclComm_t,
buffer_type,
ctypes.c_size_t,
ctypes.POINTER(ncclWindow_t),
ctypes.c_int,
],
),
# ncclResult_t ncclCommWindowDeregister(ncclComm_t comm, ncclWindow_t win);
Function("ncclCommWindowDeregister", ncclResult_t, [ncclComm_t, ncclWindow_t]),
]
# class attribute to store the mapping from the path to the library
# to avoid loading the same library multiple times
path_to_library_cache: Dict[str, Any] = {}
# class attribute to store the mapping from library path
# to the corresponding dictionary
path_to_dict_mapping: Dict[str, Dict[str, Any]] = {}
def __init__(self, so_file: Optional[str] = None):
so_file = so_file or find_nccl_library()
try:
if so_file not in NCCLLibrary.path_to_dict_mapping:
lib = ctypes.CDLL(so_file)
NCCLLibrary.path_to_library_cache[so_file] = lib
self.lib = NCCLLibrary.path_to_library_cache[so_file]
except Exception as e:
logger.error(
"Failed to load NCCL library from %s . "
"It is expected if you are not running on NVIDIA/AMD/MTHREADS GPUs. "
"Otherwise, the nccl library might not exist, be corrupted "
"or it does not support the current platform %s. "
"If you already have the library, please set the "
"environment variable SGLANG_NCCL_SO_PATH"
" to point to the correct nccl library path.",
so_file,
platform.platform(),
)
raise e
if so_file not in NCCLLibrary.path_to_dict_mapping:
_funcs: Dict[str, Any] = {}
exported_functions = NCCLLibrary.exported_functions
if hasattr(self.lib, "ncclCommWindowRegister"):
exported_functions.extend(NCCLLibrary.exported_functions_symm_mem)
for func in exported_functions:
f = getattr(self.lib, func.name)
f.restype = func.restype
f.argtypes = func.argtypes
_funcs[func.name] = f
NCCLLibrary.path_to_dict_mapping[so_file] = _funcs
self._funcs = NCCLLibrary.path_to_dict_mapping[so_file]
def ncclGetErrorString(self, result: ncclResult_t) -> str:
return self._funcs["ncclGetErrorString"](result).decode("utf-8")
def NCCL_CHECK(self, result: ncclResult_t) -> None:
if result != 0:
error_str = self.ncclGetErrorString(result)
raise RuntimeError(f"NCCL error: {error_str}")
def ncclGetRawVersion(self) -> int:
version = ctypes.c_int()
self.NCCL_CHECK(self._funcs["ncclGetVersion"](ctypes.byref(version)))
# something like 21903
return version.value
def ncclGetVersion(self) -> str:
version_str = str(self.ncclGetRawVersion())
# something like 21903 --> "2.19.3"
major = version_str[0].lstrip("0")
minor = version_str[1:3].lstrip("0")
patch = version_str[3:].lstrip("0")
return f"{major}.{minor}.{patch}"
def ncclGetUniqueId(self) -> ncclUniqueId:
unique_id = ncclUniqueId()
self.NCCL_CHECK(self._funcs["ncclGetUniqueId"](ctypes.byref(unique_id)))
return unique_id
def ncclCommInitRank(
self, world_size: int, unique_id: ncclUniqueId, rank: int
) -> ncclComm_t:
comm = ncclComm_t()
self.NCCL_CHECK(
self._funcs["ncclCommInitRank"](
ctypes.byref(comm), world_size, unique_id, rank
)
)
return comm
def ncclAllReduce(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
op: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# and `op` should be `ncclRedOp_t`
# both are aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclAllReduce"](
sendbuff, recvbuff, count, datatype, op, comm, stream
)
)
def ncclReduce(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
op: int,
root: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# and `op` should be `ncclRedOp_t`
# both are aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclReduce"](
sendbuff, recvbuff, count, datatype, op, root, comm, stream
)
)
def ncclReduceScatter(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
op: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# and `op` should be `ncclRedOp_t`
# both are aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclReduceScatter"](
sendbuff, recvbuff, count, datatype, op, comm, stream
)
)
def ncclAllGather(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
# `datatype` actually should be `ncclDataType_t`
# which is an aliases of `ctypes.c_int`
# when we pass int to a function, it will be converted to `ctypes.c_int`
# by ctypes automatically
self.NCCL_CHECK(
self._funcs["ncclAllGather"](
sendbuff, recvbuff, count, datatype, comm, stream
)
)
def ncclSend(
self,
sendbuff: buffer_type,
count: int,
datatype: int,
dest: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclSend"](sendbuff, count, datatype, dest, comm, stream)
)
def ncclRecv(
self,
recvbuff: buffer_type,
count: int,
datatype: int,
src: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclRecv"](recvbuff, count, datatype, src, comm, stream)
)
def ncclBroadcast(
self,
sendbuff: buffer_type,
recvbuff: buffer_type,
count: int,
datatype: int,
root: int,
comm: ncclComm_t,
stream: cudaStream_t,
) -> None:
self.NCCL_CHECK(
self._funcs["ncclBroadcast"](
sendbuff, recvbuff, count, datatype, root, comm, stream
)
)
def ncclCommDestroy(self, comm: ncclComm_t) -> None:
self.NCCL_CHECK(self._funcs["ncclCommDestroy"](comm))
def ncclCommWindowRegister(
self, comm: ncclComm_t, buff: buffer_type, size: int, win_flags: int
) -> ncclWindow_t:
window = ncclWindow_t()
self.NCCL_CHECK(
self._funcs["ncclCommWindowRegister"](
comm, buff, size, ctypes.byref(window), win_flags
)
)
return window
def ncclCommWindowDeregister(self, comm: ncclComm_t, window: ncclWindow_t) -> None:
self.NCCL_CHECK(self._funcs["ncclCommWindowDeregister"](comm, window))
def ncclGroupStart(self) -> None:
self.NCCL_CHECK(self._funcs["ncclGroupStart"]())
def ncclGroupEnd(self) -> None:
self.NCCL_CHECK(self._funcs["ncclGroupEnd"]())
__all__ = [
"NCCLLibrary",
"ncclDataTypeEnum",
"ncclRedOpTypeEnum",
"ncclUniqueId",
"ncclComm_t",
"cudaStream_t",
"buffer_type",
]
@@ -0,0 +1,267 @@
# SPDX-License-Identifier: Apache-2.0
import logging
import os
from enum import Enum
from functools import cache
from typing import Union
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
import sglang.srt.distributed.device_communicators.custom_all_reduce_ops as ops
from sglang.srt.distributed.device_communicators.custom_all_reduce_utils import (
is_full_nvlink,
is_weak_contiguous,
)
from sglang.srt.distributed.parallel_state import in_the_same_node_as
from sglang.srt.utils import is_cuda, is_hip
logger = logging.getLogger(__name__)
_is_cuda = is_cuda()
_is_hip = is_hip()
@cache
def qr_rocm_arch_available():
if not _is_hip:
return False
try:
props = torch.cuda.get_device_properties(0)
gcn_arch = getattr(props, "gcnArchName", "")
supported_archs = ["gfx94", "gfx95"]
return any(gfx in gcn_arch for gfx in supported_archs)
except Exception as e:
logger.warning("Failed to determine ROCm for quick allreduce: %s", e)
return False
class QuickReduceRegime(Enum):
FP = 0
INT8 = 1
INT6 = 2
INT4 = 3
NONE = 4
MB = 1024 * 1024
class QuickAllReduce:
_SUPPORTED_WORLD_SIZES = [2, 4, 8]
_SUPPORTED_DTYPES = [torch.float16, torch.bfloat16]
# The following data is based on kernel tests.
# In this order [FP, INT8, INT6, INT4].
_QR_MIN_SIZE = {
(torch.float16, 2): [1 * MB, 2 * MB, 2 * MB, 1 * MB],
(torch.float16, 4): [1 * MB, 16 * MB, 4 * MB, 2 * MB],
(torch.float16, 8): [16 * MB, 4 * MB, 4 * MB, 2 * MB],
(torch.bfloat16, 2): [2 * MB, 8 * MB, 8 * MB, 8 * MB],
(torch.bfloat16, 4): [8 * MB, 64 * MB, 64 * MB, 16 * MB],
(torch.bfloat16, 8): [16 * MB, 2048 * MB, 2048 * MB, 2048 * MB],
}
def __init__(
self, group: ProcessGroup, device: Union[int, str, torch.device]
) -> None:
"""
Custom allreduce provides non-destructive acceleration and is
available for CUDA and ROCm MI300 series.
Custom quick allreduce leverages quantization for further
acceleration on ROCm. It currently supports Q8, Q6, and Q4
quantization formats and FP(float16, bfloat16).
Quick allreduce is designed as a complement to custom allreduce.
Its initialization requires even stricter conditions.
Only the ROCm MI300 series is supported for quick allreduce at
this time.
Args:
group: the process group to work on. If None, it will use the
default process group.
device: the device to bind the CustomAllreduce to. If None,
it will be bind to f"cuda:{local_rank}".
It is the caller's responsibility to make sure each communicator
is bind to a unique device, and all communicators in this group
are in the same node.
"""
self.disabled = True
if not qr_rocm_arch_available():
logger.debug(
"Custom quick allreduce is only supported on ROCm MI300 series."
)
return
if not ops.IS_QUICK_AR_AVAILABLE:
# disable because of missing quick reduce library
# e.g. in a cuda environment
logger.info(
"Custom quick allreduce is disabled because "
"of missing custom quick allreduce library"
)
return
self.group = group
assert (
dist.get_backend(group) != dist.Backend.NCCL
), "Custom quick allreduce should be attached to a non-NCCL group."
if not all(in_the_same_node_as(group, source_rank=0)):
# No need to initialize custom quick allreduce for
# multi-node case.
logger.warning(
"Custom quick allreduce is disabled because this "
"process group spans across nodes."
)
return
rank = dist.get_rank(group=self.group)
world_size = dist.get_world_size(group=self.group)
self.rank = rank
self.world_size = world_size
if world_size == 1:
# No need to initialize QuickReduce for single GPU case.
return
if world_size not in QuickAllReduce._SUPPORTED_WORLD_SIZES:
logger.warning(
"Custom quick allreduce is disabled due to an "
"unsupported world size: %d. Supported world sizes: %s.",
world_size,
str(QuickAllReduce._SUPPORTED_WORLD_SIZES),
)
return
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
assert isinstance(device, torch.device)
self.device = device
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", None)
if cuda_visible_devices:
device_ids = list(map(int, cuda_visible_devices.split(",")))
else:
device_ids = list(range(torch.cuda.device_count()))
physical_device_id = device_ids[device.index]
tensor = torch.tensor([physical_device_id], dtype=torch.int, device="cpu")
gather_list = [
torch.tensor([0], dtype=torch.int, device="cpu")
for _ in range(self.world_size)
]
dist.all_gather(gather_list, tensor, group=self.group)
physical_device_ids = [t.item() for t in gather_list]
# test nvlink first, this will filter out most of the cases
# where custom quick allreduce is not supported
# this checks hardware and driver support for NVLink
if _is_cuda or _is_hip:
self.fully_connected = is_full_nvlink(physical_device_ids, self.world_size)
if self.world_size > 2 and not self.fully_connected:
logger.debug(
"Custom quick allreduce is disabled because it's not supported "
"on more than two PCIe-only GPUs. "
)
return
self.init_quick_all_reduce()
def init_quick_all_reduce(self):
# On RocM, bfloat16 kernels are slower than fp16
# due to slower match operations
# If environment variable is set to 1, we convert input to fp16
self.use_fp16_kernels = int(
os.environ.get("ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16", 1)
)
regime_str = os.environ.get("ROCM_QUICK_REDUCE_QUANTIZATION", "NONE")
if regime_str not in QuickReduceRegime.__members__:
logger.warning(
"Custom quick allreduce:",
f"Invalid quantization level: {regime_str}. "
"Supported levels: "
f"{list(QuickReduceRegime.__members__.keys())}",
)
return
if regime_str == "NONE":
logger.debug(
"Custom quick allreduce is disabled based "
"on env variable "
"ROCM_QUICK_REDUCE_QUANTIZATION='NONE'"
)
return
self.qr_quant_level = QuickReduceRegime[regime_str]
# TODO: If the dtype is not bfloat16 or then float16,
# quickallreduce should not be created.
# ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB is specified in MB
qr_max_size = int(os.environ.get("ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB", 0))
if qr_max_size > 0:
if qr_max_size < 1:
logger.info(
"You should not set a max_size smaller than 1MB, which can "
"lead to error or degradation to custom allreduce or rccl."
)
qr_max_size = qr_max_size * MB
# If qr_max_size is None, then 2GB is used by default.
self._ptr = ops.init_custom_qr(self.rank, self.world_size, qr_max_size)
self.qr_max_size = qr_max_size if qr_max_size > 0 else ops.qr_max_size()
self.create_shared_buffer()
self.disabled = False
def create_shared_buffer(self):
"""
Creates a shared buffer for quickreduce.
Has to be called after init_custom_qr
"""
handle = ops.qr_get_handle(self._ptr)
world_size = dist.get_world_size(group=self.group)
handles = [None] * world_size
dist.all_gather_object(handles, handle, group=self.group)
ops.qr_open_handles(self._ptr, handles)
def should_quick_allreduce(self, inp: torch.Tensor):
"""
Check if quickreduce is available
"""
if self.disabled:
return False
if inp.dtype not in self._SUPPORTED_DTYPES:
return False
inp_size = inp.numel() * inp.element_size()
# custom quick allreduce requires input byte size to be
# multiples of 16
if inp_size % 16 != 0:
return False
if not is_weak_contiguous(inp):
return False
dtype = inp.dtype
if self.use_fp16_kernels:
dtype = torch.float16
return (
inp_size <= self.qr_max_size
and inp_size
>= self._QR_MIN_SIZE[(dtype, self.world_size)][self.qr_quant_level.value]
)
def quick_all_reduce(self, inp: torch.Tensor, *, out: torch.Tensor = None):
"""Performs an out-of-place custom quick all reduce."""
# quick allreduce doesn't require a separate graph mode,
# as QR uses static IPC buffer.
if out is None:
out = torch.empty_like(inp)
ops.qr_all_reduce(
self._ptr, inp, out, self.qr_quant_level.value, self.use_fp16_kernels
)
return out
def close(self):
if not self.disabled and getattr(self, "_ptr", None):
if ops is not None:
ops.qr_destroy(self._ptr)
self._ptr = 0
self.disabled = True
def __del__(self):
self.close()
@@ -0,0 +1,519 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/shm_broadcast.py
import logging
import os
import pickle
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from multiprocessing import shared_memory
from typing import List, Optional
from unittest.mock import patch
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from zmq import IPV6 # type: ignore
from zmq import SUB, SUBSCRIBE, XPUB, XPUB_VERBOSE, Context # type: ignore
from sglang.srt.environ import envs
from sglang.srt.utils.network import NetworkAddress, get_local_ip_auto, get_open_port
from sglang.srt.utils.stale_shm_cleanup import make_shm_name
# SGLANG_RINGBUFFER_WARNING_INTERVAL can be set to 60
SGLANG_RINGBUFFER_WARNING_INTERVAL = int(
os.environ.get("SGLANG_RINGBUFFER_WARNING_INTERVAL", "60")
)
logger = logging.getLogger(__name__)
class ShmRingBuffer:
def __init__(
self,
n_reader: int,
max_chunk_bytes: int,
max_chunks: int,
name: Optional[str] = None,
):
"""
A shared memory ring buffer implementation for broadcast communication.
Essentially, it is a queue where only one will `enqueue` and multiple
will `dequeue`. The max size of each item, together with the max number
of items that can be stored in the buffer are known in advance.
In this case, we don't need to synchronize the access to
the buffer.
Buffer memory layout:
data metadata
| |
| (current_idx) | (current_idx)
v v
+-------------------------------+----------------------------------------+
| chunk0 | chunk1 | ... | chunk | metadata0 | metadata1 | ... | metadata |
+-------------------------------+----------------------------------------+
| max_chunks x max_chunk_bytes | max_chunks x (1 + n_reader) bytes |
metadata memory layout: each byte is a flag, the first byte is the written
flag, and the rest are reader flags. The flags are set to 0 by default.
+--------------+--------------+--------------+-----+--------------+
| written_flag | reader0_flag | reader1_flag | ... | readerN_flag |
+--------------+--------------+--------------+-----+--------------+
The state of metadata is as follows:
(case 1) 0???...???: the block is not written yet, cannot read, can write
(case 2) 1000...000: the block is just written, can read, cannot write
(case 3) 1???...???: the block is written and read by some readers, can read if not read, cannot write
(case 4) 1111...111: the block is written and read by all readers, cannot read, can write
State transition for readers:
When a reader finds a block that it can read (case 2 or 3), it can yield the block for caller to read.
Only after the caller finishes reading the block, the reader can mark the block as read.
Readers only mark the block as read (from 0 to 1), the writer marks the block as ready to read (from 1 to 0).
State transition for writer:
When the writer writes to a block (case 1 or 4), it first resets the written flag to 0, converting either case
to case 1. Then it can yield the block for caller to write. After the caller finishes writing the block, the writer
can reset the reader flags to 0, and mark the block as written (from 0 to 1).
NOTE: the order is important here, first reset the reader flags (so that we are still in case 1), then mark the block as written. The state transition is atomic. If we do it in the reverse order, it will go through case 3 and then back to case 2, and readers might read the intermediate case 3, which is not correct.
During creation, `name` is None and the buffer is created. We can pass the
created object to other processes by pickling it. The other processes will
get the name of the shared memory and open it, so that they can access the
same shared memory buffer.
""" # noqa
self.n_reader = n_reader
self.metadata_size = 1 + n_reader
self.max_chunk_bytes = max_chunk_bytes
self.max_chunks = max_chunks
self.total_bytes_of_buffer = (
self.max_chunk_bytes + self.metadata_size
) * self.max_chunks
self.data_offset = 0
self.metadata_offset = self.max_chunk_bytes * self.max_chunks
if name is None:
# we are creating a buffer
self.is_creator = True
self.shared_memory = shared_memory.SharedMemory(
create=True,
size=self.total_bytes_of_buffer,
name=make_shm_name("mq"),
)
# initialize the metadata section to 0
with memoryview(
self.shared_memory.buf[self.metadata_offset :]
) as metadata_buffer:
torch.frombuffer(metadata_buffer, dtype=torch.uint8).fill_(0)
else:
# we are opening an existing buffer
self.is_creator = False
# fix to https://stackoverflow.com/q/62748654/9191338
# Python incorrectly tracks shared memory even if it is not
# created by the process. The following patch is a workaround.
with patch(
"multiprocessing.resource_tracker.register",
lambda *args, **kwargs: None,
):
try:
self.shared_memory = shared_memory.SharedMemory(name=name)
assert self.shared_memory.size == self.total_bytes_of_buffer
except FileNotFoundError:
# we might deserialize the object in a different node
# in this case, this object is not used,
# and we should suppress the error
pass
def __reduce__(self):
return (
self.__class__,
(
self.n_reader,
self.max_chunk_bytes,
self.max_chunks,
self.shared_memory.name,
),
)
def __del__(self):
if hasattr(self, "shared_memory"):
self.shared_memory.close()
if self.is_creator:
self.shared_memory.unlink()
@contextmanager
def get_data(self, current_idx: int):
start = self.data_offset + current_idx * self.max_chunk_bytes
end = start + self.max_chunk_bytes
with memoryview(self.shared_memory.buf[start:end]) as buf:
yield buf
@contextmanager
def get_metadata(self, current_idx: int):
start = self.metadata_offset + current_idx * self.metadata_size
end = start + self.metadata_size
with memoryview(self.shared_memory.buf[start:end]) as buf:
yield buf
@dataclass
class Handle:
connect_ip: str
local_reader_ranks: List[int] = field(default_factory=list)
buffer: Optional[ShmRingBuffer] = None
local_subscribe_port: Optional[int] = None
remote_subscribe_port: Optional[int] = None
class MessageQueue:
def __init__(
self,
n_reader, # number of all readers
n_local_reader, # number of local readers through shared memory
local_reader_ranks: Optional[List[int]] = None,
max_chunk_bytes: int = 1024 * 1024 * 10,
max_chunks: int = 10,
connect_ip: Optional[str] = None,
):
if local_reader_ranks is None:
local_reader_ranks = list(range(n_local_reader))
else:
assert len(local_reader_ranks) == n_local_reader
self.n_local_reader = n_local_reader
n_remote_reader = n_reader - n_local_reader
self.n_remote_reader = n_remote_reader
if connect_ip is None:
connect_ip = (
get_local_ip_auto("0.0.0.0") if n_remote_reader > 0 else "127.0.0.1"
)
context = Context()
if n_local_reader > 0:
# for local readers, we will:
# 1. create a shared memory ring buffer to communicate small data
# 2. create a publish-subscribe socket to communicate large data
self.buffer = ShmRingBuffer(n_local_reader, max_chunk_bytes, max_chunks)
# XPUB is very similar to PUB,
# except that it can receive subscription messages
# to confirm the number of subscribers
self.local_socket = context.socket(XPUB)
# set the verbose option so that we can receive every subscription
# message. otherwise, we will only receive the first subscription
# see http://api.zeromq.org/3-3:zmq-setsockopt for more details
self.local_socket.setsockopt(XPUB_VERBOSE, True)
# Bind atomically to avoid get_open_port()'s check-then-bind race;
# search from SGLANG_PORT to keep the existing port range.
sglang_port = envs.SGLANG_PORT.get()
if sglang_port is not None:
local_subscribe_port = self.local_socket.bind_to_random_port(
"tcp://127.0.0.1", min_port=sglang_port, max_port=sglang_port + 8
)
else:
local_subscribe_port = self.local_socket.bind_to_random_port(
"tcp://127.0.0.1"
)
logger.debug("Bound to tcp://127.0.0.1:%d", local_subscribe_port)
self.current_idx = 0
else:
self.buffer = None # type: ignore
local_subscribe_port = None
self.local_socket = None
self.current_idx = -1
if n_remote_reader > 0:
# for remote readers, we will:
# create a publish-subscribe socket to communicate large data
self.remote_socket = context.socket(XPUB)
self.remote_socket.setsockopt(XPUB_VERBOSE, True)
remote_subscribe_port = get_open_port()
na = NetworkAddress(connect_ip, remote_subscribe_port)
if na.is_ipv6:
self.remote_socket.setsockopt(IPV6, 1)
address = na.to_tcp()
logger.debug(f"class MessageQueue: Binding remote socket to {address=}")
self.remote_socket.bind(address)
else:
remote_subscribe_port = None
self.remote_socket = None
self._is_writer = True
self._is_local_reader = False
self.local_reader_rank = -1
# rank does not matter for remote readers
self._is_remote_reader = False
self.handle = Handle(
connect_ip=connect_ip,
local_reader_ranks=local_reader_ranks,
buffer=self.buffer,
local_subscribe_port=local_subscribe_port,
remote_subscribe_port=remote_subscribe_port,
)
logger.debug("Message queue communication handle: %s", self.handle)
def export_handle(self) -> Handle:
return self.handle
@staticmethod
def create_from_handle(handle: Handle, rank) -> "MessageQueue":
self = MessageQueue.__new__(MessageQueue)
self.handle = handle
self._is_writer = False
context = Context()
if rank in handle.local_reader_ranks:
assert handle.buffer is not None
self.buffer = handle.buffer
self.current_idx = 0
self.local_reader_rank = handle.local_reader_ranks.index(rank)
self._is_local_reader = True
self._is_remote_reader = False
self.local_socket = context.socket(SUB)
self.local_socket.setsockopt_string(SUBSCRIBE, "")
socket_addr = f"tcp://127.0.0.1:{handle.local_subscribe_port}"
logger.debug("Connecting to %s", socket_addr)
self.local_socket.connect(socket_addr)
self.remote_socket = None
else:
self.buffer = None # type: ignore
self.current_idx = -1
self.local_reader_rank = -1
self._is_local_reader = False
self._is_remote_reader = True
self.local_socket = None
self.remote_socket = context.socket(SUB)
self.remote_socket.setsockopt_string(SUBSCRIBE, "")
na = NetworkAddress(handle.connect_ip, handle.remote_subscribe_port)
if na.is_ipv6:
self.remote_socket.setsockopt(IPV6, 1)
socket_addr = na.to_tcp()
logger.debug("Connecting to %s", socket_addr)
self.remote_socket.connect(socket_addr)
return self
def wait_until_ready(self):
"""This is a collective operation. All processes (including the
readers and the writer) should call this function.
"""
if self._is_writer:
# wait for all readers to connect
# local readers
for i in range(self.n_local_reader):
# wait for subscription messages from all local readers
self.local_socket.recv()
if self.n_local_reader > 0:
# send a message to all local readers
# to make sure the publish channel is working
self.local_socket.send(b"READY")
# remote readers
for i in range(self.n_remote_reader):
# wait for subscription messages from all remote readers
self.remote_socket.recv()
if self.n_remote_reader > 0:
# send a message to all remote readers
# to make sure the publish channel is working
self.remote_socket.send(b"READY")
elif self._is_local_reader:
# wait for the writer to send a message
recv = self.local_socket.recv()
assert recv == b"READY"
elif self._is_remote_reader:
# wait for the writer to send a message
recv = self.remote_socket.recv()
assert recv == b"READY"
@contextmanager
def acquire_write(self):
assert self._is_writer, "Only writers can acquire write"
start_time = time.monotonic()
n_warning = 1
while True:
with self.buffer.get_metadata(self.current_idx) as metadata_buffer:
read_count = sum(metadata_buffer[1:])
written_flag = metadata_buffer[0]
if written_flag and read_count != self.buffer.n_reader:
# this block is written and not read by all readers
# for writers, `self.current_idx` is the next block to write
# if this block is not ready to write,
# we need to wait until it is read by all readers
# Release the processor to other threads
os.sched_yield()
# if we wait for a long time, we should warn the user
if (
time.monotonic() - start_time
> SGLANG_RINGBUFFER_WARNING_INTERVAL * n_warning
):
logger.warning(
"No available block found in %s second. ",
SGLANG_RINGBUFFER_WARNING_INTERVAL,
)
n_warning += 1
continue
# found a block that is either
# (1) not written
# (2) read by all readers
# mark the block as not written
metadata_buffer[0] = 0
# let caller write to the buffer
with self.buffer.get_data(self.current_idx) as buf:
yield buf
# caller has written to the buffer
# NOTE: order is important here
# first set the read flags to 0
# then set the written flag to 1
# otherwise, the readers may think they already read the block
for i in range(1, self.buffer.n_reader + 1):
# set read flag to 0, meaning it is not read yet
metadata_buffer[i] = 0
# mark the block as written
metadata_buffer[0] = 1
self.current_idx = (self.current_idx + 1) % self.buffer.max_chunks
break
@contextmanager
def acquire_read(self):
assert self._is_local_reader, "Only readers can acquire read"
start_time = time.monotonic()
n_warning = 1
while True:
with self.buffer.get_metadata(self.current_idx) as metadata_buffer:
read_flag = metadata_buffer[self.local_reader_rank + 1]
written_flag = metadata_buffer[0]
if not written_flag or read_flag:
# this block is either
# (1) not written
# (2) already read by this reader
# for readers, `self.current_idx` is the next block to read
# if this block is not ready,
# we need to wait until it is written
# Release the processor to other threads
os.sched_yield()
# if we wait for a long time, we should warn the user
if (
time.monotonic() - start_time
> SGLANG_RINGBUFFER_WARNING_INTERVAL * n_warning
):
logger.warning(
"No available block found in %s second. ",
SGLANG_RINGBUFFER_WARNING_INTERVAL,
)
n_warning += 1
continue
# found a block that is not read by this reader
# let caller read from the buffer
with self.buffer.get_data(self.current_idx) as buf:
yield buf
# caller has read from the buffer
# set the read flag
metadata_buffer[self.local_reader_rank + 1] = 1
self.current_idx = (self.current_idx + 1) % self.buffer.max_chunks
break
def enqueue(self, obj):
assert self._is_writer, "Only writers can enqueue"
serialized_obj = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
if self.n_local_reader > 0:
if len(serialized_obj) >= self.buffer.max_chunk_bytes:
with self.acquire_write() as buf:
buf[0] = 1 # overflow
self.local_socket.send(serialized_obj)
else:
with self.acquire_write() as buf:
buf[0] = 0 # not overflow
buf[1 : len(serialized_obj) + 1] = serialized_obj
if self.n_remote_reader > 0:
self.remote_socket.send(serialized_obj)
def dequeue(self):
if self._is_local_reader:
with self.acquire_read() as buf:
overflow = buf[0] == 1
if not overflow:
# no need to know the size of serialized object
# pickle format contains the size information internally
# see https://docs.python.org/3/library/pickle.html
obj = pickle.loads(buf[1:])
if overflow:
recv = self.local_socket.recv()
obj = pickle.loads(recv)
elif self._is_remote_reader:
recv = self.remote_socket.recv()
obj = pickle.loads(recv)
else:
raise RuntimeError("Only readers can dequeue")
return obj
def broadcast_object(self, obj=None):
if self._is_writer:
self.enqueue(obj)
return obj
else:
return self.dequeue()
@staticmethod
def create_from_process_group(
pg: ProcessGroup, max_chunk_bytes, max_chunks, writer_rank=0
) -> "MessageQueue":
group_rank = dist.get_rank(pg)
group_world_size = dist.get_world_size(pg)
global_ranks = dist.get_process_group_ranks(pg)
from sglang.srt.distributed.parallel_state import in_the_same_node_as
status = in_the_same_node_as(pg, source_rank=writer_rank)
same_node_ranks = [i for i, s in enumerate(status) if s]
n_reader = group_world_size - 1
n_local_reader = len(same_node_ranks) - 1
local_reader_ranks = [i for i in same_node_ranks if i != writer_rank]
buffer_io: MessageQueue
if group_rank == writer_rank:
buffer_io = MessageQueue(
n_reader=n_reader,
n_local_reader=n_local_reader,
local_reader_ranks=local_reader_ranks,
max_chunk_bytes=max_chunk_bytes,
max_chunks=max_chunks,
)
handle = buffer_io.export_handle()
dist.broadcast_object_list(
[handle], src=global_ranks[writer_rank], group=pg
)
else:
recv = [None]
dist.broadcast_object_list(recv, src=global_ranks[writer_rank], group=pg)
handle = recv[0] # type: ignore
buffer_io = MessageQueue.create_from_handle(handle, group_rank)
buffer_io.wait_until_ready()
return buffer_io
@@ -0,0 +1,171 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/bf214ca22625e311a2c4c0dfbf7af19128f4919c/vllm/distributed/device_communicators/symm_mem.py
import logging
from typing import Optional, Union
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.srt.distributed.device_communicators.all_reduce_utils import (
TORCH_SYMM_MEM_ALL_REDUCE_MAX_SIZES,
)
from sglang.srt.utils import is_cuda, is_hip
try:
import torch.distributed._symmetric_memory as torch_symm_mem
_is_cuda = is_cuda()
_is_hip = is_hip()
torch_symm_mem_available = False
if _is_cuda:
torch_symm_mem_available = True
except ImportError:
torch_symm_mem_available = False
logger = logging.getLogger(__name__)
class TorchSymmMemCommunicator:
"""
Thin wrapper around torch-symmetric-memory collectives.
This communicator:
- Validates device capability and world size.
- Allocates a shared symmetric buffer.
- Chooses between 'multimem' and 'two-shot' all-reduce kernels.
- Exposes a fast-path all_reduce() compatible with bfloat16 inputs.
If any prerequisite is not met, the instance remains disabled and will
decline to perform symmetric-memory all-reduce.
"""
# Mapping: compute capability major -> supported world sizes for multimem
# If the current (cc_major, world_size) is not listed, we fall back
# to the two-shot path.
_WORLD_SIZES_MULTIMEM = {
9: [4, 6, 8],
10: [6, 8],
}
def __init__(self, group: ProcessGroup, device: Union[int, str, torch.device]):
"""
Args:
group: Torch process group used for rendezvous and naming.
device: Target CUDA device (index, 'cuda:X', or torch.device).
"""
self.disabled = True
self.buffer = None
self.max_size = 0
if not torch_symm_mem_available:
return
if isinstance(device, int):
device = torch.device(f"cuda:{device}")
elif isinstance(device, str):
device = torch.device(device)
torch.cuda.set_device(device)
self.dtype = torch.bfloat16
self.device = device
self.group = group
self.world_size = dist.get_world_size(self.group)
self.device_capability = torch.cuda.get_device_capability(device)[0]
supported_max_sizes = TORCH_SYMM_MEM_ALL_REDUCE_MAX_SIZES.get(
self.device_capability
)
if supported_max_sizes is None:
logger.warning(
"TorchSymmMemCommunicator: Device capability %s not supported, "
"communicator is not available.",
self.device_capability,
)
return
if self.world_size not in supported_max_sizes:
logger.warning(
"TorchSymmMemCommunicator: World size %d not supported, "
"communicator is not available.",
self.world_size,
)
return
self.max_size = supported_max_sizes[self.world_size]
self.buffer = torch_symm_mem.empty(
self.max_size // self.dtype.itemsize,
device=self.device,
dtype=self.dtype,
)
handle = torch_symm_mem.rendezvous(self.buffer, self.group.group_name)
if handle.multicast_ptr == 0:
logger.warning(
"TorchSymmMemCommunicator: torch symmetric memory "
"multicast operations are not supported."
)
self.buffer = None
self.disabled = True
return
self.disabled = False
def should_torch_symm_mem_allreduce(self, inp: torch.Tensor):
"""
Fast-path eligibility check for a given tensor.
Conditions:
- Communicator must be enabled.
- dtype must be bfloat16 (matches kernel + buffer dtype).
- Total byte size must be 4-byte aligned (hardware requirement).
- Payload must be smaller than the symmetric-memory max size.
Returns:
True if the symmetric-memory path can handle this tensor.
"""
if self.disabled:
return False
if inp.device != self.device:
return False
if inp.dtype != self.dtype:
return False
inp_size = inp.numel() * inp.element_size()
# enforce 4-byte alignment
if inp_size % 4 != 0:
return False
return inp_size < self.max_size
def all_reduce(
self, inp: torch.Tensor, *, out: Optional[torch.Tensor] = None
) -> Optional[torch.Tensor]:
"""
Perform an in-place sum all-reduce via torch symmetric memory.
Args:
inp: Input tensor on the target CUDA device (bfloat16).
out: Optional output tensor; if omitted, a new tensor is allocated.
Returns:
The reduced tensor (same shape as inp), or None if disabled.
Implementation details:
- Stages 'inp' into the symmetric buffer.
- Selects 'multimem' or 'two_shot' kernel based on topology.
- Writes the result into 'out' and returns it.
"""
if not self.should_torch_symm_mem_allreduce(inp):
return None
if out is None:
out = torch.empty_like(inp)
self.buffer[: inp.numel()].copy_(inp.view(-1))
if self.world_size in self._WORLD_SIZES_MULTIMEM.get(
self.device_capability, ()
):
torch.ops.symm_mem.multimem_all_reduce_(
self.buffer[: inp.numel()], "sum", self.group.group_name
)
else:
torch.ops.symm_mem.two_shot_all_reduce_(
self.buffer[: inp.numel()], "sum", self.group.group_name
)
out.copy_(self.buffer[: inp.numel()].view(out.shape))
return out
@@ -0,0 +1,548 @@
# SPDX-License-Identifier: Apache-2.0
"""Symmetric-memory ``multimem.st`` all-gather along the hidden (last) dim.
Each rank stores its ``[T, H/TP]`` shard into a multicast buffer in one NVLink
pass instead of an NCCL ring; ``create_state`` rendezvous once so launches are
CUDA-graph capturable.
"""
import logging
from dataclasses import dataclass
from typing import Any
import torch
import torch.distributed as dist
import torch.distributed._symmetric_memory as symm_mem
import triton
import triton.language as tl
logger = logging.getLogger(__name__)
# Each thread moves _NUMEL_PER_THREAD bf16 via one 128-bit multimem op; the
# grid-strided block count is tunable in [_MIN_BLOCKS, _MAX_BLOCKS].
_BLOCK_THREADS = 1024
_NUMEL_PER_THREAD = 8
_MIN_BLOCKS = 4
_MAX_BLOCKS = 32
# ------------------------------------------------------------------------------
# Low-level PTX helpers
# ------------------------------------------------------------------------------
@triton.jit
def _multimem_st_128(multicast_ptrs, x, y, z, w, mask):
return tl.inline_asm_elementwise(
"""
{
.reg .pred %p0;
setp.eq.s32 %p0, $6, 1;
@!%p0 bra end;
multimem.st.relaxed.sys.global.v4.f32 [$1], {$2, $3, $4, $5};
end:
}
""",
"=r,l,r,r,r,r,r",
args=[multicast_ptrs, x, y, z, w, mask.to(tl.int32)],
dtype=(tl.uint32),
is_pure=False,
pack=1,
)
@triton.jit
def _local_ld_128(in_ptr, mask):
return tl.inline_asm_elementwise(
"""
{
.reg .pred %p0;
setp.eq.s32 %p0, $5, 1;
@!%p0 bra end;
ld.relaxed.sys.global.v4.b32 {$0, $1, $2, $3}, [$4];
end:
}
""",
"=r,=r,=r,=r,l,r",
args=[in_ptr, mask.to(tl.int32)],
dtype=(tl.uint32, tl.uint32, tl.uint32, tl.uint32),
is_pure=True,
pack=1,
)
@triton.jit
def _get_tid():
return tl.inline_asm_elementwise(
"""
mov.u32 $0, %tid.x;
mov.u32 $1, %tid.y;
mov.u32 $2, %tid.z;
""",
"=r,=r,=r",
[],
dtype=(tl.uint32, tl.uint32, tl.uint32),
is_pure=True,
pack=1,
)
@triton.jit
def _get_ntid():
return tl.inline_asm_elementwise(
"""
mov.u32 $0, %ntid.x;
mov.u32 $1, %ntid.y;
mov.u32 $2, %ntid.z;
""",
"=r,=r,=r",
[],
dtype=(tl.uint32, tl.uint32, tl.uint32),
is_pure=True,
pack=1,
)
@triton.jit
def _get_flat_tid():
tid_x, tid_y, tid_z = _get_tid()
ntid_x, ntid_y, _ = _get_ntid()
return tid_z * ntid_y * ntid_x + tid_y * ntid_x + tid_x
@triton.jit
def _sync_threads():
tl.inline_asm_elementwise(
"bar.sync 0;", "=r", [], dtype=tl.int32, is_pure=False, pack=1
)
@triton.jit
def _send_signal(addrs):
tl.inline_asm_elementwise(
"""
{
.reg .u32 %tmp32_<1>;
.reg .pred %p<1>;
send_signal:
atom.global.relaxed.sys.cas.b32 %tmp32_0, [$1], 0, 1;
setp.eq.u32 %p0, %tmp32_0, 0;
@!%p0 bra send_signal;
}
""",
"=r, l",
[addrs],
dtype=tl.int32,
is_pure=False,
pack=1,
)
@triton.jit
def _send_signal_release(addrs):
tl.inline_asm_elementwise(
"""
{
.reg .u32 %tmp32_<1>;
.reg .pred %p<1>;
send_signal:
atom.global.release.sys.cas.b32 %tmp32_0, [$1], 0, 1;
setp.eq.u32 %p0, %tmp32_0, 0;
@!%p0 bra send_signal;
}
""",
"=r, l",
[addrs],
dtype=tl.int32,
is_pure=False,
pack=1,
)
@triton.jit
def _wait_signal(addrs):
tl.inline_asm_elementwise(
"""
{
.reg .u32 %tmp32_<1>;
.reg .pred %p<1>;
wait_signal:
atom.global.sys.relaxed.cas.b32 %tmp32_0, [$1], 1, 0;
setp.eq.u32 %p0, %tmp32_0, 1;
@!%p0 bra wait_signal;
}
""",
"=r, l",
[addrs],
dtype=tl.int32,
is_pure=False,
pack=1,
)
@triton.jit
def _wait_signal_acquire(addrs):
tl.inline_asm_elementwise(
"""
{
.reg .u32 %tmp32_<1>;
.reg .pred %p<1>;
wait_signal:
atom.global.sys.acquire.cas.b32 %tmp32_0, [$1], 1, 0;
setp.eq.u32 %p0, %tmp32_0, 1;
@!%p0 bra wait_signal;
}
""",
"=r, l",
[addrs],
dtype=tl.int32,
is_pure=False,
pack=1,
)
@triton.jit
def _blockwise_barrier(
signal_pad_ptrs,
rank: tl.constexpr,
world_size: tl.constexpr,
sem: tl.constexpr,
):
block_id = (
tl.program_id(2) * tl.num_programs(1) * tl.num_programs(0)
+ tl.program_id(1) * tl.num_programs(0)
+ tl.program_id(0)
)
flat_tid = _get_flat_tid()
remote_ranks = tl.arange(0, world_size)
signal_pad_ptrs = signal_pad_ptrs.to(tl.pointer_type(tl.uint64))
remote_signal_pad_addrs = tl.load(signal_pad_ptrs + remote_ranks).to(
tl.pointer_type(tl.uint32)
)
send_addrs = remote_signal_pad_addrs + block_id * world_size + rank
local_signal_pad_addr = tl.load(signal_pad_ptrs + rank).to(
tl.pointer_type(tl.uint32)
)
wait_addrs = local_signal_pad_addr + block_id * world_size + remote_ranks
if flat_tid < world_size:
if sem == "relaxed":
_send_signal(send_addrs)
_wait_signal(wait_addrs)
else:
_send_signal_release(send_addrs)
_wait_signal_acquire(wait_addrs)
@triton.jit
def _all_gather_kernel_inner(
input_ptr,
multicast_ptr,
signal_pad_ptr,
total_tokens,
hidden_offset,
LOCAL_HIDDEN: tl.constexpr,
TOTAL_HIDDEN: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
NUMEL_PER_THREAD: tl.constexpr,
RANK: tl.constexpr,
WORLD_SIZE: tl.constexpr,
SKIP_ENTRY_SYNC: tl.constexpr,
) -> None:
if SKIP_ENTRY_SYNC == 0:
_blockwise_barrier(signal_pad_ptr, RANK, WORLD_SIZE, sem="relaxed")
_sync_threads()
chunks_per_row: tl.constexpr = LOCAL_HIDDEN // NUMEL_PER_THREAD
total_hidden_chunks: tl.constexpr = TOTAL_HIDDEN // NUMEL_PER_THREAD
hidden_offset_chunks = hidden_offset // NUMEL_PER_THREAD
total_chunks = total_tokens * chunks_per_row
pid = tl.program_id(axis=0)
tid = _get_flat_tid()
block_start = pid * BLOCK_SIZE
while block_start < total_chunks:
chunk = block_start + tid
mask = chunk < total_chunks
row = chunk // chunks_per_row
col_chunk = chunk % chunks_per_row
in_ptr = input_ptr.to(tl.pointer_type(tl.uint64)) + chunk * 2
out_chunk = row * total_hidden_chunks + hidden_offset_chunks + col_chunk
out_ptr = (
multicast_ptr.to(tl.int64).to(tl.pointer_type(tl.uint64)) + out_chunk * 2
)
x, y, z, w = _local_ld_128(in_ptr, mask)
_multimem_st_128(out_ptr, x, y, z, w, mask)
block_start += tl.num_programs(axis=0) * BLOCK_SIZE
_sync_threads()
_blockwise_barrier(signal_pad_ptr, RANK, WORLD_SIZE, sem="acq_rel")
# ------------------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------------------
@dataclass
class MultimemAllGatherState:
group: dist.ProcessGroup
rank_in_group: int
world_size: int
device: torch.device
max_token_num: int
hidden_dim: int
comm_buff: torch.Tensor
# Rendezvous handle; stable for the buffer's lifetime, resolved once.
symm_mem_hdl: Any
def create_state(
group: dist.ProcessGroup,
rank_in_group: int,
max_tokens: int,
hidden_size: int,
device: torch.device | None = None,
) -> MultimemAllGatherState:
"""Allocate and rendezvous the symmetric-memory buffer. Collective: call
once outside CUDA-graph capture with identical args on every rank."""
assert type(group) is dist.ProcessGroup, f"Expected ProcessGroup, got {type(group)}"
assert hidden_size % _NUMEL_PER_THREAD == 0, (
f"hidden_size={hidden_size} must be a multiple of {_NUMEL_PER_THREAD} "
f"bf16 for 16-byte multimem.st row alignment"
)
device = device or torch.device(f"cuda:{torch.cuda.current_device()}")
# Pad holds _MAX_BLOCKS * world_size uint32 slots; max() never shrinks it.
pad_bytes = _MAX_BLOCKS * group.size() * 4
symm_mem.set_signal_pad_size(max(symm_mem.get_signal_pad_size(), pad_bytes))
with torch.inference_mode(False), torch.no_grad():
comm_buff = symm_mem.empty(
(max_tokens, hidden_size), dtype=torch.bfloat16, device=device
)
hdl = symm_mem.rendezvous(comm_buff, group=group)
assert hdl.rank == rank_in_group, (
f"symm_mem handle rank {hdl.rank} != rank_in_group {rank_in_group}; the "
f"hidden-shard offset would be wrong"
)
return MultimemAllGatherState(
group=group,
rank_in_group=rank_in_group,
world_size=group.size(),
device=device,
max_token_num=max_tokens,
hidden_dim=hidden_size,
comm_buff=comm_buff,
symm_mem_hdl=hdl,
)
def _launch_config(local_numel: int):
assert local_numel % _NUMEL_PER_THREAD == 0
return _MIN_BLOCKS, _BLOCK_THREADS, _BLOCK_THREADS // 32, _NUMEL_PER_THREAD
def all_gather_inner(
state: MultimemAllGatherState,
hidden_states: torch.Tensor,
tp_hidden_dim: int,
skip_entry_sync: bool = False,
safe: bool = True,
) -> torch.Tensor:
"""Gather ``[T, H/TP]`` shards into ``[T, H]`` along the hidden dim.
``tp_hidden_dim`` is the gathered width ``H``. Returns a clone when ``safe``,
else a view into the symmetric buffer (valid until the next collective)."""
world_size = state.world_size
assert hidden_states.dtype == torch.bfloat16, "Only bfloat16 is supported"
assert hidden_states.is_contiguous(), "hidden_states must be contiguous"
assert hidden_states.data_ptr() % 16 == 0, (
f"hidden_states.data_ptr()={hex(hidden_states.data_ptr())} must be "
f"16-byte aligned for 128-bit multimem.st"
)
assert (
tp_hidden_dim % world_size == 0
), f"tp_hidden_dim={tp_hidden_dim} must be divisible by world_size={world_size}"
local_hidden = tp_hidden_dim // world_size
assert local_hidden % _NUMEL_PER_THREAD == 0, (
f"per-rank hidden shard ({local_hidden}) must be a multiple of "
f"{_NUMEL_PER_THREAD} bf16"
)
assert tp_hidden_dim <= state.hidden_dim, (
f"comm buffer too narrow: tp_hidden_dim={tp_hidden_dim} > "
f"state.hidden_dim={state.hidden_dim}"
)
total_tokens, in_hidden = hidden_states.shape
assert (
in_hidden == local_hidden
), f"input hidden ({in_hidden}) != this rank's shard ({local_hidden})"
assert (
total_tokens <= state.max_token_num
), f"total_tokens={total_tokens} exceeds max_token_num={state.max_token_num}"
hidden_offset = local_hidden * state.rank_in_group
symm_mem_hdl = state.symm_mem_hdl
num_blocks, block_size, num_warps, numel_per_thread = _launch_config(
total_tokens * local_hidden
)
grid = (num_blocks, 1, 1)
_all_gather_kernel_inner[grid](
input_ptr=hidden_states,
multicast_ptr=symm_mem_hdl.multicast_ptr,
signal_pad_ptr=symm_mem_hdl.signal_pad_ptrs_dev,
total_tokens=total_tokens,
hidden_offset=hidden_offset,
LOCAL_HIDDEN=local_hidden,
TOTAL_HIDDEN=state.hidden_dim,
BLOCK_SIZE=block_size,
NUMEL_PER_THREAD=numel_per_thread,
RANK=symm_mem_hdl.rank,
WORLD_SIZE=symm_mem_hdl.world_size,
SKIP_ENTRY_SYNC=1 if skip_entry_sync else 0,
num_warps=num_warps,
)
output = state.comm_buff[:total_tokens, :tp_hidden_dim]
return output.clone() if safe else output
# ------------------------------------------------------------------------------
# Guarded wrapper
# ------------------------------------------------------------------------------
def recommended_max_tokens(include_prefill: bool, floor: int = 0) -> int:
"""Largest batch (tokens) to keep on the fast path; bigger falls back to
NCCL. Covers the spec-decode batch plus, if ``include_prefill``, a prefill
chunk. Returns ``floor`` if server args are unavailable."""
try:
from sglang.srt.runtime_context import get_server_args
sa = get_server_args()
def g(name: str) -> int:
v = getattr(sa, name, 0)
return v if isinstance(v, int) and v > 0 else 0
tokens = g("max_running_requests") * max(
g("speculative_num_draft_tokens"), g("speculative_eagle_topk"), 1
)
if include_prefill:
tokens = max(tokens, g("chunked_prefill_size"), g("max_prefill_tokens"))
return max(tokens, floor)
except Exception:
return floor
class MultimemAllGatherer:
"""Guarded multimem all-gather (last dim) with NCCL fallback; the single
entry point for every caller. Owns one symmetric buffer built lazily on the
first eager call, and uses the kernel only when the input fits its
dtype/shape/alignment contract. Guards use TP-replicated quantities so all
ranks pick the same path. ``skip_entry_sync=True`` drops the entry barrier;
only safe when a cross-rank sync sits between consecutive calls."""
_UNINIT = object()
def __init__(
self,
max_tokens: int,
*,
enabled: bool = True,
skip_entry_sync: bool = False,
):
self._max_tokens = int(max_tokens)
self._skip_entry_sync = skip_entry_sync
# None => always NCCL; _UNINIT => build on first eager call.
self._state = self._UNINIT if enabled else None
if self._state is self._UNINIT:
# Lazy import avoids a module-load dependency on the distributed facade.
from sglang.srt.distributed import get_tp_group
from sglang.srt.distributed.parallel_state import in_the_same_node_as
from sglang.srt.runtime_context import get_server_args
tp_group = get_tp_group()
# Only probe node topology when the deployment can actually span
# nodes. Check world_size first so a TP=1 gatherer short-circuits
# before reading server args (which may be unpublished on offline
# paths). On a single node every TP rank is co-located, so skip the
# in_the_same_node_as() all-reduce, which can segfault under some
# EP/mooncake setups, and keep multimem enabled.
if (
tp_group.world_size > 1
and get_server_args().nnodes > 1
and not all(in_the_same_node_as(tp_group.cpu_group, source_rank=0))
):
logger.warning(
"multimem all-gather disabled because the TP group spans "
"across nodes."
)
self._state = None
def __call__(self, x: torch.Tensor) -> torch.Tensor:
state = self._state
if state is self._UNINIT:
state = self._build(x)
if state is not self._UNINIT:
self._state = state
if (
state is not None
and state is not self._UNINIT
and x.dtype == torch.bfloat16
and x.dim() == 2
and x.is_contiguous()
and 0 < x.shape[0] <= state.max_token_num
and x.data_ptr() % 16 == 0
and x.shape[-1] * state.world_size <= state.hidden_dim
):
return all_gather_inner(
state,
x,
tp_hidden_dim=x.shape[-1] * state.world_size,
skip_entry_sync=self._skip_entry_sync,
safe=False,
)
# Lazy import avoids a module-load dependency on the distributed facade.
from sglang.srt.distributed import tensor_model_parallel_all_gather
return tensor_model_parallel_all_gather(x, dim=-1)
def _build(self, x: torch.Tensor):
if x.dim() != 2 or x.dtype != torch.bfloat16:
return None
if torch.cuda.is_available() and torch.cuda.is_current_stream_capturing():
# Can't allocate under capture; retry later.
return self._UNINIT
if x.shape[-1] % _NUMEL_PER_THREAD != 0:
return None
try:
from sglang.srt.distributed import get_tp_group
tp_group = get_tp_group()
if tp_group.world_size <= 1:
return None
state = create_state(
group=tp_group.device_group,
rank_in_group=tp_group.rank_in_group,
max_tokens=self._max_tokens,
hidden_size=x.shape[-1] * tp_group.world_size,
)
if state.symm_mem_hdl.multicast_ptr == 0:
# No multicast for this world size / arch; multimem.st would
# write nowhere. Fall back to NCCL.
logger.warning(
"multimem all-gather disabled (no multicast for world_size=%d)",
tp_group.world_size,
)
return None
return state
except Exception as e:
logger.warning("multimem all-gather disabled (%s)", e)
return None
@@ -0,0 +1,654 @@
import logging
import os
import struct
import time
from typing import Any, List, Optional
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.srt.utils import log_info_on_rank0
logger = logging.getLogger(__name__)
_drv = None
_FD_HEADER_BYTES = 24
_FD_SEND_TIMEOUT_S = 120.0
def _get_cuda_driver():
"""Lazily import cuda.bindings.driver (cached after first call)."""
global _drv
if _drv is None:
from cuda.bindings import driver
_drv = driver
return _drv
def check_drv(result_tuple, label):
"""Check a cuda.bindings driver call result and return the value."""
if not isinstance(result_tuple, tuple):
result_tuple = (result_tuple,)
err = result_tuple[0]
drv = _get_cuda_driver()
if err != drv.CUresult.CUDA_SUCCESS:
raise RuntimeError(f"{label}: {err}")
return result_tuple[1] if len(result_tuple) > 1 else None
def is_vmm_pointer(ptr: int) -> bool:
"""Check if a device pointer is VMM-backed (cuMemCreate/cuMemMap).
cuMemRetainAllocationHandle succeeds only on pointers from cuMemCreate;
it fails on cudaMalloc pointers.
"""
drv = _get_cuda_driver()
err, handle = drv.cuMemRetainAllocationHandle(ptr)
if err == drv.CUresult.CUDA_SUCCESS:
drv.cuMemRelease(handle)
return True
return False
def make_rw_access_desc(device_id: int):
"""A read-write, device-local ``CUmemAccessDesc`` for ``device_id``."""
drv = _get_cuda_driver()
desc = drv.CUmemAccessDesc()
desc.location.type = drv.CUmemLocationType.CU_MEM_LOCATION_TYPE_DEVICE
desc.location.id = device_id
desc.flags = drv.CUmemAccess_flags.CU_MEM_ACCESS_FLAGS_PROT_READWRITE
return desc
def all_ranks_ok(group: ProcessGroup, ok: bool) -> bool:
"""True iff ``ok`` holds on every rank in ``group`` (BAND all-reduce)."""
flag = torch.tensor([1 if ok else 0], dtype=torch.int32)
dist.all_reduce(flag, op=dist.ReduceOp.BAND, group=group)
return flag.item() == 1
def release_mappings(mappings) -> None:
"""Unmap + address-free each ``(va, span_size, [(rel, size), ...])`` mapping.
Pops from ``mappings`` so a partially-released list is safe to retry.
"""
drv = _get_cuda_driver()
while mappings:
va, span_size, mapped_chunks = mappings.pop()
for rel, size in mapped_chunks:
check_drv(drv.cuMemUnmap(int(va) + int(rel), int(size)), "cuMemUnmap")
check_drv(drv.cuMemAddressFree(int(va), int(span_size)), "cuMemAddressFree")
def _send_fd(sock, fd: int, src_rank: int, base_idx: int) -> None:
import array
import socket
fds = array.array("i", [int(fd)])
header = struct.pack("<QQQ", int(src_rank), int(base_idx), 1)
sent = sock.sendmsg(
[header],
[(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds.tobytes())],
)
if sent != len(header):
raise RuntimeError(f"sendmsg sent {sent} bytes, expected {len(header)}")
def _recv_fd(sock):
import array
import socket
fd_item_size = array.array("i").itemsize
data, ancdata, _, _ = sock.recvmsg(
_FD_HEADER_BYTES, socket.CMSG_SPACE(fd_item_size)
)
if not data:
return None
if len(data) != _FD_HEADER_BYTES:
raise RuntimeError(
f"received truncated fd header: {len(data)} < {_FD_HEADER_BYTES}"
)
src_rank, base_idx, fd_count = struct.unpack("<QQQ", data)
fds = array.array("i")
for level, cmsg_type, cmsg_data in ancdata:
if level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fd_item_size)])
if fd_count != 1 or len(fds) != 1:
for fd in fds:
os.close(fd)
raise RuntimeError(
f"expected one fd, got header={fd_count}, ancillary={len(fds)}"
)
return int(src_rank), int(base_idx), int(fds[0])
def export_shareable_handles(retained_handles, group: ProcessGroup, rank: int):
"""Export retained VMM handles, preferring FABRIC and falling back to POSIX fds.
FABRIC is used only if every rank can export it; otherwise all ranks use POSIX
fds. Returns ``(fabric_handles, posix_fds, use_fabric)`` (one list populated);
raises if both fail on any rank. Caller owns the returned ``posix_fds``.
"""
drv = _get_cuda_driver()
FABRIC = drv.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_FABRIC
POSIX_FD = drv.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR
fabric_handles: List[bytes] = []
fabric_error: Optional[Exception] = None
try:
for alloc_h in retained_handles:
fabric_h = check_drv(
drv.cuMemExportToShareableHandle(alloc_h, FABRIC, 0),
"cuMemExportToShareableHandle(FABRIC)",
)
fabric_handles.append(bytes(fabric_h.data))
fabric_ok = True
except Exception as e:
fabric_error = e
fabric_ok = False
fabric_handles = []
logger.info(
"FABRIC handle export failed on rank %s; falling back to "
"POSIX fd transport: %s",
rank,
e,
)
if all_ranks_ok(group, fabric_ok):
return fabric_handles, [], True
posix_fds: List[int] = []
posix_error: Optional[Exception] = None
try:
for alloc_h in retained_handles:
fd = check_drv(
drv.cuMemExportToShareableHandle(alloc_h, POSIX_FD, 0),
"cuMemExportToShareableHandle(POSIX_FD)",
)
posix_fds.append(int(fd))
posix_ok = True
except Exception as e:
posix_error = e
posix_ok = False
for fd in posix_fds:
try:
os.close(fd)
except OSError:
pass
posix_fds = []
if not all_ranks_ok(group, posix_ok):
cause = posix_error or fabric_error
message = (
"VMM handle export failed: FABRIC export failed on at least one "
"rank and POSIX fd export failed on at least one rank"
)
if cause is not None:
message += f"; local rank {rank} error: {cause}"
raise RuntimeError(message) from posix_error
return [], posix_fds, False
def exchange_posix_fds(
group: ProcessGroup,
rank: int,
world_size: int,
local_fds: List[int],
peer_base_counts: List[int],
):
"""Exchange POSIX file descriptors across ranks via SCM_RIGHTS over a UNIX
socket. Returns ``{(src_rank, base_idx): fd}`` for every peer. The caller
owns the received fds and must close them.
"""
import socket
import tempfile
import threading
sock_kind = getattr(socket, "SOCK_SEQPACKET", socket.SOCK_STREAM)
sock_dir = tempfile.mkdtemp(prefix="sgl_ar_fd_")
sock_path = os.path.join(sock_dir, f"rank_{rank}.sock")
server = socket.socket(socket.AF_UNIX, sock_kind)
server.settimeout(_FD_SEND_TIMEOUT_S)
received_fds = {}
errors = []
def recv_loop():
try:
for _ in range(world_size - 1):
conn, _ = server.accept()
with conn:
conn.settimeout(_FD_SEND_TIMEOUT_S)
while True:
packet = _recv_fd(conn)
if packet is None:
break
src_rank, base_idx, fd = packet
key = (src_rank, base_idx)
if key in received_fds:
os.close(fd)
raise RuntimeError(f"duplicate fd for {key}")
received_fds[key] = fd
except BaseException as e:
errors.append(e)
try:
server.bind(sock_path)
server.listen(world_size)
paths = [None] * world_size
dist.all_gather_object(paths, sock_path, group=group)
thread = threading.Thread(target=recv_loop, daemon=True)
thread.start()
try:
for peer_rank, peer_path in enumerate(paths):
if peer_rank == rank:
continue
with socket.socket(socket.AF_UNIX, sock_kind) as sock:
sock.settimeout(_FD_SEND_TIMEOUT_S)
sock.connect(peer_path)
for base_idx, fd in enumerate(local_fds):
_send_fd(sock, fd, rank, base_idx)
finally:
thread.join(_FD_SEND_TIMEOUT_S)
if thread.is_alive():
raise RuntimeError("timed out waiting for POSIX fd exchange")
if errors:
raise RuntimeError("POSIX fd exchange receive failed") from errors[0]
expected = {
(src_rank, base_idx)
for src_rank, count in enumerate(peer_base_counts)
if src_rank != rank
for base_idx in range(count)
}
missing = expected.difference(received_fds)
extra = set(received_fds).difference(expected)
if missing or extra:
for fd in received_fds.values():
os.close(fd)
raise RuntimeError(
"POSIX fd exchange mismatch: "
f"missing={sorted(missing)[:8]}, extra={sorted(extra)[:8]}"
)
return received_fds
finally:
server.close()
try:
os.unlink(sock_path)
except FileNotFoundError:
pass
try:
os.rmdir(sock_dir)
except OSError:
pass
def import_peer_handle(fabric_handle, fd, *, use_fabric: bool, peer_rank: int):
"""Import a peer allocation handle (FABRIC or POSIX fd). Returns the handle.
For POSIX the fd is duped before import so the caller keeps ownership of the
original.
"""
drv = _get_cuda_driver()
if use_fabric:
FABRIC = drv.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_FABRIC
return check_drv(
drv.cuMemImportFromShareableHandle(fabric_handle, FABRIC),
f"cuMemImportFromShareableHandle(rank={peer_rank})",
)
POSIX_FD = drv.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR
dup_fd = os.dup(fd)
try:
return check_drv(
drv.cuMemImportFromShareableHandle(dup_fd, POSIX_FD),
f"cuMemImportFromShareableHandle(rank={peer_rank}, POSIX_FD)",
)
finally:
try:
os.close(dup_fd)
except OSError:
pass
def import_and_map_alloc(
fabric_handle,
fd,
alloc_size: int,
device_id: int,
*,
use_fabric: bool,
peer_rank: int,
) -> int:
"""Import a peer allocation, map it at a freshly reserved VA, return the VA."""
drv = _get_cuda_driver()
imp_h = import_peer_handle(
fabric_handle, fd, use_fabric=use_fabric, peer_rank=peer_rank
)
prop = check_drv(
drv.cuMemGetAllocationPropertiesFromHandle(imp_h),
"cuMemGetAllocationPropertiesFromHandle",
)
gran = check_drv(
drv.cuMemGetAllocationGranularity(
prop,
drv.CUmemAllocationGranularity_flags.CU_MEM_ALLOC_GRANULARITY_RECOMMENDED,
),
"cuMemGetAllocationGranularity",
)
va = check_drv(
drv.cuMemAddressReserve(alloc_size, int(gran), 0, 0), "cuMemAddressReserve"
)
check_drv(drv.cuMemMap(int(va), alloc_size, 0, imp_h, 0), "cuMemMap")
access = make_rw_access_desc(device_id)
check_drv(drv.cuMemSetAccess(int(va), alloc_size, [access], 1), "cuMemSetAccess")
check_drv(drv.cuMemRelease(imp_h), "cuMemRelease(peer)")
return int(va)
def map_chunk_into_span(
fabric_handle,
fd,
span_va: int,
rel: int,
alloc_size: int,
device_id: int,
*,
use_fabric: bool,
peer_rank: int,
) -> None:
"""Import + map a peer chunk into a caller-reserved span at ``span_va + rel``."""
drv = _get_cuda_driver()
imp_h = import_peer_handle(
fabric_handle, fd, use_fabric=use_fabric, peer_rank=peer_rank
)
check_drv(
drv.cuMemMap(int(span_va) + rel, int(alloc_size), 0, imp_h, 0),
"cuMemMap(span)",
)
access = make_rw_access_desc(device_id)
check_drv(
drv.cuMemSetAccess(int(span_va) + rel, int(alloc_size), [access], 1),
"cuMemSetAccess(span)",
)
check_drv(drv.cuMemRelease(imp_h), "cuMemRelease(span)")
class VmmGraphInputManager:
def __init__(
self,
obj: Any,
group: ProcessGroup,
rank: int,
world_size: int,
) -> None:
self.obj = obj
self.group = group
self.rank = rank
self.world_size = world_size
self._peer_mappings = []
def register_graph_inputs(self):
"""Register graph capture inputs via VMM handle exchange.
VMM-compatible path for expandable_segments. The C++ side deduplicates
graph capture pointers into unique base allocations via cuMemGetAddressRange.
Python exports handles for each unique base, imports + cuMemMaps peer
allocations, then registers the peer VAs. FABRIC handles are preferred;
POSIX file descriptors are used when FABRIC is unavailable.
"""
FABRIC_HANDLE_BYTES = 64
MAX_VMM_BASES = 4096
MAX_CHUNKS_PER_INPUT = 16
t0 = time.perf_counter()
bases_info, input_chunk_indices, input_offsets = (
self.obj.get_graph_capture_bases()
)
if not bases_info:
return
new_count = len(input_chunk_indices)
num_bases = len(bases_info)
device_id = torch.cuda.current_device()
if num_bases > MAX_VMM_BASES:
raise RuntimeError(
f"Too many VMM bases to share: {num_bases} > {MAX_VMM_BASES}"
)
drv = _get_cuda_driver()
local_posix_fds: List[int] = []
retained_handles = []
try:
for base_ptr, _ in bases_info:
alloc_h = check_drv(
drv.cuMemRetainAllocationHandle(base_ptr),
"cuMemRetainAllocationHandle",
)
retained_handles.append(alloc_h)
local_fabric_handles, local_posix_fds, use_fabric = (
export_shareable_handles(retained_handles, self.group, self.rank)
)
local_input_chunks = [
[int(idx) for idx in indices] for indices in input_chunk_indices
]
for chunks in local_input_chunks:
if len(chunks) > MAX_CHUNKS_PER_INPUT:
raise RuntimeError(
"Too many VMM chunks for graph input: "
f"{len(chunks)} > {MAX_CHUNKS_PER_INPUT}"
)
# All-gather base metadata and per-input VMM spans. A captured tensor
# can cross expandable-segment allocation boundaries, so peer mappings
# must preserve each input's contiguous virtual-address span. FABRIC
# handles are inline metadata; POSIX fds are exchanged separately via
# SCM_RIGHTS because fd integers are process-local.
header_struct = struct.Struct("<QQ")
base_struct = struct.Struct(
f"<QQ{FABRIC_HANDLE_BYTES}s" if use_fabric else "<QQ"
)
input_struct = struct.Struct(f"<QQ{MAX_CHUNKS_PER_INPUT}Q")
base_offset = header_struct.size
input_offset = base_offset + MAX_VMM_BASES * base_struct.size
payload_size = input_offset + new_count * input_struct.size
local_payload = bytearray(payload_size)
header_struct.pack_into(local_payload, 0, num_bases, new_count)
for i, (base_ptr, alloc_size) in enumerate(bases_info):
if use_fabric:
base_struct.pack_into(
local_payload,
base_offset + i * base_struct.size,
int(base_ptr),
int(alloc_size),
local_fabric_handles[i],
)
else:
base_struct.pack_into(
local_payload,
base_offset + i * base_struct.size,
int(base_ptr),
int(alloc_size),
)
for i, (chunks, offset) in enumerate(
zip(local_input_chunks, input_offsets)
):
padded_chunks = chunks + [0] * (MAX_CHUNKS_PER_INPUT - len(chunks))
input_struct.pack_into(
local_payload,
input_offset + i * input_struct.size,
int(offset),
len(chunks),
*padded_chunks,
)
in_buf = torch.frombuffer(local_payload, dtype=torch.uint8).clone()
gather_list = [torch.empty_like(in_buf) for _ in range(self.world_size)]
dist.all_gather(gather_list, in_buf, group=self.group)
all_base_payload = []
all_input_chunks = []
all_input_offsets = []
for rank, gathered in enumerate(gather_list):
payload = gathered.numpy().tobytes()
peer_num_bases, peer_new_count = header_struct.unpack_from(payload, 0)
if peer_new_count != new_count:
raise RuntimeError(
"Mismatched graph input count across ranks: "
f"rank {rank} has {peer_new_count}, expected {new_count}"
)
peer_bases = []
for i in range(peer_num_bases):
if use_fabric:
base_ptr, alloc_size, fabric_handle = base_struct.unpack_from(
payload, base_offset + i * base_struct.size
)
else:
base_ptr, alloc_size = base_struct.unpack_from(
payload, base_offset + i * base_struct.size
)
fabric_handle = None
peer_bases.append((base_ptr, fabric_handle, alloc_size))
peer_chunks = []
peer_offsets = []
for i in range(new_count):
unpacked = input_struct.unpack_from(
payload, input_offset + i * input_struct.size
)
offset, chunk_count, *chunks = unpacked
peer_offsets.append(offset)
peer_chunks.append(list(chunks[:chunk_count]))
all_base_payload.append(peer_bases)
all_input_chunks.append(peer_chunks)
all_input_offsets.append(peer_offsets)
posix_peer_fds = {}
if not use_fabric:
posix_peer_fds = exchange_posix_fds(
self.group,
self.rank,
self.world_size,
local_posix_fds,
[len(peer_bases) for peer_bases in all_base_payload],
)
# Import + map peer allocations. Individual base mappings are kept for
# single-chunk inputs; span mappings reserve a contiguous VA range and
# map each chunk at its original relative offset.
peer_base_va = {} # (rank, base_idx) -> local VA
peer_span_va = {} # (rank, chunk_indices...) -> (local VA, peer base)
new_mappings = []
try:
for peer_rank in range(self.world_size):
if peer_rank == self.rank:
for idx, (bp, _) in enumerate(bases_info):
peer_base_va[(peer_rank, idx)] = int(bp)
continue
peer_bases = all_base_payload[peer_rank]
for idx, (_, fb, alloc_size) in enumerate(peer_bases):
fd = None if use_fabric else posix_peer_fds[(peer_rank, idx)]
va = import_and_map_alloc(
fb,
fd,
alloc_size,
device_id,
use_fabric=use_fabric,
peer_rank=peer_rank,
)
peer_base_va[(peer_rank, idx)] = va
new_mappings.append((va, alloc_size, [(0, alloc_size)]))
# Build per-input peer VA lists and register.
peer_ptrs = []
for j in range(new_count):
ptrs_j = []
for rank in range(self.world_size):
chunks = all_input_chunks[rank][j]
off = all_input_offsets[rank][j]
if len(chunks) == 1:
ptrs_j.append(peer_base_va[(rank, chunks[0])] + off)
continue
span_key = (rank, *chunks)
if span_key not in peer_span_va:
peer_bases = all_base_payload[rank]
first_base = peer_bases[chunks[0]][0]
last_base, _, last_size = peer_bases[chunks[-1]]
span_size = (
int(last_base) + int(last_size) - int(first_base)
)
if rank == self.rank:
span_va = int(first_base)
else:
span_va = check_drv(
drv.cuMemAddressReserve(span_size, 0, 0, 0),
"cuMemAddressReserve(span)",
)
mapped_chunks = []
for chunk_idx in chunks:
base_ptr, fb, alloc_size = peer_bases[chunk_idx]
rel = int(base_ptr) - int(first_base)
fd = (
None
if use_fabric
else posix_peer_fds[(rank, chunk_idx)]
)
map_chunk_into_span(
fb,
fd,
span_va,
rel,
int(alloc_size),
device_id,
use_fabric=use_fabric,
peer_rank=rank,
)
mapped_chunks.append((rel, int(alloc_size)))
new_mappings.append(
(int(span_va), span_size, mapped_chunks)
)
peer_span_va[span_key] = (int(span_va), int(first_base))
span_va, _ = peer_span_va[span_key]
ptrs_j.append(span_va + off)
peer_ptrs.append(ptrs_j)
self.obj.register_peer_mapped_inputs(peer_ptrs)
self._peer_mappings.extend(new_mappings)
except Exception:
release_mappings(new_mappings)
raise
finally:
for fd in posix_peer_fds.values():
os.close(fd)
elapsed_ms = (time.perf_counter() - t0) * 1000
transport = "FABRIC" if use_fabric else "POSIX fd"
log_info_on_rank0(
logger,
f"Registered {new_count} cuda graph addresses via "
f"{transport} handles ({num_bases} unique allocations) "
f"in {elapsed_ms:.1f} ms",
)
finally:
for fd in local_posix_fds:
os.close(fd)
for h in retained_handles:
check_drv(drv.cuMemRelease(h), "cuMemRelease(retained)")
def close(self):
if not self._peer_mappings:
return
release_mappings(self._peer_mappings)
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/device_communicators/xpu_communicator.py
import torch
import torch.distributed as dist
from torch.distributed import ProcessGroup
from sglang.srt.utils import is_xpu
class XpuCommunicator:
def __init__(self, group: ProcessGroup):
if not is_xpu():
self.disabled = True
return
self.disabled = False
self.group = group
self.world_size = dist.get_world_size(self.group)
def all_reduce(self, x: torch.Tensor) -> torch.Tensor:
dist.all_reduce(x, group=self.group)
return x
def gather(
self, input_: torch.Tensor, rank_in_group: int, dst: int = 0, dim: int = -1
):
# For xpu path, gather doesn't work properly together with ray
# cluster so we use all_gather instead for now.
input_size = input_.size()
# Allocate output tensor.
output_tensor = torch.empty(
(self.world_size,) + input_size, dtype=input_.dtype, device=input_.device
)
# All-gather.
torch.distributed.all_gather_into_tensor(
output_tensor, input_, group=self.group
)
if rank_in_group == dst:
# Reshape
output_tensor = output_tensor.movedim(0, dim)
output_tensor = output_tensor.reshape(
input_size[:dim]
+ (self.world_size * input_size[dim],)
+ input_size[dim + 1 :]
)
else:
output_tensor = None
return output_tensor
@@ -0,0 +1,113 @@
import pickle
import time
from pathlib import Path
from typing import Any, List, Optional
import pybase64
import torch
from sglang.srt.utils import MultiprocessingSerializer
class NaiveDistributed:
def __init__(self, rank: int, world_size: int, rendezvous: str):
self._rank = rank
self._world_size = world_size
self._operation_index = 0
self._directory = Path(rendezvous)
self._directory.mkdir(parents=True, exist_ok=True)
assert 0 <= rank < world_size
# both barrier to be safe, and as a sanity check
self.barrier()
def get_rank(self):
return self._rank
def get_world_size(self):
return self._world_size
def scatter(
self, tensor: torch.Tensor, scatter_list: List[torch.Tensor], src: int = 0
):
if self._rank == src:
assert len(scatter_list) == self._world_size
else:
assert scatter_list is None
gathered_objects = self.all_gather_object(
dict(
serialized_scatter_list=[
(
None
if item_rank == src
else MultiprocessingSerializer.serialize(item)
)
for item_rank, item in enumerate(scatter_list)
]
)
if self._rank == src
else dict()
)
remote_serialized_tensor = gathered_objects[src]["serialized_scatter_list"][
self._rank
]
if self._rank == src:
assert remote_serialized_tensor is None
remote_tensor = scatter_list[self._rank]
else:
remote_tensor = MultiprocessingSerializer.deserialize(
remote_serialized_tensor
)
tensor.copy_(remote_tensor)
# avoid src tensor be deleted too early
self.barrier()
def all_gather_object(self, obj: Any) -> List[Any]:
self._operation_index += 1
text_postfix = "\n"
def _get_path(interesting_rank: int):
return (
self._directory
/ f"rank{interesting_rank}_op{self._operation_index}.txt"
)
_get_path(self._rank).write_text(
pybase64.b64encode(pickle.dumps(obj)).decode("utf-8") + text_postfix
)
def _read_one(interesting_rank: int):
p = _get_path(interesting_rank)
while True:
if p.exists() and (text := p.read_text()).endswith(text_postfix):
return pickle.loads(
pybase64.b64decode(text[: -len(text_postfix)], validate=True)
)
time.sleep(0.001)
return [
_read_one(interesting_rank) for interesting_rank in range(self._world_size)
]
def barrier(self):
actual_objs = self.all_gather_object(self._rank)
assert actual_objs == list(range(self._world_size)), f"{actual_objs=}"
# Can have multi instances if needed
_instance: Optional[NaiveDistributed] = None
def get_naive_distributed():
assert _instance is not None
return _instance
def set_naive_distributed(instance: NaiveDistributed):
global _instance
assert _instance is None
_instance = instance
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True, slots=True, kw_only=True)
class ParallelState:
tp_rank: int
tp_size: int
pp_rank: int
pp_size: int
dp_rank: Optional[int]
dp_size: int
attn_tp_rank: int
attn_tp_size: int
attn_cp_rank: int
attn_cp_size: int
attn_dp_rank: int
attn_dp_size: int
moe_ep_rank: int
moe_ep_size: int
moe_dp_rank: Optional[int]
moe_dp_size: int
gpu_id: int
+262
View File
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from https://github.com/vllm-project/vllm/blob/v0.6.4.post1/vllm/distributed/utils.py
# Copyright 2023 The vLLM team.
# Adapted from
# https://github.com/NVIDIA/Megatron-LM/blob/main/megatron/core/tensor_parallel/utils.py
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
import dataclasses
import logging
import os
import pickle
import time
from collections import deque
from typing import Any, Deque, Dict, Optional, Sequence, Tuple
import torch
from torch.distributed import TCPStore
logger = logging.getLogger(__name__)
def set_global_tcp_store(store: TCPStore) -> None:
"""Install the shared TCPStore created during distributed initialization;
the handle lives on ``ctx.resources``."""
from sglang.srt.runtime_context import get_resources
get_resources().tcp_store = store
logger.info("Global TCPStore has been set")
def get_global_tcp_store() -> Optional[TCPStore]:
"""Get the existing global TCPStore.
This function provides access to the shared TCPStore instance that was
created during distributed initialization. All components (like NIXL buffers)
should use this same store for coordination.
Returns:
The global TCPStore instance, or None if not initialized yet.
"""
from sglang.srt.runtime_context import get_resources
store = get_resources().tcp_store
if store is None:
logger.warning(
"Global TCPStore not found. Make sure init_distributed_environment "
"was called with a tcp:// init method."
)
return store
def ensure_divisibility(numerator, denominator):
"""Ensure that numerator is divisible by the denominator."""
assert numerator % denominator == 0, "{} is not divisible by {}".format(
numerator, denominator
)
def divide(numerator, denominator):
"""Ensure that numerator is divisible by the denominator and return
the division value."""
ensure_divisibility(numerator, denominator)
return numerator // denominator
def split_tensor_along_last_dim(
tensor: torch.Tensor,
num_partitions: int,
contiguous_split_chunks: bool = False,
) -> Sequence[torch.Tensor]:
"""Split a tensor along its last dimension.
Arguments:
tensor: input tensor.
num_partitions: number of partitions to split the tensor
contiguous_split_chunks: If True, make each chunk contiguous
in memory.
Returns:
A list of Tensors
"""
# Get the size and dimension.
last_dim = tensor.dim() - 1
last_dim_size = divide(tensor.size()[last_dim], num_partitions)
# Split.
tensor_list = torch.split(tensor, last_dim_size, dim=last_dim)
# NOTE: torch.split does not create contiguous tensors by default.
if contiguous_split_chunks:
return tuple(chunk.contiguous() for chunk in tensor_list)
return tensor_list
def get_pp_indices(
num_hidden_layers: int, pp_rank: int, pp_size: int
) -> Tuple[int, int]:
"""Try to evenly distribute layers across partitions.
If the number of layers is not divisible by the number of partitions,
the last N partitions will have one extra layer, where N = remainder.
"""
# partition_list_str can be set to None in sglang
partition_list_str = os.getenv("SGLANG_PP_LAYER_PARTITION", None)
if partition_list_str is not None:
try:
partitions = [int(layer) for layer in partition_list_str.split(",")]
except ValueError as err:
raise ValueError(
"Invalid partition string: {}".format(partition_list_str)
) from err
if len(partitions) != pp_size:
raise ValueError(f"{len(partitions)=} does not match {pp_size=}.")
if sum(partitions) != num_hidden_layers:
raise ValueError(f"{sum(partitions)=} does not match {num_hidden_layers=}.")
start_layer = sum(partitions[:pp_rank])
end_layer = start_layer + partitions[pp_rank]
else:
base_layers = num_hidden_layers // pp_size
remainder = num_hidden_layers % pp_size
# Distribute the extra layers to the last 'remainder' partitions
if pp_rank >= pp_size - remainder:
partitions_without_extra_layer = pp_size - remainder
# This partition gets one extra layer
start_layer = pp_rank * (base_layers + 1) - partitions_without_extra_layer
end_layer = start_layer + (base_layers + 1)
else:
# This partition gets only base layers
start_layer = pp_rank * base_layers
end_layer = start_layer + base_layers
return (start_layer, end_layer)
@dataclasses.dataclass
class StatelessProcessGroup:
"""A dataclass to hold a metadata store, and the rank, world_size of the
group. Only use it to communicate metadata between processes.
For data-plane communication, create NCCL-related objects.
"""
rank: int
world_size: int
store: torch._C._distributed_c10d.Store
data_expiration_seconds: int = 3600 # 1 hour
# dst rank -> counter
send_dst_counter: Dict[int, int] = dataclasses.field(default_factory=dict)
# src rank -> counter
recv_src_counter: Dict[int, int] = dataclasses.field(default_factory=dict)
broadcast_send_counter: int = 0
broadcast_recv_src_counter: Dict[int, int] = dataclasses.field(default_factory=dict)
# A deque to store the data entries, with key and timestamp.
entries: Deque[Tuple[str, float]] = dataclasses.field(default_factory=deque)
def __post_init__(self):
assert self.rank < self.world_size
self.send_dst_counter = {i: 0 for i in range(self.world_size)}
self.recv_src_counter = {i: 0 for i in range(self.world_size)}
self.broadcast_recv_src_counter = {i: 0 for i in range(self.world_size)}
def send_obj(self, obj: Any, dst: int):
"""Send an object to a destination rank."""
self.expire_data()
key = f"send_to/{dst}/{self.send_dst_counter[dst]}"
self.store.set(key, pickle.dumps(obj))
self.send_dst_counter[dst] += 1
self.entries.append((key, time.perf_counter()))
def expire_data(self):
"""Expire data that is older than `data_expiration_seconds` seconds."""
while self.entries:
# check the oldest entry
key, timestamp = self.entries[0]
if time.perf_counter() - timestamp > self.data_expiration_seconds:
self.store.delete_key(key)
self.entries.popleft()
else:
break
def recv_obj(self, src: int) -> Any:
"""Receive an object from a source rank."""
obj = pickle.loads(
self.store.get(f"send_to/{self.rank}/{self.recv_src_counter[src]}")
)
self.recv_src_counter[src] += 1
return obj
def broadcast_obj(self, obj: Optional[Any], src: int) -> Any:
"""Broadcast an object from a source rank to all other ranks.
It does not clean up after all ranks have received the object.
Use it for limited times, e.g., for initialization.
"""
if self.rank == src:
self.expire_data()
key = f"broadcast_from/{src}/" f"{self.broadcast_send_counter}"
self.store.set(key, pickle.dumps(obj))
self.broadcast_send_counter += 1
self.entries.append((key, time.perf_counter()))
return obj
else:
key = f"broadcast_from/{src}/" f"{self.broadcast_recv_src_counter[src]}"
recv_obj = pickle.loads(self.store.get(key))
self.broadcast_recv_src_counter[src] += 1
return recv_obj
def all_gather_obj(self, obj: Any) -> list[Any]:
"""All gather an object from all ranks."""
gathered_objs = []
for i in range(self.world_size):
if i == self.rank:
gathered_objs.append(obj)
self.broadcast_obj(obj, src=self.rank)
else:
recv_obj = self.broadcast_obj(None, src=i)
gathered_objs.append(recv_obj)
return gathered_objs
def barrier(self):
"""A barrier to synchronize all ranks."""
for i in range(self.world_size):
if i == self.rank:
self.broadcast_obj(None, src=self.rank)
else:
self.broadcast_obj(None, src=i)
@staticmethod
def create(
host: str,
port: int,
rank: int,
world_size: int,
data_expiration_seconds: int = 3600,
) -> "StatelessProcessGroup":
"""A replacement for `torch.distributed.init_process_group` that does not
pollute the global state.
If we have process A and process B called `torch.distributed.init_process_group`
to form a group, and then we want to form another group with process A, B, C,
D, it is not possible in PyTorch, because process A and process B have already
formed a group, and process C and process D cannot join that group. This
function is a workaround for this issue.
`torch.distributed.init_process_group` is a global call, while this function
is a stateless call. It will return a `StatelessProcessGroup` object that can be
used for exchanging metadata. With this function, process A and process B
can call `StatelessProcessGroup.create` to form a group, and then process A, B,
C, and D can call `StatelessProcessGroup.create` to form another group.
""" # noqa
store = TCPStore(
host_name=host,
port=port,
world_size=world_size,
is_master=(rank == 0),
)
return StatelessProcessGroup(
rank=rank,
world_size=world_size,
store=store,
data_expiration_seconds=data_expiration_seconds,
)