config: a parallel size has one spelling; a patched scope declares its own (#36621)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca1d7ed8e6
commit
fd40a331bf
@@ -109,9 +109,12 @@ def mixer2_gated_norm_tensor_parallel(
|
||||
|
||||
import sglang.srt.layers.attention.mamba.mixer2_rms_norm_gated as m2
|
||||
|
||||
# Force attn-TP rank through the context (the weight loader reads it via
|
||||
# get_parallel().attn_tp_rank); avoids calling initialize_dp_attention.
|
||||
with get_parallel().override(attn_tp_rank=local_rank):
|
||||
# Force the TP topology through the context (the weight loader reads
|
||||
# get_parallel().attn_tp_rank, Mixer2RMSNormGated reads tp_size / tp_rank);
|
||||
# avoids calling initialize_dp_attention.
|
||||
with get_parallel().override(
|
||||
attn_tp_rank=local_rank, tp_size=world_size, tp_rank=local_rank
|
||||
):
|
||||
# create gated-norm with TP
|
||||
mixer = m2.Mixer2RMSNormGated(
|
||||
full_hidden_size=hidden_size,
|
||||
|
||||
@@ -12,6 +12,7 @@ import torch
|
||||
|
||||
from sglang.srt.layers.quantization.blockwise_int8 import BlockInt8Config
|
||||
from sglang.srt.layers.quantization.w8a8_int8 import W8A8Int8Config
|
||||
from sglang.srt.runtime_context import get_parallel
|
||||
from sglang.srt.utils import get_device_sm
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.layer_ut_utils import (
|
||||
@@ -112,7 +113,9 @@ class TestBlockInt8Linear(_Int8LinearCheck):
|
||||
activation_scheme="dynamic",
|
||||
weight_block_size=[128, 128],
|
||||
)
|
||||
layer = make_tp1_column_parallel_linear(quant_config, n, k)
|
||||
# create_weights reads get_parallel().tp_size, not the layer's argument.
|
||||
with get_parallel().override(tp_size=1, tp_rank=0):
|
||||
layer = make_tp1_column_parallel_linear(quant_config, n, k)
|
||||
w = torch.randn((n, k), device="cuda", dtype=torch.bfloat16) / 10
|
||||
w_int8, scale_inv, w_dequant = _quantize_int8_block(w)
|
||||
load_linear_weights(layer, weight=w_int8, weight_scale_inv=scale_inv)
|
||||
|
||||
@@ -11,6 +11,7 @@ maybe_stub_sgl_kernel()
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.managers.io_struct import GetInternalStateReq
|
||||
from sglang.srt.managers.scheduler import Scheduler
|
||||
from sglang.srt.runtime_context import get_context
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
@@ -42,18 +43,7 @@ class TestSchedulerInternalStateEnvVars(unittest.TestCase):
|
||||
)
|
||||
scheduler.draft_worker = None
|
||||
|
||||
with patch(
|
||||
"sglang.srt.managers.scheduler.get_context",
|
||||
return_value=SimpleNamespace(resolved_server_args_dict=dict),
|
||||
), patch(
|
||||
"sglang.srt.managers.scheduler.get_exec",
|
||||
return_value=SimpleNamespace(moe=SimpleNamespace(elastic_ep_backend=None)),
|
||||
), patch(
|
||||
"sglang.srt.managers.scheduler.compute_world_size", return_value=1
|
||||
), patch(
|
||||
"sglang.srt.managers.scheduler.get_parallel",
|
||||
return_value=SimpleNamespace(config=SimpleNamespace()),
|
||||
):
|
||||
with get_context().override_server_args():
|
||||
output = scheduler.get_internal_state(recv_req=GetInternalStateReq())
|
||||
|
||||
return output.internal_state
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import maybe_stub_sgl_kernel
|
||||
@@ -9,59 +8,52 @@ maybe_stub_sgl_kernel()
|
||||
|
||||
from sglang.srt.managers.io_struct import GetInternalStateReq
|
||||
from sglang.srt.managers.scheduler import Scheduler
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.srt.server_args import compute_world_size
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _make_parallel_config(
|
||||
def _shape(
|
||||
*, tp_size: int, pp_size: int, dp_size: int, enable_dp_attention: bool
|
||||
) -> SimpleNamespace:
|
||||
) -> dict:
|
||||
"""The four `parallel` leaves the world size is computed from."""
|
||||
return SimpleNamespace(
|
||||
tp_size=tp_size,
|
||||
pp_size=pp_size,
|
||||
dp_size=dp_size,
|
||||
enable_dp_attention=enable_dp_attention,
|
||||
)
|
||||
return {
|
||||
"tp_size": tp_size,
|
||||
"pp_size": pp_size,
|
||||
"dp_size": dp_size,
|
||||
"enable_dp_attention": enable_dp_attention,
|
||||
}
|
||||
|
||||
|
||||
class TestComputeWorldSize(unittest.TestCase):
|
||||
def test_a_single_gpu_server_holds_one_gpu(self):
|
||||
"""The default shape has to come out as one, or every consumer is off by a factor."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=1, pp_size=1, dp_size=1, enable_dp_attention=False
|
||||
)
|
||||
shape = _shape(tp_size=1, pp_size=1, dp_size=1, enable_dp_attention=False)
|
||||
|
||||
self.assertEqual(compute_world_size(config), 1)
|
||||
self.assertEqual(compute_world_size(**shape), 1)
|
||||
|
||||
def test_tensor_and_pipeline_stages_multiply(self):
|
||||
"""Each (pp_rank, tp_rank) pair is its own scheduler process on its own gpu."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=2, pp_size=3, dp_size=1, enable_dp_attention=False
|
||||
)
|
||||
shape = _shape(tp_size=2, pp_size=3, dp_size=1, enable_dp_attention=False)
|
||||
|
||||
self.assertEqual(compute_world_size(config), 6)
|
||||
self.assertEqual(compute_world_size(**shape), 6)
|
||||
|
||||
def test_plain_data_parallel_replicas_each_hold_their_own_gpus(self):
|
||||
"""Without dp attention every replica launches a full tensor-parallel group of its own."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False
|
||||
)
|
||||
shape = _shape(tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False)
|
||||
|
||||
self.assertEqual(compute_world_size(config), 4)
|
||||
self.assertEqual(compute_world_size(**shape), 4)
|
||||
|
||||
def test_data_parallel_attention_shares_the_tensor_parallel_gpus(self):
|
||||
"""With dp attention the dp ranks live inside the tensor-parallel world, not beside it."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=4, pp_size=1, dp_size=2, enable_dp_attention=True
|
||||
)
|
||||
shape = _shape(tp_size=4, pp_size=1, dp_size=2, enable_dp_attention=True)
|
||||
|
||||
self.assertEqual(compute_world_size(config), 4)
|
||||
self.assertEqual(compute_world_size(**shape), 4)
|
||||
|
||||
|
||||
class TestSchedulerInternalStateWorldSize(unittest.TestCase):
|
||||
def _get_internal_state(self, config: SimpleNamespace) -> dict:
|
||||
def _get_internal_state(self, shape: dict) -> dict:
|
||||
scheduler = Scheduler.__new__(Scheduler)
|
||||
scheduler.metrics_reporter = SimpleNamespace(
|
||||
last_gen_throughput=1.0,
|
||||
@@ -87,40 +79,27 @@ class TestSchedulerInternalStateWorldSize(unittest.TestCase):
|
||||
)
|
||||
scheduler.draft_worker = None
|
||||
|
||||
with patch(
|
||||
"sglang.srt.managers.scheduler.get_context",
|
||||
return_value=SimpleNamespace(resolved_server_args_dict=dict),
|
||||
), patch(
|
||||
"sglang.srt.managers.scheduler.get_exec",
|
||||
return_value=SimpleNamespace(moe=SimpleNamespace(elastic_ep_backend=None)),
|
||||
), patch(
|
||||
"sglang.srt.managers.scheduler.get_parallel",
|
||||
return_value=SimpleNamespace(config=config),
|
||||
):
|
||||
with get_context().override_server_args(**shape):
|
||||
output = scheduler.get_internal_state(recv_req=GetInternalStateReq())
|
||||
|
||||
return output.internal_state
|
||||
|
||||
def test_the_internal_state_reports_the_whole_server(self):
|
||||
"""A consumer sizing an external fleet reads the gpus the server occupies, not the declared sizes."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False
|
||||
)
|
||||
shape = _shape(tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False)
|
||||
|
||||
internal_state = self._get_internal_state(config)
|
||||
internal_state = self._get_internal_state(shape)
|
||||
|
||||
self.assertEqual(internal_state["world_size"], 4)
|
||||
|
||||
def test_the_reported_size_is_not_one_replica_of_a_data_parallel_server(self):
|
||||
"""Each plain dp replica has its own process group, so no scheduler can report the whole server from it."""
|
||||
config = _make_parallel_config(
|
||||
tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False
|
||||
)
|
||||
shape = _shape(tp_size=2, pp_size=1, dp_size=2, enable_dp_attention=False)
|
||||
|
||||
internal_state = self._get_internal_state(config)
|
||||
internal_state = self._get_internal_state(shape)
|
||||
|
||||
self.assertNotEqual(
|
||||
internal_state["world_size"], config.tp_size * config.pp_size
|
||||
internal_state["world_size"], shape["tp_size"] * shape["pp_size"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -63,79 +63,85 @@ def _run(rank: int, world: int, port: int):
|
||||
LayerSplitDSATokenToKVPool,
|
||||
)
|
||||
|
||||
cp_rank = get_parallel().attn_cp_rank
|
||||
cp_size = get_parallel().attn_cp_size
|
||||
assert cp_size == world
|
||||
# This worker builds the groups but never publishes, so the scope declares
|
||||
# the size it runs at.
|
||||
with get_parallel().override(attn_cp_size=world):
|
||||
cp_rank = get_parallel().attn_cp_rank
|
||||
cp_size = get_parallel().attn_cp_size
|
||||
assert cp_size == world
|
||||
|
||||
pool = LayerSplitDSATokenToKVPool(
|
||||
SIZE,
|
||||
page_size=PAGE_SIZE,
|
||||
kv_lora_rank=KV_LORA_RANK,
|
||||
dtype=torch.bfloat16,
|
||||
qk_rope_head_dim=QK_ROPE,
|
||||
layer_num=LAYER_NUM,
|
||||
device=f"cuda:{rank}",
|
||||
index_head_dim=INDEX_HEAD_DIM,
|
||||
enable_memory_saver=False,
|
||||
kv_cache_dim=KV_LORA_RANK + QK_ROPE,
|
||||
layer_shard_rank=cp_rank,
|
||||
layer_shard_size=cp_size,
|
||||
)
|
||||
|
||||
# Owner writes a layer-distinct constant into each owned kv_buffer layer.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
if pool._is_layer_owned(layer_id):
|
||||
pool.kv_buffer[layer_id].fill_(float(layer_id + 1))
|
||||
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
|
||||
# Every rank reads every layer; broadcast must surface the owner's value.
|
||||
ok = True
|
||||
for layer_id in range(LAYER_NUM):
|
||||
buf = pool._get_broadcastable_kv_buffer(layer_id)
|
||||
expected = float(layer_id + 1)
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - expected) > 1e-3:
|
||||
print(f"[rank {rank}] layer {layer_id}: expected {expected}, got {got}")
|
||||
ok = False
|
||||
assert ok, f"rank {rank} read stale/incorrect broadcast contents"
|
||||
|
||||
# Indexer buffer owner-broadcast: owner writes a layer-distinct value, then
|
||||
# every rank must read it back for every layer.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
store_buf = pool.get_index_k_with_scale_buffer(layer_id)
|
||||
assert (
|
||||
store_buf.data_ptr() == pool.index_k_with_scale_buffer[layer_id].data_ptr()
|
||||
pool = LayerSplitDSATokenToKVPool(
|
||||
SIZE,
|
||||
page_size=PAGE_SIZE,
|
||||
kv_lora_rank=KV_LORA_RANK,
|
||||
dtype=torch.bfloat16,
|
||||
qk_rope_head_dim=QK_ROPE,
|
||||
layer_num=LAYER_NUM,
|
||||
device=f"cuda:{rank}",
|
||||
index_head_dim=INDEX_HEAD_DIM,
|
||||
enable_memory_saver=False,
|
||||
kv_cache_dim=KV_LORA_RANK + QK_ROPE,
|
||||
layer_shard_rank=cp_rank,
|
||||
layer_shard_size=cp_size,
|
||||
)
|
||||
if pool._is_layer_owned(layer_id):
|
||||
store_buf.fill_(layer_id + 10)
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
for layer_id in range(LAYER_NUM):
|
||||
# invalidate any cached remote copy so the read forces a fresh broadcast
|
||||
pool.invalidate_index_buffer_for_layer(layer_id)
|
||||
buf = pool._get_broadcastable_index_buffer(layer_id)
|
||||
expected = layer_id + 10
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - expected) > 1e-3:
|
||||
print(f"[rank {rank}] index layer {layer_id}: exp {expected}, got {got}")
|
||||
ok = False
|
||||
assert ok, f"rank {rank} read stale/incorrect index broadcast contents"
|
||||
|
||||
# Async prefetch path: prefetch layer, then read must return owner value.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
pool.remote_kv_layer_id = None # force a fresh broadcast
|
||||
pool.prefetch_kv_buffer(layer_id)
|
||||
buf = pool._get_broadcastable_kv_buffer(layer_id)
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - float(layer_id + 1)) > 1e-3:
|
||||
print(f"[rank {rank}] prefetch layer {layer_id}: got {got}")
|
||||
ok = False
|
||||
assert ok, f"rank {rank} prefetch path returned incorrect contents"
|
||||
# Owner writes a layer-distinct constant into each owned kv_buffer layer.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
if pool._is_layer_owned(layer_id):
|
||||
pool.kv_buffer[layer_id].fill_(float(layer_id + 1))
|
||||
|
||||
print(f"[rank {rank}] OK: all {LAYER_NUM} layers read correct owner contents")
|
||||
torch.distributed.barrier()
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
|
||||
# Every rank reads every layer; broadcast must surface the owner's value.
|
||||
ok = True
|
||||
for layer_id in range(LAYER_NUM):
|
||||
buf = pool._get_broadcastable_kv_buffer(layer_id)
|
||||
expected = float(layer_id + 1)
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - expected) > 1e-3:
|
||||
print(f"[rank {rank}] layer {layer_id}: expected {expected}, got {got}")
|
||||
ok = False
|
||||
assert ok, f"rank {rank} read stale/incorrect broadcast contents"
|
||||
|
||||
# Indexer buffer owner-broadcast: owner writes a layer-distinct value, then
|
||||
# every rank must read it back for every layer.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
store_buf = pool.get_index_k_with_scale_buffer(layer_id)
|
||||
assert (
|
||||
store_buf.data_ptr()
|
||||
== pool.index_k_with_scale_buffer[layer_id].data_ptr()
|
||||
)
|
||||
if pool._is_layer_owned(layer_id):
|
||||
store_buf.fill_(layer_id + 10)
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
for layer_id in range(LAYER_NUM):
|
||||
# invalidate any cached remote copy so the read forces a fresh broadcast
|
||||
pool.invalidate_index_buffer_for_layer(layer_id)
|
||||
buf = pool._get_broadcastable_index_buffer(layer_id)
|
||||
expected = layer_id + 10
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - expected) > 1e-3:
|
||||
print(
|
||||
f"[rank {rank}] index layer {layer_id}: exp {expected}, got {got}"
|
||||
)
|
||||
ok = False
|
||||
assert ok, f"rank {rank} read stale/incorrect index broadcast contents"
|
||||
|
||||
# Async prefetch path: prefetch layer, then read must return owner value.
|
||||
for layer_id in range(LAYER_NUM):
|
||||
pool.remote_kv_layer_id = None # force a fresh broadcast
|
||||
pool.prefetch_kv_buffer(layer_id)
|
||||
buf = pool._get_broadcastable_kv_buffer(layer_id)
|
||||
got = buf.float().mean().item()
|
||||
if abs(got - float(layer_id + 1)) > 1e-3:
|
||||
print(f"[rank {rank}] prefetch layer {layer_id}: got {got}")
|
||||
ok = False
|
||||
assert ok, f"rank {rank} prefetch path returned incorrect contents"
|
||||
|
||||
print(f"[rank {rank}] OK: all {LAYER_NUM} layers read correct owner contents")
|
||||
torch.distributed.barrier()
|
||||
|
||||
|
||||
class TestLayerSplitDSABroadcast(CustomTestCase):
|
||||
|
||||
@@ -67,7 +67,6 @@ from sglang.srt.multimodal.transport.cuda_ipc import (
|
||||
CudaIpcTensorTransportProxy,
|
||||
)
|
||||
from sglang.srt.runtime_context import (
|
||||
ParallelContext,
|
||||
get_context,
|
||||
get_parallel,
|
||||
publish,
|
||||
@@ -911,7 +910,7 @@ def test_kimi_k3_normal_cache_path_connects_real_producer_to_model_consumer():
|
||||
hot_items = pickle.loads(pickle.dumps(hot.mm_items))
|
||||
|
||||
with (
|
||||
patch.object(ParallelContext, "config", SimpleNamespace(tp_size=1)),
|
||||
get_parallel().override(tp_size=1),
|
||||
patch(
|
||||
"sglang.srt.multimodal.processors.kimi_k25._gpu_preprocess_images",
|
||||
return_value=(
|
||||
@@ -975,7 +974,7 @@ def test_kimi_k3_model_accepts_mixed_cached_eager_and_deferred_artifacts():
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(ParallelContext, "config", SimpleNamespace(tp_size=1)),
|
||||
get_parallel().override(tp_size=1),
|
||||
patch(
|
||||
"sglang.srt.multimodal.processors.kimi_k25._gpu_preprocess_images",
|
||||
return_value=(torch.full((1, 3), 2.0), torch.tensor([[1, 1, 1]])),
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import List, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.layer_ut_utils import init_single_process_dist
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
@@ -36,10 +37,16 @@ from sglang.test.test_utils import CustomTestCase
|
||||
register_cpu_ci(est_time=30, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _ensure_dist_initialized() -> None:
|
||||
def _ensure_dist_initialized(cls) -> None:
|
||||
"""CCA reads the TP rank / world size inside ``__init__`` to size its
|
||||
head-parallel projections, so the groups must exist before construction."""
|
||||
head-parallel projections. The rank is the live group's, so the groups must
|
||||
exist before construction; the size answers from the published ``parallel``
|
||||
bag, so the case has to publish a context as well.
|
||||
"""
|
||||
init_single_process_dist()
|
||||
override = get_context().override_server_args(tp_size=1)
|
||||
override.install()
|
||||
cls.addClassCleanup(override.restore)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -243,7 +250,7 @@ def _make_tiny_cca(
|
||||
class TestZayaCCA(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_ensure_dist_initialized()
|
||||
_ensure_dist_initialized(cls)
|
||||
|
||||
def test_single_chunk_matches_reference(self):
|
||||
"""A single-chunk extend with empty prefix matches the no-state path."""
|
||||
@@ -540,7 +547,7 @@ class TestZayaCCATensorParallel(CustomTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_ensure_dist_initialized()
|
||||
_ensure_dist_initialized(cls)
|
||||
|
||||
def _slice_full_state_dict_into_rank(self, ref_cca, tp_cca, tp_rank: int):
|
||||
"""Copy the reference's full weights into the per-rank CCA, using the
|
||||
|
||||
@@ -433,19 +433,11 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
mapping = namespace_of(ServerArgs)
|
||||
self.assertGreater(len(mapping), 400, "the namespace mapping collapsed")
|
||||
|
||||
# The five sizes keep a live property shadowing the bare name; the
|
||||
# comparison below reaches them anyway, through `get_parallel().config`.
|
||||
self.assertGreaterEqual(
|
||||
_live_topology_leaves()
|
||||
& {
|
||||
"tp_size",
|
||||
"pp_size",
|
||||
"moe_dp_size",
|
||||
"attn_cp_size",
|
||||
"dcp_size",
|
||||
},
|
||||
{"tp_size", "pp_size", "moe_dp_size", "attn_cp_size", "dcp_size"},
|
||||
"a parallel size stopped being served from the live topology",
|
||||
self.assertEqual(
|
||||
set(),
|
||||
_live_topology_leaves() & set(mapping),
|
||||
"a parallel leaf gained a live member of the same name, so the "
|
||||
"comparison below reads the group rather than the published leaf",
|
||||
)
|
||||
|
||||
compared = 0
|
||||
@@ -461,10 +453,6 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
unreachable.append(f"no get_{groups[0]}() for {path}.{field}")
|
||||
continue
|
||||
node = accessor()
|
||||
if groups[0] == "parallel":
|
||||
# Bare names there are the live topology; the published
|
||||
# leaves are one hop down, so the reader takes that hop.
|
||||
node = node.config
|
||||
try:
|
||||
for group in groups[1:]:
|
||||
node = getattr(node, group)
|
||||
|
||||
@@ -420,7 +420,7 @@ def reads_a_leaf_through_the_alias(runner):
|
||||
|
||||
|
||||
def hands_the_accessor_to_a_helper():
|
||||
return compute_world_size(get_server_args())
|
||||
return attention_backends_of(get_server_args())
|
||||
|
||||
|
||||
def reads_the_view(runner):
|
||||
@@ -580,7 +580,7 @@ class TestResolutionReadsTheDeclarations(CustomTestCase):
|
||||
"""
|
||||
helpers = _config_reading_helpers()
|
||||
decided = _declared_fields()
|
||||
for name in ("m3_fp8_attn_gemm_enabled", "compute_world_size"):
|
||||
for name in ("m3_fp8_attn_gemm_enabled", "attention_backends_of"):
|
||||
self.assertIn(name, helpers, f"the helper derivation lost {name}")
|
||||
for field in ("speculative_num_draft_tokens", "attention_backend"):
|
||||
self.assertIn(field, decided, f"the declared set lost {field}")
|
||||
@@ -598,8 +598,8 @@ class TestResolutionReadsTheDeclarations(CustomTestCase):
|
||||
"sa_local.attention_backend",
|
||||
"self._server_args.attention_backend",
|
||||
"engine_args.attention_backend",
|
||||
"compute_world_size(get_server_args())"
|
||||
" reads " + ", ".join(helpers["compute_world_size"]),
|
||||
"attention_backends_of(get_server_args())"
|
||||
" reads " + ", ".join(helpers["attention_backends_of"]),
|
||||
},
|
||||
"the scan lost a spelling, or started flagging a legal one:\n "
|
||||
+ "\n ".join(sorted(flagged)),
|
||||
|
||||
@@ -13,9 +13,6 @@ slot.
|
||||
The reads that remain live in ``runtime_context.py`` (exempt by module): the
|
||||
``@property`` / method members computed from several fields plus the HF config,
|
||||
which are not namespace leaves and have no home but ``ServerArgs``.
|
||||
Separately, ``_CONFIGURED_SIZE_CALL_SITES`` registers every business read of
|
||||
``get_parallel().config.<size>`` — the config tier of a size whose bare name is
|
||||
the live topology — with the reason the live property cannot serve it.
|
||||
|
||||
What the scan sees: ``get_server_args().field``, an alias (``sa =
|
||||
get_server_args()`` then ``sa.field`` -- function-local, module-level, or parked
|
||||
@@ -48,250 +45,6 @@ _PACKAGE_ROOT = Path(next(iter(sglang.__path__)))
|
||||
# resolution pipeline.
|
||||
_SLOT_OWNERS = ("srt/runtime_context.py", "srt/server_args.py", "srt/arg_groups/")
|
||||
|
||||
# Every configured read of a live-shadowed size (``get_parallel().config.pp_size``
|
||||
# and its four siblings), with the reason the live topology cannot answer there.
|
||||
# The test below asserts this map is exactly the set of such reads, so the
|
||||
# reasons cannot drift away from the code.
|
||||
_CONFIGURED_SIZE_CALL_SITES = {
|
||||
("srt/layers/cp/base.py", "attn_cp_size"): (
|
||||
"the lazy strategy bind in a worker: the CP group is what the strategy "
|
||||
"is being built for, and the configured width is what describes it"
|
||||
),
|
||||
("benchmark/one_batch.py", "pp_size"): (
|
||||
"CPU affinity for this rank, computed right after the work function "
|
||||
"publishes and before dist init, so the groups do not exist yet"
|
||||
),
|
||||
("benchmark/one_batch.py", "tp_size"): (
|
||||
"the same affinity computation: the layout is the configured one, and "
|
||||
"the live group is not up at this point in the work function"
|
||||
),
|
||||
("srt/entrypoints/engine.py", "pp_size"): (
|
||||
"the launch path decides how many scheduler processes to spawn; it runs "
|
||||
"before any of them exists, so there is no group to ask"
|
||||
),
|
||||
("srt/entrypoints/engine.py", "attn_cp_size"): (
|
||||
"the launcher's per-TP-rank layout, computed while deciding what to "
|
||||
"spawn -- the groups it is laying out do not exist yet"
|
||||
),
|
||||
("srt/entrypoints/engine.py", "moe_dp_size"): (
|
||||
"the MoE factor of that same pre-spawn layout"
|
||||
),
|
||||
("srt/ray/engine.py", "pp_size"): (
|
||||
"the Ray driver sizes the actor placement group; the actors it is about "
|
||||
"to create are the ones that will hold the process groups"
|
||||
),
|
||||
("srt/ray/engine.py", "tp_size"): (
|
||||
"the same placement arithmetic as the stage count: the driver sizes "
|
||||
"the actors that will hold the process groups"
|
||||
),
|
||||
("srt/ray/data_parallel_controller.py", "tp_size"): (
|
||||
"the same arithmetic on the DP path, also in the driver"
|
||||
),
|
||||
("srt/ray/data_parallel_controller.py", "pp_size"): (
|
||||
"same placement arithmetic on the DP path -- ranks per TP group, "
|
||||
"computed in the driver before the actors start"
|
||||
),
|
||||
("srt/ray/data_parallel_controller.py", "attn_cp_size"): (
|
||||
"the attention-CP factor of that same placement arithmetic, and the one "
|
||||
"size whose live value cannot express the configured intent when "
|
||||
"attn_cp_size > moe_dp_size aliases the groups"
|
||||
),
|
||||
("srt/layers/attention/dsa/dsa_indexer.py", "pp_size"): (
|
||||
"gates `pp_size > 1 and not get_pp_group()...`; the short circuit is the "
|
||||
"point, since with PP off the group is never touched, which is what lets "
|
||||
"the Indexer be constructed before distributed init"
|
||||
),
|
||||
("srt/managers/scheduler.py", "pp_size"): (
|
||||
"dispatch_event_loop picks the PP event loop; the MLX runner stub never "
|
||||
"initializes torch.distributed, so the live property asserts before the "
|
||||
"MLX loop can start -- the configured leaf answers the same value "
|
||||
"wherever the live groups exist"
|
||||
),
|
||||
("srt/mem_cache/kv_cache_configurator.py", "pp_size"): (
|
||||
"decides whether the token capacity needs a cross-PP all-reduce at all; "
|
||||
"asking the configured size keeps that decision independent of whether a "
|
||||
"PP group is installed in this process"
|
||||
),
|
||||
("srt/layers/dp_attention.py", "attn_cp_size"): (
|
||||
"compared against the configured moe_dp_size below"
|
||||
),
|
||||
("srt/layers/dp_attention.py", "moe_dp_size"): (
|
||||
"the configuration this predicate detects (attn_cp_size > moe_dp_size) is "
|
||||
"the one where initialize_model_parallel aliases _MOE_DP to _ATTN_CP, so "
|
||||
"the live sizes are equal there and a live comparison is always false"
|
||||
),
|
||||
("srt/managers/scheduler.py", "tp_size"): (
|
||||
"configure_scheduler_process runs before the scheduler's own process "
|
||||
"groups exist -- configuring the process is what it is for -- so there "
|
||||
"is nothing live to ask yet"
|
||||
),
|
||||
("srt/managers/scheduler.py", "moe_dp_size"): (
|
||||
"same pre-distributed-init arithmetic in configure_scheduler_process"
|
||||
),
|
||||
("srt/managers/scheduler.py", "attn_cp_size"): (
|
||||
"same pre-distributed-init arithmetic in configure_scheduler_process"
|
||||
),
|
||||
("srt/managers/scheduler.py", "dcp_size"): (
|
||||
"same pre-distributed-init arithmetic in configure_scheduler_process"
|
||||
),
|
||||
("srt/model_executor/runner/base_runner.py", "tp_size"): (
|
||||
"the same window as the stage count next to it: a draft runner shares "
|
||||
"the target's groups, so the live property would answer for the wrong "
|
||||
"runner"
|
||||
),
|
||||
("srt/model_executor/cpu_graph_runner.py", "tp_size"): (
|
||||
"the same window, on the CPU graph path"
|
||||
),
|
||||
("srt/entrypoints/v1_loads.py", "tp_size"): (
|
||||
"the accelerator count is arithmetic over the launch shape, reported "
|
||||
"from the tokenizer process, which holds no model groups"
|
||||
),
|
||||
("srt/disaggregation/nixl/conn.py", "tp_size"): (
|
||||
"the NIXL rank arithmetic runs on the transfer path, which the CPU-only "
|
||||
"conn tests exercise without starting torch.distributed"
|
||||
),
|
||||
("srt/managers/tokenizer_control_mixin.py", "tp_size"): (
|
||||
"the tokenizer divides its worker count by the launch width; it holds "
|
||||
"no model groups"
|
||||
),
|
||||
("srt/model_executor/runner/base_runner.py", "pp_size"): (
|
||||
"the runner's layer window is arithmetic over the configured stage "
|
||||
"count; a draft runner shares the target's groups, so the live "
|
||||
"property would answer for the wrong runner"
|
||||
),
|
||||
("srt/model_executor/cpu_graph_runner.py", "pp_size"): (
|
||||
"the same window, on the CPU graph path"
|
||||
),
|
||||
(
|
||||
"srt/managers/scheduler_components/metrics_reporter.py",
|
||||
"pp_size",
|
||||
): (
|
||||
"the reporter labels its metrics with the stage count it was launched "
|
||||
"with, which is configuration; the live group answers per process"
|
||||
),
|
||||
("srt/speculative/eagle_draft_cuda_graph_runner.py", "pp_size"): (
|
||||
"the draft runner's window over the target's stages: its own groups are "
|
||||
"the target's, so the configured count is the one that describes it"
|
||||
),
|
||||
(
|
||||
"srt/speculative/eagle_draft_extend_cuda_graph_runner.py",
|
||||
"pp_size",
|
||||
): ("the same draft window, on the extend path"),
|
||||
(
|
||||
"srt/speculative/multi_layer_eagle_draft_extend_cuda_graph_runner.py",
|
||||
"pp_size",
|
||||
): ("the same draft window, multi-layer extend"),
|
||||
("srt/speculative/frozen_kv_mtp_cuda_graph_runner.py", "pp_size"): (
|
||||
"the same draft window, frozen-KV MTP"
|
||||
),
|
||||
("srt/managers/data_parallel_controller.py", "pp_size"): (
|
||||
"the controller lays out its schedulers' ranks before spawning them, so "
|
||||
"the groups it is sizing for do not exist yet"
|
||||
),
|
||||
("srt/managers/data_parallel_controller.py", "attn_cp_size"): (
|
||||
"the same pre-spawn rank arithmetic"
|
||||
),
|
||||
("srt/managers/data_parallel_controller.py", "moe_dp_size"): (
|
||||
"the same pre-spawn rank arithmetic"
|
||||
),
|
||||
("srt/entrypoints/v1_loads.py", "pp_size"): (
|
||||
"the /v1/loads accelerator count is arithmetic over the launch shape, "
|
||||
"reported from the tokenizer process, which holds no model groups"
|
||||
),
|
||||
("srt/disaggregation/common/conn.py", "pp_size"): (
|
||||
"the bootstrap connection is built by the KV manager on the transfer "
|
||||
"path, which the CPU-only conn tests exercise without ever starting "
|
||||
"torch.distributed"
|
||||
),
|
||||
("srt/elastic_ep/elastic_ep.py", "tp_size"): (
|
||||
"the joiner's rank window is computed against the size the process was "
|
||||
"configured with, not the size of the group it is about to join"
|
||||
),
|
||||
("srt/elastic_ep/expert_backup_manager.py", "tp_size"): (
|
||||
"the backup server counts the clients it expects to report in, which "
|
||||
"is how many the launch configured -- the live group is what they are "
|
||||
"still joining"
|
||||
),
|
||||
(
|
||||
"srt/model_executor/model_runner_components/startup_weight_load.py",
|
||||
"tp_size",
|
||||
): (
|
||||
"the load options are assembled in ModelRunner.__init__ for a runner "
|
||||
"that may be a draft, whose groups are the target's; the configured "
|
||||
"sizes are what the record answered before"
|
||||
),
|
||||
(
|
||||
"srt/model_executor/model_runner_components/startup_weight_load.py",
|
||||
"pp_size",
|
||||
): ("same options object, same reason"),
|
||||
(
|
||||
"srt/model_executor/model_runner_components/startup_weight_load.py",
|
||||
"attn_cp_size",
|
||||
): ("same options object, same reason"),
|
||||
(
|
||||
"srt/model_executor/model_runner_components/startup_weight_load.py",
|
||||
"dcp_size",
|
||||
): ("same options object, same reason"),
|
||||
(
|
||||
"srt/model_executor/model_runner_components/spec_aux_hidden_state.py",
|
||||
"tp_size",
|
||||
): (
|
||||
"the draft KV bytes/token estimate sizes the memory pool before the "
|
||||
"draft runner exists, so its shard count is configuration"
|
||||
),
|
||||
("srt/eplb/expert_location.py", "tp_size"): (
|
||||
"the elastic-EP joiner window, used to size the expert layout: the "
|
||||
"size the process was configured with, not the group it is joining"
|
||||
),
|
||||
("srt/utils/cuda_vmm_transport_utils.py", "tp_size"): (
|
||||
"the consumer count is configured fan-out arithmetic (tp_size // "
|
||||
"dp_size), which is what the record answered before"
|
||||
),
|
||||
("srt/disaggregation/encoder/runtime.py", "tp_size"): (
|
||||
"the encode server's launch entry sizes its workers before it has "
|
||||
"spawned any of them"
|
||||
),
|
||||
("srt/disaggregation/encoder/grpc_server.py", "tp_size"): (
|
||||
"the same worker-count arithmetic on the gRPC entry: it spawns the TP "
|
||||
"workers, so their groups do not exist yet"
|
||||
),
|
||||
("srt/disaggregation/encoder/server.py", "tp_size"): (
|
||||
"`MMEncoder` builds its own TP group from this size -- "
|
||||
"`initialize_model_parallel` is the call being handed it, so there is "
|
||||
"nothing live to ask"
|
||||
),
|
||||
("srt/disaggregation/encoder/receiver.py", "tp_size"): (
|
||||
"the receiver labels and shards by the launch width; it runs in the "
|
||||
"tokenizer process, which holds no encoder groups"
|
||||
),
|
||||
("srt/managers/rust_server.py", "tp_size"): (
|
||||
"the rust server decides its transport from the launch width, in the "
|
||||
"tokenizer process, which holds no model groups"
|
||||
),
|
||||
("compile_deep_gemm.py", "tp_size"): (
|
||||
"the warm-up request fans bootstrap rooms across the launch's ranks; it "
|
||||
"runs in the tokenizer process, which holds no model groups"
|
||||
),
|
||||
("srt/utils/common.py", "tp_size"): (
|
||||
"the require_*_tp_gather predicates compared the configured tp_size "
|
||||
"when they read the record; the live property answers a different "
|
||||
"question wherever the groups alias, so the configured accessor is the "
|
||||
"mechanical substitution and the live one would be a semantic change"
|
||||
),
|
||||
("srt/model_loader/loader.py", "moe_dp_size"): (
|
||||
"the same dict already carries the live moe_dp_size under 'dp'; this entry "
|
||||
"is the configured intent"
|
||||
),
|
||||
("srt/models/kimi_k25.py", "tp_size"): (
|
||||
"the IPC refcount must match the configured TP consumer count captured "
|
||||
"when the tokenizer creates MmItemMemoryPool; a live attention subgroup "
|
||||
"size could strand leases in the bounded pool"
|
||||
),
|
||||
("srt/models/kimi_k3.py", "tp_size"): (
|
||||
"same as kimi_k25: the IPC refcount must agree with the recycler's waiter"
|
||||
),
|
||||
}
|
||||
|
||||
_DIRECT_BASELINE = 0
|
||||
_ALIAS_BASELINE = 0
|
||||
|
||||
@@ -594,222 +347,6 @@ class TestGlobalConfigReadRatchet(CustomTestCase):
|
||||
self._check("alias-form", alias, _ALIAS_BASELINE)
|
||||
|
||||
|
||||
def _live_shadowed_sizes() -> frozenset:
|
||||
"""Names that are BOTH a live ``ParallelContext`` property and a ``parallel``
|
||||
config leaf.
|
||||
|
||||
Derived from the two sides themselves: a size that gains a live property, or
|
||||
a live property that gains a leaf, joins the registry's subject set without a
|
||||
list here.
|
||||
"""
|
||||
from sglang.srt.arg_groups.arg_utils import namespace_of
|
||||
from sglang.srt.runtime_context import ParallelContext
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
live = {
|
||||
name
|
||||
for name, value in vars(ParallelContext).items()
|
||||
if isinstance(value, property)
|
||||
}
|
||||
leaves = {
|
||||
field for field, path in namespace_of(ServerArgs).items() if path == "parallel"
|
||||
}
|
||||
shadowed = frozenset(live & leaves)
|
||||
assert shadowed, "no live-shadowed size found; the derivation is broken"
|
||||
return shadowed
|
||||
|
||||
|
||||
def _parallel_config_reads(tree, subjects):
|
||||
"""Names in ``subjects`` read through the parallel bag's ``config`` hop.
|
||||
|
||||
Sees ``get_parallel().config.pp_size``, the module-qualified spelling, a
|
||||
local bound to either hop (``p = get_parallel()`` / ``cfg = p.config``), and
|
||||
the ``getattr`` form of each.
|
||||
"""
|
||||
fns, modules = set(), set()
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module
|
||||
and node.module.endswith("runtime_context")
|
||||
):
|
||||
fns |= {a.asname or a.name for a in node.names if a.name == "get_parallel"}
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
# `from sglang.srt import runtime_context as rc` binds the module.
|
||||
for a in node.names:
|
||||
if f"{node.module}.{a.name}".endswith("runtime_context"):
|
||||
modules.add(a.asname or a.name)
|
||||
elif isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
if a.name.endswith("runtime_context"):
|
||||
# Unaliased, the call site spells the whole dotted path.
|
||||
modules.add(a.asname or a.name)
|
||||
|
||||
def dotted(node):
|
||||
parts = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
if not isinstance(node, ast.Name):
|
||||
return None
|
||||
parts.append(node.id)
|
||||
return ".".join(reversed(parts))
|
||||
|
||||
def is_bag_call(node):
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id in fns
|
||||
return (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "get_parallel"
|
||||
and dotted(func.value) in modules
|
||||
)
|
||||
|
||||
bag_aliases, config_aliases = set(), set()
|
||||
for _ in range(2): # a local copy of a local is still the same object
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
value = node.value
|
||||
if is_bag_call(value) or (
|
||||
isinstance(value, ast.Name) and value.id in bag_aliases
|
||||
):
|
||||
bucket = bag_aliases
|
||||
elif (
|
||||
isinstance(value, ast.Attribute)
|
||||
and value.attr == "config"
|
||||
and (
|
||||
is_bag_call(value.value)
|
||||
or (
|
||||
isinstance(value.value, ast.Name)
|
||||
and value.value.id in bag_aliases
|
||||
)
|
||||
)
|
||||
) or (isinstance(value, ast.Name) and value.id in config_aliases):
|
||||
bucket = config_aliases
|
||||
else:
|
||||
continue
|
||||
bucket |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
|
||||
def is_config_hop(node):
|
||||
return (
|
||||
isinstance(node, ast.Attribute)
|
||||
and node.attr == "config"
|
||||
and (
|
||||
is_bag_call(node.value)
|
||||
or (isinstance(node.value, ast.Name) and node.value.id in bag_aliases)
|
||||
)
|
||||
) or (isinstance(node, ast.Name) and node.id in config_aliases)
|
||||
|
||||
found = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr in subjects:
|
||||
base, name = node.value, node.attr
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "getattr"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and node.args[1].value in subjects
|
||||
):
|
||||
base, name = node.args[0], node.args[1].value
|
||||
else:
|
||||
continue
|
||||
if is_config_hop(base):
|
||||
found.add(name)
|
||||
return found
|
||||
|
||||
|
||||
_READ_SPELLINGS = (
|
||||
"from sglang.srt.runtime_context import get_parallel\nx = get_parallel().config.tp_size",
|
||||
"from sglang.srt.runtime_context import get_parallel as gp\nx = gp().config.tp_size",
|
||||
"from sglang.srt import runtime_context as rc\nx = rc.get_parallel().config.tp_size",
|
||||
"import sglang.srt.runtime_context\nx = sglang.srt.runtime_context.get_parallel().config.tp_size",
|
||||
"from sglang.srt.runtime_context import get_parallel\np = get_parallel()\nx = p.config.tp_size",
|
||||
"from sglang.srt.runtime_context import get_parallel\nc = get_parallel().config\nx = c.tp_size",
|
||||
'from sglang.srt.runtime_context import get_parallel\nx = getattr(get_parallel().config, "tp_size")',
|
||||
)
|
||||
|
||||
|
||||
class TestParallelConfigReadSpellings(CustomTestCase):
|
||||
"""``_parallel_config_reads`` resolves every spelling it claims to.
|
||||
|
||||
The scan below decides what the documented set is compared against, so a
|
||||
spelling it cannot resolve does not fail anything -- it drops the read.
|
||||
"""
|
||||
|
||||
def test_every_documented_spelling_resolves(self):
|
||||
for source in _READ_SPELLINGS:
|
||||
with self.subTest(source=source):
|
||||
found = _parallel_config_reads(ast.parse(source), {"tp_size"})
|
||||
self.assertEqual({"tp_size"}, set(found))
|
||||
|
||||
def test_the_live_property_is_not_a_config_read(self):
|
||||
source = (
|
||||
"from sglang.srt.runtime_context import get_parallel\n"
|
||||
"x = get_parallel().tp_size"
|
||||
)
|
||||
self.assertEqual(
|
||||
set(), set(_parallel_config_reads(ast.parse(source), {"tp_size"}))
|
||||
)
|
||||
|
||||
|
||||
class TestConfiguredSizeCallSites(CustomTestCase):
|
||||
"""The configured-vs-live exceptions are enumerated, with reasons.
|
||||
|
||||
``get_parallel().config.tp_size`` answers what the process was configured
|
||||
with where the bare ``get_parallel().tp_size`` answers what the process ended
|
||||
up with. Each site that needs the former is listed above with why the live
|
||||
property cannot serve it, and this case fails if the code and that list
|
||||
disagree.
|
||||
|
||||
The unit is **(file, size)**, not the individual read: a second
|
||||
``.config.pp_size`` in a file already registered for it collapses into the
|
||||
same entry, so the reason has to cover the file's use of that size rather
|
||||
than one line. A new file, or a new size in a listed file, is what this
|
||||
catches -- through any spelling of the hop.
|
||||
"""
|
||||
|
||||
def test_the_call_sites_match_the_documented_set(self):
|
||||
subjects = _live_shadowed_sizes()
|
||||
found = set()
|
||||
scanned = 0
|
||||
for path in sorted(_PACKAGE_ROOT.rglob("*.py")):
|
||||
rel = path.relative_to(_PACKAGE_ROOT).as_posix()
|
||||
if rel.startswith(_SLOT_OWNERS):
|
||||
continue
|
||||
source = path.read_text()
|
||||
# Every spelling `_parallel_config_reads` resolves -- the direct
|
||||
# call, an aliased import, a module-qualified call, a local bound to
|
||||
# either hop -- needs the name in the source, so skipping the rest is
|
||||
# free. Filtering on anything narrower silently empties the scan.
|
||||
if "get_parallel" not in source:
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
found |= {(rel, name) for name in _parallel_config_reads(tree, subjects)}
|
||||
self.assertGreater(
|
||||
scanned,
|
||||
50,
|
||||
f"the pre-filter left only {scanned} files to scan; the derivation "
|
||||
"is broken, not the tree",
|
||||
)
|
||||
documented = set(_CONFIGURED_SIZE_CALL_SITES)
|
||||
self.assertEqual(
|
||||
documented,
|
||||
found,
|
||||
"configured-size reads drifted from their documented reasons.\n"
|
||||
f" undocumented: {sorted(found - documented)}\n"
|
||||
f" stale entries: {sorted(documented - found)}",
|
||||
)
|
||||
|
||||
|
||||
class TestNoRenamedAccessorImports(CustomTestCase):
|
||||
"""The baseline scanner matches ``get_server_args`` by its literal name, so
|
||||
an ``import ... as`` rename would walk a read straight past the zero
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""Launch paths read the configured parallel sizes, not the live ones.
|
||||
|
||||
`get_parallel().pp_size` and its four siblings are read-through properties over
|
||||
the process groups, so they answer only after distributed init. The launcher
|
||||
decides how many processes to spawn *before* that, and a live read there raises
|
||||
`Distributed environment is not initialized` -- a startup crash no unit test
|
||||
reaches, because nothing short of booting a server runs the launcher. The
|
||||
configured answer is one hop away on the same object,
|
||||
`get_parallel().config.pp_size`, which reads the published `parallel` bag.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import sglang
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=9, suite="base-a-test-cpu")
|
||||
|
||||
_PACKAGE_ROOT = pathlib.Path(sglang.__file__).resolve().parent
|
||||
|
||||
|
||||
def _live_shadowed() -> dict:
|
||||
"""{name: remedy} for every name that is BOTH a live ParallelContext
|
||||
property and a `parallel` config leaf.
|
||||
|
||||
Derived from the two sides themselves, so a new size that gains a live
|
||||
property (or a live property that gains a leaf) is watched without a second
|
||||
list here. ParallelContext shadows more properties than these -- every
|
||||
`_v(name, ...)` one raises the same "Distributed environment is not
|
||||
initialized" -- but only a shadowed name has a configured answer to point a
|
||||
launcher at.
|
||||
"""
|
||||
from sglang.srt.arg_groups.arg_utils import namespace_of
|
||||
from sglang.srt.runtime_context import ParallelContext
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
live = {
|
||||
name
|
||||
for name, value in vars(ParallelContext).items()
|
||||
if isinstance(value, property)
|
||||
}
|
||||
leaves = {
|
||||
field for field, path in namespace_of(ServerArgs).items() if path == "parallel"
|
||||
}
|
||||
shadowed = live & leaves
|
||||
assert shadowed, (
|
||||
"no live-shadowed parallel size found; the derivation is broken, not "
|
||||
"the tree"
|
||||
)
|
||||
return {name: f"get_parallel().config.{name}" for name in sorted(shadowed)}
|
||||
|
||||
|
||||
_LIVE_SHADOWED = _live_shadowed()
|
||||
|
||||
# Launch paths that decide how many children to spawn are derived below
|
||||
# from the spawn itself. These launch without a size-driven spawn, so no
|
||||
# derivation reaches them and they are carried by hand.
|
||||
_HAND_CARRIED = (
|
||||
"srt/entrypoints/http_server.py",
|
||||
"srt/entrypoints/sidecar.py",
|
||||
"srt/ray/data_parallel_controller.py",
|
||||
"srt/ray/engine.py",
|
||||
"srt/ray/http_server.py",
|
||||
)
|
||||
|
||||
|
||||
def _multiprocessing_names(tree):
|
||||
"""Names bound to multiprocessing, to one of its start contexts, or to the
|
||||
process constructors themselves."""
|
||||
modules, constructors = set(), set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
if a.name == "multiprocessing" or a.name.startswith("multiprocessing."):
|
||||
modules.add(a.asname or a.name.split(".")[0])
|
||||
elif a.name == "torch.multiprocessing":
|
||||
modules.add(a.asname or "torch")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module in (
|
||||
"multiprocessing",
|
||||
"multiprocessing.context",
|
||||
"torch.multiprocessing",
|
||||
):
|
||||
constructors |= {
|
||||
a.asname or a.name for a in node.names if a.name == "Process"
|
||||
}
|
||||
elif node.module == "concurrent.futures":
|
||||
constructors |= {
|
||||
a.asname or a.name
|
||||
for a in node.names
|
||||
if a.name == "ProcessPoolExecutor"
|
||||
}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
||||
func = node.value.func
|
||||
if (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "get_context"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id in modules
|
||||
):
|
||||
modules |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
return modules, constructors
|
||||
|
||||
|
||||
def _spawns_from_a_size(tree) -> bool:
|
||||
"""Does a function here spawn a child *and* read a live-shadowed size?
|
||||
|
||||
Both tiers count: deriving on the live read alone drops a launcher from the
|
||||
scan the moment it is converted, so the guard would only watch the ones
|
||||
that already fail it.
|
||||
"""
|
||||
modules, constructors = _multiprocessing_names(tree)
|
||||
for fn in ast.walk(tree):
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
spawns = False
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr in (
|
||||
"Process",
|
||||
"ProcessPoolExecutor",
|
||||
"Popen",
|
||||
"spawn",
|
||||
):
|
||||
# `mp.Process(`, `mp.get_context("spawn").Process(` and
|
||||
# `subprocess.Popen(` all reach a child process; the receiver of
|
||||
# a chained call is itself a call, so this cannot require a bare
|
||||
# Name.
|
||||
spawns = True
|
||||
elif isinstance(func, ast.Name) and func.id in constructors:
|
||||
spawns = True
|
||||
if not spawns:
|
||||
continue
|
||||
# A record read (`server_args.tp_size`) sizes a spawn too, but it cannot
|
||||
# raise pre-dist; only a bag read is this guard's subject.
|
||||
live, configured = _shadowed_size_reads(tree, scope=fn)
|
||||
if live or configured:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parallel_bag_names(tree):
|
||||
"""What this module calls `get_parallel`, plus any runtime_context alias.
|
||||
|
||||
A literal-name match reads only one spelling; an aliased import or a
|
||||
module-qualified call is the same read with a different surface.
|
||||
"""
|
||||
names, modules = set(), set()
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module
|
||||
and node.module.endswith("runtime_context")
|
||||
):
|
||||
names |= {
|
||||
a.asname or a.name for a in node.names if a.name == "get_parallel"
|
||||
}
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
# `from sglang.srt import runtime_context as rc` binds the module,
|
||||
# so `rc.get_parallel()` is the same call under another spelling.
|
||||
for a in node.names:
|
||||
if f"{node.module}.{a.name}".endswith("runtime_context"):
|
||||
modules.add(a.asname or a.name)
|
||||
elif isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
if a.name.endswith("runtime_context"):
|
||||
modules.add(a.asname or a.name.split(".")[0])
|
||||
return names, modules
|
||||
|
||||
|
||||
def _is_parallel_bag_call(node, names, modules) -> bool:
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
if isinstance(node.func, ast.Name):
|
||||
return node.func.id in names
|
||||
return (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "get_parallel"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id in modules
|
||||
)
|
||||
|
||||
|
||||
def _bag_aliases(tree, names, qualified):
|
||||
"""Locals bound to either tier: `p = get_parallel()` then `p.pp_size` is the
|
||||
same live read one line later, and `cfg = get_parallel().config` then
|
||||
`cfg.pp_size` is the same configured read."""
|
||||
live, config = set(), set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
value = node.value
|
||||
if _is_parallel_bag_call(value, names, qualified):
|
||||
bucket = live
|
||||
elif (
|
||||
isinstance(value, ast.Attribute)
|
||||
and value.attr == "config"
|
||||
and _is_parallel_bag_call(value.value, names, qualified)
|
||||
):
|
||||
bucket = config
|
||||
else:
|
||||
continue
|
||||
bucket |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
return live, config
|
||||
|
||||
|
||||
def _shadowed_size_reads(module_tree, scope=None):
|
||||
"""(live, configured) reads of a live-shadowed size in `scope`.
|
||||
|
||||
`<parallel bag>.tp_size` is the live group; `<parallel bag>.config.tp_size`
|
||||
is the published leaf. Both spellings are reported so a caller can tell a
|
||||
launcher that reads the topology at all from one that reads it live.
|
||||
|
||||
What binds the bag -- the import, a module-level alias -- lives at module
|
||||
scope, so those names always come from `module_tree` even when only one
|
||||
function is being walked. Deriving them from the function alone finds no
|
||||
import, reports no reads, and quietly answers "this launcher reads nothing".
|
||||
"""
|
||||
names, qualified = _parallel_bag_names(module_tree)
|
||||
live_aliases, config_aliases = _bag_aliases(module_tree, names, qualified)
|
||||
|
||||
def is_live_bag(node):
|
||||
return _is_parallel_bag_call(node, names, qualified) or (
|
||||
isinstance(node, ast.Name) and node.id in live_aliases
|
||||
)
|
||||
|
||||
def is_config_bag(node):
|
||||
return (
|
||||
isinstance(node, ast.Attribute)
|
||||
and node.attr == "config"
|
||||
and is_live_bag(node.value)
|
||||
) or (isinstance(node, ast.Name) and node.id in config_aliases)
|
||||
|
||||
live, configured = [], []
|
||||
for node in ast.walk(scope if scope is not None else module_tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr in _LIVE_SHADOWED:
|
||||
base, name, spelling = node.value, node.attr, "attribute"
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "getattr"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and node.args[1].value in _LIVE_SHADOWED
|
||||
):
|
||||
base, name, spelling = node.args[0], node.args[1].value, "getattr"
|
||||
else:
|
||||
continue
|
||||
if is_config_bag(base):
|
||||
configured.append((node.lineno, name, spelling))
|
||||
elif is_live_bag(base):
|
||||
live.append((node.lineno, name, spelling))
|
||||
return live, configured
|
||||
|
||||
|
||||
def _launch_paths():
|
||||
"""(relative path, tree) per module that runs before its process groups.
|
||||
|
||||
A module that sizes a spawn loop from a parallel-bag size is derived from
|
||||
the spawn itself; `_HAND_CARRIED` holds the launch entries that spawn
|
||||
nothing, which no derivation can reach.
|
||||
"""
|
||||
seen = {}
|
||||
sizes = frozenset(_LIVE_SHADOWED)
|
||||
for path in sorted(_PACKAGE_ROOT.rglob("*.py")):
|
||||
source = path.read_text()
|
||||
# Every spawn shape below names Process, ProcessPoolExecutor or Popen.
|
||||
if not any(name in source for name in ("Process", "Popen", "spawn")):
|
||||
continue
|
||||
if not any(name in source for name in sizes):
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
if _spawns_from_a_size(tree):
|
||||
seen[str(path.relative_to(_PACKAGE_ROOT))] = tree
|
||||
for rel in _HAND_CARRIED:
|
||||
seen.setdefault(rel, ast.parse((_PACKAGE_ROOT / rel).read_text()))
|
||||
return sorted(seen.items())
|
||||
|
||||
|
||||
class TestLaunchPathsReadConfiguredSizes(CustomTestCase):
|
||||
def test_configured_sizes_hold_when_the_live_topology_disagrees(self):
|
||||
"""The other direction: groups exist and answer something else.
|
||||
|
||||
The check above proves nobody reads a live size too early. It says
|
||||
nothing about what `.config.<size>` returns once the groups *are* up and
|
||||
answering a different number -- which is not hypothetical: elastic EP
|
||||
scales the live topology away from what the operator configured, and
|
||||
that divergence is the entire reason the two tiers are separate. With
|
||||
only the early-read direction covered, a `config` hop that quietly
|
||||
delegated to the live property would look correct.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from sglang.srt.runtime_context import (
|
||||
ParallelContext,
|
||||
get_parallel,
|
||||
publish,
|
||||
reset_context,
|
||||
)
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
directory = tempfile.mkdtemp(prefix="configured_sizes_")
|
||||
with open(os.path.join(directory, "config.json"), "w") as handle:
|
||||
json.dump(
|
||||
{
|
||||
"architectures": ["LlamaForCausalLM"],
|
||||
"model_type": "llama",
|
||||
"hidden_size": 16,
|
||||
"intermediate_size": 32,
|
||||
"num_attention_heads": 2,
|
||||
"num_key_value_heads": 2,
|
||||
"num_hidden_layers": 2,
|
||||
"vocab_size": 128,
|
||||
"max_position_embeddings": 2048,
|
||||
},
|
||||
handle,
|
||||
)
|
||||
# No resolve_once() here: `tp_size` is raw input, so the configured
|
||||
# value is 2 either way.
|
||||
server_args = ServerArgs(model_path=directory, device="cuda", tp_size=2)
|
||||
self.addCleanup(reset_context)
|
||||
publish(server_args, role="scheduler")
|
||||
|
||||
# The live getter behind each property, read out of ParallelContext
|
||||
# rather than listed here.
|
||||
context_source = ast.parse(
|
||||
(_PACKAGE_ROOT / "srt" / "runtime_context.py").read_text(
|
||||
encoding="utf-8-sig"
|
||||
)
|
||||
)
|
||||
parallel_class = next(
|
||||
node
|
||||
for node in ast.walk(context_source)
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ParallelContext"
|
||||
)
|
||||
live_getter = {}
|
||||
for method in parallel_class.body:
|
||||
if not isinstance(method, ast.FunctionDef):
|
||||
continue
|
||||
for call in ast.walk(method):
|
||||
if not (
|
||||
isinstance(call, ast.Call)
|
||||
and isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "_v"
|
||||
and call.args
|
||||
and isinstance(call.args[0], ast.Constant)
|
||||
):
|
||||
continue
|
||||
getter = call.args[1]
|
||||
if isinstance(getter, ast.Attribute):
|
||||
live_getter[call.args[0].value] = getter.attr
|
||||
state = "sglang.srt.distributed.parallel_state"
|
||||
missing = sorted(set(_LIVE_SHADOWED) - set(live_getter))
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
f"these sizes no longer have a live property to diverge from: {missing}",
|
||||
)
|
||||
for name in sorted(_LIVE_SHADOWED):
|
||||
with self.subTest(size=name):
|
||||
target = f"{state}.{live_getter[name]}"
|
||||
configured = getattr(get_parallel().config, name)
|
||||
with patch(target, return_value=configured + 41):
|
||||
self.assertEqual(
|
||||
get_parallel().__getattribute__(name),
|
||||
configured + 41,
|
||||
f"{name} no longer follows the live topology",
|
||||
)
|
||||
self.assertEqual(
|
||||
getattr(get_parallel().config, name),
|
||||
configured,
|
||||
f"get_parallel().config.{name} followed the live topology "
|
||||
"instead of the published configuration",
|
||||
)
|
||||
from sglang.srt.arg_groups.overrides import resolution_result
|
||||
|
||||
self.assertEqual(
|
||||
resolution_result(server_args, "nccl_port"),
|
||||
getattr(get_parallel(), "nccl_port"),
|
||||
"a config-only leaf read bare disagreed with what resolution decided",
|
||||
)
|
||||
reset_context()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, r"'parallel' not published"):
|
||||
getattr(ParallelContext(), "nccl_port")
|
||||
with self.assertRaisesRegex(AttributeError, r"has no 'not_a_leaf'"):
|
||||
getattr(ParallelContext(), "not_a_leaf")
|
||||
|
||||
def test_no_live_topology_read_before_distributed_init(self):
|
||||
offenders = []
|
||||
for rel, tree in _launch_paths():
|
||||
live, _ = _shadowed_size_reads(tree)
|
||||
for lineno, name, spelling in live:
|
||||
through = " through getattr" if spelling == "getattr" else ""
|
||||
offenders.append(
|
||||
f"{rel}:{lineno} reads the live {name}{through}; "
|
||||
f"use {_LIVE_SHADOWED[name]}"
|
||||
)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"launch paths run before distributed init:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -61,7 +61,7 @@ class TestRayDriverReadsTheBags(CustomTestCase):
|
||||
self._publish(tp_size=2, pp_size=1, dp_size=1, enable_dp_attention=False)
|
||||
self.assertEqual(_compute_world_size(), 2)
|
||||
get_context().override("test.ray_driver", tp_size=8)
|
||||
self.assertEqual(get_parallel().config.tp_size, 8)
|
||||
self.assertEqual(get_parallel().tp_size, 8)
|
||||
self.assertEqual(_compute_world_size(), 8)
|
||||
|
||||
def test_the_driver_modules_read_no_field_off_a_record(self):
|
||||
@@ -112,8 +112,7 @@ class TestRayDriverReadsTheBags(CustomTestCase):
|
||||
offenders,
|
||||
[],
|
||||
"the Ray driver reads a config field off a record; the driver runs "
|
||||
"after the publish, so read `get_parallel().config`:\n "
|
||||
+ "\n ".join(offenders),
|
||||
"after the publish, so read `get_parallel()`:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,21 +39,16 @@ _DP = "sglang.srt.layers.dp_attention"
|
||||
SIZE_RANK_DELEGATIONS = [
|
||||
("world_size", f"{_PS}.get_world_size"),
|
||||
("world_rank", f"{_PS}.get_world_rank"),
|
||||
("tp_size", f"{_PS}.get_tensor_model_parallel_world_size"),
|
||||
("tp_rank", f"{_PS}.get_tensor_model_parallel_rank"),
|
||||
("dcp_size", f"{_PS}.get_dcp_world_size"),
|
||||
("dcp_rank", f"{_PS}.get_dcp_rank"),
|
||||
("pp_size", f"{_PS}.get_pipeline_model_parallel_world_size"),
|
||||
("pp_rank", f"{_PS}.get_pipeline_model_parallel_rank"),
|
||||
("moe_ep_size", f"{_PS}.get_moe_expert_parallel_world_size"),
|
||||
("moe_ep_rank", f"{_PS}.get_moe_expert_parallel_rank"),
|
||||
("moe_dp_size", f"{_PS}.get_moe_data_parallel_world_size"),
|
||||
("moe_dp_rank", f"{_PS}.get_moe_data_parallel_rank"),
|
||||
("moe_tp_size", f"{_PS}.get_moe_tensor_parallel_world_size"),
|
||||
("moe_tp_rank", f"{_PS}.get_moe_tensor_parallel_rank"),
|
||||
("attn_tp_size", f"{_PS}.get_attn_tensor_model_parallel_world_size"),
|
||||
("attn_tp_rank", f"{_PS}.get_attn_tensor_model_parallel_rank"),
|
||||
("attn_cp_size", f"{_PS}.get_attn_context_model_parallel_world_size"),
|
||||
("attn_cp_rank", f"{_PS}.get_attn_context_model_parallel_rank"),
|
||||
("attn_dp_size", f"{_DP}.get_attention_dp_size"),
|
||||
("attn_dp_rank", f"{_DP}.get_attention_dp_rank"),
|
||||
@@ -897,9 +892,8 @@ class TestForwardFlags(_IsolatedServerArgs):
|
||||
|
||||
def test_parallel_config_leaves_trace_under_torch_compile(self):
|
||||
# Regression: gate helpers such as ``enable_moe_dense_fully_dp()`` read
|
||||
# parallel config leaves inside compiled model forwards through the
|
||||
# `config` property, which must stay dynamo-traceable
|
||||
# (``object.__getattribute__`` graph-breaks).
|
||||
# parallel config leaves inside compiled model forwards, which must
|
||||
# stay dynamo-traceable (``object.__getattribute__`` graph-breaks).
|
||||
# fullgraph=True turns any graph break back into a failure.
|
||||
import torch
|
||||
|
||||
@@ -1368,5 +1362,28 @@ class TestNamedAccessorsCallWhatTheyWrap(CustomTestCase):
|
||||
self.assertEqual([], wrong, "\n".join(wrong))
|
||||
|
||||
|
||||
class TestParallelLeafReads(_IsolatedServerArgs):
|
||||
"""The contract ``ParallelContext.__getattr__`` answers a parallel leaf on."""
|
||||
|
||||
def test_a_leaf_answers_what_resolution_decided(self):
|
||||
from sglang.srt.arg_groups.overrides import resolution_result
|
||||
|
||||
with get_context().override_server_args() as server_args:
|
||||
self.assertEqual(
|
||||
resolution_result(server_args, "nccl_port"),
|
||||
get_parallel().nccl_port,
|
||||
"a parallel leaf read off the context disagreed with what "
|
||||
"resolution decided",
|
||||
)
|
||||
|
||||
def test_before_publish_the_error_names_the_namespace(self):
|
||||
with self.assertRaisesRegex(ValueError, r"'parallel' not published"):
|
||||
getattr(ParallelContext(), "nccl_port")
|
||||
|
||||
def test_an_unknown_name_is_still_an_attribute_error(self):
|
||||
with self.assertRaisesRegex(AttributeError, r"has no 'not_a_leaf'"):
|
||||
getattr(ParallelContext(), "not_a_leaf")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -91,7 +91,7 @@ class TestContextOverride(CustomTestCase):
|
||||
speculative_accept_threshold_single=0.5,
|
||||
speculative_accept_threshold_acc=0.9,
|
||||
)
|
||||
self.assertEqual(rc.get_parallel().config.pp_max_micro_batch_size, 8)
|
||||
self.assertEqual(rc.get_parallel().pp_max_micro_batch_size, 8)
|
||||
self.assertEqual(rc.get_spec().speculative_accept_threshold_single, 0.5)
|
||||
self.assertEqual(rc.get_spec().speculative_accept_threshold_acc, 0.9)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user