Deprecate the parallel getters the context answers, and ratchet them shut (#40342)

This commit is contained in:
Cheng Wan
2026-09-21 12:25:32 -07:00
committed by GitHub
parent 65be3fa71a
commit 73f071db52
44 changed files with 933 additions and 501 deletions
@@ -97,9 +97,14 @@ def init_world_group(
def _sync_srt_world_group() -> None:
import sglang.srt.distributed.parallel_state as srt_parallel_state
from sglang.srt.runtime_context import get_parallel
if srt_parallel_state._WORLD is None:
srt_parallel_state._WORLD = _WORLD
if srt_parallel_state._WORLD is _WORLD:
# On the context too: that is where a handle is read from, and
# assigning the module global above does not reach it.
get_parallel().override_permanently(world_group=_WORLD)
def _clear_srt_world_group() -> None:
@@ -132,6 +137,27 @@ def _sync_srt_tp_group() -> None:
srt_parallel_state._ATTN_TP = _TP
if srt_parallel_state._ATTN_TP is _TP:
get_parallel().override_permanently(
# The group itself, because that is what the `srt` context answers
# a handle with -- assigning the module global above does not reach
# it. `tp_size` comes with them: the group is as wide as the world
# while the dummy carries this package's, and the widths below are
# quotients of one number, so stating a subset would describe a
# layout that does not exist.
tp_group=_TP,
attn_tp_group=_TP,
tp_size=_TP.world_size,
# The ranks too. The shared layers shard by them -- `vision.py`
# reads `attn_tp_rank`, every `srt` linear built without an
# explicit rank reads `tp_rank` -- and this package publishes no
# rank bundle, so nothing else writes one. The draft has no
# pipeline, context or expert dimension of its own, so those
# positions are zero.
tp_rank=_TP.rank_in_group,
attn_tp_rank=_TP.rank_in_group,
moe_tp_rank=_TP.rank_in_group,
attn_cp_rank=0,
pp_rank=0,
moe_ep_rank=0,
**derive_parallel_widths(
tp_size=_TP.world_size,
attn_cp_size=1,
@@ -151,6 +177,10 @@ def _clear_srt_tp_group() -> None:
if srt_parallel_state._ATTN_TP is _TP:
srt_parallel_state._ATTN_TP = None
get_parallel().clear_stamp()
if srt_parallel_state._WORLD is not None:
# `clear_stamp` drops every stamped name; the WORLD group this
# package lent is still built, so hand it back.
get_parallel().override_permanently(world_group=srt_parallel_state._WORLD)
if srt_parallel_state._TP is _TP:
srt_parallel_state._TP = None
@@ -2,6 +2,7 @@ from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import call, patch
import pytest
import torch
from torch import nn
@@ -135,7 +136,7 @@ def test_srt_attention_tp_group_tracks_diffusion_tp_group():
# `world_size`, because lending the group also states the parallel widths it
# implies -- the shared `srt` vision layers ask for `attn_tp_size`, and this
# package publishes no `srt` config for that read to resolve against.
tp_group = SimpleNamespace(world_size=2)
tp_group = SimpleNamespace(world_size=2, rank_in_group=1)
with (
patch.object(parallel_state, "_TP", tp_group),
@@ -147,11 +148,22 @@ def test_srt_attention_tp_group_tracks_diffusion_tp_group():
assert srt_parallel_state._TP is tp_group
assert srt_parallel_state._ATTN_TP is tp_group
assert get_parallel().attn_tp_size == 2
# The handle too: assigning the module global does not reach the `srt`
# context, which is what the shared layers ask for a group.
assert get_parallel().tp_group is tp_group
assert get_parallel().attn_tp_group is tp_group
# And the ranks the shared layers shard by. Nothing else writes one
# here: this package publishes no rank bundle, so a handle without a
# rank leaves every `srt` linear unable to say which shard it is.
assert get_parallel().tp_rank == 1
assert get_parallel().attn_tp_rank == 1
parallel_state._clear_srt_tp_group()
assert srt_parallel_state._TP is None
assert srt_parallel_state._ATTN_TP is None
with pytest.raises(RuntimeError):
get_parallel().tp_group
def test_srt_owned_groups_are_not_overwritten_or_cleared():
+6 -1
View File
@@ -107,7 +107,12 @@ class Derived(msgspec.Struct, frozen=True):
``publish`` and stored as an ordinary bag leaf -- a plain attribute load,
which is what a read inside compiled model code needs.
Every declaration carries ``fn`` today, the parallel quotients included:
A declaration with no ``fn`` is one nothing can compute: a rank, or a
process group. Those are written into the namespace at runtime -- by
``publish`` from the spawn bundle, or by the build that creates the group --
and until then the name has no answer.
Most declarations carry ``fn``, the parallel quotients included:
they are a function of the configured leaves, so they are computed at
publish like the rest. What is special about them is not how they are
computed but that a stamp can move one afterwards -- ``initialize_dp_attention``
@@ -320,3 +320,73 @@ class Parallel(msgspec.Struct):
doc="Whether decode context parallelism is in play: `dcp_size` is "
"wider than one rank, which is exactly when the group gets built.",
)
# -- written at runtime, not carried by any configuration --------------
#
# No `fn`: nothing here is a function of the leaves above. A rank is
# written by `publish` from the spawn bundle; a group by the build that
# creates it. Until one of them has run there is no answer, and a read
# says so rather than deriving something that would answer a different
# question.
tp_rank = Derived(doc="This process's place in the tensor-parallel group.")
pp_rank = Derived(doc="This process's place in the pipeline group.")
moe_ep_rank = Derived(doc="This process's place in the expert-parallel group.")
moe_dp_rank = Derived(doc=("This process's place in the MoE data-parallel group."))
moe_tp_rank = Derived(
doc=("This process's place in the MoE tensor-parallel group.")
)
attn_tp_rank = Derived(
doc=("This process's place in the attention tensor-parallel group.")
)
attn_cp_rank = Derived(
doc=("This process's place in the attention context-parallel group.")
)
dcp_rank = Derived(
doc=("This process's place in the decode context-parallel group.")
)
attn_dcp_rank = Derived(
doc=(
"Decode context-parallel rank inside the attention TP group, "
"zero where decode context parallelism is off."
)
)
attn_dp_rank = Derived(
doc=(
"This process's index in the attention-DP group, computed from "
"`tp_rank` when `initialize_dp_attention` runs."
)
)
dp_rank = Derived(
doc=(
"Which data-parallel replica this process serves, as the data "
"parallel controller numbered them at spawn. `None` when there "
"is no controller: unlike the other ranks it is a position in "
"no group, which is why the spawn states it."
)
)
launch_world_rank = Derived(
doc=(
"This process's rank in the WORLD group as built. A scale-up "
"does not renumber it."
)
)
launch_world_size = Derived(
fn="sglang.srt.runtime_context.launch_world_size_of",
doc="Width the WORLD group was built at -- what a scale-up leaves "
"behind rather than updates.",
)
max_world_size = Derived(
fn="sglang.srt.runtime_context.max_world_size_of",
doc="Ranks the WORLD group has room for: `--max-ep-size` when set, "
"otherwise the launch width.",
)
world_group = Derived(doc="The WORLD group.")
tp_group = Derived(doc="The tensor-parallel group.")
pp_group = Derived(doc="The pipeline group.")
moe_ep_group = Derived(doc="The expert-parallel group.")
moe_dp_group = Derived(doc="The MoE data-parallel group.")
moe_tp_group = Derived(doc="The MoE tensor-parallel group.")
attn_tp_group = Derived(doc="The attention tensor-parallel group.")
attn_cp_group = Derived(doc="The attention context-parallel group.")
shared_experts_tp_group = Derived(doc=("The shared-expert tensor-parallel group."))
dcp_group = Derived(doc="The decode context-parallel group.")
+140 -43
View File
@@ -24,10 +24,13 @@ If you only need to use the distributed environment without model/pipeline
"""
import contextlib
import functools
import gc
import logging
import os
import pickle
import sys
import warnings
import weakref
from collections import namedtuple
from contextlib import contextmanager, nullcontext
@@ -50,7 +53,6 @@ from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph impo
)
from sglang.srt.platforms.device_mixin import _DEVICE_TO_DISTRIBUTED_BACKEND
from sglang.srt.runtime_context import (
_validate_parallel,
derive_parallel_widths,
get_global_dwdp_manager,
get_parallel,
@@ -2072,8 +2074,7 @@ _WORLD: Optional[GroupCoordinator] = None
def get_world_group() -> GroupCoordinator:
assert _WORLD is not None, "world group is not initialized"
return _WORLD
return get_parallel().world_group
def init_world_group(
@@ -2152,43 +2153,37 @@ _DCP: Optional[GroupCoordinator] = None
# duplicate GroupCoordinator for prefill in PD-Multiplexing
_PDMUX_PREFILL_TP_GROUP: Optional[GroupCoordinator] = None
_ENABLE_PDMUX_P_TP: bool = False
@contextmanager
def pdmux_prefill_tp_group():
"""Run on the prefill stream's own tensor-parallel communicator.
def set_pdmux_status(enable_prefill_multiplexing: bool):
global _ENABLE_PDMUX_P_TP
_ENABLE_PDMUX_P_TP = enable_prefill_multiplexing
PD multiplexing builds a duplicate TP group -- the same ranks, a second
communicator -- so prefill and decode can occupy separate streams without
serialising on one. Nothing about the topology differs, so the scope states
the handle and nothing else.
"""
assert _PDMUX_PREFILL_TP_GROUP is not None, (
"tensor model parallel group for PD-Multiplexing Prefill is not initialized"
)
with get_parallel().override(tp_group=_PDMUX_PREFILL_TP_GROUP):
yield
def get_tp_group() -> GroupCoordinator:
if _ENABLE_PDMUX_P_TP:
assert _PDMUX_PREFILL_TP_GROUP is not None, (
"tensor model parallel group for PD-Multiplexing Prefill is not initialized"
)
return _PDMUX_PREFILL_TP_GROUP
assert _TP is not None, "tensor model parallel group is not initialized"
return _TP
return get_parallel().tp_group
def get_attn_tp_group() -> GroupCoordinator:
assert _ATTN_TP is not None, (
"attention tensor model parallel group is not initialized"
)
return _ATTN_TP
return get_parallel().attn_tp_group
def get_shared_experts_tp_group() -> GroupCoordinator:
assert _SHARED_EXPERTS_TP is not None, (
"shared-expert tensor model parallel group is not initialized"
)
return _SHARED_EXPERTS_TP
return get_parallel().shared_experts_tp_group
def get_attn_cp_group() -> GroupCoordinator:
assert _ATTN_CP is not None, (
"attention context model parallel group is not initialized"
)
return _ATTN_CP
return get_parallel().attn_cp_group
def get_dcp_group_no_assert() -> Optional[GroupCoordinator]:
@@ -2196,8 +2191,7 @@ def get_dcp_group_no_assert() -> Optional[GroupCoordinator]:
def get_dcp_group() -> GroupCoordinator:
assert _DCP is not None, "decode context parallel group is not initialized"
return _DCP
return get_parallel().dcp_group
_MOE_DP: Optional[GroupCoordinator] = None
@@ -2206,18 +2200,15 @@ _MOE_TP: Optional[GroupCoordinator] = None
def get_moe_dp_group() -> GroupCoordinator:
assert _MOE_DP is not None, "moe data parallel group is not initialized"
return _MOE_DP
return get_parallel().moe_dp_group
def get_moe_ep_group() -> GroupCoordinator:
assert _MOE_EP is not None, "expert model parallel group is not initialized"
return _MOE_EP
return get_parallel().moe_ep_group
def get_moe_tp_group() -> GroupCoordinator:
assert _MOE_TP is not None, "expert model parallel group is not initialized"
return _MOE_TP
return get_parallel().moe_tp_group
# kept for backward compatibility
@@ -2233,8 +2224,7 @@ def get_self_pp_group() -> GroupCoordinator:
def get_pp_group() -> GroupCoordinator:
assert _PP is not None, "pipeline model parallel group is not initialized"
return _PP
return get_parallel().pp_group
# kept for backward compatibility
@@ -2513,6 +2503,10 @@ def init_distributed_environment(
assert _WORLD.world_size == torch.distributed.get_world_size(), (
"world group already initialized with a different world size"
)
# Stated here rather than with the groups below it: WORLD is built in this
# function, and every group `initialize_model_parallel` builds is placed by
# reading it back.
get_parallel().override_permanently(world_group=_WORLD)
def initialize_model_parallel(
@@ -2692,7 +2686,7 @@ def initialize_model_parallel(
rank_offset=rank_offset,
max_world_size=max_world_size,
)
if get_tensor_model_parallel_rank() == 0:
if _TP.rank_in_group == 0:
logger.info(
f"DCP enabled, dcp_size={decode_context_parallel_size}, tp_size={tensor_model_parallel_size}"
)
@@ -2942,10 +2936,32 @@ def initialize_model_parallel(
)
# The groups just built and the configuration they were built from are two
# accounts of one layout. Check them against each other here, where the
# disagreement is still attributable, rather than letting a collective run
# on the wrong peers.
_validate_parallel(get_parallel(), "group build")
# accounts of one layout, and this is where they meet: stating a group
# checks the identities, so a group built on the wrong peers is refused
# here rather than hanging in a collective later.
#
# A dimension this configuration does not have is left unstated -- `_DCP`
# is None without decode context parallelism -- so reading it says the
# group was never built, which is what these getters have always said,
# rather than handing back a None to fail on at the collective.
#
# WORLD is not here: it is built and stated by
# `init_distributed_environment`, which is what lets every build above
# place its group by reading `get_world_group().local_rank`.
built = {
"tp_group": _TP,
"pp_group": _PP,
"moe_ep_group": _MOE_EP,
"moe_dp_group": _MOE_DP,
"moe_tp_group": _MOE_TP,
"attn_tp_group": _ATTN_TP,
"attn_cp_group": _ATTN_CP,
"shared_experts_tp_group": _SHARED_EXPERTS_TP,
"dcp_group": _DCP,
}
get_parallel().override_permanently(
**{name: group for name, group in built.items() if group is not None}
)
def create_custom_parallel_group(
@@ -3041,8 +3057,8 @@ def patch_pipeline_parallel_group(pp_group: GroupCoordinator):
assert not _PP_STATE_PATCHED, "Should not call when it's already patched"
_PP_STATE_PATCHED = True
old_pp_group = get_pp_group()
global _PP
old_pp_group = _PP
_PP = pp_group
try:
# `pp_size` is a configured leaf: unlike the rank and the handle it
@@ -3096,8 +3112,8 @@ def patch_tensor_parallel_group(tp_group: GroupCoordinator, *, owns_attention: b
assert not _TP_STATE_PATCHED, "Should not call when it's already patched"
_TP_STATE_PATCHED = True
old_tp_group = get_tp_group()
global _TP
old_tp_group = _TP
_TP = tp_group
narrowed = dict(
tp_size=tp_group.world_size,
@@ -3437,3 +3453,84 @@ def monkey_patch_vllm_parallel_state(reverse: bool = False):
setattr(vllm_parallel_state, "get_pp_group", get_pp_group)
setattr(vllm_parallel_state, "get_tp_group", get_tp_group)
setattr(vllm_parallel_state, "get_world_group", get_world_group)
# --- deprecation ---------------------------------------------------------
#
# These getters are the definition of a name, not a second spelling of it.
# Business code asks `get_parallel()`, which answers by calling them and which
# a scope can redirect; a call that arrives here directly cannot be redirected,
# so a draft worker's scope does not reach it. The package that defines them
# keeps calling them -- a read there would go through the context back into
# itself -- so the warning fires only for callers outside it, and once per
# name, because the point is to name the replacement rather than to fill a log.
_EXEMPT_CALLERS = ("sglang.srt.distributed.",)
# Which context name each getter here answers. The shim's own bookkeeping --
# what a getter was replaced by is of no interest to whoever declares the field
# -- so it is written next to the warning that uses it.
_CONTEXT_NAME_OF = {
"get_world_group": "world_group",
"get_tp_group": "tp_group",
"get_pp_group": "pp_group",
"get_moe_ep_group": "moe_ep_group",
"get_moe_dp_group": "moe_dp_group",
"get_moe_tp_group": "moe_tp_group",
"get_attn_tp_group": "attn_tp_group",
"get_attn_cp_group": "attn_cp_group",
"get_shared_experts_tp_group": "shared_experts_tp_group",
"get_dcp_group": "dcp_group",
"get_world_size": "launch_world_size",
"get_world_rank": "launch_world_rank",
"get_tensor_model_parallel_rank": "tp_rank",
"get_pipeline_model_parallel_rank": "pp_rank",
"get_moe_expert_parallel_rank": "moe_ep_rank",
"get_moe_data_parallel_rank": "moe_dp_rank",
"get_moe_tensor_parallel_rank": "moe_tp_rank",
"get_attn_tensor_model_parallel_rank": "attn_tp_rank",
"get_attn_context_model_parallel_rank": "attn_cp_rank",
"get_dcp_rank": "dcp_rank",
}
# The width getters read a built group; the context answers the same names from
# the configuration. Those are one answer rather than two only for the groups
# the build checks against the configuration -- `_WIDTH_AND_GROUP` in
# `runtime_context` -- so only those are listed here. `moe_dp`, `moe_tp` and
# `dcp` are not on that list and are deliberately absent: the MoE-DP group is
# the attention-CP group when the latter is wider, and the other two are simply
# not pinned yet.
_CONTEXT_NAME_OF["get_tensor_model_parallel_world_size"] = "tp_size"
_CONTEXT_NAME_OF["get_attn_tensor_model_parallel_world_size"] = "attn_tp_size"
_CONTEXT_NAME_OF["get_attn_context_model_parallel_world_size"] = "attn_cp_size"
_CONTEXT_NAME_OF["get_pipeline_model_parallel_world_size"] = "pp_size"
_CONTEXT_NAME_OF["get_moe_expert_parallel_world_size"] = "moe_ep_size"
_ALREADY_WARNED: set = set()
def _warn_if_called_from_outside(name: str, replacement: str):
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if name not in _ALREADY_WARNED:
caller = sys._getframe(1).f_globals.get("__name__", "")
if not caller.startswith(_EXEMPT_CALLERS):
_ALREADY_WARNED.add(name)
warnings.warn(
f"{name}() is deprecated; read "
f"get_parallel().{replacement} instead, which answers the "
"same thing and can be redirected by a scope",
DeprecationWarning,
stacklevel=2,
)
return fn(*args, **kwargs)
return wrapper
return decorate
for _name, _replacement in _CONTEXT_NAME_OF.items():
_fn = globals().get(_name)
if _fn is not None:
globals()[_name] = _warn_if_called_from_outside(_name, _replacement)(_fn)
del _name, _replacement, _fn
@@ -58,7 +58,7 @@ def ranks_per_host() -> int:
return 1
try:
launch_world_size = get_parallel().launch_world_size
except AssertionError:
except (RuntimeError, ValueError):
return 1
if launch_world_size == 1:
return 1
@@ -104,7 +104,7 @@ def sync_fixed_hicache_size(size: int, host_size: int) -> int:
from sglang.srt.runtime_context import get_parallel
pp_group = get_parallel().pp_group
except AssertionError:
except RuntimeError:
return size
if pp_group.world_size <= 1:
@@ -41,7 +41,6 @@ from sglang.srt.compilation import torch_compile_decoration
from sglang.srt.compilation.torch_compile_decoration import set_torch_compile_config
from sglang.srt.distributed.parallel_state import (
graph_capture,
set_pdmux_status,
)
from sglang.srt.dllm.config import DllmConfig
from sglang.srt.environ import envs
@@ -1053,7 +1052,6 @@ class DecodeCudaGraphRunner(BaseCudaGraphRunner):
with self.backend.capture_session(self.stream):
self._capture_one_stream()
else:
set_pdmux_status(False)
for i, sg in enumerate(self.stream_groups):
with (
graph_capture(stream=sg[1]) as graph_capture_context,
+2 -2
View File
@@ -2073,7 +2073,7 @@ class PreshardedModelLoader(DefaultModelLoader):
try:
g = get_parallel().world_group
return g.rank_in_group, g.world_size
except (AssertionError, AttributeError):
except (AssertionError, AttributeError, RuntimeError):
return 0, 1
@staticmethod
@@ -2082,7 +2082,7 @@ class PreshardedModelLoader(DefaultModelLoader):
try:
get_parallel().world_group.barrier()
except (AssertionError, AttributeError):
except (AssertionError, AttributeError, RuntimeError):
pass
@staticmethod
+2 -3
View File
@@ -23,7 +23,6 @@ from transformers import PretrainedConfig
import sglang.srt.models.deepseek_v2 as deepseek_v2
from sglang.srt.configs.gigachat35 import GigaChat35Config
from sglang.srt.distributed import get_pp_group
from sglang.srt.layers.communicator import get_attn_tp_context
from sglang.srt.layers.layernorm import GemmaRMSNorm, RMSNorm
from sglang.srt.layers.linear import ColumnParallelLinear
@@ -515,7 +514,7 @@ class GigaChat35Model(nn.Module):
self.config = config
self.padding_idx = getattr(config, "pad_token_id", None)
self.vocab_size = config.vocab_size
self.pp_group = get_pp_group()
self.pp_group = get_parallel().pp_group
if self.pp_group.is_first_rank:
self.embed_tokens = VocabParallelEmbedding(
@@ -605,7 +604,7 @@ class GigaChat35ForCausalLM(DeepseekV2WeightLoaderMixin, nn.Module):
nn.Module.__init__(self)
self.config = config
self.quant_config = quant_config
self.pp_group = get_pp_group()
self.pp_group = get_parallel().pp_group
self.tp_size = get_parallel().tp_size
self.num_fused_shared_experts = 0
+1 -2
View File
@@ -18,7 +18,6 @@ from typing import Iterable, Optional
import torch
from torch import nn
from sglang.srt.distributed import get_pp_group
from sglang.srt.layers.logits_processor import LogitsProcessor
from sglang.srt.layers.quantization.base_config import QuantizationConfig
from sglang.srt.layers.vocab_parallel_embedding import (
@@ -140,7 +139,7 @@ class GigaChat35ForCausalLMNextN(DeepseekV2WeightLoaderMixin, nn.Module):
nn.Module.__init__(self)
self.config = config
self.quant_config = quant_config
self.pp_group = get_pp_group()
self.pp_group = get_parallel().pp_group
self.tp_size = get_parallel().tp_size
self.num_fused_shared_experts = 0
self.draft_model_idx = draft_model_idx or 0
@@ -11,7 +11,7 @@ import torch
import torch.distributed as dist
from torch.cuda.streams import ExternalStream
from sglang.srt.distributed.parallel_state import set_pdmux_status
from sglang.srt.distributed.parallel_state import pdmux_prefill_tp_group
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.multiplex.pdmux_context import (
get_current_stream_idx,
@@ -21,7 +21,7 @@ from sglang.srt.multiplex.pdmux_context import (
load_pdmux_config,
set_current_stream_idx,
)
from sglang.srt.runtime_context import get_disagg
from sglang.srt.runtime_context import get_device, get_disagg, get_parallel
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import ScheduleBatch
@@ -37,7 +37,7 @@ class SchedulerMultiplexMixin:
# for pd_multiplexing, Init stream_groups, exclude normal stream for prefill only and decode only
self.pdmux_config = load_pdmux_config(get_disagg().pdmux_config_path)
initialize_stream_groups(self.gpu_id, self.pdmux_config)
initialize_stream_groups(get_device().gpu_id, self.pdmux_config)
self.stream_groups = get_stream_groups()
self.sm_counts = get_sm_counts()
self.real_sm_group_num = len(self.stream_groups)
@@ -113,12 +113,10 @@ class SchedulerMultiplexMixin:
while True:
with torch.cuda.stream(decode_stream):
set_pdmux_status(False)
self.ingest_requests()
running_batch = self.running_batch
with torch.cuda.stream(prefill_stream):
set_pdmux_status(True)
with torch.cuda.stream(prefill_stream), pdmux_prefill_tp_group():
sm_count = self.sm_counts[stream_idx][0]
if not wait_prefill_kernel_done:
created, running_batch = self.update_split_prefill_batch(
@@ -128,7 +126,6 @@ class SchedulerMultiplexMixin:
adjust_stream_group = created or adjust_stream_group
with torch.cuda.stream(decode_stream):
set_pdmux_status(False)
running_batch = self.update_running_batch(running_batch)
self.running_batch = running_batch
adjust_stream_group = adjust_stream_group or (
@@ -152,15 +149,13 @@ class SchedulerMultiplexMixin:
)
with torch.cuda.stream(decode_stream):
set_pdmux_status(False)
# process decode batch
if running_batch and not running_batch.is_empty():
decode_result = self.run_batch(running_batch)
decode_done = True
else:
decode_done = False
with torch.cuda.stream(prefill_stream):
set_pdmux_status(True)
with torch.cuda.stream(prefill_stream), pdmux_prefill_tp_group():
if (
self.split_prefill_batch
and not self.split_prefill_batch.is_empty()
@@ -197,13 +192,11 @@ class SchedulerMultiplexMixin:
prefill_done = False
with torch.cuda.stream(decode_stream):
set_pdmux_status(False)
decode_stream.synchronize()
if decode_done:
self.process_batch_result(running_batch, decode_result)
with torch.cuda.stream(prefill_stream):
set_pdmux_status(True)
with torch.cuda.stream(prefill_stream), pdmux_prefill_tp_group():
if prefill_done and self.split_prefill_batch.split_prefill_finished:
wait_prefill_kernel_done = True
prefill_exe_done_flag = prefill_exe_done.query()
@@ -214,7 +207,7 @@ class SchedulerMultiplexMixin:
)
self.tp_cpu_group.allreduce(flags, dist.ReduceOp.SUM).wait()
if flags.item() == self.tp_size:
if flags.item() == get_parallel().tp_size:
self.process_batch_result(
self.split_prefill_batch, prefill_result
)
+83 -170
View File
@@ -110,119 +110,16 @@ def _parallel_config_leaves() -> frozenset:
)
# Ranks and group handles: the names no configuration carries, each with the
# canonical getter that answers it live. This table is their declaration, the
# way `arg_groups/fields/parallel.py` is the leaves' and `Derived` is the
# widths'. `None` marks a name only a stamp can answer: no coordinator knows
# this process's attention-DP rank.
# Ranks and group handles: the names no configuration carries. This table is
# their declaration, the way `arg_groups/fields/parallel.py` is the leaves' and
# `Derived` is the widths'. A group handle names the getter that owns it,
# because the module that builds the groups is where it lives; a rank is a
# position in one of those groups, so it is read off the handle. `None` marks a
# name only a stamp can answer: no coordinator knows this process's
# attention-DP rank.
_MISSING_READ = object()
class Live(msgspec.Struct, frozen=True):
"""How a rank / group / world width is answered, and what it means.
`source` is the canonical getter's name in `parallel_state`, a callable
taking the context, or `None` for a name only a stamp can answer.
Most entries in the table below are a bare getter name: a rank or a group
handle is its own explanation. This shape is for a name whose meaning is
not in its getter, and it carries the prose with the declaration rather
than in a second table keyed by the same names.
"""
source: Any = None
doc: str = ""
# For a stamp-only name (`source=None`): what a reader should be told when
# nothing has stamped it. These names have no fallback by construction, so
# the message is the only thing pointing at what did not happen.
unstamped: str = ""
_LIVE_READS: dict = {
# Two widths of the WORLD group: what it was built at, and what it has
# room for. Both are properties of the group itself. How much of that room
# is currently serving is elastic-EP state, owned by `ElasticEPStateManager`
# and asked of it directly -- a width that lives somewhere else does not
# become a WORLD fact by being readable from here.
"launch_world_size": Live(
source="get_world_size",
doc=(
"Width the WORLD group was built at: `len(ranks)`, frozen when the "
"coordinator was constructed. What every startup reader wants -- "
"memory accounting, KV cache sizing, graph capture, weight loading "
"-- and what a scale-up leaves behind rather than updates."
),
),
"max_world_size": Live(
source=lambda self: self.max_ep_size or self.launch_world_size,
doc=(
"Ranks the WORLD group has room for: `--max-ep-size` when it is "
"set, otherwise the launch width. This is the ceiling the process "
"group was pre-allocated to -- mooncake sizes its active-rank mask "
"to it -- which is why `init_distributed_environment` takes it "
"under this name. Whether the group can grow at all is a separate "
"question, answered by the leaf being set rather than by this width."
),
),
"launch_world_rank": Live(
source="get_world_rank",
doc=(
"This process's rank in the WORLD group as built. Frozen with the "
"coordinator, exactly like `launch_world_size`, and named for the "
"same reason: a scale-up does not renumber it."
),
),
"tp_rank": "get_tensor_model_parallel_rank",
"pp_rank": "get_pipeline_model_parallel_rank",
"moe_ep_rank": "get_moe_expert_parallel_rank",
"moe_dp_rank": "get_moe_data_parallel_rank",
"moe_tp_rank": "get_moe_tensor_parallel_rank",
"attn_tp_rank": "get_attn_tensor_model_parallel_rank",
"attn_cp_rank": "get_attn_context_model_parallel_rank",
"dcp_rank": "get_dcp_rank",
"attn_dcp_rank": lambda self: self.dcp_rank if self.dcp_enabled else 0,
"attn_dp_rank": Live(
source=None,
doc=(
"This process's index in the attention-DP group. Computed from "
"`tp_rank` when the attention topology is initialized, and moved "
"by an elastic scale-up, so no coordinator can answer it."
),
unstamped=(
"it is computed from this process's `tp_rank` when the attention "
"topology is initialized, so a process that never ran "
"`initialize_dp_attention` has no answer to give"
),
),
"dp_rank": Live(
source=None,
doc=(
"Which data-parallel replica this process serves, as the data "
"parallel controller numbered them at spawn. `None` when there is "
"no controller. Unlike `attn_dp_rank` and `moe_dp_rank` it is not "
"a position in any process group -- no group has one member per "
"replica -- which is why nothing can derive it and the spawn "
"states it instead."
),
unstamped=(
"it is a spawn identity, handed to `publish(..., ranks=...)` by "
"the process entry; a process that published without a rank "
"bundle has no replica index to report"
),
),
"world_group": "get_world_group",
"tp_group": "get_tp_group",
"pp_group": "get_pp_group",
"moe_ep_group": "get_moe_ep_group",
"moe_dp_group": "get_moe_dp_group",
"moe_tp_group": "get_moe_tp_group",
"attn_tp_group": "get_attn_tp_group",
"attn_cp_group": "get_attn_cp_group",
"shared_experts_tp_group": "get_shared_experts_tp_group",
"dcp_group": "get_dcp_group",
}
@functools.lru_cache(maxsize=1)
def _parallel_fields() -> frozenset:
"""Every name `ParallelContext` answers for, read from the declarations.
@@ -232,8 +129,8 @@ def _parallel_fields() -> frozenset:
* configured leaves -- the `parallel` namespace of the record;
* derived widths -- the `Derived` declarations beside those leaves;
* ranks and group handles -- `_LIVE_READS`, which is where they are
declared because no configuration carries them.
* ranks and group handles -- declared beside the leaves with no `fn`,
because nothing computes them; they are written at runtime.
The set is the union of those three, so `override()` cannot refuse a name
the class answers for.
@@ -244,7 +141,7 @@ def _parallel_fields() -> frozenset:
derived = {
name for name, decl in vars(Parallel).items() if isinstance(decl, Derived)
}
return frozenset(_parallel_config_leaves() | derived | set(_LIVE_READS))
return frozenset(_parallel_config_leaves() | derived)
def derive_attention_widths(
@@ -331,6 +228,7 @@ def derive_spawn_ranks(
% (tp_size // moe_dp_size)
// (tp_size // moe_dp_size // moe_ep_size)
),
"moe_tp_rank": tp_rank % (tp_size // moe_dp_size // moe_ep_size),
}
@@ -404,6 +302,24 @@ def parallel_widths_of(cfg: Any) -> dict:
)
def launch_world_size_of(cfg: Any):
"""`launch_world_size`, computed at publish.
The width `bootstrap` builds the WORLD at: one rank per pipeline stage of
each tensor-parallel group, above the offset a scale joiner comes in at --
zero for everyone else, which is the convention `spawn_world_rank` uses for
the same arithmetic. A scale-up does not move it, which is the point of the
name.
"""
return cfg.ep_join_rank_offset + cfg.tp_size * cfg.pp_size
def max_world_size_of(cfg: Any):
"""`max_world_size`, computed at publish. The ceiling the group is
pre-allocated to: `--max-ep-size` when set, otherwise the launch width."""
return cfg.max_ep_size or launch_world_size_of(cfg)
def attn_tp_size_of(cfg: Any):
"""`attn_tp_size`, computed at publish. See `parallel_widths_of`."""
return parallel_widths_of(cfg)["attn_tp_size"]
@@ -586,24 +502,22 @@ def _validate_parallel(parallel, source: str) -> None:
class ParallelContext:
"""Parallel-topology namespace: one spelling per name.
Ranks and group handles are read-through ``@property`` over the canonical
getters, so they answer with the **live** process groups and raise before
distributed init. Every other name ``tp_size`` and its size siblings
included, alongside config-only leaves such as ``nccl_port`` is answered
from the published ``parallel`` bag, in any process at any point after
publish.
Every name is answered by a lookup, never by asking a process group. A
configured leaf and a width derived from one come off the published
``parallel`` bag; a rank is written by ``publish`` from the spawn bundle,
and a group handle by ``initialize_model_parallel`` as it builds them. A
read before the write that answers it says which write is missing rather
than deriving a number from whatever is installed -- the two answer
different questions, and a plausible wrong rank surfaces as a hang in a
collective far from here.
A size is read from the configuration because the groups are built at
exactly the configured widths. Two things do not follow that rule and are
asked of the group itself: ``initialize_model_parallel`` aliases ``_MOE_DP``
to ``_ATTN_CP`` when ``attn_cp_size > moe_dp_size``, so a reader that means
the MoE communicator's width calls ``get_moe_cp_size()``; and
``patch_tensor_parallel_group`` runs a scope under a different TP group,
which it declares by overriding ``tp_size``, ``tp_rank`` and ``tp_group``
for its duration. Elastic EP is a third case, and it needs no rule here: it
scales ``ep_size`` / ``dp_size`` on the published bag while the group
coordinators keep the width they were constructed with, so the two are
different names rather than two answers to one name.
That makes a scope a matter of stating names: ``patch_tensor_parallel_group``
runs a draft worker under a different TP group by overriding the members it
changes, and PD multiplexing points ``tp_group`` at the prefill
communicator the same way. Elastic EP needs no rule at all: it scales
``ep_size`` / ``dp_size`` on the published bag while ``launch_world_size``
keeps the width the groups were built at, so the two are different names
rather than two answers to one name.
"""
__slots__ = ("_overrides", "_stamp", "_config")
@@ -624,10 +538,9 @@ class ParallelContext:
def _read(self, name):
"""The one read path, for every kind of name in the namespace.
Scoped override, then the permanent stamp, then what the name is
answered by when nobody has stated it: the published leaf for a
configured value or a derived width, the canonical getter for a rank
or a group handle.
Scoped override, then the permanent stamp, then the published bag.
A rank or a group handle is on no bag, so once those three are out the
name has not been written yet and the read says so.
The two override maps stay separate because they are taken down by
different things -- a `with` block and `clear_stamp()` -- and
@@ -643,21 +556,17 @@ class ParallelContext:
config = self._config
if config is not None and name in config._fields:
return getattr(config, name)
live = _LIVE_READS.get(name, _MISSING_READ)
if live is not _MISSING_READ:
source = live.source if isinstance(live, Live) else live
if isinstance(source, str):
return getattr(_ps(), source)()
if source is not None:
return source(self)
why = live.unstamped if isinstance(live, Live) else ""
raise RuntimeError(
f"parallel name {name!r} is not available: "
+ (why or "nothing has stamped it in this process")
)
if config is None and name in _parallel_config_leaves():
raise ValueError("config namespace 'parallel' not published")
if name in _derived_widths():
declared = _derived_widths().get(name)
if declared is not None and not declared.fn:
raise RuntimeError(
f"parallel name {name!r} has not been written in this process. "
+ declared.doc
+ f" Write it by publishing a rank bundle or building the groups, "
f"or state it with get_parallel().override({name}=...)"
)
if declared is not None:
raise RuntimeError(
f"derived parallel width {name!r} is not available: it is computed "
"from the configured leaves at publish, and permanently corrected "
@@ -731,33 +640,24 @@ def _derived_widths() -> dict:
def _install_parallel_properties() -> None:
"""Give `ParallelContext` a property per name that is not a config leaf.
The quotients are declared in `arg_groups/fields/parallel.py`, beside the
leaves they are computed from; the ranks and group handles are declared in
`_LIVE_READS`, because no configuration carries them. Properties rather
than names left to `__getattr__` because the class surface is what the
guards introspect -- `hasattr(ParallelContext, "tp_group")` and
`vars(ParallelContext)` are how the tests check the set from the class
side -- and because each one carries its `Derived.doc`.
Every name the namespace answers that is not a plain leaf: the quotients,
and the ranks and group handles declared beside them with no `fn`.
Properties rather than names left to `__getattr__` because the class
surface is what the guards introspect -- `hasattr(ParallelContext,
"tp_size")` and `vars(ParallelContext)` are how the tests read the set from
the class side -- and because each one carries its `Derived.doc`.
Every one of them resolves through `_read`, so there is a single priority
chain rather than one per kind of name.
"""
docs = {name: decl.doc for name, decl in _derived_widths().items()}
docs.update(
{
name: live.doc
for name, live in _LIVE_READS.items()
if isinstance(live, Live) and live.doc
}
)
for name in list(_derived_widths()) + list(_LIVE_READS):
for name, decl in _derived_widths().items():
def getter(self, _name=name):
return self._read(_name)
getter.__name__ = name
getter.__doc__ = docs.get(name)
getter.__doc__ = decl.doc
setattr(ParallelContext, name, property(getter))
@@ -1912,6 +1812,12 @@ def publish(
),
)
_CONTEXT._publish_role = role
# Zero for every process when decode context parallelism is off, which is a
# fact about the configuration and not about the spawn -- so it answers
# without a rank bundle, the way it did when it stood for a group that was
# never built. With DCP on it is a position, and the bundle below states it.
if not _CONTEXT.parallel.dcp_enabled:
_CONTEXT.parallel.override_permanently(attn_dcp_rank=0)
if ranks is not None and ranks.gpu_id is not None:
_CONTEXT.override("spawn", gpu_id=ranks.gpu_id)
if ranks is not None:
@@ -1931,18 +1837,25 @@ def publish(
moe_dp_size=parallel.moe_dp_size,
moe_ep_size=parallel.moe_ep_size,
)
# `moe_dp_rank` is a different quantity when the MoE-DP group is
# aliased to the attention-CP one: the group answers the CP index,
# while this computes the MoE-DP index. Leave it to the group there, so
# one name does not mean two things.
# `initialize_model_parallel` aliases the MoE-DP group to the
# attention-CP one when the CP dimension is the wider of the two, so
# this process's place in it is its CP index rather than the MoE-DP
# index the arithmetic above gives.
if parallel.moe_dp_size < parallel.attn_cp_size:
placement.pop("moe_dp_rank")
placement["moe_dp_rank"] = placement["attn_cp_rank"]
# `dp_rank` is recorded whatever it is, None included: replicas are
# separate WORLD groups, so no rank implies it and `None` is the answer
# "no controller" rather than an absence.
placement["dp_rank"] = ranks.dp_rank
placement["launch_world_rank"] = ranks.world_rank
placement.update(_attention_ranks(parallel, placement["tp_rank"]))
# A DCP group is a contiguous slice of a TP group, so this process's
# place in one is its TP rank folded by that width. `attn_dcp_rank` is
# the same number, and zero where decode context parallelism is off, so
# a reader does not have to ask whether it is on first.
if parallel.dcp_enabled:
placement["dcp_rank"] = placement["tp_rank"] % parallel.dcp_size
placement["attn_dcp_rank"] = placement.get("dcp_rank", 0)
# One stamp, not two: the identities are checked on every write, and a
# half-placed process satisfies none of them.
parallel.override_permanently(**placement)
@@ -24,8 +24,11 @@ from sglang.srt.runtime_context import get_context, get_parallel
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
# Unit tests run without distributed initialization. Backends that size buffers by
# attention tensor-parallel degree should see the single-rank default.
_parallel_override = get_parallel().override(attn_tp_size=1)
# attention tensor-parallel degree should see the single-rank default, and a
# backend that places itself in the decode context-parallel group needs a
# position: nothing publishes here, so there is no configuration to derive the
# zero these tests run at from.
_parallel_override = get_parallel().override(attn_tp_size=1, attn_dcp_rank=0)
_parallel_override.__enter__()
DEFAULT_HEAD_DIM = 16
+14 -1
View File
@@ -2095,7 +2095,13 @@ def publish_build_topology(*, world_rank: int = 0, **server_args_fields):
build outlive any block, so the configuration describing them has to as
well. Callers that tear the groups down are already resetting the process.
"""
from sglang.srt.runtime_context import SpawnRanks, publish, reset_context
from sglang.srt.distributed import parallel_state
from sglang.srt.runtime_context import (
SpawnRanks,
get_parallel,
publish,
reset_context,
)
from sglang.srt.server_args import ServerArgs
reset_context()
@@ -2104,6 +2110,13 @@ def publish_build_topology(*, world_rank: int = 0, **server_args_fields):
role="test",
ranks=SpawnRanks(world_rank=world_rank),
)
# Callers that go on to build groups have already run
# `init_distributed_environment`, which states the WORLD group -- and the
# build below places every group it creates by reading that back. The reset
# above drops it, so hand it over again: publishing a configuration does not
# unbuild a process group.
if parallel_state._WORLD is not None:
get_parallel().override_permanently(world_group=parallel_state._WORLD)
_GPU_IDLE_TIMEOUT_SECS = 30.0
+3 -1
View File
@@ -134,7 +134,9 @@ class TestFilterDcpLocalChunkKvIndices(CustomTestCase):
def test_identity_without_dcp(self):
kv = torch.arange(37)
with rc.get_parallel().override(dcp_enabled=False, dcp_size=1, dcp_rank=0):
with rc.get_parallel().override(
dcp_enabled=False, dcp_size=1, dcp_rank=0, attn_dcp_rank=0
):
self.assertIs(
filter_dcp_local_chunk_kv_indices(
kv, torch.tensor([0]), torch.tensor([37])
@@ -25,6 +25,7 @@ import torch
import torch.distributed as dist
import sglang.srt.distributed.parallel_state as ps
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(
@@ -130,6 +131,9 @@ def init_distributed():
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
cpu_group = coord.cpu_group
nccl_group = coord.device_group
@@ -14,6 +14,7 @@ from sglang.kernels.jit.benchmark import marker
from sglang.kernels.jit.benchmark.utils import get_benchmark_range, multigpu_bench_main
from sglang.kernels.jit.utils import cache_once, is_arch_support_pdl
from sglang.kernels.ops.communication.mp import register_comm_cleanup
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
register_cuda_ci(
@@ -61,6 +62,9 @@ def _init_cpu_group() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
torch.cuda.set_stream(torch.cuda.Stream())
return coord.cpu_group
@@ -33,6 +33,7 @@ from sglang.srt.distributed.device_communicators.triton_symm_mem_ag import (
all_gather_inner,
create_state,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
register_cuda_ci(
@@ -77,6 +78,9 @@ def _init_cpu_group() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=ps._WORLD)
atexit.register(dist.destroy_process_group)
logging.disable(logging.INFO)
torch.cuda.set_stream(torch.cuda.Stream())
@@ -36,6 +36,7 @@ from sglang.kernels.ops.communication.mp import register_comm_cleanup
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
register_cuda_ci(
@@ -108,6 +109,9 @@ def _init_cpu_group() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
logging.disable(logging.INFO)
torch.cuda.set_stream(torch.cuda.Stream())
@@ -37,6 +37,7 @@ from sglang.kernels.ops.communication.mp import register_comm_cleanup
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
@@ -129,6 +130,9 @@ def _init_cpu_group_once() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
cpu_group = coord.cpu_group
assert isinstance(cpu_group, dist.ProcessGroup)
@@ -31,6 +31,7 @@ from sglang.srt.distributed.device_communicators.triton_symm_mem_ag import (
all_gather_inner,
create_state,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
@@ -70,6 +71,9 @@ def _init_cpu_group_once() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=ps._WORLD)
atexit.register(dist.destroy_process_group)
logging.disable(logging.INFO)
torch.cuda.set_stream(torch.cuda.Stream())
@@ -24,6 +24,7 @@ from sglang.kernels.ops.communication.mp import register_comm_cleanup
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
@@ -92,6 +93,9 @@ def _init_cpu_group_once() -> dist.ProcessGroup:
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
cpu_group = coord.cpu_group
assert isinstance(cpu_group, dist.ProcessGroup)
@@ -30,6 +30,7 @@ from sglang.kernels.ops.kimi_k3 import all_reduce
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
@@ -65,6 +66,9 @@ def _init_world():
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
logging.disable(logging.INFO)
torch.cuda.set_stream(torch.cuda.Stream())
@@ -20,6 +20,7 @@ from sglang.kernels.ops.kimi_k3 import (
from sglang.srt.distributed.device_communicators.custom_all_reduce_v2 import (
CustomAllReduceV2,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kernels.utils import multigpu_pytest_main
@@ -53,6 +54,9 @@ def _init_world():
local_rank=local_rank,
backend="nccl",
)
# The context answers a handle from what was stated on it, not from
# this module global, so a rank stood up by hand says so itself.
get_parallel().override_permanently(world_group=coord)
atexit.register(dist.destroy_process_group)
cpu_group = coord.cpu_group
assert isinstance(cpu_group, dist.ProcessGroup)
@@ -48,6 +48,7 @@ from sglang.srt.mem_cache.multimodal_cache import (
EmbeddingResult,
MultiModalStaticCache,
)
from sglang.srt.runtime_context import get_parallel
from sglang.srt.utils.common import safe_pickle_loads
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -1011,10 +1012,7 @@ class TestEncoderDelivery(CustomTestCase):
statuses[1].copy_(torch.tensor([400, 1, 0, 0]))
with (
patch(
"sglang.srt.distributed.parallel_state.get_tp_group",
return_value=TPGroup(),
),
get_parallel().override(tp_group=TPGroup()),
patch(
"sglang.srt.disaggregation.encoder.server.torch.distributed.all_gather",
side_effect=all_gather,
@@ -1051,10 +1049,7 @@ class TestEncoderDelivery(CustomTestCase):
statuses[1][2] += 1
with (
patch(
"sglang.srt.distributed.parallel_state.get_tp_group",
return_value=TPGroup(),
),
get_parallel().override(tp_group=TPGroup()),
patch(
"sglang.srt.disaggregation.encoder.server.torch.distributed.all_gather",
side_effect=all_gather,
@@ -1095,10 +1090,7 @@ class TestEncoderDelivery(CustomTestCase):
statuses[1].copy_(local_status)
with (
patch(
"sglang.srt.distributed.parallel_state.get_tp_group",
return_value=TPGroup(),
),
get_parallel().override(tp_group=TPGroup()),
patch(
"sglang.srt.disaggregation.encoder.server.torch.distributed.all_gather",
side_effect=all_gather,
@@ -9,7 +9,7 @@ import unittest
from unittest.mock import MagicMock, call, patch
from sglang.srt.environ import envs
from sglang.srt.runtime_context import get_context
from sglang.srt.runtime_context import get_context, get_parallel
from sglang.test.test_utils import CustomTestCase
@@ -201,12 +201,12 @@ class TestRegisterToBootstrap(CustomTestCase):
self.assertIn("10.0.0.1", url_used)
@patch("sglang.srt.disaggregation.common.conn.requests.put")
# The consumer reads the group through `get_parallel()`, which reads
# through to the canonical getter, so that is where the stub belongs.
@patch("sglang.srt.distributed.parallel_state.get_world_group")
def test_rust_attention_dp_replicates_complete_topology_across_hosts(
self, mock_world_group, mock_put
self, mock_put
):
# The consumer reads the group through `get_parallel()`, so the
# stub is stated there rather than in the module the build writes.
mock_world_group = MagicMock()
success_resp = MagicMock()
success_resp.status_code = 200
mock_put.return_value = success_resp
@@ -230,9 +230,12 @@ class TestRegisterToBootstrap(CustomTestCase):
for dp_rank, tp_rank, host, rank_port, _ in schedulers
]
mock_world_group.return_value.all_gather_object.side_effect = gather_topology
mock_world_group.all_gather_object.side_effect = gather_topology
with envs.SGLANG_RUST_SERVER.override(True):
with (
get_parallel().override(world_group=mock_world_group),
envs.SGLANG_RUST_SERVER.override(True),
):
for dp_rank, tp_rank, local_ip, _, rust_http_port in schedulers:
manager = self._make_manager()
manager.attn_dp_size = 2
@@ -277,7 +280,7 @@ class TestRegisterToBootstrap(CustomTestCase):
gather_call.args[0]["attn_dp_rank"],
gather_call.args[0]["attn_tp_rank"],
)
for gather_call in mock_world_group.return_value.all_gather_object.call_args_list
for gather_call in mock_world_group.all_gather_object.call_args_list
],
[(dp, tp) for dp, tp, _, _, _ in schedulers],
)
@@ -93,6 +93,9 @@ def _make_prefill_aware_swa_runner(
page_size=1,
attn_cp_size=1,
tp_size=1,
# The backend still reads the runner's frozen record for these two;
# same single-rank placement, stated where it looks for it.
ps=SimpleNamespace(attn_cp_size=1, tp_size=1),
is_draft_worker=False,
server_args=server_args,
attention_chunk_size=None,
@@ -14,6 +14,7 @@ from sglang.test.test_utils import CustomTestCase, maybe_stub_sgl_kernel
maybe_stub_sgl_kernel()
from sglang.srt.layers.moe import mega_moe
from sglang.srt.runtime_context import get_parallel
register_cpu_ci(est_time=8, suite="base-a-test-cpu")
@@ -271,11 +272,8 @@ class TestDeepGemmMegaMoeApi(CustomTestCase):
"init_new",
return_value=object(),
),
patch(
"sglang.srt.runtime_context.get_parallel",
return_value=SimpleNamespace(
moe_ep_group=SimpleNamespace(device_group=object())
),
get_parallel().override(
moe_ep_group=SimpleNamespace(device_group=object())
),
):
mega_moe._run_mega_routed(
@@ -11,6 +11,7 @@ from unittest.mock import Mock, patch
from parameterized import parameterized
from sglang.srt.distributed import parallel_state
from sglang.srt.managers import scheduler as scheduler_module
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.managers.scheduler import Scheduler
@@ -168,12 +169,21 @@ class TestSchedulerIdleStepCounters(CustomTestCase):
)
with (
patch(f"{PDMUX_MODULE}.get_current_stream_idx", return_value=0),
patch(f"{PDMUX_MODULE}.set_pdmux_status"),
patch(f"{PDMUX_MODULE}.torch.cuda.empty_cache"),
patch(
f"{PDMUX_MODULE}.torch.cuda.stream",
side_effect=lambda stream: nullcontext(),
),
# The prefill section runs under the duplicate communicator
# `--enable-pdmux` builds, in place of the module flag this
# replaces. The loop has no process groups at all, so stand
# one in: the scope refuses to open without it rather than
# letting prefill quietly share the decode communicator.
patch.object(
parallel_state,
"_PDMUX_PREFILL_TP_GROUP",
SimpleNamespace(world_size=1, rank_in_group=0),
),
):
self.run_and_check(
scheduler,
@@ -297,7 +297,9 @@ class TestPPMambaPoolSizing(unittest.TestCase):
server_args=SimpleNamespace(),
spec_algorithm=SimpleNamespace(is_none=lambda: True),
layer_info=SimpleNamespace(start_layer=start, end_layer=end),
ps=SimpleNamespace(attn_dp_size=1, pp_size=pp_size),
# The runner carries its placement as plain attributes.
attn_dp_size=1,
pp_size=pp_size,
hybrid_gdn_config=None,
model_config=SimpleNamespace(
hf_config=SimpleNamespace(), num_hidden_layers=cls.TOTAL_LAYERS
@@ -304,16 +304,13 @@ class TestHostMemoryBudget(CustomTestCase):
def test_ranks_per_host_divides_world_size_by_nodes(self):
# The launcher slices ranks uniformly across nodes, so the co-located
# rank count is world_size // nnodes — no hostname collective.
fake_group = unittest.mock.Mock(world_size=16)
# tp_size=16 states the launch width the count divides -- the
# published configuration is where ranks_per_host reads it from.
with (
get_context().override_server_args(nnodes=2),
get_context().override_server_args(nnodes=2, tp_size=16),
unittest.mock.patch.object(
torch.distributed, "is_initialized", return_value=True
),
unittest.mock.patch(
"sglang.srt.distributed.parallel_state.get_world_group",
return_value=fake_group,
),
):
self.assertEqual(base.ranks_per_host(), 8)
@@ -248,7 +248,7 @@ class TestUnifiedMLATokenToKVPool(unittest.TestCase):
] = float(layer + 1)
with (
get_parallel().override(dcp_enabled=False),
get_parallel().override(dcp_enabled=False, attn_dcp_rank=0),
mock.patch(
"sglang.srt.mem_cache.memory_pool.current_platform.synchronize"
),
@@ -411,7 +411,9 @@ class TestMambaAllocatorCpuCopyIsPhysical(unittest.TestCase):
_FakeKVCache(pool.max_slots("full")),
_FakeKVCache(pool.max_slots("mamba")),
)
with get_parallel().override(dcp_enabled=False, attn_dcp_size=1):
with get_parallel().override(
dcp_enabled=False, attn_dcp_size=1, attn_dcp_rank=0
):
allocator = UnifiedMambaTokenToKVPoolAllocator(
unified_buffer=pool, kvcache=kvcache, device=_DEV, page_size=ps
)
@@ -18,6 +18,7 @@ from sglang.srt.model_executor.runner_backend.full_cuda_graph_backend import (
FullCudaGraphBackend,
)
from sglang.srt.model_executor.runner_utils import pool
from sglang.srt.runtime_context import get_parallel
from sglang.srt.speculative import dflash_utils, dflash_worker_v2, eagle_utils
from sglang.srt.speculative.dflash_worker_v2 import DFlashWorkerV2
from sglang.test.ci.ci_register import register_cuda_ci
@@ -222,12 +223,7 @@ class TestGraphPoolBorrow(CustomTestCase):
"sglang.srt.layers.dp_attention.is_dp_attention_enabled",
return_value=False,
),
# `parallel_state`, not the package re-export: a stub on the
# re-export is never consulted.
patch(
"sglang.srt.distributed.parallel_state.get_tp_group",
return_value=tp_group,
),
get_parallel().override(tp_group=tp_group),
patch(
"sglang.kernels.ops.speculative.sampling.tree_speculative_sampling_target_only",
side_effect=fake_sampling,
@@ -11,7 +11,7 @@ from sglang.srt.model_executor.cuda_graph_config import (
)
from sglang.srt.model_executor.model_runner_components import kv_pool_runtime
from sglang.srt.model_executor.pool_configurator import MemoryPoolConfig
from sglang.srt.runtime_context import get_context
from sglang.srt.runtime_context import get_context, get_parallel
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -60,9 +60,8 @@ class TestCanaryHeadroom(CustomTestCase):
),
),
patch.object(kv_pool_runtime.torch.cuda, "synchronize"),
patch(
"sglang.srt.distributed.parallel_state.get_world_group",
return_value=SimpleNamespace(world_size=1, cpu_group=None),
get_parallel().override(
world_group=SimpleNamespace(world_size=1, cpu_group=None)
),
patch.object(kv_pool_runtime, "get_available_gpu_memory", return_value=20),
patch.object(kv_pool_runtime, "mambaish_config", return_value=None),
@@ -36,7 +36,11 @@ def mock_cpu_env(kv_size=2, tp_size=1, swa_eviction_interval=4):
with (
patch("torch._utils._element_size", return_value=kv_size),
get_parallel().override(attn_tp_size=tp_size),
# A width is a whole topology: state the TP siblings the identities
# relate it to, not the attention share alone.
get_parallel().override(
tp_size=tp_size, attn_tp_size=tp_size, moe_tp_size=tp_size
),
envs.SGLANG_SWA_EVICTION_INTERVAL.override(swa_eviction_interval),
):
yield
@@ -53,9 +53,7 @@ class TestModelOptExport(unittest.TestCase):
self.mock_logger.start()
# Mock all distributed functions that might be called
self.mock_get_tp_group = patch(
"sglang.srt.distributed.parallel_state.get_tp_group"
)
self.mock_get_tp_group = patch("sglang.srt.distributed.parallel_state._TP")
self.mock_get_tp_group.start()
# Mock model parallel initialization check
@@ -82,9 +82,7 @@ class TestModelOptModelLoader(CustomTestCase):
self.mock_logger.start()
# Mock all distributed functions that might be called
self.mock_get_tp_group = patch(
"sglang.srt.distributed.parallel_state.get_tp_group"
)
self.mock_get_tp_group = patch("sglang.srt.distributed.parallel_state._TP")
self.mock_get_tp_group.start()
# Mock model parallel initialization check
@@ -25,6 +25,7 @@ from sglang.srt.model_loader.weight_utils import (
fastsafetensors_weights_iterator,
safetensors_weights_iterator,
)
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -237,10 +238,7 @@ class TestPrefetchCheckpoints(CustomTestCase):
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", _InlineExecutor),
patch("concurrent.futures.wait", side_effect=_wait_all),
patch(
"sglang.srt.distributed.parallel_state.get_world_group",
return_value=FakeWorldGroup(),
),
get_parallel().override(world_group=FakeWorldGroup()),
patch(
"sglang.srt.model_loader.weight_utils._prefetch_checkpoint_file",
side_effect=lambda path, cancel_event: loaded_paths.append(path),
@@ -652,10 +652,7 @@ class TestStructuralSignature(unittest.TestCase):
# must see the same gathered list and thus the same aggregate.
fake_group.all_gather_object.side_effect = lambda local: ["sig-pp0", "sig-pp1"]
with mock.patch(
"sglang.srt.distributed.parallel_state.get_world_group",
return_value=fake_group,
):
with get_parallel().override(world_group=fake_group):
agg_from_rank0 = (
PreshardedModelLoader._make_rank_invariant_structural_signature(
"sig-pp0"
@@ -673,10 +670,7 @@ class TestStructuralSignature(unittest.TestCase):
"sig-pp0",
"sig-pp1-changed",
]
with mock.patch(
"sglang.srt.distributed.parallel_state.get_world_group",
return_value=fake_group,
):
with get_parallel().override(world_group=fake_group):
agg_changed = (
PreshardedModelLoader._make_rank_invariant_structural_signature(
"sig-pp0"
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from sglang.srt.models.transformers import TransformersBase
from sglang.srt.runtime_context import get_parallel
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -54,10 +55,9 @@ class TestTransformersFallbackSkipSubstrs(CustomTestCase):
pass
with (
patch(
"sglang.srt.distributed.parallel_state.get_pp_group",
return_value=SimpleNamespace(),
),
# `__init__` only stashes the pipeline group, so an empty
# stand-in carries it past the read.
get_parallel().override(pp_group=SimpleNamespace()),
patch(
"sglang.srt.models.transformers.get_hf_text_config",
return_value=SimpleNamespace(),
@@ -1,6 +1,5 @@
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import torch
@@ -13,6 +12,7 @@ from sglang.srt.multimodal.internvl_vit_cuda_graph_runner import (
InternViTCudaGraphRunner,
)
from sglang.srt.multimodal.vit_cuda_graph_runner import ViTCudaGraphRunner
from sglang.srt.runtime_context import get_parallel
class _Block:
@@ -32,12 +32,10 @@ def _runner(*, use_data_parallel: bool) -> ViTCudaGraphRunner:
def test_dp_vit_graph_capture_does_not_enter_tp_communication_capture():
runner = _runner(use_data_parallel=True)
with patch(
"sglang.srt.distributed.parallel_state.get_tp_group",
side_effect=AssertionError("DP capture must be rank-local"),
):
with runner._capture_context():
pass
# No tp_group is stated, so reading one would raise: the DP path must not
# ask for the TP group at all.
with runner._capture_context():
pass
def test_non_dp_vit_graph_capture_uses_tp_communication_capture():
@@ -52,9 +50,7 @@ def test_non_dp_vit_graph_capture_uses_tp_communication_capture():
group = SimpleNamespace(ca_comm=SimpleNamespace(capture=lambda: Capture()))
runner = _runner(use_data_parallel=False)
with patch(
"sglang.srt.distributed.parallel_state.get_tp_group", return_value=group
):
with get_parallel().override(tp_group=group):
with runner._capture_context():
pass
assert entered == [True]
@@ -132,18 +132,18 @@ def _stash_overlay(server_args):
def _live_topology_leaves():
"""Names `ParallelContext` serves from the live topology, not the config.
"""Names `ParallelContext` answers from a runtime write, not the config.
Read out of `_LIVE_READS`, which is where those names are declared.
Inferring them from "did the read raise" is wrong -- it only raises while
the process groups are missing, so in a process where an earlier test built
them the property answers the *live* size and a leaf check reads it as a
config mismatch (`parallel.tp_size: bag=1 resolution=2`). Whether a name is
Read off the declarations that carry no `fn`, which is what those are.
Inferring them from "did the read raise" is wrong -- it raises only while
nothing has written the name, so in a process where an earlier test stated
one the property answers that value and a leaf check reads it as a config
mismatch (`parallel.tp_size: bag=1 resolution=2`). Whether a name is
shadowed is a property of the declaration, not of the process.
"""
from sglang.srt.runtime_context import _LIVE_READS
from sglang.srt.runtime_context import _derived_widths
return frozenset(_LIVE_READS)
return frozenset(n for n, d in _derived_widths().items() if not d.fn)
class TestResolutionDeclarations(CustomTestCase):
@@ -249,6 +249,8 @@ def test_worker_folds_a_gate_admitted_quantized_selector_head(monkeypatch):
block_size=8,
selector=object(),
model_runner=SimpleNamespace(tp_rank=0),
# The worker rank-gates its logging on its own frozen record.
ps=SimpleNamespace(tp_rank=0),
draft_model=SimpleNamespace(lm_head=None),
device="cpu",
_selector_sampling_enabled=True,
@@ -283,6 +285,8 @@ def test_worker_warns_once_when_selector_sampling_is_disabled(monkeypatch):
_selector_sampling_enabled=False,
_warned_sampling_fallback=False,
model_runner=SimpleNamespace(tp_rank=0),
# The worker rank-gates its logging on its own frozen record.
ps=SimpleNamespace(tp_rank=0),
)
batch = SimpleNamespace(sampling_info=SimpleNamespace(is_all_greedy=False))
+439 -171
View File
@@ -10,8 +10,10 @@ import os
import pathlib as _pathlib
import shutil
import tempfile
import types
import unittest
import warnings
from types import SimpleNamespace
from unittest.mock import patch
import msgspec
@@ -41,7 +43,6 @@ from sglang.srt.runtime_context import (
RuntimeContext,
SpawnRanks,
_FlagGroupBase,
_validate_parallel,
assert_published,
derive_parallel_widths,
get_context,
@@ -111,39 +112,58 @@ def _scope_entries_that_say_nothing(paths):
_PS = "sglang.srt.distributed.parallel_state"
def _parallel_state():
from sglang.srt.distributed import parallel_state
return parallel_state
_DP = "sglang.srt.layers.dp_attention"
# Ranks and the launch width are asked of the group: they are not implied by
# anything, so there is nothing to derive them from. The quotients are not
# here -- `attn_tp_size` and its siblings are functions of the configured
# leaves, and `TestDerivedWidths` pins them. `attn_dp_rank` is not here either: no group coordinator
# knows it, so it is stamped when the attention topology is initialized and
# `TestStampedRanks` is what pins it. The other world width is not here
# because the group does not know it; `TestTheTwoWorldWidths` pins it.
SIZE_RANK_DELEGATIONS = [
("launch_world_size", f"{_PS}.get_world_size"),
("launch_world_rank", f"{_PS}.get_world_rank"),
("tp_rank", f"{_PS}.get_tensor_model_parallel_rank"),
("dcp_rank", f"{_PS}.get_dcp_rank"),
("pp_rank", f"{_PS}.get_pipeline_model_parallel_rank"),
("moe_ep_rank", f"{_PS}.get_moe_expert_parallel_rank"),
("moe_dp_rank", f"{_PS}.get_moe_data_parallel_rank"),
("moe_tp_rank", f"{_PS}.get_moe_tensor_parallel_rank"),
("attn_tp_rank", f"{_PS}.get_attn_tensor_model_parallel_rank"),
("attn_cp_rank", f"{_PS}.get_attn_context_model_parallel_rank"),
]
#: The groups `initialize_model_parallel` states on the context, by the module
#: global it builds each one into. WORLD is not among them: it is built and
#: stated by `init_distributed_environment`, one call earlier.
GROUP_STAMPS = {
"tp_group": "_TP",
"dcp_group": "_DCP",
"pp_group": "_PP",
"moe_ep_group": "_MOE_EP",
"moe_dp_group": "_MOE_DP",
"moe_tp_group": "_MOE_TP",
"attn_tp_group": "_ATTN_TP",
"attn_cp_group": "_ATTN_CP",
"shared_experts_tp_group": "_SHARED_EXPERTS_TP",
}
GROUP_DELEGATIONS = [
("world_group", f"{_PS}.get_world_group"),
("tp_group", f"{_PS}.get_tp_group"),
("dcp_group", f"{_PS}.get_dcp_group"),
("pp_group", f"{_PS}.get_pp_group"),
("moe_ep_group", f"{_PS}.get_moe_ep_group"),
("moe_dp_group", f"{_PS}.get_moe_dp_group"),
("moe_tp_group", f"{_PS}.get_moe_tp_group"),
("attn_tp_group", f"{_PS}.get_attn_tp_group"),
("attn_cp_group", f"{_PS}.get_attn_cp_group"),
]
def _groups_the_build_states() -> dict:
"""What `initialize_model_parallel` hands the context, read out of its
source: `{context name: the module global it passes}`.
Out of the source because that is the only place the whole set appears at
once -- calling the function needs ten live process groups.
"""
import ast
import inspect
import textwrap
from sglang.srt.distributed import parallel_state
body = textwrap.dedent(inspect.getsource(parallel_state.initialize_model_parallel))
for node in ast.walk(ast.parse(body)):
keys = getattr(node, "keys", None)
if (
isinstance(node, ast.Dict)
and keys
and all(
isinstance(k, ast.Constant) and str(k.value).endswith("_group")
for k in keys
)
):
return {k.value: v.id for k, v in zip(node.keys, node.values)}
raise AssertionError("initialize_model_parallel states no group at all")
class TestRuntimeContextSingletons(CustomTestCase):
@@ -171,29 +191,91 @@ class _IsolatedOverrides(CustomTestCase):
super().tearDown()
class TestTheBuildStatesEveryGroup(_IsolatedOverrides):
"""Nothing derives a group, so one the build forgets to state is a name
that answers "not written" for the rest of the process -- and the reader
that finds out is a model layer, a long way from here."""
def test_the_build_states_every_group_the_context_declares(self):
from sglang.srt.runtime_context import _parallel_fields
declared = {name for name in _parallel_fields() if name.endswith("_group")}
self.assertEqual(declared, set(GROUP_STAMPS) | {"world_group"})
def test_the_world_group_is_stated_where_it_is_built(self):
"""`initialize_model_parallel` places every group it builds by reading
`get_world_group().local_rank`, so WORLD has to be answerable before it
runs -- one function earlier, where it is constructed."""
import ast
import inspect
import textwrap
from sglang.srt.distributed import parallel_state
body = textwrap.dedent(
inspect.getsource(parallel_state.init_distributed_environment)
)
stated = {
kw.arg
for node in ast.walk(ast.parse(body))
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "override_permanently"
for kw in node.keywords
}
self.assertIn("world_group", stated)
def test_nothing_reads_a_name_this_build_has_not_stated_yet(self):
"""The stamp is at the end, so a getter called before it answers a name
nothing has written -- a crash at startup, in a process no unit test
runs. What the function may read is what a *previous* call stated, and
that is WORLD alone: a rank belongs to the spawn, and this build does
not get to assume the spawn ran first."""
import ast
import inspect
import textwrap
from sglang.srt.distributed import parallel_state
body = textwrap.dedent(
inspect.getsource(parallel_state.initialize_model_parallel)
)
read = set()
for node in ast.walk(ast.parse(body)):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
name = parallel_state._CONTEXT_NAME_OF.get(node.func.id)
if name is not None:
read.add(name)
self.assertTrue(read, "no getter is called here; this proves nothing")
self.assertEqual(read - {"world_group"}, set())
def test_each_group_is_stated_from_the_global_it_was_built_into(self):
self.assertEqual(_groups_the_build_states(), GROUP_STAMPS)
def test_a_dimension_the_configuration_has_not_got_is_left_unstated(self):
"""`_DCP` is None without decode context parallelism, and every one of
these getters has always refused to answer for a group that was never
built rather than handing back a None to fail on at the collective."""
import ast
import inspect
import textwrap
from sglang.srt.distributed import parallel_state
body = textwrap.dedent(
inspect.getsource(parallel_state.initialize_model_parallel)
)
stamp = next(
node
for node in ast.walk(ast.parse(body))
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "override_permanently"
)
self.assertIn("is not None", ast.unparse(stamp))
class TestParallelDelegation(_IsolatedOverrides):
def test_size_rank_delegate_to_canonical_getters(self):
# Patch each getter to a distinct sentinel: a miswired attribute would read
# a different (unpatched) getter and fail.
for i, (attr, target) in enumerate(SIZE_RANK_DELEGATIONS):
sentinel = 1000 + i
with patch(target, return_value=sentinel):
self.assertEqual(
getattr(get_parallel(), attr),
sentinel,
msg=f"{attr} must delegate to {target}",
)
def test_groups_delegate_to_canonical_getters(self):
for attr, target in GROUP_DELEGATIONS:
sentinel = object()
with patch(target, return_value=sentinel):
self.assertIs(
getattr(get_parallel(), attr),
sentinel,
msg=f"{attr} must delegate to {target}",
)
def test_wrapper_holds_no_resolved_state(self):
# __slots__: no __dict__; the only instance state is the override hook.
self.assertFalse(hasattr(get_parallel(), "__dict__"))
@@ -204,50 +286,46 @@ class TestParallelDelegation(_IsolatedOverrides):
class TestTheTwoWorldWidths(_IsolatedOverrides):
"""Two questions about the WORLD group: what it was built at, and what it
has room for.
"""Two questions about the WORLD group: what it was launched at, and what
it has room for.
Neither is stored here. How much of that room is serving after a scale-up
is elastic-EP state, and is asked of the manager that owns it rather than
mirrored onto this namespace.
Both are arithmetic over the configured leaves and are worked out at
publish, so they answer in a process that never builds the group -- which
is where a good half of the readers are. How much of that room is serving
after a scale-up is neither of them: that is elastic-EP state, asked of the
manager that owns it rather than mirrored onto this namespace.
"""
def test_the_launch_width_is_what_the_group_was_built_at(self):
with patch(f"{_PS}.get_world_size", return_value=4):
self.assertEqual(get_parallel().launch_world_size, 4)
def _published(self, **fields):
reset_context()
self.addCleanup(reset_context)
publish(ServerArgs(model_path="dummy", **fields), role="test")
return get_parallel()
def test_the_launch_width_is_a_rank_per_stage_of_each_group(self):
self.assertEqual(self._published(tp_size=4, pp_size=2).launch_world_size, 8)
def test_the_launch_width_spans_the_ranks_a_joiner_came_in_above(self):
"""A scale joiner lays its own groups out at `tp * pp`, while its WORLD
spans the cohort already running underneath it as well."""
parallel = self._published(tp_size=4, pp_size=1, ep_join_rank_offset=8)
self.assertEqual(parallel.launch_world_size, 12)
def test_the_ceiling_is_the_configured_one_when_there_is_one(self):
parallel = get_parallel()
with (
parallel.override(max_ep_size=32),
patch(
f"{_PS}.get_world_size",
side_effect=AssertionError("the built group must not be asked"),
),
):
self.assertEqual(parallel.max_world_size, 32)
self.assertEqual(self._published(tp_size=4, max_ep_size=32).max_world_size, 32)
def test_without_a_configured_ceiling_the_room_is_the_launch_width(self):
parallel = get_parallel()
with (
parallel.override(max_ep_size=None),
patch(f"{_PS}.get_world_size", return_value=8),
):
self.assertEqual(parallel.max_world_size, 8)
parallel = self._published(tp_size=8)
self.assertEqual(parallel.launch_world_size, 8)
self.assertEqual(parallel.max_world_size, 8)
def test_each_width_can_be_stated_on_its_own(self):
"""Stating one must not answer for the other: they are two names."""
parallel = get_parallel()
with (
parallel.override(launch_world_size=2, max_ep_size=None),
patch(
f"{_PS}.get_world_size",
side_effect=AssertionError("the built group must not be asked"),
),
):
parallel = self._published(tp_size=8)
with parallel.override(launch_world_size=2):
self.assertEqual(parallel.launch_world_size, 2)
self.assertEqual(parallel.max_world_size, 2)
with parallel.override(max_ep_size=6):
self.assertEqual(parallel.max_world_size, 8)
with parallel.override(max_world_size=6):
self.assertEqual(parallel.max_world_size, 6)
self.assertEqual(parallel.launch_world_size, 2)
@@ -380,18 +458,20 @@ class TestAttentionRanksComeFromPublish(_IsolatedOverrides):
role="test",
ranks=SpawnRanks(world_rank=5),
)
with patch(
f"{_PS}.get_attn_tensor_model_parallel_rank",
side_effect=AssertionError("no group must be consulted"),
):
with patch.object(_parallel_state(), "_ATTN_TP", None):
self.assertEqual(get_parallel().attn_tp_rank, 1)
self.assertEqual(get_parallel().attn_dp_rank, 1)
def test_without_a_bundle_it_still_asks_the_group(self):
"""Unchanged for every process that publishes without a placement."""
def test_without_a_bundle_a_rank_read_says_what_is_missing(self):
"""There is nothing to fall back to. Deriving one from whatever group
happens to be installed would answer a different question -- where this
process sits in that group, not where the launcher put it."""
publish(ServerArgs(model_path="dummy", tp_size=8), role="test")
with patch(f"{_PS}.get_attn_tensor_model_parallel_rank", return_value=3):
self.assertEqual(get_parallel().attn_tp_rank, 3)
with self.assertRaises(RuntimeError) as caught:
get_parallel().attn_tp_rank
message = str(caught.exception)
self.assertIn("has not been written in this process", message)
self.assertIn("override(attn_tp_rank=...)", message)
class TestStampedRanks(_IsolatedOverrides):
@@ -568,16 +648,14 @@ class TestEveryDeclaredParallelNameIsStatable(_IsolatedOverrides):
The bag carries the declared quotients as well as the operator's
leaves, and both are ahead of the live getter once a configuration is
published: a name in `_LIVE_READS` and in either of them would answer
published: a declared name that is also a leaf would answer
from the getter before publish and from the bag after."""
from sglang.srt.runtime_context import (
_LIVE_READS,
_derived_widths,
_parallel_config_leaves,
)
self.assertEqual(set(_LIVE_READS) & _parallel_config_leaves(), set())
self.assertEqual(set(_LIVE_READS) & set(_derived_widths()), set())
self.assertEqual(set(_derived_widths()) & _parallel_config_leaves(), set())
def test_an_undeclared_name_is_refused(self):
with self.assertRaises(ValueError):
@@ -741,19 +819,43 @@ class TestParallelDCP(_IsolatedOverrides):
self.assertTrue(parallel.dcp_enabled)
self.assertEqual(parallel.attn_dcp_size, 8)
def test_the_dcp_rank_still_reads_the_group(self):
"""A rank is not implied by the configuration, so it reads the group --
gated on a width that is."""
with (
get_parallel().override(tp_size=8, dcp_size=8, dcp_enabled=False),
patch(f"{_PS}.get_dcp_rank", side_effect=AssertionError),
):
self.assertEqual(get_parallel().attn_dcp_rank, 0)
with (
get_parallel().override(tp_size=8, dcp_size=8, dcp_enabled=True),
patch(f"{_PS}.get_dcp_rank", return_value=3),
):
self.assertEqual(get_parallel().attn_dcp_rank, 3)
def _placed(self, world_rank, **fields):
reset_context()
self.addCleanup(reset_context)
publish(
ServerArgs(model_path="dummy", **fields),
role="test",
ranks=SpawnRanks(world_rank=world_rank),
)
return get_parallel()
def test_the_dcp_rank_is_where_the_tp_rank_falls_in_its_slice(self):
"""A DCP group is a contiguous slice of the TP group, so the place in
one is the TP rank folded by the width."""
parallel = self._placed(5, tp_size=8, dcp_size=4)
self.assertEqual(parallel.dcp_rank, 1)
self.assertEqual(parallel.attn_dcp_rank, 1)
def test_the_gated_off_rank_answers_without_a_spawn_bundle(self):
"""Zero for every process when decode context parallelism is off, so a
reader on a path that never publishes a bundle -- a memory pool, an
attention backend built in a unit test -- still gets an answer. It
stood for a group that was never built before, and it has to keep
answering the same way."""
reset_context()
self.addCleanup(reset_context)
publish(ServerArgs(model_path="dummy", tp_size=8), role="test")
self.assertEqual(get_parallel().attn_dcp_rank, 0)
def test_the_dcp_rank_is_gated_on_a_width_the_configuration_carries(self):
"""Zero where decode context parallelism is off, so a reader does not
have to ask whether it is on before asking where it sits -- and the
gated-off name is not answered at all, because no group holds it."""
parallel = self._placed(5, tp_size=8, dcp_size=1)
self.assertFalse(parallel.dcp_enabled)
self.assertEqual(parallel.attn_dcp_rank, 0)
with self.assertRaises(RuntimeError):
parallel.dcp_rank
def test_the_width_does_not_consult_the_platform(self):
with patch("sglang.srt.utils.is_cuda", return_value=False) as is_cuda:
@@ -2074,10 +2176,16 @@ class TestDerivedWidths(_IsolatedOverrides):
{name for name in widths if "world" in name},
set(),
)
parallel = get_parallel()
parallel.override_permanently(attn_tp_size=4)
with patch(f"{_PS}.get_world_size", return_value=9):
self.assertEqual(parallel.launch_world_size, 9)
# Its own arithmetic spans the offset, which is the leaf the quotients
# above are not given and could not account for.
from sglang.srt.runtime_context import launch_world_size_of
self.assertEqual(
launch_world_size_of(
SimpleNamespace(ep_join_rank_offset=8, tp_size=4, pp_size=1)
),
12,
)
def test_the_bare_name_is_gone(self):
"""It answered two questions, so every reader had to remember which.
@@ -2315,15 +2423,27 @@ class TestTheDerivedHalfIsDeclared(CustomTestCase):
f"{name} is declared but no property was installed",
)
def test_the_declared_set_is_what_derive_parallel_widths_produces(self):
"""The declaration is not a second list to keep in step: it names
exactly the quotients the derivation returns."""
from sglang.srt.arg_groups.arg_utils import Derived
from sglang.srt.arg_groups.fields.parallel import Parallel
def test_a_computed_name_names_the_function_that_computes_it(self):
"""The declaration is not a second list to keep in step: a name that
is a function of the leaves points at a function called after it, and
that function existing is the whole of what publish needs."""
import importlib
from sglang.srt.runtime_context import _derived_widths
computed = {n: d.fn for n, d in _derived_widths().items() if d.fn}
self.assertTrue(computed, "nothing is computed from the leaves")
for name, fn in computed.items():
module, _, attr = fn.rpartition(".")
self.assertEqual(attr, f"{name}_of", f"{name} is computed by {attr}")
self.assertTrue(callable(getattr(importlib.import_module(module), attr)))
def test_the_arithmetic_produces_nothing_that_is_not_declared(self):
"""The other side of it: a key the derivation returns and no
declaration names is a width the namespace never answers with, and the
group build re-states it into a name nobody can read."""
from sglang.srt.runtime_context import _derived_widths
declared = {
name for name, value in vars(Parallel).items() if isinstance(value, Derived)
}
produced = set(
derive_parallel_widths(
tp_size=8,
@@ -2335,7 +2455,7 @@ class TestTheDerivedHalfIsDeclared(CustomTestCase):
dcp_enabled=False,
)
)
self.assertEqual(declared, produced)
self.assertEqual(produced - set(_derived_widths()), set())
def test_a_declared_quotient_is_not_a_record_field(self):
"""It has no operator input to preserve, and the record is what crosses
@@ -2405,15 +2525,34 @@ class TestTheAccessorsHaveNoCallersOutsideTheirPackage(CustomTestCase):
has its own parallel state.
"""
#: Not topology. `get_self_pp_group` builds the single-rank group a draft
#: pipeline scope installs, so there is nothing for the context to answer
#: with until the scope has installed it.
#: May have callers. `get_self_pp_group` builds the single-rank group a
#: draft pipeline scope installs, so there is nothing for the context to
#: answer with until the scope has installed it; the other two are not
#: topology at all.
ALLOWED = {
"get_self_pp_group",
"get_default_distributed_backend",
"get_mooncake_transfer_engine",
}
#: Zero callers required, but not deprecated either: the context has no
#: name that answers the same question.
#:
#: The three widths read a group the build does not check against the
#: configuration, so "the group's width" and "the configured width" are two
#: facts -- the MoE-DP group is the attention-CP group when the latter is
#: wider, and the other two are simply not pinned yet. Pinning them in
#: `_WIDTH_AND_GROUP` is what would let them move.
NOT_ANSWERED_BY_THE_CONTEXT = {
"get_moe_data_parallel_world_size",
"get_moe_tensor_parallel_world_size",
"get_dcp_world_size",
# Answers `None` where the context asserts, which is the whole point of
# the caller that wants it.
"get_dcp_group_no_assert",
"get_torch_distributed_pg_options",
}
def _accessors(self):
"""Derived from the source, not listed here: a guard whose subject set
is written by hand stops watching whatever gets added next."""
@@ -2458,6 +2597,139 @@ class TestTheAccessorsHaveNoCallersOutsideTheirPackage(CustomTestCase):
"context cannot answer them",
)
#: How many callers each exempt accessor has outside the defining package.
#: A ratchet, not a description: these may go down and never up, and a name
#: that reaches zero comes off the list. Anything not here must have none.
ALLOWED_CALLERS = {
"get_self_pp_group": 1,
"get_default_distributed_backend": 1,
"get_mooncake_transfer_engine": 6,
}
def test_the_exempt_accessors_do_not_grow_new_callers(self):
"""The zero-caller rule above cannot cover the three that are not
topology, so they get a count instead. Ratchets only turn one way: a
number that has to go up means a new business-code reader of a name the
context should be answering."""
for name, allowed in sorted(self.ALLOWED_CALLERS.items()):
callers = self._callers(name)
self.assertLessEqual(
len(callers),
allowed,
f"{name} grew a caller: {callers}. Read it through "
f"get_parallel() if the context can answer it; if it truly "
f"cannot, lower this number only when one goes away.",
)
def test_every_getter_the_context_answers_is_deprecated(self):
"""The other half of the ratchet: the deprecation set is derived from
the table that maps a context name to the getter behind it, so dropping
a getter out of that table would quietly take it off the list. This
fails if one of them stops being marked."""
from sglang.srt.distributed import parallel_state
marked = set(parallel_state._CONTEXT_NAME_OF)
unclassified = (
self._accessors() - self.ALLOWED - self.NOT_ANSWERED_BY_THE_CONTEXT
)
for name in sorted(unclassified):
if name in marked:
continue
# Not answered by the context and not exempt: a getter that is
# neither is a name with no home, which is what this module exists
# to prevent.
self.assertIn(
name,
marked,
f"{name} is neither deprecated nor listed as exempt -- give it "
"a context name or say here why it has none",
)
def test_calling_one_from_outside_the_package_is_deprecated(self):
"""The getters stay -- they are the definition -- but a call that comes
from outside the package that defines them cannot be redirected by a
scope, so it says what to read instead."""
import warnings
from sglang.srt.distributed import parallel_state
parallel_state._ALREADY_WARNED.discard("get_tensor_model_parallel_rank")
self.addCleanup(
parallel_state._ALREADY_WARNED.discard, "get_tensor_model_parallel_rank"
)
with warnings.catch_warnings(record=True) as seen:
warnings.simplefilter("always")
try:
parallel_state.get_tensor_model_parallel_rank()
except Exception:
pass
messages = [str(w.message) for w in seen]
self.assertTrue(
any("get_parallel().tp_rank" in m for m in messages),
f"expected the replacement to be named, got {messages}",
)
def test_nothing_the_context_answers_with_calls_back_into_the_package(self):
"""The read path reaches the stored group, not the getter that used to
wrap it -- which is what lets the getters be deprecated without the
replacement tripping the warning meant for people who bypass it."""
import inspect
from sglang.srt.distributed import parallel_state
from sglang.srt.runtime_context import ParallelContext, _derived_widths
written = {n for n, d in _derived_widths().items() if not d.fn}
self.assertTrue(written, "no written-at-runtime names; this proves nothing")
self.assertIn("tp_group", written)
# The read path is lookups only -- override, stamp, bag. Nothing in it
# can reach a getter, which is what lets them be deprecated without the
# replacement tripping the warning meant for people who bypass it.
body = inspect.getsource(ParallelContext._read)
self.assertNotIn("_ps()", body)
self.assertNotIn("parallel_state", body)
self.assertNotIn("sglang.srt.runtime_context", parallel_state._EXEMPT_CALLERS)
def test_a_scope_reaches_callers_that_went_straight_to_the_getter(self):
"""The getters read the context, so redirecting a group redirects them
too. PD multiplexing needs exactly this: the in-package readers have to
follow the prefill communicator, not just the ones asking the context."""
from sglang.srt.distributed import parallel_state
stand_in = SimpleNamespace(world_size=1, rank_in_group=0)
with get_parallel().override(tp_group=stand_in):
self.assertIs(get_parallel().tp_group, stand_in)
self.assertIs(parallel_state.get_tp_group(), stand_in)
def test_the_package_that_defines_them_is_not_warned_at(self):
"""`srt/distributed/` keeps calling them: a read there would go through
the context back into itself."""
import warnings
from sglang.srt.distributed import parallel_state
parallel_state._ALREADY_WARNED.discard("get_tensor_model_parallel_rank")
self.addCleanup(
parallel_state._ALREADY_WARNED.discard, "get_tensor_model_parallel_rank"
)
caller = types.ModuleType("sglang.srt.distributed.pretend_internal")
caller.__dict__["call"] = lambda: (
parallel_state.get_tensor_model_parallel_rank()
)
exec(
"def call():\n from sglang.srt.distributed import parallel_state\n"
" return parallel_state.get_tp_group()",
caller.__dict__,
)
with warnings.catch_warnings(record=True) as seen:
warnings.simplefilter("always")
try:
caller.call()
except Exception:
pass
self.assertEqual([str(w.message) for w in seen], [])
def test_the_guard_would_notice_a_caller(self):
"""The subject set is derived, so this checks the search finds a real
call rather than that the list happens to be empty: `get_self_pp_group`
@@ -2571,37 +2843,39 @@ class TestTheTopologyIdentities(CustomTestCase):
def test_a_group_built_at_another_width_is_refused(self):
"""The other end of the same identity: what the configuration says and
what the coordinators were actually built at, checked where the
disagreement is still attributable to the build."""
from sglang.srt.distributed import parallel_state
what the coordinators were actually built at. Stating a group is how
the build hands it over, so that write is where the disagreement
surfaces -- still attributable to the build, and before a collective
runs on the wrong peers."""
from sglang.srt.distributed.parallel_state import GroupCoordinator
self._publish_square()
wrong = GroupCoordinator.__new__(GroupCoordinator)
wrong.world_size = 8
wrong.rank_in_group = 0
with patch.object(parallel_state, "_TP", wrong):
with self.assertRaises(ValueError) as caught:
_validate_parallel(get_parallel(), "group build")
with self.assertRaises(ValueError) as caught:
get_parallel().override_permanently(tp_group=wrong)
message = str(caught.exception)
self.assertIn("set by group build", message)
self.assertIn("tp_group.world_size == tp_size", message)
self.assertIn("built 8, configured 4", message)
# The refused write left nothing behind: the name is unwritten, not
# holding a group no identity accepts.
with self.assertRaises(RuntimeError):
get_parallel().tp_group
def test_a_group_built_at_the_configured_width_is_quiet(self):
from sglang.srt.distributed import parallel_state
from sglang.srt.distributed.parallel_state import GroupCoordinator
self._publish_square()
right = GroupCoordinator.__new__(GroupCoordinator)
right.world_size = 4
right.rank_in_group = 3
with patch.object(parallel_state, "_TP", right):
_validate_parallel(get_parallel(), "group build")
get_parallel().override_permanently(tp_group=right)
self.assertIs(get_parallel().tp_group, right)
def test_a_draft_scope_states_a_consistent_topology(self):
"""The scope narrows four names at once, so the identity applies to it
-- and holds, which is what step lets the guard stay on."""
-- and holds, which is what lets the guard stay on."""
from sglang.srt.distributed import parallel_state
from sglang.srt.distributed.parallel_state import GroupCoordinator
@@ -2609,9 +2883,8 @@ class TestTheTopologyIdentities(CustomTestCase):
group = GroupCoordinator.__new__(GroupCoordinator)
group.world_size = 2
group.rank_in_group = 1
with patch.object(parallel_state, "_TP", group):
with parallel_state.patch_tensor_parallel_group(group, owns_attention=True):
self.assertEqual(get_parallel().attn_tp_size, 2)
with parallel_state.patch_tensor_parallel_group(group, owns_attention=True):
self.assertEqual(get_parallel().attn_tp_size, 2)
class TestWhoAnswersDuringADraftScope(CustomTestCase):
@@ -2650,11 +2923,10 @@ class TestWhoAnswersDuringADraftScope(CustomTestCase):
group = self._single_member_group()
self._two_stage_pipeline()
self.assertEqual(get_parallel().pp_size, 2)
with patch.object(parallel_state, "_PP", group):
with parallel_state.patch_pipeline_parallel_group(group):
self.assertEqual(get_parallel().pp_size, 1)
self.assertEqual(get_parallel().pp_rank, 0)
self.assertIs(get_parallel().pp_group, group)
with parallel_state.patch_pipeline_parallel_group(group):
self.assertEqual(get_parallel().pp_size, 1)
self.assertEqual(get_parallel().pp_rank, 0)
self.assertIs(get_parallel().pp_group, group)
self.assertEqual(get_parallel().pp_size, 2)
self.assertEqual(get_parallel().pp_rank, 1)
@@ -2685,28 +2957,25 @@ class TestWhoAnswersDuringADraftScope(CustomTestCase):
self.assertEqual(get_parallel().attn_tp_size, 2)
group = self._group(world_size=2, rank=1)
with patch.object(parallel_state, "_TP", group):
with parallel_state.patch_tensor_parallel_group(group, owns_attention=True):
parallel = get_parallel()
self.assertEqual(parallel.tp_size, 2)
self.assertEqual(parallel.attn_tp_size, 2)
self.assertEqual(parallel.attn_tp_rank, 1)
self.assertEqual(parallel.attn_dp_size, 1)
self.assertEqual(parallel.attn_dp_rank, 0)
self.assertEqual(parallel.attn_cp_size, 1)
self.assertEqual(parallel.attn_cp_rank, 0)
# `dp_size` is the deployment's replica count, not a property
# of the group being installed, so the scope leaves it alone --
# `require_mlp_tp_gather` asserts on it under dp attention.
self.assertEqual(parallel.dp_size, 2)
# The whole point of stating the rest: the identity the
# override path and the group build both check holds in here.
self.assertEqual(
parallel.tp_size,
parallel.attn_tp_size
* parallel.attn_dp_size
* parallel.attn_cp_size,
)
with parallel_state.patch_tensor_parallel_group(group, owns_attention=True):
parallel = get_parallel()
self.assertEqual(parallel.tp_size, 2)
self.assertEqual(parallel.attn_tp_size, 2)
self.assertEqual(parallel.attn_tp_rank, 1)
self.assertEqual(parallel.attn_dp_size, 1)
self.assertEqual(parallel.attn_dp_rank, 0)
self.assertEqual(parallel.attn_cp_size, 1)
self.assertEqual(parallel.attn_cp_rank, 0)
# `dp_size` is the deployment's replica count, not a property of
# the group being installed, so the scope leaves it alone --
# `require_mlp_tp_gather` asserts on it under dp attention.
self.assertEqual(parallel.dp_size, 2)
# The whole point of stating the rest: the identity the override
# path and the group build both check holds in here.
self.assertEqual(
parallel.tp_size,
parallel.attn_tp_size * parallel.attn_dp_size * parallel.attn_cp_size,
)
self.assertEqual(get_parallel().attn_dp_size, 2)
self.assertEqual(get_parallel().dp_size, 2)
@@ -2790,9 +3059,8 @@ class TestWhoAnswersDuringADraftScope(CustomTestCase):
self._two_stage_pipeline()
self.assertEqual(get_parallel().pp_size, 2)
group = self._single_member_group()
with patch.object(parallel_state, "_PP", group):
with parallel_state.patch_pipeline_parallel_group(group):
checker = WeightChecker(get_model=lambda: None)
with parallel_state.patch_pipeline_parallel_group(group):
checker = WeightChecker(get_model=lambda: None)
# The scope has closed and the context answers the target's shape again.
self.assertEqual(get_parallel().pp_size, 2)