2798 lines
114 KiB
Python
2798 lines
114 KiB
Python
"""Unit tests for runtime_context: delegation, singletons, and override()."""
|
|
|
|
from sglang.test.ci.ci_register import register_cpu_ci
|
|
|
|
register_cpu_ci(est_time=33, suite="base-a-test-cpu")
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import pathlib as _pathlib
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
import warnings
|
|
from unittest.mock import patch
|
|
|
|
import msgspec
|
|
import msgspec.structs
|
|
|
|
import sglang as _sglang
|
|
import sglang.srt.server_args as server_args_module
|
|
from sglang.srt.arg_groups import prefill_buffer_ceiling
|
|
from sglang.srt.arg_groups.arg_utils import NS, A, Arg
|
|
from sglang.srt.arg_groups.model_override_base import resolving_view
|
|
from sglang.srt.arg_groups.overrides import (
|
|
attention_backends_of,
|
|
)
|
|
from sglang.srt.arg_groups.overrides import (
|
|
mamba_cache_chunk_size as mamba_cache_chunk_size_of,
|
|
)
|
|
from sglang.srt.arg_groups.overrides import (
|
|
max_prefill_buffer_tokens as max_prefill_buffer_tokens_of,
|
|
)
|
|
from sglang.srt.arg_groups.overrides import (
|
|
resolution_result,
|
|
resolved_view,
|
|
)
|
|
from sglang.srt.runtime_context import (
|
|
Flags,
|
|
ParallelContext,
|
|
RuntimeContext,
|
|
SpawnRanks,
|
|
_FlagGroupBase,
|
|
_validate_parallel,
|
|
assert_published,
|
|
derive_parallel_widths,
|
|
get_context,
|
|
get_exec,
|
|
get_flags,
|
|
get_parallel,
|
|
get_schedule,
|
|
get_server_args,
|
|
max_prefill_buffer_tokens,
|
|
max_speculative_num_draft_tokens,
|
|
publish,
|
|
publish_role,
|
|
reset_context,
|
|
)
|
|
from sglang.srt.server_args import ServerArgs
|
|
from sglang.test.test_utils import CustomTestCase
|
|
|
|
_SRT = _pathlib.Path(next(iter(_sglang.__path__))).resolve() / "srt"
|
|
_PACKAGE = _pathlib.Path(next(iter(_sglang.__path__))).resolve()
|
|
|
|
|
|
def _sources():
|
|
"""Every Python file this checkout ships, package and siblings alike.
|
|
|
|
The package alone is the wrong subject set for anything about entries or
|
|
public names: `benchmark/`, `examples/` and the top-level `test/` call the
|
|
same doors and are not covered by any suite that would notice them break.
|
|
An installed package has no siblings, and then this is the package alone."""
|
|
roots = [_PACKAGE]
|
|
checkout = _PACKAGE.parents[1]
|
|
roots += [
|
|
checkout / name
|
|
for name in ("benchmark", "examples", "scripts", "test")
|
|
if (checkout / name).is_dir()
|
|
]
|
|
for root in roots:
|
|
for path in root.rglob("*.py"):
|
|
yield path
|
|
|
|
|
|
def _scope_entries_that_say_nothing(paths):
|
|
"""Draft-scope entries that do not state `owns_attention`, as `path:line`.
|
|
|
|
The scope either narrows the draft's attention and expert identity or
|
|
leaves the target's in place, and only the worker knows which -- so the
|
|
keyword has no default. Omitting it is a `TypeError`, but only on the path
|
|
that runs, and those paths want a GPU and a draft model.
|
|
"""
|
|
import ast
|
|
|
|
missing = []
|
|
for path in paths:
|
|
try:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
except (SyntaxError, UnicodeDecodeError):
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
name = getattr(func, "attr", None) or getattr(func, "id", None)
|
|
if name not in ("draft_tp_context", "patch_tensor_parallel_group"):
|
|
continue
|
|
if not any(kw.arg == "owns_attention" for kw in node.keywords):
|
|
missing.append(f"{path}:{node.lineno}")
|
|
return missing
|
|
|
|
|
|
_PS = "sglang.srt.distributed.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"),
|
|
]
|
|
|
|
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"),
|
|
]
|
|
|
|
|
|
class TestRuntimeContextSingletons(CustomTestCase):
|
|
def test_singletons(self):
|
|
self.assertIs(get_parallel(), get_parallel())
|
|
self.assertIsInstance(get_parallel(), ParallelContext)
|
|
self.assertIsInstance(get_context(), RuntimeContext)
|
|
self.assertIs(get_context().parallel, get_parallel())
|
|
|
|
|
|
class _IsolatedOverrides(CustomTestCase):
|
|
"""Give each test a clean override map, restoring afterward only the overrides
|
|
installed outside it (e.g. by another test file sharing the process)."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
p = get_parallel()
|
|
self._saved_overrides = dict(p._overrides)
|
|
p._overrides.clear()
|
|
|
|
def tearDown(self):
|
|
p = get_parallel()
|
|
p._overrides.clear()
|
|
p._overrides.update(self._saved_overrides)
|
|
super().tearDown()
|
|
|
|
|
|
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__"))
|
|
# tp_group IS exposed: live delegation handles PD-multiplexing / the tp patch.
|
|
self.assertTrue(hasattr(ParallelContext, "tp_group"))
|
|
# local_attn_dp is intentionally not part of the wrapper surface.
|
|
self.assertFalse(hasattr(ParallelContext, "local_attn_dp_size"))
|
|
|
|
|
|
class TestTheTwoWorldWidths(_IsolatedOverrides):
|
|
"""Two questions about the WORLD group: what it was built 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.
|
|
"""
|
|
|
|
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 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)
|
|
|
|
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)
|
|
|
|
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"),
|
|
),
|
|
):
|
|
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, 6)
|
|
self.assertEqual(parallel.launch_world_size, 2)
|
|
|
|
|
|
class TestSpawnIdentities(_IsolatedOverrides):
|
|
"""`dp_rank` and `gpu_id` come from the spawn, because nothing else has them.
|
|
|
|
Both vary per process while the record is identical across them, and
|
|
neither is a position in any process group -- no group has one member per
|
|
data-parallel replica. So the process entry states them at publish.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
parallel = get_parallel()
|
|
self._saved_stamp = dict(parallel._stamp)
|
|
self.addCleanup(
|
|
lambda: (
|
|
parallel.clear_stamp(),
|
|
parallel.override_permanently(**self._saved_stamp),
|
|
)
|
|
)
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
|
|
def test_one_rank_fixes_the_rest(self):
|
|
"""Every other rank is a position in a group laid out from the widths,
|
|
so `world_rank` is the whole placement: rank 5 of a `tp=4, pp=2` world
|
|
is the second stage's second device."""
|
|
publish(
|
|
ServerArgs(model_path="dummy", tp_size=4, pp_size=2),
|
|
role="test",
|
|
ranks=SpawnRanks(world_rank=5, dp_rank=2),
|
|
)
|
|
parallel = get_parallel()
|
|
self.assertEqual(parallel.launch_world_rank, 5)
|
|
self.assertEqual(parallel.tp_rank, 1)
|
|
self.assertEqual(parallel.pp_rank, 1)
|
|
self.assertEqual(parallel.dp_rank, 2)
|
|
|
|
def test_no_controller_is_an_answer_not_a_failure(self):
|
|
"""`dp_rank=None` means "not under a data parallel controller", which
|
|
is a fact about the deployment, unlike never having been told. The
|
|
replicas are separate WORLD groups, so no rank implies it."""
|
|
publish(
|
|
ServerArgs(model_path="dummy", tp_size=2),
|
|
role="test",
|
|
ranks=SpawnRanks(world_rank=0, dp_rank=None),
|
|
)
|
|
self.assertIsNone(get_parallel().dp_rank)
|
|
|
|
def test_publishing_without_a_bundle_names_what_is_missing(self):
|
|
publish(ServerArgs(model_path="dummy", tp_size=2), role="test")
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
get_parallel().dp_rank
|
|
self.assertIn("rank bundle", str(caught.exception))
|
|
|
|
def test_the_attention_rank_keeps_its_own_explanation(self):
|
|
"""Two stamp-only names, two different reasons to be missing."""
|
|
publish(ServerArgs(model_path="dummy", tp_size=2), role="test")
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
get_parallel().attn_dp_rank
|
|
self.assertIn("initialize_dp_attention", str(caught.exception))
|
|
|
|
|
|
class TestAttentionRanksComeFromPublish(_IsolatedOverrides):
|
|
"""With a spawn bundle, a rank read works before any group exists.
|
|
|
|
This is what `ParallelState` provided by being a plain frozen record, and
|
|
what the topology init could not: it needs the groups. Deriving at publish
|
|
is what lets a reader ask the context in a process that never initialises
|
|
distributed -- every unit test that builds a scheduler component, for one.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
parallel = get_parallel()
|
|
self._saved_stamp = dict(parallel._stamp)
|
|
self.addCleanup(
|
|
lambda: (
|
|
parallel.clear_stamp(),
|
|
parallel.override_permanently(**self._saved_stamp),
|
|
)
|
|
)
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
|
|
def test_it_matches_the_topology_init_for_every_shape(self):
|
|
"""Cross-checked against the function the groups use, not restated.
|
|
|
|
Same inputs, two callers: one has them from the configuration and the
|
|
spawn, the other from the groups it just built.
|
|
"""
|
|
from sglang.srt.layers.dp_attention import compute_dp_attention_world_info
|
|
|
|
shapes = [
|
|
(8, 1, 1, False),
|
|
(8, 2, 1, True),
|
|
(8, 4, 1, True),
|
|
(8, 2, 2, True),
|
|
(16, 4, 2, True),
|
|
]
|
|
for tp_size, dp_size, attn_cp_size, dp_attn in shapes:
|
|
for tp_rank in range(tp_size):
|
|
reset_context()
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy",
|
|
tp_size=tp_size,
|
|
dp_size=dp_size,
|
|
attn_cp_size=attn_cp_size,
|
|
enable_dp_attention=dp_attn,
|
|
),
|
|
role="test",
|
|
ranks=SpawnRanks(world_rank=tp_rank),
|
|
)
|
|
want_tp, _, want_dp, _ = compute_dp_attention_world_info(
|
|
dp_attn, tp_rank, tp_size, dp_size, attn_cp_size
|
|
)
|
|
msg = f"tp={tp_size} dp={dp_size} cp={attn_cp_size} rank={tp_rank}"
|
|
self.assertEqual(get_parallel().attn_tp_rank, want_tp, msg)
|
|
self.assertEqual(get_parallel().attn_dp_rank, want_dp, msg)
|
|
|
|
def test_the_rank_reads_without_a_process_group(self):
|
|
"""No distributed init, no patching of any getter."""
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=2, enable_dp_attention=True
|
|
),
|
|
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"),
|
|
):
|
|
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."""
|
|
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)
|
|
|
|
|
|
class TestStampedRanks(_IsolatedOverrides):
|
|
"""`attn_dp_rank` comes from the stamp, and says so when there is none.
|
|
|
|
It is the one rank no group answers with: `initialize_dp_attention`
|
|
computes it from this process's `tp_rank`. Falling back to anything would
|
|
be inventing a placement for this process.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
parallel = get_parallel()
|
|
self._saved_derived = dict(parallel._stamp)
|
|
parallel.clear_stamp()
|
|
self.addCleanup(
|
|
lambda: (
|
|
parallel.clear_stamp(),
|
|
parallel.override_permanently(**self._saved_derived),
|
|
)
|
|
)
|
|
|
|
def test_the_stamp_is_the_answer(self):
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_dp_rank=3)
|
|
self.assertEqual(parallel.attn_dp_rank, 3)
|
|
# An elastic scale-up restamps it; the newest stamp wins.
|
|
parallel.override_permanently(attn_dp_rank=9)
|
|
self.assertEqual(parallel.attn_dp_rank, 9)
|
|
|
|
def test_a_scope_still_wins_over_the_stamp(self):
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_dp_rank=3)
|
|
with parallel.override(attn_dp_rank=0):
|
|
self.assertEqual(parallel.attn_dp_rank, 0)
|
|
self.assertEqual(parallel.attn_dp_rank, 3)
|
|
|
|
def test_unstamped_names_the_cause(self):
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
get_parallel().attn_dp_rank
|
|
self.assertIn("initialize_dp_attention", str(caught.exception))
|
|
|
|
def test_a_stated_width_reaches_the_padding_mode(self):
|
|
"""The reason this PR exists, from a reader's side.
|
|
|
|
`get_dp_padding_mode` reads the attention-DP width. Before the width
|
|
had one home, a scoped `override` moved the context and left the
|
|
module global answering, so stating a topology moved only half the
|
|
runtime: this asserted `SUM_LEN` with the width stated as 1.
|
|
"""
|
|
from sglang.srt.layers.dp_attention import DpPaddingMode
|
|
|
|
with get_parallel().override(attn_dp_size=1):
|
|
mode = DpPaddingMode.get_dp_padding_mode(
|
|
is_extend_in_batch=True, global_num_tokens=[3, 5]
|
|
)
|
|
self.assertIs(mode, DpPaddingMode.MAX_LEN)
|
|
|
|
# And the branch it would have taken with the target's width.
|
|
with get_parallel().override(attn_dp_size=2):
|
|
mode = DpPaddingMode.get_dp_padding_mode(
|
|
is_extend_in_batch=True, global_num_tokens=[3, 5]
|
|
)
|
|
self.assertIs(mode, DpPaddingMode.SUM_LEN)
|
|
|
|
def test_the_gather_slot_follows_the_list_that_was_gathered(self):
|
|
"""The DP sync gathers over the attention-DP replicas, or over the
|
|
expanded WORLD once a scale-up has moved the gather there. The index
|
|
into that list is a property of the gather, so it is read beside the
|
|
flag that says which one happened rather than kept on the topology."""
|
|
from sglang.srt.layers.dp_attention import dp_gather_slot
|
|
|
|
self.addCleanup(reset_context)
|
|
dp_flags = get_flags().dp
|
|
saved = (
|
|
dp_flags.use_world_group_for_gather,
|
|
dp_flags.joiner_skip_all_gather,
|
|
)
|
|
|
|
def restore():
|
|
(
|
|
dp_flags.use_world_group_for_gather,
|
|
dp_flags.joiner_skip_all_gather,
|
|
) = saved
|
|
|
|
self.addCleanup(restore)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=8, enable_dp_attention=True
|
|
),
|
|
role="test",
|
|
ranks=SpawnRanks(world_rank=3),
|
|
)
|
|
parallel = get_parallel()
|
|
dp_flags.use_world_group_for_gather = False
|
|
self.assertEqual(dp_gather_slot(), parallel.attn_dp_rank)
|
|
|
|
# After a scale-up the gather spans the expanded WORLD, and the joining
|
|
# cohort is numbered from its offset.
|
|
dp_flags.use_world_group_for_gather = True
|
|
dp_flags.joiner_skip_all_gather = False
|
|
parallel.override_permanently(ep_join_rank_offset=8)
|
|
self.assertEqual(dp_gather_slot(), 8 + parallel.tp_rank)
|
|
# and the topology it was read off is untouched
|
|
self.assertEqual(parallel.attn_dp_size, 8)
|
|
self.assertEqual(parallel.tp_size, 8)
|
|
|
|
def test_a_scale_up_writes_no_width(self):
|
|
"""The identities stay unconditional because nothing overrides them:
|
|
the scale-up only points the gather at the expanded WORLD."""
|
|
from sglang.srt.layers.dp_attention import update_dp_attention_post_scale
|
|
|
|
dp_flags = get_flags().dp
|
|
saved_gather = dp_flags.use_world_group_for_gather
|
|
self.addCleanup(setattr, dp_flags, "use_world_group_for_gather", saved_gather)
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=8, enable_dp_attention=True
|
|
),
|
|
role="test",
|
|
ranks=SpawnRanks(world_rank=3),
|
|
)
|
|
parallel = get_parallel()
|
|
before = (parallel.attn_dp_size, parallel.attn_dp_rank, parallel.tp_size)
|
|
update_dp_attention_post_scale(new_dp_size=16, new_dp_rank=11)
|
|
self.assertTrue(dp_flags.use_world_group_for_gather)
|
|
self.assertEqual(
|
|
(parallel.attn_dp_size, parallel.attn_dp_rank, parallel.tp_size), before
|
|
)
|
|
|
|
|
|
class TestEveryDeclaredParallelNameIsStatable(_IsolatedOverrides):
|
|
"""The overridable set is read from the declarations, not maintained by hand.
|
|
|
|
A hand-kept list can hold a name the class does not answer, or miss one it
|
|
does; either way `override()` refuses or accepts the wrong thing with
|
|
nothing to say so. The three tests below check the set against the
|
|
declarations from both sides.
|
|
"""
|
|
|
|
def test_every_declared_name_can_be_stated_and_reads_back(self):
|
|
from sglang.srt.runtime_context import _parallel_fields
|
|
|
|
names = sorted(_parallel_fields())
|
|
# Sizes, ranks, groups and the configured leaves of the namespace.
|
|
self.assertGreater(len(names), 30)
|
|
parallel = get_parallel()
|
|
for name in names:
|
|
sentinel = object()
|
|
with parallel.override(**{name: sentinel}):
|
|
self.assertIs(getattr(parallel, name), sentinel, msg=name)
|
|
|
|
def test_every_name_the_class_answers_for_is_in_the_set(self):
|
|
"""Cross-check from the other side: the class's own surface.
|
|
|
|
Derived from the class rather than from the same declarations the set
|
|
is built from, so a source dropped out of `_parallel_fields` shows up
|
|
here instead of agreeing with itself.
|
|
"""
|
|
from sglang.srt.runtime_context import _parallel_fields
|
|
|
|
answered = {
|
|
name
|
|
for name, value in vars(ParallelContext).items()
|
|
if isinstance(value, property)
|
|
}
|
|
self.assertTrue(answered)
|
|
self.assertEqual(answered - _parallel_fields(), set())
|
|
|
|
def test_a_live_name_is_never_also_answered_from_the_bag(self):
|
|
"""The two answer differently, so a name in both would make the read
|
|
order -- not the declaration -- decide which one a caller gets.
|
|
|
|
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
|
|
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())
|
|
|
|
def test_an_undeclared_name_is_refused(self):
|
|
with self.assertRaises(ValueError):
|
|
with get_parallel().override(not_a_parallel_name=1):
|
|
pass
|
|
|
|
|
|
class TestReadsWithoutAPublishedConfig(_IsolatedOverrides):
|
|
"""The namespace has to answer in a process that publishes nothing.
|
|
|
|
`multimodal_gen` lends its own TP group to shared `srt` layers from a
|
|
process with no `srt` config to publish against, and those layers ask for
|
|
`attn_tp_size` anyway -- through code `multimodal_gen` does not own, which
|
|
is why grepping that package for `get_parallel()` finds nothing while the
|
|
read plainly happens.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
parallel = get_parallel()
|
|
self._saved_stamp = dict(parallel._stamp)
|
|
self.addCleanup(
|
|
lambda: (
|
|
parallel.clear_stamp(),
|
|
parallel.override_permanently(**self._saved_stamp),
|
|
)
|
|
)
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
|
|
def test_a_stamped_width_reads_with_nothing_published(self):
|
|
parallel = get_parallel()
|
|
self.assertIsNone(parallel._config)
|
|
parallel.override_permanently(
|
|
**derive_parallel_widths(
|
|
tp_size=2,
|
|
attn_cp_size=1,
|
|
attn_dp_size=1,
|
|
moe_ep_size=1,
|
|
moe_dp_size=1,
|
|
dcp_size=1,
|
|
dcp_enabled=False,
|
|
)
|
|
)
|
|
self.assertEqual(parallel.attn_tp_size, 2)
|
|
self.assertEqual(parallel.moe_tp_size, 2)
|
|
|
|
def test_an_unstamped_width_still_names_the_cause(self):
|
|
"""Without a stamp there is nothing to answer with, and the failure
|
|
has to say so rather than invent a width."""
|
|
with self.assertRaisesRegex(RuntimeError, r"not available"):
|
|
get_parallel().attn_tp_size
|
|
|
|
|
|
class TestPrivateAttributeProbing(_IsolatedOverrides):
|
|
def test_probing_a_private_name_does_not_recurse(self):
|
|
"""`copy` and `pickle` probe for hooks before `__init__` has run.
|
|
|
|
`__getattr__` reaches for `self._config`, so if it did not refuse
|
|
underscore names outright, probing one on a half-built instance would
|
|
recurse until the stack ran out.
|
|
"""
|
|
fresh = ParallelContext.__new__(ParallelContext) # slots unset
|
|
for probe in ("_config", "_stamp", "_overrides", "__deepcopy__"):
|
|
with self.assertRaises(AttributeError, msg=probe):
|
|
getattr(fresh, probe)
|
|
|
|
def test_a_built_context_survives_a_copy(self):
|
|
import copy
|
|
|
|
self.assertIsInstance(copy.copy(get_parallel()), ParallelContext)
|
|
|
|
|
|
class TestAWidthReadStaysTraceable(_IsolatedOverrides):
|
|
"""A width read inside compiled model code must stay inside the graph.
|
|
|
|
Shared layers read widths inside a compiled forward. A graph break there
|
|
is a performance regression and nothing else -- every suite stays green
|
|
through it -- so `fullgraph=True` is what turns it into a failure. This
|
|
pins the read path, whichever form it takes: the sibling leaf test
|
|
compiles names served by `__getattr__` and they trace too.
|
|
"""
|
|
|
|
def test_a_width_read_compiles_into_the_graph(self):
|
|
import torch
|
|
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="test",
|
|
)
|
|
|
|
def read(x):
|
|
return x * get_parallel().attn_tp_size
|
|
|
|
# backend="eager": this pins tracing, not code generation, and stays
|
|
# runnable on a box with no inductor toolchain.
|
|
compiled = torch.compile(read, fullgraph=True, backend="eager")
|
|
self.assertEqual(compiled(torch.ones(3)).tolist(), [4.0, 4.0, 4.0])
|
|
|
|
|
|
class TestParallelOverride(_IsolatedOverrides):
|
|
def test_override_takes_precedence(self):
|
|
p = get_parallel()
|
|
with p.override(tp_size=99, tp_rank=3, attn_dp_size=8):
|
|
self.assertEqual(p.tp_size, 99)
|
|
self.assertEqual(p.tp_rank, 3)
|
|
self.assertEqual(p.attn_dp_size, 8)
|
|
# same singleton: a fresh get_parallel() sees the override too
|
|
self.assertEqual(get_parallel().tp_size, 99)
|
|
self.assertEqual(p._overrides, {})
|
|
|
|
def test_override_can_force_groups(self):
|
|
sentinel = object()
|
|
with get_parallel().override(tp_group=sentinel):
|
|
self.assertIs(get_parallel().tp_group, sentinel)
|
|
|
|
def test_override_nests_and_restores(self):
|
|
p = get_parallel()
|
|
with p.override(tp_size=2):
|
|
self.assertEqual(p.tp_size, 2)
|
|
with p.override(tp_size=4, pp_size=2):
|
|
self.assertEqual(p.tp_size, 4)
|
|
self.assertEqual(p.pp_size, 2)
|
|
self.assertEqual(p.tp_size, 2)
|
|
self.assertNotIn("pp_size", p._overrides)
|
|
|
|
def test_override_unknown_key_raises_and_does_not_mutate(self):
|
|
p = get_parallel()
|
|
with self.assertRaises(ValueError):
|
|
with p.override(tp_sizee=1): # typo
|
|
pass
|
|
self.assertEqual(p._overrides, {})
|
|
|
|
|
|
class TestParallelDCP(_IsolatedOverrides):
|
|
"""The DCP width is a quotient; the DCP rank is a live reading.
|
|
|
|
They used to be tested the same way, by mocking the group getters, because
|
|
the width read the group too. It does not: `attn_dcp_size` is
|
|
`dcp_size if dcp_enabled else 1`, so the way to state it is to state the
|
|
leaves.
|
|
"""
|
|
|
|
def _published(self, **fields):
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(ServerArgs(model_path="dummy", **fields), role="test")
|
|
return get_parallel()
|
|
|
|
def test_attn_dcp_is_one_when_dcp_is_off(self):
|
|
parallel = self._published(tp_size=8, dcp_size=1)
|
|
self.assertFalse(parallel.dcp_enabled)
|
|
self.assertEqual(parallel.attn_dcp_size, 1)
|
|
|
|
def test_attn_dcp_is_the_configured_width_when_on(self):
|
|
parallel = self._published(tp_size=8, dcp_size=8)
|
|
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 test_the_width_does_not_consult_the_platform(self):
|
|
with patch("sglang.srt.utils.is_cuda", return_value=False) as is_cuda:
|
|
parallel = self._published(tp_size=8, dcp_size=8)
|
|
self.assertTrue(parallel.dcp_enabled)
|
|
self.assertEqual(parallel.attn_dcp_size, 8)
|
|
is_cuda.assert_not_called()
|
|
|
|
|
|
class _IsolatedServerArgs(CustomTestCase):
|
|
"""Save/restore the published ServerArgs around each test (the slot is
|
|
process-global; another test file sharing the process may have published)."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self._saved_server_args = get_context()._server_args
|
|
|
|
def tearDown(self):
|
|
if self._saved_server_args is None:
|
|
reset_context()
|
|
else:
|
|
get_context().set_server_args(self._saved_server_args)
|
|
super().tearDown()
|
|
|
|
|
|
class TestServerArgsOwnership(_IsolatedServerArgs):
|
|
"""V2b: the context owns the slot; the legacy getters are identity shims."""
|
|
|
|
def test_legacy_setter_publishes_into_context(self):
|
|
# Identity, not equality: the slot holds the very object published.
|
|
sentinel = ServerArgs(model_path="dummy")
|
|
server_args_module.set_global_server_args_for_scheduler(sentinel)
|
|
self.assertIs(get_server_args(), sentinel)
|
|
self.assertIs(get_context().server_args, sentinel)
|
|
|
|
def test_the_retired_accessor_raises_and_names_the_replacement(self):
|
|
"""`get_global_server_args` is retired: it answered with the record,
|
|
so a caller reading a field resolution had decided got a stale value
|
|
and no error at all.
|
|
|
|
`RuntimeError` unconditionally, not a warning first: a
|
|
`DeprecationWarning` is filtered by default outside `__main__`, so no
|
|
production caller would have seen it, and under
|
|
`-W error::DeprecationWarning` it would have changed the exception a
|
|
caller catches. The message has to name where to read instead, since
|
|
the answer differs by what the caller wanted.
|
|
"""
|
|
with self.assertRaises(RuntimeError) as cm:
|
|
server_args_module.get_global_server_args()
|
|
message = str(cm.exception)
|
|
self.assertIn("runtime_context", message)
|
|
self.assertIn("get_server_args()", message)
|
|
|
|
# And the type does not change when warnings are errors.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error")
|
|
with self.assertRaises(RuntimeError):
|
|
server_args_module.get_global_server_args()
|
|
|
|
def test_tokenizer_alias_is_distinct_role_shim(self):
|
|
# Deliberately NOT an alias: the two legacy setters publish with
|
|
# different process roles (scheduler vs tokenizer).
|
|
self.assertIsNot(
|
|
server_args_module.set_global_server_args_for_tokenizer,
|
|
server_args_module.set_global_server_args_for_scheduler,
|
|
)
|
|
|
|
def test_pre_publish_error_verbatim(self):
|
|
reset_context()
|
|
with self.assertRaises(ValueError) as cm:
|
|
get_server_args()
|
|
self.assertEqual(str(cm.exception), "Global server args is not set yet!")
|
|
|
|
def test_republish_overwrite_allowed(self):
|
|
first = ServerArgs(model_path="dummy")
|
|
second = ServerArgs(model_path="dummy")
|
|
server_args_module.set_global_server_args_for_scheduler(first)
|
|
server_args_module.set_global_server_args_for_scheduler(second)
|
|
self.assertIs(get_server_args(), second)
|
|
|
|
def test_reset_context_clears_owned_store(self):
|
|
server_args_module.set_global_server_args_for_scheduler(
|
|
ServerArgs(model_path="dummy")
|
|
)
|
|
reset_context()
|
|
with self.assertRaises(ValueError):
|
|
get_server_args()
|
|
|
|
|
|
class TestAssertPublished(_IsolatedServerArgs):
|
|
"""Publishing is the process entry's job; the constructors only check.
|
|
|
|
`ModelRunner`, `TokenizerManager` and `MMEncoder` assert. A publish inside
|
|
a process that has already published re-projects the bags, discarding every
|
|
`override()` taken since and the provenance log with it, so a constructor
|
|
that finds nothing published fails loud.
|
|
"""
|
|
|
|
def _record(self, **fields):
|
|
return ServerArgs(model_path="dummy", **fields)
|
|
|
|
def test_the_check_leaves_a_live_process_alone(self):
|
|
record = self._record(grammar_backend="xgrammar")
|
|
publish(record, role="scheduler")
|
|
get_context().override("grammar.import_fallback", grammar_backend="none")
|
|
|
|
assert_published(record, role="scheduler")
|
|
|
|
self.assertEqual(
|
|
get_exec().kernel.grammar_backend,
|
|
"none",
|
|
"the check re-projected the bags, so the import fallback was "
|
|
"discarded and the process reports a backend it is not using",
|
|
)
|
|
self.assertEqual(
|
|
len(get_context().overrides_log()),
|
|
1,
|
|
"the provenance of the override went with it",
|
|
)
|
|
|
|
def test_a_different_record_fails(self):
|
|
first = self._record(grammar_backend="xgrammar")
|
|
publish(first, role="scheduler")
|
|
second = self._record(grammar_backend="llguidance")
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "a different record is published"):
|
|
assert_published(second, role="scheduler")
|
|
|
|
self.assertIs(
|
|
get_server_args(),
|
|
first,
|
|
"the failing check published anyway",
|
|
)
|
|
|
|
def test_an_empty_slot_fails(self):
|
|
"""An empty slot fails."""
|
|
reset_context()
|
|
record = self._record(grammar_backend="xgrammar")
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError, "nothing is published in this process"
|
|
):
|
|
assert_published(record, role="scheduler")
|
|
|
|
def test_the_same_record_under_a_different_role_fails(self):
|
|
"""The role decides which namespaces this process may read."""
|
|
record = self._record()
|
|
publish(record, role="tokenizer")
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "published under role 'tokenizer'"):
|
|
assert_published(record, role="scheduler")
|
|
|
|
self.assertEqual(publish_role(), "tokenizer")
|
|
|
|
|
|
class TestServerArgsScopedOverride(_IsolatedServerArgs):
|
|
"""ctx.override_server_args: the config tier's scoped test override —
|
|
tests force execution paths by overriding the context, not by
|
|
hand-building and publishing config objects."""
|
|
|
|
def test_install_publishes_fresh_config_with_fields(self):
|
|
reset_context()
|
|
override = get_context().override_server_args(
|
|
attention_backend="triton", chunked_prefill_size=-1
|
|
)
|
|
published = override.install()
|
|
self.assertIs(get_server_args(), published)
|
|
# The hook declares; the record keeps the operator's input, so the
|
|
# values are read where resolution puts them.
|
|
self.assertEqual(resolution_result(published, "attention_backend"), "triton")
|
|
self.assertEqual(resolution_result(published, "chunked_prefill_size"), -1)
|
|
# unnamed fields keep their dataclass defaults
|
|
self.assertEqual(resolution_result(published, "tp_size"), 1)
|
|
|
|
def test_unknown_fields_are_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
get_context().override_server_args(not_a_config_field=1).install()
|
|
|
|
def test_restore_reinstates_previous_publish(self):
|
|
previous = object()
|
|
get_context().set_server_args(previous)
|
|
override = get_context().override_server_args(tp_size=8)
|
|
override.install()
|
|
self.assertEqual(get_parallel().tp_size, 8)
|
|
override.restore()
|
|
self.assertIs(get_server_args(), previous)
|
|
|
|
def test_restore_reinstates_the_empty_slot(self):
|
|
reset_context()
|
|
with get_context().override_server_args():
|
|
get_server_args() # published inside the scope
|
|
with self.assertRaises(ValueError):
|
|
get_server_args()
|
|
|
|
def test_nesting_restores_in_order(self):
|
|
reset_context()
|
|
with get_context().override_server_args(tp_size=2) as outer:
|
|
with get_context().override_server_args(tp_size=4):
|
|
self.assertEqual(get_parallel().tp_size, 4)
|
|
self.assertIs(get_server_args(), outer)
|
|
self.assertEqual(get_parallel().tp_size, 2)
|
|
|
|
def test_private_attribute_seeding(self):
|
|
# Property caches (e.g. _mamba_cache_chunk_size) are seeded through
|
|
# the same call; the strict guard exempts underscore names.
|
|
published = (
|
|
get_context().override_server_args(_mamba_cache_chunk_size=64).install()
|
|
)
|
|
self.assertEqual(mamba_cache_chunk_size_of(published), 64)
|
|
|
|
def test_an_underscore_field_is_declared_like_any_other(self):
|
|
"""The split is fields vs not-fields, not the leading underscore.
|
|
|
|
`_speculative_draft_quantization_explicitly_set` is a real field
|
|
published under `spec`. Seeding it as a raw attribute instead of
|
|
declaring it would leave the earlier resolution authoritative, so both
|
|
the resolution and the bag would keep answering the pre-override value
|
|
while the record said otherwise.
|
|
"""
|
|
from sglang.srt.arg_groups.overrides import resolution_result
|
|
from sglang.srt.runtime_context import get_spec
|
|
|
|
name = "_speculative_draft_quantization_explicitly_set"
|
|
self.assertIn(name, ServerArgs.__struct_fields__)
|
|
|
|
published = get_context().override_server_args(**{name: True}).install()
|
|
# The record keeps the operator's input, as it does for every other
|
|
# field; the override travels as a declaration.
|
|
self.assertIsNone(getattr(published, name))
|
|
self.assertIs(resolution_result(published, name), True)
|
|
self.assertIs(getattr(get_spec(), name), True)
|
|
|
|
def test_installed_config_arms_the_strict_guard(self):
|
|
# The published dummy must behave like a resolved config: bare writes
|
|
# raise.
|
|
published = get_context().override_server_args(tp_size=2).install()
|
|
with self.assertRaises(AttributeError):
|
|
published.tp_size = 4
|
|
self.assertEqual(resolution_result(published, "tp_size"), 2)
|
|
|
|
def test_restore_resets_the_capture_seed(self):
|
|
# install() seeds flags.capture from the published dummy; restore()
|
|
# must put back the pre-install runtime state on both restore paths.
|
|
reset_context()
|
|
self.assertFalse(get_flags().capture.enable_torch_compile)
|
|
override = get_context().override_server_args(enable_torch_compile=True)
|
|
override.install()
|
|
self.assertTrue(get_flags().capture.enable_torch_compile)
|
|
override.restore()
|
|
self.assertFalse(get_flags().capture.enable_torch_compile)
|
|
|
|
def test_double_install_rejected(self):
|
|
override = get_context().override_server_args()
|
|
override.install()
|
|
with self.assertRaises(AssertionError):
|
|
override.install()
|
|
|
|
|
|
class _FakeCaptureGroup(_FlagGroupBase):
|
|
gamma: int = 0
|
|
|
|
|
|
class TestFlagsTier(_IsolatedServerArgs):
|
|
"""Runtime-flags tier: typed groups, typo-safe writes, override primitive.
|
|
|
|
Resolved configuration lives on server_args fields (materialized at the
|
|
end of __post_init__); the flags tier only carries runtime state
|
|
(today: the capture lifecycle)."""
|
|
|
|
def test_wiring_and_groups(self):
|
|
flags = get_flags()
|
|
self.assertIs(flags, get_context().flags)
|
|
self.assertIsInstance(flags, Flags)
|
|
self.assertTrue(hasattr(flags, "capture"))
|
|
|
|
def test_typo_safety(self):
|
|
group = _FakeCaptureGroup()
|
|
with self.assertRaises(AttributeError):
|
|
group.gamma_misspelled = 2 # undeclared leaf
|
|
with self.assertRaises(AttributeError):
|
|
get_flags().not_a_flag = 1
|
|
|
|
def test_override_is_transactional(self):
|
|
group = _FakeCaptureGroup()
|
|
with group.override(gamma=99):
|
|
self.assertEqual(group.gamma, 99)
|
|
self.assertEqual(group.gamma, 0)
|
|
with self.assertRaises(ValueError):
|
|
with group.override(gamma=2, delta=3): # delta undeclared
|
|
pass
|
|
self.assertEqual(group.gamma, 0) # validated before any write
|
|
|
|
def test_reset_context_installs_fresh_flags(self):
|
|
old = get_flags()
|
|
old.capture.enable_torch_compile = True
|
|
reset_context()
|
|
self.assertIsNot(get_flags(), old)
|
|
self.assertFalse(get_flags().capture.enable_torch_compile)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _FakeResolvedArgs:
|
|
"""Publishable fixture with a resolvable whitelist (real flat leaves)."""
|
|
|
|
page_size: A[int | None, Arg(help="p", resolvable=True), NS("schedule")] = None
|
|
sampling_backend: A[
|
|
str | None, Arg(help="s", resolvable=True), NS("exec.kernel")
|
|
] = None
|
|
attention_backend: A[str | None, Arg(help="ab"), NS("exec.kernel")] = None
|
|
prefill_attention_backend: A[str | None, Arg(help="pab"), NS("exec.kernel")] = None
|
|
decode_attention_backend: A[str | None, Arg(help="dab"), NS("exec.kernel")] = None
|
|
disable_radix_cache: A[bool, Arg(help="drc"), NS("memory")] = False
|
|
mamba_radix_cache_strategy: A[str, Arg(help="mrcs"), NS("exec.mamba")] = "auto"
|
|
speculative_algorithm: A[str | None, Arg(help="sa"), NS("spec")] = None
|
|
speculative_num_draft_tokens: A[int | None, Arg(help="d"), NS("spec")] = None
|
|
speculative_adaptive: A[bool, Arg(help="a"), NS("spec")] = False
|
|
speculative_adaptive_config: A[str | None, Arg(help="c"), NS("spec")] = None
|
|
load_format: A[str, Arg(help="lf"), NS("model")] = "auto"
|
|
remote_instance_weight_loader_backend: A[str, Arg(help="rb"), NS("model")] = "nccl"
|
|
remote_instance_weight_loader_start_seed_via_transfer_engine: A[
|
|
bool, Arg(help="rs"), NS("model")
|
|
] = False
|
|
modelexpress_config: A[str | None, Arg(help="mx"), NS("model")] = None
|
|
disaggregation_mode: A[str, Arg(help="dm"), NS("disagg")] = "null"
|
|
max_running_requests: A[int | None, Arg(help="mrr"), NS("schedule")] = None
|
|
chunked_prefill_size: A[int, Arg(help="cps"), NS("schedule")] = -1
|
|
max_prefill_tokens: A[int, Arg(help="mpt"), NS("schedule")] = 16384
|
|
enable_dynamic_chunking: A[bool, Arg(help="edc"), NS("schedule")] = False
|
|
cuda_graph_config: A[object | None, Arg(help="cgc"), NS("exec.graph")] = None
|
|
tp_size: A[int, Arg(help="tp"), NS("parallel")] = 1
|
|
pp_size: A[int, Arg(help="pp"), NS("parallel")] = 1
|
|
_resolved_overrides: list = dataclasses.field(default_factory=list)
|
|
|
|
|
|
class TestMoeFlagsGroup(_IsolatedServerArgs):
|
|
"""flags.moe: materialized by initialize_moe_config; the ACTIVE backends
|
|
swap under the speculative contexts and restore on exit."""
|
|
|
|
def _init(self, **kw):
|
|
from sglang.srt.layers.moe.utils import initialize_moe_config
|
|
|
|
defaults = dict(
|
|
moe_a2a_backend="none",
|
|
moe_runner_backend="auto",
|
|
speculative_moe_runner_backend=None,
|
|
speculative_moe_a2a_backend=None,
|
|
deepep_mode="auto",
|
|
deepep_config=None,
|
|
enable_two_batch_overlap=False,
|
|
enable_single_batch_overlap=False,
|
|
tbo_token_distribution_threshold=0.48,
|
|
disable_flashinfer_cutlass_moe_fp4_allgather=False,
|
|
quantization=None,
|
|
disable_shared_experts_fusion=False,
|
|
)
|
|
defaults.update(kw)
|
|
# The flags are seeded from the bags, so the test publishes a config
|
|
# carrying these values.
|
|
override = get_context().override_server_args(**defaults)
|
|
override.install()
|
|
self.addCleanup(override.restore)
|
|
initialize_moe_config()
|
|
|
|
def test_lazy_defaults_before_initialize(self):
|
|
from sglang.srt.layers.moe.utils import (
|
|
get_moe_a2a_backend,
|
|
get_moe_runner_backend,
|
|
is_tbo_enabled,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertTrue(get_moe_a2a_backend().is_none())
|
|
self.assertEqual(get_moe_runner_backend().name, "AUTO")
|
|
self.assertFalse(is_tbo_enabled())
|
|
|
|
def test_initialize_materializes_group(self):
|
|
from sglang.srt.layers.moe.utils import get_moe_a2a_backend, is_tbo_enabled
|
|
|
|
self._init(moe_a2a_backend="deepep", enable_two_batch_overlap=True)
|
|
self.assertTrue(get_moe_a2a_backend().is_deepep())
|
|
self.assertTrue(is_tbo_enabled())
|
|
self.assertEqual(get_flags().moe.deepep_config, "")
|
|
|
|
def test_speculative_swap_and_restore(self):
|
|
from sglang.srt.layers.moe.utils import (
|
|
get_moe_a2a_backend,
|
|
get_moe_runner_backend,
|
|
speculative_moe_a2a_backend_context,
|
|
speculative_moe_backend_context,
|
|
)
|
|
|
|
self._init(
|
|
moe_a2a_backend="deepep",
|
|
moe_runner_backend="triton",
|
|
speculative_moe_runner_backend="auto",
|
|
speculative_moe_a2a_backend="none",
|
|
)
|
|
with speculative_moe_backend_context(), speculative_moe_a2a_backend_context():
|
|
self.assertEqual(get_moe_runner_backend().name, "AUTO")
|
|
self.assertTrue(get_moe_a2a_backend().is_none())
|
|
# MTP layers are unquantized: fp4 allgather is forced off
|
|
self.assertTrue(get_flags().moe.disable_fp4_allgather)
|
|
self.assertTrue(get_flags().moe.speculative_context)
|
|
self.assertEqual(get_moe_runner_backend().name, "TRITON")
|
|
self.assertTrue(get_moe_a2a_backend().is_deepep())
|
|
self.assertFalse(get_flags().moe.disable_fp4_allgather)
|
|
self.assertFalse(get_flags().moe.speculative_context)
|
|
|
|
def test_swap_restores_on_exception(self):
|
|
from sglang.srt.layers.moe.utils import (
|
|
get_moe_runner_backend,
|
|
speculative_moe_backend_context,
|
|
)
|
|
|
|
self._init(moe_runner_backend="triton", speculative_moe_runner_backend="auto")
|
|
with self.assertRaises(RuntimeError):
|
|
with speculative_moe_backend_context():
|
|
raise RuntimeError("boom")
|
|
self.assertEqual(get_moe_runner_backend().name, "TRITON")
|
|
|
|
|
|
class TestDpFlagsGroup(_IsolatedServerArgs):
|
|
"""flags.dp: the DP-attention runtime flags; is_dp_attention_enabled is a
|
|
thin shim over the group leaf."""
|
|
|
|
def test_shim_reads_the_leaf(self):
|
|
from sglang.srt.layers.dp_attention import is_dp_attention_enabled
|
|
|
|
reset_context()
|
|
self.assertFalse(is_dp_attention_enabled())
|
|
get_flags().dp.enabled = True
|
|
self.assertTrue(is_dp_attention_enabled())
|
|
|
|
def test_scoped_override_forces_the_predicate(self):
|
|
from sglang.srt.layers.dp_attention import is_dp_attention_enabled
|
|
|
|
reset_context()
|
|
with get_flags().dp.override(enabled=True):
|
|
self.assertTrue(is_dp_attention_enabled())
|
|
self.assertFalse(is_dp_attention_enabled())
|
|
|
|
|
|
class TestResources(_IsolatedServerArgs):
|
|
"""ctx.resources: named slots for process-level resource handles with one
|
|
reset lifecycle; owning accessors keep their creation/publish semantics."""
|
|
|
|
def test_graph_pool_lazy_create_and_reuse(self):
|
|
from types import SimpleNamespace
|
|
|
|
from sglang.srt.model_executor.runner_utils.pool import (
|
|
get_global_graph_memory_pool,
|
|
get_or_create_global_graph_memory_pool,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertIsNone(get_global_graph_memory_pool())
|
|
dev = SimpleNamespace(graph_pool_handle=lambda: object())
|
|
handle = get_or_create_global_graph_memory_pool(dev)
|
|
self.assertIs(get_or_create_global_graph_memory_pool(dev), handle)
|
|
|
|
def test_expert_recorder_noop_default_and_injection(self):
|
|
from sglang.srt.eplb.expert_distribution import (
|
|
get_global_expert_distribution_recorder,
|
|
)
|
|
from sglang.srt.runtime_context import get_resources
|
|
|
|
reset_context()
|
|
self.assertEqual(
|
|
type(get_global_expert_distribution_recorder()).__name__,
|
|
"_ExpertDistributionRecorderNoop",
|
|
)
|
|
with get_resources().override(expert_distribution_recorder="mock"):
|
|
self.assertEqual(get_global_expert_distribution_recorder(), "mock")
|
|
|
|
def test_expert_location_metadata_publish_once_until_reset(self):
|
|
from sglang.srt.eplb.expert_location import (
|
|
get_global_expert_location_metadata,
|
|
set_global_expert_location_metadata,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertIsNone(get_global_expert_location_metadata())
|
|
set_global_expert_location_metadata("meta")
|
|
with self.assertRaises(AssertionError):
|
|
set_global_expert_location_metadata("again")
|
|
reset_context()
|
|
self.assertIsNone(get_global_expert_location_metadata())
|
|
|
|
|
|
class TestNamedStreams(_IsolatedServerArgs):
|
|
"""ctx.get_stream(name): keyed get-or-create (the persistent-buffer
|
|
pattern); set_stream installs explicitly."""
|
|
|
|
def test_get_or_create_shares_by_name(self):
|
|
from unittest.mock import patch
|
|
|
|
reset_context()
|
|
created = []
|
|
|
|
class _FakeStream:
|
|
def __init__(self):
|
|
created.append(self)
|
|
|
|
with patch("torch.cuda.Stream", _FakeStream):
|
|
a = get_context().get_stream("alt")
|
|
b = get_context().get_stream("alt")
|
|
c = get_context().get_stream("other")
|
|
self.assertIs(a, b)
|
|
self.assertIsNot(a, c)
|
|
self.assertEqual(len(created), 2)
|
|
|
|
def test_get_buffer_keyed_lazy(self):
|
|
reset_context()
|
|
created = []
|
|
|
|
def factory():
|
|
created.append(object())
|
|
return created[-1]
|
|
|
|
a = get_context().get_buffer("ws", factory)
|
|
b = get_context().get_buffer("ws", factory)
|
|
self.assertIs(a, b)
|
|
self.assertEqual(len(created), 1)
|
|
self.assertIsNot(get_context().get_buffer("other", factory), a)
|
|
|
|
def test_set_stream_installs_explicitly(self):
|
|
reset_context()
|
|
sentinel = object()
|
|
get_context().set_stream("alt", sentinel)
|
|
self.assertIs(get_context().get_stream("alt"), sentinel)
|
|
|
|
def test_reset_clears_the_registry(self):
|
|
reset_context()
|
|
get_context().set_stream("alt", object())
|
|
reset_context()
|
|
self.assertEqual(get_context().resources.streams, {})
|
|
|
|
def test_capturer_slots_roundtrip_and_reset(self):
|
|
from sglang.srt.state_capturer.indexer_topk import (
|
|
get_global_indexer_capturer,
|
|
set_global_indexer_capturer,
|
|
)
|
|
from sglang.srt.state_capturer.routed_experts import (
|
|
get_global_experts_capturer,
|
|
set_global_experts_capturer,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertIsNone(get_global_indexer_capturer())
|
|
self.assertIsNone(get_global_experts_capturer())
|
|
indexer, experts = object(), object()
|
|
set_global_indexer_capturer(indexer)
|
|
set_global_experts_capturer(experts)
|
|
self.assertIs(get_global_indexer_capturer(), indexer)
|
|
self.assertIs(get_global_experts_capturer(), experts)
|
|
reset_context()
|
|
self.assertIsNone(get_global_indexer_capturer())
|
|
self.assertIsNone(get_global_experts_capturer())
|
|
|
|
def test_tcp_store_slot_roundtrip_and_reset(self):
|
|
from sglang.srt.distributed.utils import (
|
|
get_global_tcp_store,
|
|
set_global_tcp_store,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertIsNone(get_global_tcp_store())
|
|
store = object()
|
|
set_global_tcp_store(store)
|
|
self.assertIs(get_global_tcp_store(), store)
|
|
reset_context()
|
|
self.assertIsNone(get_global_tcp_store())
|
|
|
|
def test_trace_level_env_seeded_lazy_default(self):
|
|
from sglang.srt.observability.trace import (
|
|
get_global_trace_level,
|
|
set_global_trace_level,
|
|
)
|
|
|
|
reset_context()
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("SGLANG_TRACE_LEVEL", None)
|
|
self.assertEqual(get_global_trace_level(), 3)
|
|
set_global_trace_level(5)
|
|
self.assertEqual(get_global_trace_level(), 5)
|
|
reset_context()
|
|
with patch.dict(os.environ, {"SGLANG_TRACE_LEVEL": "1"}):
|
|
self.assertEqual(get_global_trace_level(), 1)
|
|
|
|
|
|
class TestEpBufferState(_IsolatedServerArgs):
|
|
"""EP dispatcher buffer managers: state lives on ctx.resources; the
|
|
facade keeps the mode-transition and clean semantics."""
|
|
|
|
def test_deepep_dispatch_mode_transitions_and_reset(self):
|
|
try:
|
|
from sglang.srt.layers.moe.token_dispatcher.deepep import DeepEPBuffer
|
|
except ImportError:
|
|
self.skipTest("deep_ep not installed")
|
|
|
|
reset_context()
|
|
cleans = []
|
|
|
|
class _FakeBuffer:
|
|
low_latency_mode = True
|
|
|
|
def clean_low_latency_buffer(self, *args):
|
|
cleans.append(args)
|
|
|
|
state = DeepEPBuffer._state()
|
|
state.buffer = _FakeBuffer()
|
|
state.hidden_size = 7168
|
|
state.num_max_dispatch_tokens_per_rank = 128
|
|
state.num_experts = 256
|
|
|
|
DeepEPBuffer.set_dispatch_mode_as_normal()
|
|
# NORMAL -> LOW_LATENCY must clean the low-latency buffer once.
|
|
DeepEPBuffer.set_dispatch_mode_as_low_latency()
|
|
self.assertEqual(cleans, [(128, 7168, 256)])
|
|
# LOW_LATENCY -> LOW_LATENCY must not clean again.
|
|
DeepEPBuffer.set_dispatch_mode_as_low_latency()
|
|
self.assertEqual(len(cleans), 1)
|
|
|
|
reset_context()
|
|
self.assertIsNone(DeepEPBuffer._state().buffer)
|
|
|
|
|
|
class TestForwardFlags(_IsolatedServerArgs):
|
|
"""ctx.forward: contextvar-backed per-forward flags; scoped() restores,
|
|
threads see defaults."""
|
|
|
|
def test_scoped_set_restore_and_nesting(self):
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
fwd = get_forward()
|
|
self.assertFalse(fwd.multi_stream)
|
|
with fwd.scoped(multi_stream=True):
|
|
self.assertTrue(fwd.multi_stream)
|
|
with fwd.scoped(multi_stream=False):
|
|
self.assertFalse(fwd.multi_stream)
|
|
self.assertTrue(fwd.multi_stream)
|
|
self.assertFalse(fwd.multi_stream)
|
|
|
|
def test_scoped_restores_on_exception_and_validates_keys(self):
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
fwd = get_forward()
|
|
with self.assertRaises(RuntimeError):
|
|
with fwd.scoped(moe_output_buffer="buf"):
|
|
raise RuntimeError("boom")
|
|
self.assertIsNone(fwd.moe_output_buffer)
|
|
with self.assertRaises(ValueError):
|
|
with fwd.scoped(nope=1):
|
|
pass
|
|
with self.assertRaises(AttributeError):
|
|
fwd.multi_stream = True # attribute writes are rejected
|
|
|
|
def test_threads_see_defaults(self):
|
|
import threading
|
|
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
fwd = get_forward()
|
|
seen = {}
|
|
with fwd.scoped(multi_stream=True):
|
|
|
|
def probe():
|
|
seen["value"] = get_forward().multi_stream
|
|
|
|
worker = threading.Thread(target=probe)
|
|
worker.start()
|
|
worker.join()
|
|
self.assertFalse(seen["value"]) # a new thread sees the default
|
|
|
|
def test_graph_visible_flags_trace_under_torch_compile(self):
|
|
# Regression: dynamo cannot trace ContextVar.get, and these flags are
|
|
# read inside compiled model code (vocab embedding, communicator, DP
|
|
# gather) — they must stay plain-slot backed. fullgraph=True turns
|
|
# any graph break back into a failure.
|
|
import torch
|
|
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
|
|
@torch.compile(fullgraph=True, backend="eager", dynamic=False)
|
|
def probe(x):
|
|
fwd = get_forward()
|
|
if fwd.attn_input_scattered:
|
|
x = x + 1
|
|
if fwd.is_extend_in_batch:
|
|
x = x + 2
|
|
if fwd.fuse_mlp_allreduce:
|
|
x = x + 4
|
|
if fwd.mlp_reduce_scatter:
|
|
x = x + 8
|
|
if fwd.flashinfer_trtllm_bypass:
|
|
x = x + 16
|
|
return x
|
|
|
|
self.assertEqual(probe(torch.zeros(())).item(), 0)
|
|
with get_forward().scoped(attn_input_scattered=True):
|
|
self.assertEqual(probe(torch.zeros(())).item(), 1)
|
|
get_forward().set("is_extend_in_batch", True)
|
|
self.assertEqual(probe(torch.zeros(())).item(), 2)
|
|
get_forward().set("is_extend_in_batch", False)
|
|
with get_forward().scoped(
|
|
fuse_mlp_allreduce=True,
|
|
mlp_reduce_scatter=True,
|
|
flashinfer_trtllm_bypass=True,
|
|
):
|
|
self.assertEqual(probe(torch.zeros(())).item(), 28)
|
|
self.assertEqual(probe(torch.zeros(())).item(), 0)
|
|
|
|
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, which must
|
|
# stay dynamo-traceable (``object.__getattribute__`` graph-breaks).
|
|
# fullgraph=True turns any graph break back into a failure.
|
|
import torch
|
|
|
|
from sglang.srt.runtime_context import get_parallel
|
|
|
|
reset_context()
|
|
with get_context().override_server_args(moe_dense_tp_size=1, dwdp_size=4):
|
|
|
|
@torch.compile(fullgraph=True, backend="eager", dynamic=False)
|
|
def probe(x):
|
|
par = get_parallel()
|
|
if par.enable_prefill_cp:
|
|
x = x + 1
|
|
if par.moe_dense_tp_size == 1:
|
|
x = x + 2
|
|
if par.dwdp_size > 1:
|
|
x = x + 4
|
|
return x
|
|
|
|
self.assertEqual(probe(torch.zeros(())).item(), 6)
|
|
|
|
def test_graph_visible_flags_are_process_visible_across_threads(self):
|
|
# Documented divergence from the contextvar-backed flags: plain slots
|
|
# are process-global (the storage form these flags had before the
|
|
# tier), so another thread sees the current value, not the default.
|
|
import threading
|
|
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
seen = {}
|
|
with get_forward().scoped(attn_input_scattered=True):
|
|
|
|
def probe():
|
|
seen["value"] = get_forward().attn_input_scattered
|
|
|
|
worker = threading.Thread(target=probe)
|
|
worker.start()
|
|
worker.join()
|
|
self.assertTrue(seen["value"])
|
|
self.assertFalse(get_forward().attn_input_scattered)
|
|
|
|
def test_multi_stream_shims(self):
|
|
from sglang.srt.utils.multi_stream_utils import (
|
|
do_multi_stream,
|
|
with_multi_stream,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertFalse(do_multi_stream())
|
|
with with_multi_stream(True):
|
|
self.assertTrue(do_multi_stream())
|
|
self.assertFalse(do_multi_stream())
|
|
|
|
def test_attn_tp_context_per_forward_slots(self):
|
|
from types import SimpleNamespace
|
|
|
|
from sglang.srt.layers.communicator import get_attn_tp_context
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
ctx = get_attn_tp_context()
|
|
self.assertFalse(ctx.input_scattered)
|
|
fb = SimpleNamespace(
|
|
forward_mode=SimpleNamespace(
|
|
is_extend=lambda: False, is_target_verify=lambda: False
|
|
),
|
|
input_ids=None,
|
|
can_run_tbo=False,
|
|
)
|
|
sentinel = SimpleNamespace(fetch_qkv_latent=lambda: "qkv")
|
|
with ctx.maybe_input_scattered(fb):
|
|
ctx.set_attn_inputs(sentinel)
|
|
self.assertEqual(ctx.fetch_qkv_latent(), "qkv")
|
|
# attn inputs are cleared at scope exit, flag restored
|
|
self.assertIsNone(get_forward().attn_inputs)
|
|
self.assertFalse(ctx.input_scattered)
|
|
|
|
def test_dp_buffer_state_split(self):
|
|
import torch
|
|
|
|
from sglang.srt.layers.dp_attention import _DpGatheredBufferWrapper as wrapper
|
|
from sglang.srt.layers.dp_attention import (
|
|
get_dp_dtype,
|
|
get_dp_global_num_tokens,
|
|
get_global_dp_buffer_len,
|
|
is_dp_max_padding,
|
|
set_dp_buffer_len,
|
|
)
|
|
|
|
reset_context()
|
|
# metadata is init-static (flags.dp); sizing is per-forward sticky
|
|
wrapper.set_metadata(64, torch.float16, torch.device("cpu"))
|
|
self.assertEqual(get_dp_dtype(), torch.float16)
|
|
set_dp_buffer_len(128, 32, True, [64, 64])
|
|
self.assertEqual(get_global_dp_buffer_len(), 128)
|
|
self.assertTrue(is_dp_max_padding())
|
|
self.assertEqual(get_dp_global_num_tokens(), [64, 64])
|
|
set_dp_buffer_len(256, 64, False) # sticky until the next write
|
|
self.assertEqual(get_global_dp_buffer_len(), 256)
|
|
self.assertFalse(is_dp_max_padding())
|
|
self.assertIsNone(get_dp_global_num_tokens())
|
|
reset_context()
|
|
self.assertIsNone(get_dp_dtype())
|
|
|
|
def test_is_extend_in_batch_sticky_within_thread(self):
|
|
from sglang.srt.layers.dp_attention import (
|
|
get_is_extend_in_batch,
|
|
set_is_extend_in_batch,
|
|
)
|
|
|
|
reset_context()
|
|
self.assertFalse(get_is_extend_in_batch())
|
|
set_is_extend_in_batch(True)
|
|
self.assertTrue(get_is_extend_in_batch()) # sticky until next write
|
|
set_is_extend_in_batch(False)
|
|
self.assertFalse(get_is_extend_in_batch())
|
|
|
|
def test_moe_output_buffer_ctx(self):
|
|
from sglang.srt.layers.moe.moe_runner.base import moe_output_buffer_ctx
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
sentinel = object()
|
|
with moe_output_buffer_ctx(sentinel):
|
|
self.assertIs(get_forward().moe_output_buffer, sentinel)
|
|
self.assertIsNone(get_forward().moe_output_buffer)
|
|
|
|
def test_mlp_comm_forward_flags(self):
|
|
"""Decoder-published MLP collective flags: scoped restore + skip helpers."""
|
|
from sglang.srt.layers.moe.utils import (
|
|
should_skip_mlp_all_reduce,
|
|
should_skip_post_experts_all_reduce,
|
|
)
|
|
from sglang.srt.runtime_context import get_forward
|
|
|
|
reset_context()
|
|
fwd = get_forward()
|
|
self.assertFalse(fwd.fuse_mlp_allreduce)
|
|
self.assertFalse(fwd.mlp_reduce_scatter)
|
|
self.assertFalse(fwd.flashinfer_trtllm_bypass)
|
|
self.assertFalse(should_skip_mlp_all_reduce())
|
|
|
|
with fwd.scoped(fuse_mlp_allreduce=True):
|
|
self.assertTrue(fwd.fuse_mlp_allreduce)
|
|
self.assertTrue(should_skip_mlp_all_reduce())
|
|
# Fusion alone is enough to skip post-experts AR.
|
|
self.assertTrue(should_skip_post_experts_all_reduce(is_tp_path=True))
|
|
self.assertFalse(fwd.fuse_mlp_allreduce)
|
|
self.assertFalse(should_skip_mlp_all_reduce())
|
|
|
|
with fwd.scoped(mlp_reduce_scatter=True):
|
|
self.assertTrue(fwd.mlp_reduce_scatter)
|
|
self.assertTrue(should_skip_mlp_all_reduce())
|
|
self.assertFalse(fwd.mlp_reduce_scatter)
|
|
|
|
with fwd.scoped(flashinfer_trtllm_bypass=True):
|
|
self.assertTrue(fwd.flashinfer_trtllm_bypass)
|
|
self.assertFalse(fwd.flashinfer_trtllm_bypass)
|
|
|
|
def test_dp_reduce_scatterv_requires_single_rank_attention_dp_shards(self):
|
|
from sglang.srt.layers.moe.utils import should_use_dp_reduce_scatterv
|
|
|
|
reset_context()
|
|
with patch(
|
|
"sglang.srt.layers.moe.utils.is_dp_attention_enabled",
|
|
return_value=True,
|
|
):
|
|
# The optimized path is valid when the collective group and the
|
|
# variable-split list have the same number of entries.
|
|
with get_parallel().override(tp_size=8, attn_dp_size=8, moe_ep_size=8):
|
|
self.assertTrue(should_use_dp_reduce_scatterv())
|
|
|
|
# Otherwise the standard all-reduce plus scatter path must be used.
|
|
with get_parallel().override(tp_size=8, attn_dp_size=2, moe_ep_size=2):
|
|
self.assertFalse(should_use_dp_reduce_scatterv())
|
|
|
|
|
|
class TestPublishLifecycle(_IsolatedServerArgs):
|
|
"""Publish installs the resolved server_args and seeds the capture tier."""
|
|
|
|
def _publish(self, **kw):
|
|
args = _FakeResolvedArgs(**kw)
|
|
get_context().set_server_args(args)
|
|
return args
|
|
|
|
def test_capture_tier_seeded_at_publish(self):
|
|
args = self._publish(page_size=1)
|
|
args.enable_torch_compile = True
|
|
get_context().set_server_args(args) # re-publish picks up the value
|
|
self.assertTrue(get_flags().capture.enable_torch_compile)
|
|
# capture-time write (B4) targets the capture leaf
|
|
get_flags().capture.enable_torch_compile = False
|
|
self.assertFalse(get_flags().capture.enable_torch_compile)
|
|
|
|
def test_capture_tier_defaults_for_sentinel_publish(self):
|
|
get_context().set_server_args(object())
|
|
self.assertFalse(get_flags().capture.enable_torch_compile)
|
|
|
|
|
|
class TestDerivedPredicatesAgreeAcrossTiers(_IsolatedServerArgs):
|
|
"""One definition per predicate, checked rather than asserted in prose.
|
|
|
|
Each of these exists twice by construction -- once over a config-shaped
|
|
object (the resolution pipeline's `*_of` helper, which `ServerArgs`
|
|
delegates to) and once over the published bags. The pair must agree on
|
|
every input, or a decision made before publish differs from the same
|
|
decision made after it.
|
|
"""
|
|
|
|
_STRATEGIES = ("auto", "no_buffer", "extra_buffer", "extra_buffer_lazy")
|
|
|
|
def test_the_mamba_extra_buffer_predicate_has_one_answer(self):
|
|
"""It used to be asserted that two spellings agreed. There is one now:
|
|
the declaration computes it at publish, and the bag carries it."""
|
|
for disable_radix_cache in (False, True):
|
|
for strategy in self._STRATEGIES:
|
|
with self.subTest(radix=disable_radix_cache, strategy=strategy):
|
|
reset_context()
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy",
|
|
disable_radix_cache=disable_radix_cache,
|
|
mamba_radix_cache_strategy=strategy,
|
|
),
|
|
role="test",
|
|
)
|
|
expected = disable_radix_cache is False and strategy in (
|
|
"extra_buffer",
|
|
"extra_buffer_lazy",
|
|
)
|
|
self.assertEqual(
|
|
get_exec().mamba.enable_mamba_extra_buffer, expected
|
|
)
|
|
self.assertEqual(
|
|
get_exec().mamba.enable_mamba_extra_buffer_lazy,
|
|
disable_radix_cache is False
|
|
and strategy == "extra_buffer_lazy",
|
|
)
|
|
|
|
def test_prefill_buffer_ceiling_matches_the_member(self):
|
|
from sglang.srt.runtime_context import max_prefill_buffer_tokens
|
|
|
|
for chunked in (-1, 0, 1024, 8192):
|
|
for dynamic in (False, True):
|
|
for pp in (1, 4):
|
|
for max_prefill in (0, 2048, 16384):
|
|
with self.subTest(
|
|
chunked=chunked,
|
|
dynamic=dynamic,
|
|
pp=pp,
|
|
max_prefill=max_prefill,
|
|
):
|
|
args = _FakeResolvedArgs(
|
|
chunked_prefill_size=chunked,
|
|
enable_dynamic_chunking=dynamic,
|
|
pp_size=pp,
|
|
max_prefill_tokens=max_prefill,
|
|
)
|
|
get_context().set_server_args(args)
|
|
self.assertEqual(
|
|
max_prefill_buffer_tokens_of(args),
|
|
max_prefill_buffer_tokens(),
|
|
)
|
|
|
|
def test_prefill_buffer_ceiling_hook_honored_across_tiers(self):
|
|
args = _FakeResolvedArgs(
|
|
chunked_prefill_size=8192,
|
|
enable_dynamic_chunking=True,
|
|
pp_size=4,
|
|
max_prefill_tokens=16384,
|
|
)
|
|
|
|
def provider(record, default_ceiling):
|
|
self.assertIs(record, args)
|
|
return default_ceiling + 5
|
|
|
|
with patch.object(prefill_buffer_ceiling, "_prefill_buffer_ceiling_fn", None):
|
|
register = prefill_buffer_ceiling.register_prefill_buffer_ceiling
|
|
self.assertEqual(max_prefill_buffer_tokens_of(args), 16384)
|
|
self.assertIs(register(provider), provider)
|
|
register(provider)
|
|
with self.assertRaisesRegex(RuntimeError, "already registered"):
|
|
register(lambda record, default_ceiling: default_ceiling)
|
|
for record_or_view in (args, resolving_view(args), resolved_view(args)):
|
|
self.assertEqual(max_prefill_buffer_tokens_of(record_or_view), 16389)
|
|
get_context().set_server_args(args)
|
|
self.assertEqual(max_prefill_buffer_tokens(), 16389)
|
|
with get_schedule().override(max_prefill_tokens=32768):
|
|
self.assertEqual(max_prefill_buffer_tokens(), 32773)
|
|
self.assertEqual(args.max_prefill_tokens, 16384)
|
|
|
|
def test_prefill_buffer_ceiling_provider_can_preserve_defaults(self):
|
|
args = _FakeResolvedArgs(chunked_prefill_size=4096)
|
|
|
|
def provider(record, default_ceiling):
|
|
return default_ceiling
|
|
|
|
with patch.object(prefill_buffer_ceiling, "_prefill_buffer_ceiling_fn", None):
|
|
prefill_buffer_ceiling.register_prefill_buffer_ceiling(provider)
|
|
for record_or_view in (args, resolving_view(args), resolved_view(args)):
|
|
self.assertEqual(max_prefill_buffer_tokens_of(record_or_view), 4096)
|
|
get_context().set_server_args(args)
|
|
self.assertEqual(max_prefill_buffer_tokens(), 4096)
|
|
|
|
def test_activation_reserve_matches_the_member(self):
|
|
from types import SimpleNamespace
|
|
|
|
from sglang.srt.arg_groups.overrides import (
|
|
pre_capture_activation_reserve_mb_of,
|
|
)
|
|
from sglang.srt.runtime_context import pre_capture_activation_reserve_mb
|
|
|
|
graph = SimpleNamespace(decode=SimpleNamespace(max_bs=64))
|
|
cases = (
|
|
dict(disaggregation_mode="null", chunked_prefill_size=8192),
|
|
dict(disaggregation_mode="null", chunked_prefill_size=-1),
|
|
dict(
|
|
disaggregation_mode="null",
|
|
chunked_prefill_size=-1,
|
|
max_prefill_tokens=1024,
|
|
),
|
|
dict(disaggregation_mode="decode", max_running_requests=32),
|
|
dict(disaggregation_mode="decode", max_running_requests=None),
|
|
dict(
|
|
disaggregation_mode="decode",
|
|
max_running_requests=None,
|
|
speculative_num_draft_tokens=4,
|
|
),
|
|
dict(
|
|
disaggregation_mode="null",
|
|
chunked_prefill_size=8192,
|
|
tp_size=8,
|
|
pp_size=2,
|
|
),
|
|
)
|
|
for case in cases:
|
|
for gpu_mem in (None, 20 * 1024, 80 * 1024):
|
|
with self.subTest(gpu_mem=gpu_mem, **case):
|
|
args = _FakeResolvedArgs(cuda_graph_config=graph, **case)
|
|
get_context().set_server_args(args)
|
|
self.assertEqual(
|
|
pre_capture_activation_reserve_mb_of(args, gpu_mem),
|
|
pre_capture_activation_reserve_mb(gpu_mem),
|
|
)
|
|
|
|
def test_remote_instance_transfer_engine_matches_the_member(self):
|
|
from sglang.srt.runtime_context import remote_instance_transfer_engine_enabled
|
|
|
|
backends = ("nccl", "transfer_engine", "modelexpress")
|
|
transports = (None, '{"transport": "transfer_engine"}', '{"transport": "nixl"}')
|
|
for seed_via_te in (False, True):
|
|
for load_format in ("auto", "remote_instance"):
|
|
for backend in backends:
|
|
for mx in transports:
|
|
with self.subTest(
|
|
seed=seed_via_te,
|
|
load_format=load_format,
|
|
backend=backend,
|
|
modelexpress=mx,
|
|
):
|
|
args = _FakeResolvedArgs(
|
|
load_format=load_format,
|
|
remote_instance_weight_loader_backend=backend,
|
|
remote_instance_weight_loader_start_seed_via_transfer_engine=seed_via_te,
|
|
modelexpress_config=mx,
|
|
)
|
|
get_context().set_server_args(args)
|
|
for override in (None, "remote_instance", "auto"):
|
|
self.assertEqual(
|
|
ServerArgs.remote_instance_weight_loader_use_transfer_engine(
|
|
args, override
|
|
),
|
|
remote_instance_transfer_engine_enabled(override),
|
|
)
|
|
|
|
def test_attention_backends_match_the_member(self):
|
|
from sglang.srt.runtime_context import attention_backends
|
|
|
|
backends = (None, "fa3", "triton")
|
|
for base in backends:
|
|
for prefill in backends:
|
|
for decode in backends:
|
|
with self.subTest(base=base, prefill=prefill, decode=decode):
|
|
args = _FakeResolvedArgs(
|
|
attention_backend=base,
|
|
prefill_attention_backend=prefill,
|
|
decode_attention_backend=decode,
|
|
)
|
|
get_context().set_server_args(args)
|
|
self.assertEqual(
|
|
attention_backends_of(resolved_view(args)),
|
|
attention_backends(),
|
|
)
|
|
|
|
|
|
class TestAdaptiveDraftBoundLifecycle(_IsolatedServerArgs):
|
|
"""The adaptive draft-token bound is snapshotted at each publication."""
|
|
|
|
def _write_config(self, steps):
|
|
path = os.path.join(tempfile.mkdtemp(prefix="adaptive_cfg_"), "adaptive.json")
|
|
self.addCleanup(shutil.rmtree, os.path.dirname(path), ignore_errors=True)
|
|
with open(path, "w") as handle:
|
|
json.dump({"1": {"candidate_steps": steps}}, handle)
|
|
return path
|
|
|
|
def test_republishing_recomputes_the_bound(self):
|
|
path = self._write_config([2])
|
|
get_context().set_server_args(
|
|
_FakeResolvedArgs(
|
|
speculative_num_draft_tokens=3,
|
|
speculative_adaptive=True,
|
|
speculative_adaptive_config=path,
|
|
)
|
|
)
|
|
self.assertEqual(max_speculative_num_draft_tokens(), 3)
|
|
|
|
with open(path, "w") as handle:
|
|
json.dump({"1": {"candidate_steps": [4]}}, handle)
|
|
# The new publication must not retain the previous capacity.
|
|
get_context().set_server_args(
|
|
_FakeResolvedArgs(
|
|
speculative_num_draft_tokens=3,
|
|
speculative_adaptive=True,
|
|
speculative_adaptive_config=path,
|
|
)
|
|
)
|
|
self.assertEqual(max_speculative_num_draft_tokens(), 5)
|
|
|
|
def test_reset_clears_the_bound(self):
|
|
path = self._write_config([2])
|
|
get_context().set_server_args(
|
|
_FakeResolvedArgs(
|
|
speculative_num_draft_tokens=3,
|
|
speculative_adaptive=True,
|
|
speculative_adaptive_config=path,
|
|
)
|
|
)
|
|
self.assertEqual(max_speculative_num_draft_tokens(), 3)
|
|
reset_context()
|
|
with open(path, "w") as handle:
|
|
json.dump({"1": {"candidate_steps": [6]}}, handle)
|
|
get_context().set_server_args(
|
|
_FakeResolvedArgs(
|
|
speculative_num_draft_tokens=3,
|
|
speculative_adaptive=True,
|
|
speculative_adaptive_config=path,
|
|
)
|
|
)
|
|
self.assertEqual(max_speculative_num_draft_tokens(), 7)
|
|
|
|
|
|
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")
|
|
|
|
|
|
class TestDerivedWidths(_IsolatedOverrides):
|
|
"""The widths no flag sets are computed from the leaves and permanently
|
|
overridable.
|
|
|
|
`attn_tp_size` and its siblings used to be read back off the group
|
|
coordinator that was built from them, which made the answer depend on
|
|
distributed init and, after an elastic scale, disagree with the leaves.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
parallel = get_parallel()
|
|
self._saved_derived = dict(parallel._stamp)
|
|
parallel.clear_stamp()
|
|
self.addCleanup(
|
|
lambda: (
|
|
parallel.clear_stamp(),
|
|
parallel.override_permanently(**self._saved_derived),
|
|
)
|
|
)
|
|
|
|
def test_the_published_configuration_decides_the_widths(self):
|
|
"""The quotients are computed once, at publish, from the leaves.
|
|
|
|
Every input is a record field, so there is nothing to recompute on a
|
|
read: `publish` fills the bag and the bag is the answer.
|
|
"""
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="test",
|
|
)
|
|
self.assertEqual(get_parallel().attn_tp_size, 4)
|
|
self.assertEqual(get_parallel().attn_dp_size, 2)
|
|
self.assertEqual(get_parallel().moe_tp_size, 8)
|
|
|
|
reset_context()
|
|
publish(
|
|
ServerArgs(model_path="dummy", tp_size=8, ep_size=4, moe_dp_size=2),
|
|
role="test",
|
|
)
|
|
self.assertEqual(get_parallel().moe_tp_size, 1)
|
|
|
|
def test_a_topology_is_stated_by_naming_the_width(self):
|
|
"""Overriding a leaf does not move the quotient -- the quotient is not
|
|
recomputed on read. Naming it is how a caller states one, and naming
|
|
only some of them is refused: the caller owns the arithmetic, the
|
|
context only checks it."""
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(ServerArgs(model_path="dummy", tp_size=8), role="test")
|
|
self.assertEqual(get_parallel().attn_tp_size, 8)
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
with get_parallel().override(tp_size=2):
|
|
pass
|
|
self.assertIn(
|
|
"tp_size == attn_tp_size * attn_dp_size * attn_cp_size",
|
|
str(caught.exception),
|
|
)
|
|
|
|
with get_parallel().override(tp_size=2, attn_tp_size=2, moe_tp_size=2):
|
|
self.assertEqual(get_parallel().tp_size, 2)
|
|
self.assertEqual(get_parallel().attn_tp_size, 2)
|
|
with get_parallel().override(attn_tp_size=4, tp_size=4, moe_tp_size=4):
|
|
self.assertEqual(get_parallel().attn_tp_size, 4)
|
|
|
|
def test_an_unstated_topology_still_fails(self):
|
|
"""Neutral leaves are for the dimensions a caller is not using, not for
|
|
a caller that stated nothing: every width would come back 1, which is a
|
|
plausible-looking number invented out of nothing."""
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
get_parallel().attn_tp_size
|
|
self.assertIn("not available", str(caught.exception))
|
|
|
|
def test_a_permanent_override_and_a_live_group_both_win_over_the_leaves(self):
|
|
"""Order is scoped override, then the stamp, then the published leaf.
|
|
|
|
No group is consulted for a width -- `test_the_group_is_never_consulted`
|
|
in this class asserts that -- so a stamp is what an elastic scale-up
|
|
leaves behind, and the leaf answers only where there is none.
|
|
"""
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_tp_size=7)
|
|
self.addCleanup(parallel.clear_stamp)
|
|
with parallel.override(tp_size=8, attn_dp_size=2):
|
|
self.assertEqual(parallel.attn_tp_size, 7)
|
|
|
|
def test_the_quotients_come_from_the_leaves(self):
|
|
widths = derive_parallel_widths(
|
|
tp_size=8,
|
|
attn_cp_size=1,
|
|
attn_dp_size=2,
|
|
moe_ep_size=4,
|
|
moe_dp_size=2,
|
|
dcp_size=1,
|
|
dcp_enabled=False,
|
|
)
|
|
self.assertEqual(widths["attn_tp_size"], 8 // 2 // 1)
|
|
self.assertEqual(widths["moe_tp_size"], 8 // 4 // 2)
|
|
self.assertEqual(widths["attn_dcp_size"], 1)
|
|
|
|
def test_no_world_width_is_a_quotient_of_the_leaves(self):
|
|
"""Deriving one would answer with the joining cohort's own width on a
|
|
scale joiner, which lays its groups out at `tp * pp` while WORLD spans
|
|
`ep_join_rank_offset + tp * pp`. The launch width comes off the group
|
|
that was actually built; the ceiling is not this function's to give
|
|
either, and `TestTheTwoWorldWidths` says where each comes from."""
|
|
widths = derive_parallel_widths(
|
|
tp_size=4,
|
|
attn_cp_size=1,
|
|
attn_dp_size=1,
|
|
moe_ep_size=1,
|
|
moe_dp_size=1,
|
|
dcp_size=1,
|
|
dcp_enabled=False,
|
|
)
|
|
self.assertEqual(
|
|
{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)
|
|
|
|
def test_the_bare_name_is_gone(self):
|
|
"""It answered two questions, so every reader had to remember which.
|
|
|
|
Both spellings fail: reading it, and stating it -- the overridable set
|
|
is derived from the same declarations the read path is, so a name that
|
|
cannot be read cannot be stated either.
|
|
"""
|
|
with self.assertRaisesRegex(AttributeError, r"has no 'world_size'"):
|
|
get_parallel().world_size
|
|
with self.assertRaisesRegex(ValueError, r"unknown parallel field"):
|
|
with get_parallel().override(world_size=4):
|
|
pass
|
|
|
|
def test_a_permanently_overridden_width_is_what_the_reader_answers_with(self):
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_tp_size=4, moe_tp_size=1)
|
|
with patch(
|
|
f"{_PS}.get_attn_tensor_model_parallel_world_size",
|
|
side_effect=AssertionError("the group must not be asked"),
|
|
):
|
|
self.assertEqual(parallel.attn_tp_size, 4)
|
|
|
|
def test_a_scoped_override_still_wins_over_the_permanent_one(self):
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_tp_size=4)
|
|
with parallel.override(attn_tp_size=1):
|
|
self.assertEqual(parallel.attn_tp_size, 1)
|
|
self.assertEqual(parallel.attn_tp_size, 4)
|
|
|
|
def test_the_group_is_never_consulted(self):
|
|
"""There is no third source. A quotient comes from a scoped override, a
|
|
permanent override, or the published leaf -- never from a group
|
|
coordinator."""
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
with patch(
|
|
f"{_PS}.get_attn_tensor_model_parallel_world_size",
|
|
side_effect=AssertionError("the group must not be consulted"),
|
|
):
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=8, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="test",
|
|
)
|
|
self.assertEqual(get_parallel().attn_tp_size, 4)
|
|
|
|
def test_with_neither_the_failure_names_the_cause(self):
|
|
with patch(
|
|
f"{_PS}.get_attn_tensor_model_parallel_world_size",
|
|
side_effect=AssertionError("attention tp group is not initialized"),
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, r"derived parallel width"):
|
|
get_parallel().attn_tp_size
|
|
|
|
def test_the_permanent_override_is_cleared_and_reset(self):
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_dp_size=2)
|
|
self.assertEqual(parallel.attn_dp_size, 2)
|
|
# Elastic scaling overrides again where it updates the live width.
|
|
parallel.override_permanently(attn_dp_size=4)
|
|
self.assertEqual(parallel.attn_dp_size, 4)
|
|
parallel.clear_stamp()
|
|
with parallel.override(tp_size=8, attn_dp_size=1):
|
|
self.assertEqual(parallel.attn_dp_size, 1)
|
|
|
|
def test_reset_context_drops_the_permanent_override(self):
|
|
"""The permanent override belongs to the lifecycle that made it.
|
|
|
|
`_read` prefers it over the published leaf, so one that
|
|
outlived `reset_context()` would let the next test read the previous
|
|
topology.
|
|
"""
|
|
parallel = get_parallel()
|
|
parallel.override_permanently(attn_tp_size=4)
|
|
self.assertEqual(parallel.attn_tp_size, 4)
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(ServerArgs(model_path="dummy", tp_size=1), role="test")
|
|
self.assertEqual(get_parallel().attn_tp_size, 1)
|
|
|
|
def test_the_rank_helper_agrees_with_the_override(self):
|
|
"""`compute_dp_attention_world_info` keeps the ranks and takes the
|
|
widths from the same derivation `override_permanently`'s callers use."""
|
|
from sglang.srt.layers.dp_attention import compute_dp_attention_world_info
|
|
|
|
for tp_size, dp_size, attn_cp_size in ((8, 2, 1), (8, 2, 2), (16, 4, 2)):
|
|
_, attn_tp_size, _, attn_dp_size = compute_dp_attention_world_info(
|
|
True, 0, tp_size, dp_size, attn_cp_size
|
|
)
|
|
widths = derive_parallel_widths(
|
|
tp_size=tp_size,
|
|
attn_cp_size=attn_cp_size,
|
|
attn_dp_size=attn_dp_size,
|
|
moe_ep_size=1,
|
|
moe_dp_size=1,
|
|
dcp_size=1,
|
|
dcp_enabled=False,
|
|
)
|
|
self.assertEqual(attn_tp_size, widths["attn_tp_size"])
|
|
self.assertEqual(attn_dp_size, widths["attn_dp_size"])
|
|
|
|
def test_recomputing_from_published_leaves_matches_the_publish_bag(self):
|
|
"""`initialize_model_parallel` no longer overrides anything -- see
|
|
`test_initialize_model_parallel_no_longer_touches_the_bag` below --
|
|
so every real caller must forward leaves that already match its own
|
|
published config, because nothing corrects a mismatch anymore.
|
|
`scheduler.py`'s `ps.attn_dp_size`/`ps.moe_ep_size`/etc, and the
|
|
weight-cache daemon's own already-published config, both do -- this
|
|
pins that the formula they'd recompute from those leaves
|
|
(`derive_attention_widths`, `derive_parallel_widths`, the same ones
|
|
`publish` itself used) agrees with what's already in the bag, across
|
|
the widths `test_the_rank_helper_agrees_with_the_override` does not
|
|
vary -- moe_ep_size, moe_dp_size, and dcp_size -- using real
|
|
`publish()`.
|
|
|
|
A caller that does NOT keep the two in sync is a bug in that caller,
|
|
not something this framework silently corrects: two real ones existed
|
|
(`test/registered/eplb/test_lplb_distributed.py` and
|
|
`test/manual/ep/test_flashinfer_dispatcher.py`, both publishing a
|
|
placeholder config and then building real groups at a width it never
|
|
reflected) and were fixed by publishing the actual width instead of
|
|
relying on a correction to paper over the mismatch.
|
|
"""
|
|
shapes = (
|
|
dict(tp_size=8),
|
|
dict(tp_size=8, dp_size=2, enable_dp_attention=True),
|
|
dict(tp_size=8, ep_size=4, moe_dp_size=2),
|
|
dict(tp_size=8, dcp_size=8),
|
|
)
|
|
for shape in shapes:
|
|
with self.subTest(shape=shape):
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(ServerArgs(model_path="dummy", **shape), role="test")
|
|
parallel = get_parallel()
|
|
published = {
|
|
"attn_tp_size": parallel.attn_tp_size,
|
|
"attn_dp_size": parallel.attn_dp_size,
|
|
"moe_ep_size": parallel.moe_ep_size,
|
|
"moe_tp_size": parallel.moe_tp_size,
|
|
"dcp_enabled": parallel.dcp_enabled,
|
|
"attn_dcp_size": parallel.attn_dcp_size,
|
|
}
|
|
# What every real `initialize_model_parallel` caller forwards:
|
|
# its own already-published leaves, through the same two
|
|
# functions the bag was projected with.
|
|
recomputed = derive_parallel_widths(
|
|
tp_size=parallel.tp_size,
|
|
attn_cp_size=parallel.attn_cp_size,
|
|
attn_dp_size=(
|
|
parallel.dp_size if parallel.enable_dp_attention else 1
|
|
),
|
|
moe_ep_size=parallel.ep_size,
|
|
moe_dp_size=parallel.moe_dp_size,
|
|
dcp_size=parallel.dcp_size,
|
|
dcp_enabled=parallel.dcp_size > 1,
|
|
)
|
|
self.assertEqual(published, recomputed)
|
|
|
|
def test_initialize_model_parallel_builds_at_the_published_widths(self):
|
|
"""The build takes every width from the context rather than from an
|
|
argument, so "published one width, built another" is no longer a state
|
|
a caller can reach -- there is nothing left to translate, and nothing
|
|
to correct afterwards either.
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from sglang.srt.distributed import parallel_state
|
|
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
world_size = 8
|
|
publish(ServerArgs(model_path="dummy", tp_size=world_size), role="test")
|
|
self.assertEqual(get_parallel().attn_tp_size, world_size)
|
|
|
|
built_at = []
|
|
with (
|
|
patch.object(parallel_state, "_WORLD", None),
|
|
patch.object(parallel_state, "_TP", None),
|
|
patch.object(parallel_state, "_DCP", None),
|
|
patch.object(parallel_state, "_ATTN_CP", None),
|
|
patch.object(parallel_state, "_ATTN_TP", None),
|
|
patch.object(parallel_state, "_MOE_DP", None),
|
|
patch.object(parallel_state, "_MOE_EP", None),
|
|
patch.object(parallel_state, "_MOE_TP", None),
|
|
patch.object(parallel_state, "_PP", None),
|
|
patch.object(parallel_state, "_SELF_PP", None),
|
|
patch("torch.distributed.is_initialized", return_value=True),
|
|
patch("torch.distributed.get_world_size", return_value=world_size),
|
|
patch("torch.distributed.get_rank", return_value=0),
|
|
patch("torch.distributed.get_backend", return_value="nccl"),
|
|
patch.object(
|
|
parallel_state,
|
|
"init_model_parallel_group",
|
|
side_effect=lambda group_ranks, *a, **k: (
|
|
built_at.append(group_ranks),
|
|
Mock(device_group=Mock()),
|
|
)[1],
|
|
),
|
|
patch.object(parallel_state, "get_world_group") as mock_world_group,
|
|
):
|
|
mock_world_group.return_value = Mock(device_group=Mock(), local_rank=0)
|
|
parallel_state.initialize_model_parallel()
|
|
self.addCleanup(parallel_state.destroy_model_parallel)
|
|
|
|
# The first group built is TP, one group spanning the published width.
|
|
self.assertEqual(built_at[0], [list(range(world_size))])
|
|
self.assertEqual(get_parallel().attn_tp_size, world_size)
|
|
|
|
|
|
class TestTheDerivedHalfIsDeclared(CustomTestCase):
|
|
"""The quotients are declared beside the leaves, in the same class.
|
|
|
|
A namespace is one file and one class. `Parallel` says both what an
|
|
operator can set and what that decides; the quotients are unannotated, so
|
|
they are not dataclass fields and never reach the record.
|
|
`ParallelContext` installs a property per declaration rather than carrying
|
|
its own list, so the two cannot drift.
|
|
"""
|
|
|
|
def test_every_declared_quotient_has_a_property(self):
|
|
from sglang.srt.arg_groups.arg_utils import Derived
|
|
from sglang.srt.arg_groups.fields.parallel import Parallel
|
|
|
|
declared = {
|
|
name for name, value in vars(Parallel).items() if isinstance(value, Derived)
|
|
}
|
|
self.assertTrue(declared, "the derived half is empty")
|
|
for name in declared:
|
|
self.assertIsInstance(
|
|
getattr(type(get_context().parallel), name, None),
|
|
property,
|
|
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
|
|
|
|
declared = {
|
|
name for name, value in vars(Parallel).items() if isinstance(value, Derived)
|
|
}
|
|
produced = set(
|
|
derive_parallel_widths(
|
|
tp_size=8,
|
|
attn_cp_size=1,
|
|
attn_dp_size=2,
|
|
moe_ep_size=1,
|
|
moe_dp_size=1,
|
|
dcp_size=1,
|
|
dcp_enabled=False,
|
|
)
|
|
)
|
|
self.assertEqual(declared, produced)
|
|
|
|
def test_a_declared_quotient_is_not_a_record_field(self):
|
|
"""It has no operator input to preserve, and the record is what crosses
|
|
a process boundary."""
|
|
|
|
from sglang.srt.arg_groups.arg_utils import Derived
|
|
from sglang.srt.arg_groups.fields.parallel import Parallel
|
|
from sglang.srt.server_args import ServerArgs
|
|
|
|
fields = {f.name for f in msgspec.structs.fields(ServerArgs)}
|
|
for name, value in vars(Parallel).items():
|
|
if isinstance(value, Derived):
|
|
self.assertNotIn(name, fields)
|
|
|
|
|
|
class TestAnEntryThatBuildsARunnerHandsOverItsPlacement(CustomTestCase):
|
|
"""`ModelRunner.__init__` reads a recorded identity, so an entry that
|
|
publishes without a bundle and then builds one fails at construction.
|
|
|
|
Every such entry is an `__main__`-reachable path, so nothing in the unit
|
|
suite exercises it; the benchmark entry was found this way rather than by
|
|
a test. This walks the sources instead: a module that publishes and builds
|
|
a runner has to pass `ranks=`.
|
|
"""
|
|
|
|
def test_every_publisher_that_builds_a_runner_passes_a_bundle(self):
|
|
import ast as _ast
|
|
|
|
root = _pathlib.Path(next(iter(_sglang.__path__))).resolve()
|
|
offenders = []
|
|
for path in root.rglob("*.py"):
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
if "ModelRunner(" not in text or "publish(" not in text:
|
|
continue
|
|
tree = _ast.parse(text)
|
|
builds = any(
|
|
isinstance(n, _ast.Call)
|
|
and getattr(n.func, "id", getattr(n.func, "attr", None))
|
|
== "ModelRunner"
|
|
for n in _ast.walk(tree)
|
|
)
|
|
if not builds:
|
|
continue
|
|
for node in _ast.walk(tree):
|
|
if (
|
|
isinstance(node, _ast.Call)
|
|
and getattr(node.func, "id", None) == "publish"
|
|
and not any(kw.arg == "ranks" for kw in node.keywords)
|
|
):
|
|
offenders.append(f"{path.relative_to(root)}:{node.lineno}")
|
|
self.assertEqual(
|
|
offenders,
|
|
[],
|
|
"these publish without a spawn bundle and then build a ModelRunner, "
|
|
"whose construction reads a recorded identity:\n "
|
|
+ "\n ".join(offenders),
|
|
)
|
|
|
|
|
|
class TestTheAccessorsHaveNoCallersOutsideTheirPackage(CustomTestCase):
|
|
"""`parallel_state`'s getters are the definition, not a second spelling.
|
|
|
|
Business code asks `get_parallel()`; a call that goes straight to the getter
|
|
is a read the context cannot redirect, which is what a scope needs it to be
|
|
able to do. The package that defines them is exempt -- a read there would
|
|
go through the context back into itself -- and so is `multimodal_gen`, which
|
|
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.
|
|
ALLOWED = {
|
|
"get_self_pp_group",
|
|
"get_default_distributed_backend",
|
|
"get_mooncake_transfer_engine",
|
|
}
|
|
|
|
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."""
|
|
from sglang.srt.distributed import parallel_state as parallel_state_module
|
|
|
|
source = _pathlib.Path(parallel_state_module.__file__).read_text().splitlines()
|
|
return {
|
|
line[len("def ") : line.index("(")]
|
|
for line in source
|
|
if line.startswith("def get_") or line.startswith("def is_")
|
|
}
|
|
|
|
def _callers(self, name):
|
|
import re
|
|
|
|
from sglang.srt.distributed import parallel_state as parallel_state_module
|
|
|
|
root = _pathlib.Path(parallel_state_module.__file__).parents[2]
|
|
pattern = re.compile(rf"(?<![.\w]){re.escape(name)}\(")
|
|
hits = []
|
|
for path in root.rglob("*.py"):
|
|
rel = path.relative_to(root).as_posix()
|
|
if rel.startswith(("srt/distributed/", "multimodal_gen/", "test/")):
|
|
continue
|
|
for number, line in enumerate(path.read_text().splitlines(), 1):
|
|
if line.lstrip().startswith(("def ", "#")):
|
|
continue
|
|
if pattern.search(line):
|
|
hits.append(f"{rel}:{number}")
|
|
return hits
|
|
|
|
def test_no_business_code_calls_them(self):
|
|
offenders = {}
|
|
for name in sorted(self._accessors() - self.ALLOWED):
|
|
callers = self._callers(name)
|
|
if callers:
|
|
offenders[name] = callers
|
|
self.assertEqual(
|
|
offenders,
|
|
{},
|
|
"read these through get_parallel() instead, or say here why the "
|
|
"context cannot answer them",
|
|
)
|
|
|
|
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`
|
|
is exempt and does have one caller."""
|
|
self.assertTrue(self._callers("get_self_pp_group"))
|
|
|
|
|
|
class TestTheTopologyIdentities(CustomTestCase):
|
|
"""One set of identities, checked wherever the layout is written.
|
|
|
|
Each one is injected in the direction that breaks it and in the direction
|
|
that keeps it: a guard that only ever fires is as uninformative as one that
|
|
never does. They hold unconditionally -- a caller that states one leaf owes
|
|
the quotients that follow from it, because a namespace describing no real
|
|
layout is what the guard exists to refuse.
|
|
"""
|
|
|
|
def _publish_square(self):
|
|
"""tp=4 over two attention-DP replicas of two: every identity holds."""
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=4, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="scheduler",
|
|
ranks=SpawnRanks(world_rank=3, dp_rank=1),
|
|
)
|
|
|
|
def test_a_published_topology_is_consistent(self):
|
|
"""The quiet direction, and the reason publish can check everything:
|
|
it is the one write that establishes the whole layout."""
|
|
self._publish_square()
|
|
parallel = get_parallel()
|
|
self.assertEqual(parallel.tp_size, 4)
|
|
self.assertEqual(parallel.attn_dp_size, 2)
|
|
self.assertEqual(parallel.attn_tp_size, 2)
|
|
self.assertEqual(parallel.tp_rank, 3)
|
|
|
|
def test_a_width_that_does_not_factor_is_refused(self):
|
|
self._publish_square()
|
|
with self.assertRaises(ValueError) as caught:
|
|
with get_parallel().override(
|
|
tp_size=4, attn_tp_size=3, attn_dp_size=1, attn_cp_size=1
|
|
):
|
|
pass
|
|
message = str(caught.exception)
|
|
self.assertIn("set by override", message)
|
|
self.assertIn("attn_tp_size * attn_dp_size * attn_cp_size", message)
|
|
self.assertIn("4 != 3 * 1 * 1", message)
|
|
|
|
def test_a_rank_at_its_width_is_refused(self):
|
|
self._publish_square()
|
|
with self.assertRaises(ValueError) as caught:
|
|
with get_parallel().override(tp_rank=4, tp_size=4):
|
|
pass
|
|
self.assertIn("0 <= tp_rank < tp_size", str(caught.exception))
|
|
|
|
def test_a_moe_width_that_does_not_factor_is_refused(self):
|
|
self._publish_square()
|
|
with self.assertRaises(ValueError) as caught:
|
|
with get_parallel().override(
|
|
tp_size=4, moe_ep_size=1, moe_dp_size=1, moe_tp_size=3
|
|
):
|
|
pass
|
|
message = str(caught.exception)
|
|
self.assertIn("moe_ep_size * moe_dp_size * moe_tp_size", message)
|
|
self.assertIn("4 != 1 * 1 * 3", message)
|
|
|
|
def test_a_rank_the_attention_layout_cannot_produce_is_refused(self):
|
|
"""`tp_rank` is not free of the attention ranks: the layout derives one
|
|
from the other, so a set that does not satisfy it places this process
|
|
in two different seats at once."""
|
|
self._publish_square()
|
|
with self.assertRaises(ValueError) as caught:
|
|
with get_parallel().override(
|
|
tp_rank=0,
|
|
attn_dp_rank=1,
|
|
attn_cp_rank=0,
|
|
attn_tp_rank=0,
|
|
attn_cp_size=1,
|
|
attn_tp_size=2,
|
|
):
|
|
pass
|
|
self.assertIn("attn_tp_size + attn_tp_rank", str(caught.exception))
|
|
|
|
def test_the_published_ranks_satisfy_the_layout(self):
|
|
"""The quiet direction for the same identity: publish derives the
|
|
attention ranks from `tp_rank` through that very equation, so a
|
|
published process always sits in one seat."""
|
|
self._publish_square()
|
|
parallel = get_parallel()
|
|
self.assertEqual(
|
|
parallel.tp_rank,
|
|
(parallel.attn_dp_rank * parallel.attn_cp_size + parallel.attn_cp_rank)
|
|
* parallel.attn_tp_size
|
|
+ parallel.attn_tp_rank,
|
|
)
|
|
|
|
def test_a_refused_write_leaves_nothing_behind(self):
|
|
"""The scope never opened, so the value it tried to state must not be
|
|
readable afterwards -- a half-applied override is the state this guard
|
|
exists to prevent."""
|
|
self._publish_square()
|
|
with self.assertRaises(ValueError):
|
|
with get_parallel().override(
|
|
tp_size=4, attn_tp_size=3, attn_dp_size=1, attn_cp_size=1
|
|
):
|
|
pass
|
|
self.assertEqual(get_parallel().attn_tp_size, 2)
|
|
|
|
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
|
|
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")
|
|
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)
|
|
|
|
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")
|
|
|
|
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."""
|
|
from sglang.srt.distributed import parallel_state
|
|
from sglang.srt.distributed.parallel_state import GroupCoordinator
|
|
|
|
self._publish_square()
|
|
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)
|
|
|
|
|
|
class TestWhoAnswersDuringADraftScope(CustomTestCase):
|
|
"""A draft worker runs in one process with the target, under a scope.
|
|
|
|
Two things have to hold for that to be workable, and neither is visible
|
|
from a single read: inside the scope every source agrees on the draft's
|
|
shape, and a reader that runs *outside* it still gets the draft's answer
|
|
from whatever it carried out.
|
|
"""
|
|
|
|
def _single_member_group(self):
|
|
from sglang.srt.distributed.parallel_state import GroupCoordinator
|
|
|
|
group = GroupCoordinator.__new__(GroupCoordinator)
|
|
group.world_size = 1
|
|
group.rank_in_group = 0
|
|
return group
|
|
|
|
def _two_stage_pipeline(self):
|
|
"""This process is stage 1 of 2, published the way a spawn states it."""
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(model_path="dummy", pp_size=2),
|
|
role="scheduler",
|
|
ranks=SpawnRanks(world_rank=1),
|
|
)
|
|
|
|
def test_the_pipeline_swap_states_every_member_it_installs(self):
|
|
"""`pp_size` is a configured leaf: unlike `pp_rank` it does not follow
|
|
the group being swapped underneath, so a scope that installs a group
|
|
without stating its width reports the target's."""
|
|
from sglang.srt.distributed import parallel_state
|
|
|
|
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)
|
|
self.assertEqual(get_parallel().pp_size, 2)
|
|
self.assertEqual(get_parallel().pp_rank, 1)
|
|
|
|
def _group(self, world_size, rank):
|
|
from sglang.srt.distributed.parallel_state import GroupCoordinator
|
|
|
|
group = GroupCoordinator.__new__(GroupCoordinator)
|
|
group.world_size = world_size
|
|
group.rank_in_group = rank
|
|
return group
|
|
|
|
def test_the_tensor_swap_states_the_draft_has_no_attention_replica(self):
|
|
"""The draft runs the whole model on the group being installed. Its
|
|
attention identity is therefore that group, with one replica -- while
|
|
the target this process also serves is attention-DP over four ranks."""
|
|
from sglang.srt.distributed import parallel_state
|
|
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=4, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="scheduler",
|
|
ranks=SpawnRanks(world_rank=0, dp_rank=0),
|
|
)
|
|
self.assertEqual(get_parallel().attn_dp_size, 2)
|
|
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,
|
|
)
|
|
self.assertEqual(get_parallel().attn_dp_size, 2)
|
|
self.assertEqual(get_parallel().dp_size, 2)
|
|
|
|
def test_every_caller_says_whether_the_draft_owns_its_attention(self):
|
|
"""The scope cannot work it out from the group it is handed: the same
|
|
call site passes an attention-TP slice for one draft and the target's
|
|
whole TP group for another, and the two want opposite answers. So the
|
|
worker states it, and a caller that forgets is the bug this catches --
|
|
`owns_attention` has no default, but a missing one is a TypeError only
|
|
on the path that runs, and these paths need a GPU and a draft model."""
|
|
self.assertEqual(
|
|
_scope_entries_that_say_nothing(_sources()),
|
|
[],
|
|
"these enter the draft scope without saying",
|
|
)
|
|
|
|
def test_the_census_would_notice_one(self):
|
|
"""Two ways for it to report zero and still be wrong: the matcher does
|
|
not recognise the call, or the walk never reaches the file. A draft
|
|
scope entered from `benchmark/` breaks the same way as one in the
|
|
package and no suite covers it, so the roots are part of the check."""
|
|
trees = {
|
|
part
|
|
for path in _sources()
|
|
for part in ("benchmark", "examples", "scripts", "test")
|
|
if f"/{part}/" in path.as_posix()
|
|
}
|
|
self.assertEqual(
|
|
trees,
|
|
{"benchmark", "examples", "scripts", "test"},
|
|
"the walk misses a tree that can enter the scope",
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = _pathlib.Path(tmp) / "probe.py"
|
|
probe.write_text(
|
|
"with self.draft_tp_context(runner.tp_group):\n pass\n",
|
|
encoding="utf-8",
|
|
)
|
|
self.assertEqual(_scope_entries_that_say_nothing([probe]), [f"{probe}:1"])
|
|
|
|
def test_a_full_width_swap_leaves_the_attention_layout_alone(self):
|
|
"""The other caller. A draft built outside any scope carries the
|
|
target's whole TP group, and the graph capture installs *that* -- so
|
|
the process is still one of two attention-DP replicas and still gathers
|
|
with the other one. Narrowing here would claim a replica count it does
|
|
not have, and the reader that acts on it is a collective: the DP gather
|
|
takes its buffer size from the replica count and its communicator from
|
|
this group, so the two stop agreeing and the all-gather is refused."""
|
|
from sglang.srt.distributed import parallel_state
|
|
|
|
reset_context()
|
|
self.addCleanup(reset_context)
|
|
publish(
|
|
ServerArgs(
|
|
model_path="dummy", tp_size=4, dp_size=2, enable_dp_attention=True
|
|
),
|
|
role="scheduler",
|
|
ranks=SpawnRanks(world_rank=0, dp_rank=0),
|
|
)
|
|
whole_tp = self._group(world_size=4, rank=0)
|
|
with patch.object(parallel_state, "_TP", whole_tp):
|
|
with parallel_state.patch_tensor_parallel_group(
|
|
whole_tp, owns_attention=False
|
|
):
|
|
parallel = get_parallel()
|
|
self.assertEqual(parallel.tp_size, 4)
|
|
self.assertEqual(parallel.attn_dp_size, 2)
|
|
self.assertEqual(parallel.attn_tp_size, 2)
|
|
self.assertEqual(parallel.dp_size, 2)
|
|
|
|
def test_a_report_built_for_a_runner_follows_that_runner(self):
|
|
"""A weight check is an on-demand request served from the scheduler
|
|
loop, so it runs outside the scope that describes a draft runner. Its
|
|
report has to name the runner it was built for, which is why it holds
|
|
a record instead of asking the context."""
|
|
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
|
|
from sglang.srt.utils.weight_checker import WeightChecker
|
|
|
|
draft = ParallelState.trivial(pp_rank=0, pp_size=1)
|
|
checker = WeightChecker(get_model=lambda: None, ps=draft)
|
|
self._two_stage_pipeline()
|
|
info = checker._parallelism_info()
|
|
self.assertEqual((info.pp_rank, info.pp_size), (0, 1))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|