config: project the config bags from the resolution result (#35906)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cheng Wan
2026-08-23 01:18:24 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 0e22777572
commit 4bc79a1b49
44 changed files with 1448 additions and 661 deletions
@@ -1,7 +1,6 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from sglang.srt.kv_canary.capacities import CanaryLaunchCapacities
from sglang.srt.model_executor.cuda_graph_config import (
@@ -9,6 +8,7 @@ from sglang.srt.model_executor.cuda_graph_config import (
CudaGraphConfig,
PhaseConfig,
)
from sglang.srt.runtime_context import get_context
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -16,28 +16,33 @@ register_cpu_ci(est_time=5, suite="base-a-test-cpu")
class TestComputeLaunchCapacities(CustomTestCase):
@staticmethod
def _make_server_args(*, max_bs: int) -> SimpleNamespace:
return SimpleNamespace(
cuda_graph_config=CudaGraphConfig(
decode=PhaseConfig(backend=Backend.FULL, max_bs=max_bs)
),
speculative_num_draft_tokens=0,
chunked_prefill_size=None,
max_prefill_tokens=128,
)
@staticmethod
def _from_args(
self,
*,
max_bs: int,
max_seq_len: int,
max_total_num_tokens: int | None = None,
speculative_num_draft_tokens: int | None = 0,
) -> CanaryLaunchCapacities:
"""`from_args` reads the published configuration, so publish one.
Handing it a stand-in object stopped meaning anything when the reads
moved to the config bags: the parameter was ignored and the values
under test came from whatever the process had published.
"""
if max_total_num_tokens is None:
max_total_num_tokens = max_bs * max_seq_len
override = get_context().override_server_args(
cuda_graph_config=CudaGraphConfig(
decode=PhaseConfig(backend=Backend.FULL, max_bs=max_bs)
),
speculative_num_draft_tokens=speculative_num_draft_tokens,
chunked_prefill_size=None,
max_prefill_tokens=128,
)
override.install()
self.addCleanup(override.restore)
return CanaryLaunchCapacities.from_args(
server_args=TestComputeLaunchCapacities._make_server_args(max_bs=max_bs),
req_to_token_pool_size=max_bs,
max_seq_len_per_req=max_seq_len,
pool_slot_count=max_total_num_tokens,
@@ -60,14 +65,11 @@ class TestComputeLaunchCapacities(CustomTestCase):
def test_from_args_treats_missing_speculative_draft_tokens_as_zero(self) -> None:
"""per_forward_write_entry_capacity is floored by max_prefill_tokens when batch * tokens_per_req is smaller."""
server_args = self._make_server_args(max_bs=2)
server_args.speculative_num_draft_tokens = None
capacities = CanaryLaunchCapacities.from_args(
server_args=server_args,
req_to_token_pool_size=2,
max_seq_len_per_req=32,
pool_slot_count=64,
capacities = self._from_args(
max_bs=2,
max_seq_len=32,
max_total_num_tokens=64,
speculative_num_draft_tokens=None,
)
self.assertEqual(capacities.per_forward_write_entry_capacity, 128)
@@ -84,12 +86,7 @@ class TestComputeLaunchCapacities(CustomTestCase):
def test_from_args_rejects_empty_pool_capacity(self) -> None:
"""Verify derived launch capacities reject invalid pool sizing."""
with self.assertRaisesRegex(ValueError, "pool_slot_count"):
CanaryLaunchCapacities.from_args(
server_args=self._make_server_args(max_bs=1),
req_to_token_pool_size=1,
max_seq_len_per_req=1,
pool_slot_count=0,
)
self._from_args(max_bs=1, max_seq_len=1, max_total_num_tokens=0)
if __name__ == "__main__":
@@ -2,6 +2,8 @@ import unittest
from types import SimpleNamespace
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -17,14 +19,21 @@ def _req(output_len: int, input_len: int = 8, priority=None):
def _args(policy: str = "length", low_first: bool = False):
return SimpleNamespace(
"""The retraction order reads the schedule bag, so the policy has to be
published rather than handed in."""
return ServerArgs(
model_path="dummy",
retraction_policy=policy,
schedule_low_priority_values_first=low_first,
)
def _order(reqs, args):
return ScheduleBatch._get_decode_retraction_order(reqs, args)
publish(args, role="test")
try:
return ScheduleBatch._get_decode_retraction_order(reqs)
finally:
reset_context()
class TestRetractionOrder(CustomTestCase):
@@ -20,6 +20,7 @@ from sglang.srt.managers.schedule_batch import Req, ScheduleBatch
from sglang.srt.managers.scheduler import Scheduler
from sglang.srt.managers.scheduler_components.pool_stats_observer import PoolStats
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.sampling.sampling_params import SamplingParams
register_cpu_ci(est_time=15, suite="base-a-test-cpu")
@@ -27,6 +28,15 @@ register_cpu_ci(est_time=9, suite="base-c-test-cpu")
class TestSchedulerPauseGeneration(unittest.TestCase):
def setUp(self):
# The scheduler runs after its process publishes; retraction reads the
# disaggregation and schedule bags rather than the record it is handed.
from sglang.srt.server_args import ServerArgs
super().setUp()
publish(ServerArgs(model_path="dummy"), role="test")
self.addCleanup(reset_context)
def _new_scheduler(self) -> Scheduler:
scheduler = Scheduler.__new__(Scheduler)
scheduler._engine_paused = False
@@ -15,6 +15,7 @@ see.
"""
import ast
import copy
import dataclasses
import json
import os
@@ -22,8 +23,11 @@ import pathlib
import shutil
import tempfile
import unittest
import unittest.mock
import sglang
from sglang.srt import server_args as server_args_module
from sglang.srt.arg_groups.overrides import resolution_result
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -203,6 +207,36 @@ def _stash_overlay(server_args):
return overlay
def _live_topology_leaves():
"""Names `ParallelContext` serves from the live topology, not the config.
Read out of the class: each shadowed name arrives as `self._v("<name>",
<getter>)`. Inferring them from "did the read raise" is wrong -- it only
raises while the process groups are missing, so in a process where an
earlier test built them the property answers the *live* size and a leaf
check reads it as a config mismatch (`parallel.tp_size: bag=1
resolution=2`). Whether they are shadowed is a property of the class, not
of the process.
"""
tree = ast.parse((_SRT / "runtime_context.py").read_text(encoding="utf-8-sig"))
parallel = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef) and node.name == "ParallelContext"
)
names = set()
for node in ast.walk(parallel):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "_v"
and node.args
and isinstance(node.args[0], ast.Constant)
):
names.add(node.args[0].value)
return frozenset(names)
class TestResolutionDeclarations(CustomTestCase):
def setUp(self):
# Resolution writes environment variables, and those outlive the
@@ -275,6 +309,224 @@ class TestResolutionDeclarations(CustomTestCase):
+ "\n ".join(unexplained),
)
def test_the_projection_input_is_the_resolved_configuration(self):
"""What the bags are built from equals what the record ends up holding.
The projection reads `raw input + declarations` rather than the
fields, so that it keeps working when the declarations stop
materializing. While they still do, the two have to agree leaf for
leaf -- a difference means the projection would publish something the
record does not say, which is the failure this whole transition is
meant to avoid.
"""
from sglang.srt.arg_groups.arg_utils import namespace_of
from sglang.srt.arg_groups.overrides import resolution_result
differences = []
for shape in _SHAPES:
server_args = self._resolve(shape)
for field in namespace_of(type(server_args)):
projected = resolution_result(server_args, field)
on_record = getattr(server_args, field)
if projected != on_record:
differences.append(
f"{shape} -> {field}: projection={projected!r} "
f"record={on_record!r}"
)
self.assertEqual(
differences,
[],
"the projection and the record disagree about a config leaf:\n "
+ "\n ".join(differences),
)
def test_every_published_leaf_is_what_resolution_decided(self):
"""One hop further than the check above: the leaf a reader reads.
The projection's *input* agreeing with the record says nothing about
the last hop: whether the leaf is reachable through the path the
metadata declares, and whether it carries the resolved value once it
is. Both sides here come from that metadata, so this cannot tell that
a field is assigned to the *wrong* group -- the readers are the
independent source for that, and
`test_server_args_namespaces.py::test_the_readers_agree_with_the_namespace_metadata`
is where the two are compared.
"""
import sglang.srt.runtime_context as runtime_context
from sglang.srt.arg_groups.arg_utils import namespace_of
from sglang.srt.arg_groups.overrides import resolution_result
from sglang.srt.runtime_context import publish, reset_context
mapping = namespace_of(ServerArgs)
self.assertGreater(len(mapping), 400, "the namespace mapping collapsed")
shadowed = _live_topology_leaves()
self.assertGreaterEqual(
shadowed
& {
"tp_size",
"pp_size",
"moe_dp_size",
"attn_cp_size",
"dcp_size",
},
{"tp_size", "pp_size", "moe_dp_size", "attn_cp_size", "dcp_size"},
"a parallel size stopped being served from the live topology; if it "
"is a plain config leaf now, it belongs in the comparison below",
)
compared = 0
unreachable, mismatched = [], []
for shape in _SHAPES:
self.addCleanup(reset_context)
server_args = self._resolve(shape)
publish(server_args, role="scheduler")
for field, path in mapping.items():
if field in shadowed:
# Served from the process groups by design; `configured_*()`
# is what answers with the configured value, and
# test_launch_path_reads_configured_sizes pins that.
continue
groups = path.split(".")
accessor = getattr(runtime_context, f"get_{groups[0]}", None)
if accessor is None:
unreachable.append(f"no get_{groups[0]}() for {path}.{field}")
continue
node = accessor()
try:
for group in groups[1:]:
node = getattr(node, group)
leaf = getattr(node, field)
except Exception as exc:
unreachable.append(f"{path}.{field}: {type(exc).__name__}: {exc}")
continue
decided = resolution_result(server_args, field)
compared += 1
if leaf is not decided and leaf != decided:
mismatched.append(
f"{shape} -> {path}.{field}: bag={leaf!r} resolution={decided!r}"
)
reset_context()
self.assertEqual(
unreachable,
[],
"these leaves are mapped to a namespace that cannot serve them, so "
"a reader following the mapping raises:\n " + "\n ".join(unreachable),
)
self.assertEqual(
mismatched,
[],
"the published leaf and the resolution result disagree:\n "
+ "\n ".join(mismatched),
)
self.assertGreater(
compared, 2000, f"only {compared} leaves were compared; the walk broke"
)
def test_a_child_that_received_the_record_publishes_the_same_bags(self):
"""A forked worker gets the record by pickle, and re-projects from it.
Every process publishes, so a child's bags are only right if the
declarations travelled with the object -- and the gate has to hold on
the far side, or the child re-runs handlers over their own output. The
parent's bags are the reference: this is the multi-process half of the
projection, and nothing else exercises it.
"""
import pickle
import sglang.srt.runtime_context as runtime_context
from sglang.srt.arg_groups.arg_utils import namespace_of
from sglang.srt.runtime_context import publish, reset_context
mapping = namespace_of(ServerArgs)
def leaves():
out = {}
for field, path in mapping.items():
groups = path.split(".")
accessor = getattr(runtime_context, f"get_{groups[0]}", None)
if accessor is None:
continue
node = accessor()
try:
for group in groups[1:]:
node = getattr(node, group)
out[f"{path}.{field}"] = repr(getattr(node, field))
except Exception:
continue
return out
for shape in _SHAPES:
self.addCleanup(reset_context)
parent = self._resolve(shape)
publish(parent, role="scheduler")
expected = leaves()
blob = pickle.dumps(parent)
reset_context()
child = pickle.loads(blob)
entered = []
original = ServerArgs._run_resolution_pipeline
def counted(self, _original=original):
entered.append(1)
return _original(self)
with unittest.mock.patch.object(
ServerArgs, "_run_resolution_pipeline", counted
):
publish(child, role="scheduler")
self.assertEqual(
entered,
[],
f"{shape}: the child resolved again, so its handlers ran over "
"the parent's output",
)
differences = {
key: (expected[key], value)
for key, value in leaves().items()
if expected.get(key) != value
}
self.assertEqual(
differences,
{},
f"{shape}: the child published different values than the "
f"parent: {differences}",
)
reset_context()
def test_late_resolution_reaches_the_projection(self):
"""Resolution staged after `__post_init__` is still resolution.
The parser detection and the LoRA normalization run at launcher stage --
they need a tokenizer, a chat template, an adapter directory -- and they
write through `declare_late_resolution`. If those writes only reached
the fields, the bags would describe the *unresolved* value: a server
launched with `--reasoning-parser auto` would advertise and apply
`auto` after detection had already replaced it.
A real model path, not the dummy one: a dummy record never materializes,
so its `resolve_once` re-runs and re-snapshots the raw input from
already-late-resolved fields, which hides exactly this.
"""
from sglang.srt.arg_groups.overrides import declare_late_resolution
from sglang.srt.runtime_context import get_serving, publish, reset_context
server_args = self._resolve({"reasoning_parser": "auto"})
self.addCleanup(reset_context)
declare_late_resolution(
server_args, "template-detection", reasoning_parser="qwen3"
)
self.assertEqual(
resolution_result(server_args, "reasoning_parser"),
"qwen3",
"the projection still reports what the caller asked for, so the "
"bags would publish an unresolved parser",
)
publish(server_args, role="tokenizer")
self.assertEqual(get_serving().reasoning_parser, "qwen3")
self.assertEqual(server_args.reasoning_parser, get_serving().reasoning_parser)
def test_the_stash_agrees_with_the_fields_it_declared(self):
mismatches = []
for shape in _SHAPES:
@@ -362,6 +614,90 @@ class TestResolutionDeclarations(CustomTestCase):
+ "\n ".join(inversions),
)
def test_a_nested_resolution_decision_reaches_the_bags(self):
"""Resolution also decides *inside* a declared object.
The graph sizing writes `cuda_graph_config.decode.max_bs` through the
object the parse step declared -- no field is assigned, so nothing
records it. It reaches the bags because the stash holds that same
object; a copy taken when it was declared would publish the `None` the
parse step declared while the process runs with a real batch size.
"""
from sglang.srt.runtime_context import get_exec, publish, reset_context
server_args = self._resolve({"disaggregation_mode": "prefill"})
self.addCleanup(reset_context)
# Snapshot before publishing: the bag serves the very object the record
# holds, so comparing them after the fact compares an object with
# itself and passes however the projection behaves.
expected = copy.deepcopy(server_args.cuda_graph_config)
publish(server_args, role="scheduler")
published = get_exec().graph.cuda_graph_config
resolved = expected
self.assertIsNotNone(
published.decode.max_bs,
"the published graph config carries the batch size the parse step "
"declared, not the one the sizing handler decided",
)
self.assertEqual(
(
published.decode.max_bs,
published.decode.backend,
published.prefill.max_bs,
published.prefill.backend,
),
(
resolved.decode.max_bs,
resolved.decode.backend,
resolved.prefill.max_bs,
resolved.prefill.backend,
),
"the bags and the record disagree about the graph configuration, "
"so a decision made inside the declared object was dropped",
)
def test_every_platform_hook_that_takes_the_record_is_captured(self):
"""A second out-of-tree config hook must not arrive uncaptured.
`apply_server_args_defaults` is the one method on the platform
interface that is handed the record, and its implementations live in
other distributions -- no source scan of this tree can see what they
write, so the pipeline diffs the record across the call instead. A new
hook of the same shape would be invisible again, and this is what
notices. Derived from the interface rather than listed: a rename keeps
working, an addition fails.
"""
interface = _SRT / "platforms" / "interface.py"
tree = ast.parse(interface.read_text(encoding="utf-8-sig"))
taking_the_record = set()
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
arguments = node.args
names = [
arg.arg
for arg in arguments.posonlyargs + arguments.args + arguments.kwonlyargs
]
if any(name == "server_args" or name.endswith("_args") for name in names):
taking_the_record.add(node.name)
self.assertEqual(
taking_the_record,
{"apply_server_args_defaults"},
"the platform interface hands the startup record to a method this "
"test does not know about; either it only reads, or its writes need "
"capturing like apply_server_args_defaults",
)
pipeline = (_SRT / "server_args.py").read_text(encoding="utf-8-sig")
for hook in sorted(taking_the_record):
self.assertIn(
f"current_platform.{hook},",
pipeline,
f"{hook} is called directly instead of through the write "
"capture, so an out-of-tree plugin's defaults would be dropped "
"by the projection",
)
def test_the_shapes_reach_the_fields_they_are_meant_to(self):
"""A green agreement check over an empty stash would prove nothing."""
declared = set()
@@ -376,6 +712,41 @@ class TestResolutionDeclarations(CustomTestCase):
+ "\n ".join(missing),
)
def test_a_platform_plugin_default_reaches_the_projection(self):
"""An out-of-tree platform writes the fields; the diff declares them.
The plugin interface is not ours to convert -- implementations live in
other distributions -- so its writes are captured rather than declared.
Without the capture the projection falls through to the raw snapshot,
which was taken before the plugin ran, and publishes the value the
plugin overrode.
"""
# The pipeline asks the platform other questions on the way through
# (whether it is out of tree, whether it supports piecewise capture),
# and which of those it reaches depends on the host.
class _Plugin(type(server_args_module.current_platform)):
device_name = "oot"
def apply_server_args_defaults(self, server_args):
server_args.attention_backend = "triton"
server_args.schedule_conservativeness = 0.5
with unittest.mock.patch.object(
server_args_module, "current_platform", _Plugin()
):
server_args = self._resolve({})
self.assertEqual(
(
resolution_result(server_args, "attention_backend"),
resolution_result(server_args, "schedule_conservativeness"),
),
("triton", 0.5),
"the platform plugin's defaults did not reach the resolution "
"result, so the projection publishes what the operator passed "
"instead of what the platform decided",
)
if __name__ == "__main__":
unittest.main()
@@ -104,6 +104,54 @@ _CONFIGURED_SIZE_CALL_SITES = {
("srt/managers/scheduler.py", "configured_attn_cp_size"): (
"same pre-distributed-init arithmetic in configure_scheduler_process"
),
("srt/managers/scheduler.py", "configured_dcp_size"): (
"same pre-distributed-init arithmetic in configure_scheduler_process"
),
("srt/disaggregation/common/conn.py", "configured_pp_size"): (
"the bootstrap connection is built by the KV manager on the transfer "
"path, which the CPU-only conn tests exercise without ever starting "
"torch.distributed"
),
("srt/elastic_ep/elastic_ep.py", "configured_tp_size"): (
"the joiner's rank window is computed against the size the process was "
"configured with, not the size of the group it is about to join"
),
("srt/elastic_ep/expert_backup_manager.py", "configured_tp_size"): (
"the backup server counts the clients it expects to report in, which "
"is how many the launch configured -- the live group is what they are "
"still joining"
),
(
"srt/model_executor/model_runner_components/startup_weight_load.py",
"configured_tp_size",
): (
"the load options are assembled in ModelRunner.__init__ for a runner "
"that may be a draft, whose groups are the target's; the configured "
"sizes are what the record answered before"
),
(
"srt/model_executor/model_runner_components/startup_weight_load.py",
"configured_pp_size",
): ("same options object, same reason"),
(
"srt/model_executor/model_runner_components/startup_weight_load.py",
"configured_attn_cp_size",
): ("same options object, same reason"),
(
"srt/model_executor/model_runner_components/startup_weight_load.py",
"configured_dcp_size",
): ("same options object, same reason"),
(
"srt/model_executor/model_runner_components/spec_aux_hidden_state.py",
"configured_tp_size",
): (
"the draft KV bytes/token estimate sizes the memory pool before the "
"draft runner exists, so its shard count is configuration"
),
("srt/eplb/expert_location.py", "configured_tp_size"): (
"the elastic-EP joiner window, used to size the expert layout: the "
"size the process was configured with, not the group it is joining"
),
("srt/utils/cuda_vmm_transport_utils.py", "configured_tp_size"): (
"the consumer count is configured fan-out arithmetic (tp_size // "
"dp_size), which is what the record answered before"
@@ -28,7 +28,7 @@ _LIVE_SHADOWED = {
"pp_size": "configured_pp_size()",
"moe_dp_size": "configured_moe_dp_size()",
"attn_cp_size": "configured_attn_cp_size()",
"dcp_size": "a configured accessor (none exists yet; add one beside configured_pp_size)",
"dcp_size": "configured_dcp_size()",
}
# Launch paths that decide how many children to spawn are derived below
@@ -234,6 +234,119 @@ def _launch_paths():
class TestLaunchPathsReadConfiguredSizes(CustomTestCase):
def test_configured_sizes_hold_when_the_live_topology_disagrees(self):
"""The other direction: groups exist and answer something else.
The check above proves nobody reads a live size too early. It says
nothing about what `configured_*()` returns once the groups *are* up
and answering a different number -- which is not hypothetical: elastic
EP scales the live topology away from what the operator configured, and
that divergence is the entire reason these five helpers exist. With
only the early-read direction covered, a helper that quietly delegated
to the live property would look correct.
"""
import json
import os
import tempfile
from unittest.mock import patch
from sglang.srt.runtime_context import (
configured_attn_cp_size,
configured_dcp_size,
configured_moe_dp_size,
configured_pp_size,
configured_tp_size,
get_parallel,
publish,
reset_context,
)
from sglang.srt.server_args import ServerArgs
directory = tempfile.mkdtemp(prefix="configured_sizes_")
with open(os.path.join(directory, "config.json"), "w") as handle:
json.dump(
{
"architectures": ["LlamaForCausalLM"],
"model_type": "llama",
"hidden_size": 16,
"intermediate_size": 32,
"num_attention_heads": 2,
"num_key_value_heads": 2,
"num_hidden_layers": 2,
"vocab_size": 128,
"max_position_embeddings": 2048,
},
handle,
)
# No resolve_once() here: `tp_size` is raw input, so the configured
# value is 2 either way.
server_args = ServerArgs(model_path=directory, device="cuda", tp_size=2)
self.addCleanup(reset_context)
publish(server_args, role="scheduler")
# The live getter behind each property, read out of ParallelContext
# rather than listed here.
context_source = ast.parse(
(_PACKAGE_ROOT / "srt" / "runtime_context.py").read_text(
encoding="utf-8-sig"
)
)
parallel_class = next(
node
for node in ast.walk(context_source)
if isinstance(node, ast.ClassDef) and node.name == "ParallelContext"
)
live_getter = {}
for method in parallel_class.body:
if not isinstance(method, ast.FunctionDef):
continue
for call in ast.walk(method):
if not (
isinstance(call, ast.Call)
and isinstance(call.func, ast.Attribute)
and call.func.attr == "_v"
and call.args
and isinstance(call.args[0], ast.Constant)
):
continue
getter = call.args[1]
if isinstance(getter, ast.Attribute):
live_getter[call.args[0].value] = getter.attr
state = "sglang.srt.distributed.parallel_state"
helpers = {
"tp_size": configured_tp_size,
"pp_size": configured_pp_size,
"moe_dp_size": configured_moe_dp_size,
"attn_cp_size": configured_attn_cp_size,
"dcp_size": configured_dcp_size,
}
missing = sorted(set(helpers) - set(live_getter))
self.assertEqual(
missing,
[],
f"these sizes no longer have a live property to diverge from: {missing}",
)
cases = tuple(
(name, helper, f"{state}.{live_getter[name]}")
for name, helper in helpers.items()
)
for name, helper, target in cases:
with self.subTest(size=name):
configured = helper()
with patch(target, return_value=configured + 41):
self.assertEqual(
get_parallel().__getattribute__(name),
configured + 41,
f"{name} no longer follows the live topology",
)
self.assertEqual(
helper(),
configured,
f"configured_{name}() followed the live topology instead "
"of the published configuration",
)
reset_context()
def test_no_live_topology_read_before_distributed_init(self):
offenders = []
for rel, tree in _launch_paths():
@@ -47,6 +47,156 @@ def _field_names():
class TestServerArgsNamespaces(CustomTestCase):
def test_no_module_shadows_a_bag_accessor(self):
"""An accessor name bound twice in one module is a silent wrong read.
This has happened twice. Once a module imported `get_model` from the
context and a same-named helper from elsewhere, and once `get_device`
-- which names three different things in this tree: the bag accessor,
the device-string utility, and a platform method. The second import
wins, the converted line calls the wrong callable, and the failure is
an AttributeError on whichever branch reaches it, which for a
per-pass recorder or a specific accelerator can be none of the ones a
CPU suite runs. Nothing else notices; a name scan looks fine.
"""
import ast
import collections
import pathlib as _pathlib
import sglang
srt = _pathlib.Path(sglang.__file__).resolve().parent / "srt"
context_module = ast.parse(
(srt / "runtime_context.py").read_text(encoding="utf-8-sig")
)
accessors = {
node.name
for node in context_module.body
if isinstance(node, ast.FunctionDef)
and (node.name.startswith("get_") or node.name.startswith("configured_"))
}
self.assertGreater(len(accessors), 20, "the accessor derivation broke")
shadowed = []
for path in sorted(srt.rglob("*.py")):
if path.name == "runtime_context.py":
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except SyntaxError:
self.fail(f"unparsable module in the census: {path}")
bindings = collections.defaultdict(set)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
origin = node.module or ""
kind = (
"context"
if origin.endswith("runtime_context")
else f"{origin or '.'}"
)
for alias in node.names:
bindings[alias.asname or alias.name].add((kind, node.lineno))
elif isinstance(node, ast.Import):
for alias in node.names:
bindings[(alias.asname or alias.name).split(".")[0]].add(
("import", node.lineno)
)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
bindings[node.name].add(("def", node.lineno))
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
bindings[target.id].add(("assign", node.lineno))
for name, where in bindings.items():
if name not in accessors:
continue
kinds = {kind for kind, _ in where}
# The same accessor imported from the context more than once
# (module level plus a lazy import inside a function) is one
# object under one name; a *different* origin is the hazard.
if "context" in kinds and kinds - {"context"}:
shadowed.append(
f"{path.relative_to(srt)}: {name} <- "
+ ", ".join(
f"{k}@{l}" for k, l in sorted(where, key=lambda w: w[1])
)
)
self.assertEqual(
shadowed,
[],
"a bag accessor shares its name with another binding in the same "
"module, so the converted reads call whichever import came last; "
"alias one of them:\n " + "\n ".join(shadowed),
)
def test_the_readers_agree_with_the_namespace_metadata(self):
"""Two independent sources say where a leaf lives; they must match.
The metadata is one source and the ~2000 hand-written reads
(`get_schedule().chunked_prefill_size`) are the other. Checking the
projection against the metadata cannot catch a field assigned to the
wrong group -- both sides come from the same marker, so the check is
true by construction. The readers are written by hand, so a
disagreement means one of the two is wrong, and every reader on the
losing side raises `has no leaf/subgroup` at runtime on whichever
branch reaches it first.
"""
import ast
import pathlib as _pathlib
import sglang
srt = _pathlib.Path(sglang.__file__).resolve().parent / "srt"
mapping = namespace_of(ServerArgs)
accessors = {
f"get_{group}" for group in {p.split(".")[0] for p in mapping.values()}
}
sites = 0
disagreements = []
for path in sorted(srt.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except SyntaxError:
self.fail(f"unparsable module in the census: {path}")
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute):
continue
chain, cursor = [], node
while isinstance(cursor, ast.Attribute):
chain.append(cursor.attr)
cursor = cursor.value
if not (
isinstance(cursor, ast.Call)
and isinstance(cursor.func, ast.Name)
and cursor.func.id in accessors
):
continue
chain.reverse()
field = chain[-1]
if field not in mapping:
continue
sites += 1
read = [cursor.func.id[len("get_") :]] + chain[:-1]
if mapping[field].split(".") != read:
disagreements.append(
f"{path.relative_to(srt)}:{node.lineno} reads "
f"{'.'.join(read)}.{field}, metadata says "
f"{mapping[field]}.{field}"
)
self.assertEqual(
disagreements,
[],
"a reader and the namespace metadata disagree about where a leaf "
"lives; one of them is wrong:\n " + "\n ".join(disagreements),
)
self.assertGreater(
sites,
1500,
f"only {sites} bag reads were matched; the scan broke and this "
"check stopped covering anything",
)
def test_every_field_has_a_namespace(self):
nsmap = namespace_of(ServerArgs)
missing = sorted(_field_names() - set(nsmap))
@@ -129,6 +129,15 @@ _ENV_MATRIX = (({}, {"SGLANG_IS_IN_CI": "true"}),)
_PASSED = frozenset({"model_path", "device", "random_seed"})
_EXPOSED = {
("dllm/config.py", "max_running_requests"),
("dllm/config.py", "model_path"),
("multimodal/processors/base_processor.py", "image_processor_backend"),
("speculative/spec_registry.py", "disable_overlap_schedule"),
("layers/moe/utils.py", "deepep_mode"),
("layers/moe/utils.py", "moe_a2a_backend"),
("layers/moe/utils.py", "moe_runner_backend"),
("layers/moe/utils.py", "quantization"),
("layers/moe/utils.py", "speculative_moe_runner_backend"),
("entrypoints/sidecar.py", "grpc_port"),
("configs/embedding_model_spec.py", "chunked_prefill_size"),
("configs/embedding_model_spec.py", "cuda_graph_config"),
@@ -144,10 +153,6 @@ _EXPOSED = {
("configs/model_config.py", "quantization"),
("configs/model_config.py", "speculative_algorithm"),
("configs/model_config.py", "speculative_draft_model_quantization"),
("disaggregation/common/conn.py", "disaggregation_bootstrap_port"),
("disaggregation/common/conn.py", "pp_size"),
("disaggregation/decode_kvcache_offload_manager.py", "hicache_io_backend"),
("disaggregation/decode_kvcache_offload_manager.py", "served_model_name"),
("disaggregation/utils.py", "disaggregation_transfer_backend"),
("distributed/bootstrap.py", "disable_custom_all_reduce"),
("distributed/bootstrap.py", "enable_symm_mem"),
@@ -155,38 +160,6 @@ _EXPOSED = {
("distributed/bootstrap.py", "flashinfer_allreduce_fusion_backend"),
("distributed/bootstrap.py", "moe_a2a_backend"),
("distributed/bootstrap.py", "pre_warm_nccl"),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"disaggregation_ib_device",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"disaggregation_mode",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"disaggregation_transfer_backend",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"enable_hierarchical_cache",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"encoder_transfer_backend",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"mooncake_ib_device",
),
("dllm/config.py", "max_running_requests"),
("dllm/config.py", "model_path"),
("elastic_ep/elastic_ep.py", "elastic_ep_initial_size"),
("elastic_ep/elastic_ep.py", "ep_join_mode"),
("elastic_ep/elastic_ep.py", "moe_a2a_backend"),
("elastic_ep/expert_backup_manager.py", "disaggregation_ib_device"),
("elastic_ep/expert_backup_manager.py", "load_format"),
("elastic_ep/expert_backup_manager.py", "mooncake_ib_device"),
("entrypoints/engine.py", "attn_cp_size"),
("entrypoints/engine.py", "enable_symm_mem"),
("entrypoints/engine.py", "moe_dp_size"),
@@ -198,17 +171,6 @@ _EXPOSED = {
("entrypoints/engine.py", "tool_call_parser"),
("eplb/eplb_manager.py", "ep_dispatch_algorithm"),
("eplb/eplb_manager.py", "expert_distribution_recorder_buffer_size"),
("eplb/expert_distribution.py", "deepep_mode"),
("eplb/expert_distribution.py", "device"),
("eplb/expert_distribution.py", "expert_distribution_recorder_mode"),
("eplb/expert_distribution.py", "moe_a2a_backend"),
("eplb/expert_location.py", "device"),
("eplb/expert_location.py", "eplb_algorithm"),
("kv_canary/api.py", "disaggregation_mode"),
("kv_canary/api.py", "speculative_num_steps"),
("kv_canary/capacities.py", "chunked_prefill_size"),
("kv_canary/capacities.py", "cuda_graph_config"),
("kv_canary/capacities.py", "speculative_num_draft_tokens"),
("layers/cp/base.py", "attn_cp_size"),
("layers/cp/base.py", "cp_strategy"),
("layers/cp/base.py", "enable_prefill_cp"),
@@ -216,11 +178,6 @@ _EXPOSED = {
("layers/cp/bcg.py", "enable_prefill_cp"),
("layers/flashinfer_comm_fusion.py", "flashinfer_allreduce_fusion_backend"),
("layers/moe/kt_ep_wrapper.py", "chunked_prefill_size"),
("layers/moe/utils.py", "deepep_mode"),
("layers/moe/utils.py", "moe_a2a_backend"),
("layers/moe/utils.py", "moe_runner_backend"),
("layers/moe/utils.py", "quantization"),
("layers/moe/utils.py", "speculative_moe_runner_backend"),
("layers/quantization/unquant.py", "enable_deterministic_inference"),
("lora/lora_manager.py", "enable_lora_overlap_loading"),
("lora/marlin_lora_temp/policy.py", "enable_lora"),
@@ -231,124 +188,18 @@ _EXPOSED = {
("managers/data_parallel_controller.py", "moe_dp_size"),
("managers/data_parallel_controller.py", "pp_size"),
("managers/data_parallel_controller.py", "soft_watchdog_timeout"),
("managers/disagg_service.py", "disaggregation_bootstrap_port"),
("managers/disagg_service.py", "disaggregation_mode"),
("managers/disagg_service.py", "disaggregation_transfer_backend"),
("managers/overlap_utils.py", "speculative_algorithm"),
("managers/prefill_delayer.py", "disable_overlap_schedule"),
("managers/rust_server.py", "mm_process_config"),
("managers/schedule_batch.py", "disaggregation_mode"),
("managers/scheduler.py", "attn_cp_size"),
("managers/scheduler.py", "disable_overlap_schedule"),
("managers/scheduler.py", "disaggregation_mode"),
("managers/scheduler.py", "enable_hierarchical_cache"),
("managers/scheduler.py", "enable_lora"),
("managers/scheduler.py", "enable_lora_overlap_loading"),
("managers/scheduler.py", "moe_dp_size"),
("managers/scheduler.py", "pp_size"),
("managers/scheduler.py", "soft_watchdog_timeout"),
("managers/scheduler.py", "speculative_algorithm"),
("managers/tokenizer_manager.py", "served_model_name"),
("managers/tp_worker.py", "disable_overlap_schedule"),
("managers/tp_worker.py", "model_path"),
("managers/tp_worker.py", "random_seed"),
("managers/tp_worker.py", "speculative_algorithm"),
("managers/tp_worker.py", "tokenizer_path"),
("mem_cache/hiradix_cache.py", "hicache_io_backend"),
("mem_cache/hiradix_cache.py", "hicache_mem_layout"),
("mem_cache/hiradix_cache.py", "served_model_name"),
("mem_cache/hybrid_cache/hybrid_pool_assembler.py", "hicache_io_backend"),
("mem_cache/hybrid_cache/hybrid_pool_assembler.py", "hicache_mem_layout"),
("mem_cache/hybrid_cache/hybrid_pool_assembler.py", "served_model_name"),
("mem_cache/kv_cache_builder.py", "hicache_mem_layout"),
("mem_cache/radix_cache_cpp.py", "enable_hierarchical_cache"),
("model_executor/model_runner.py", "device"),
("model_executor/model_runner.py", "speculative_algorithm"),
("model_executor/model_runner.py", "speculative_draft_attention_backend"),
("model_executor/model_runner_components/load_model_utils.py", "load_format"),
("model_executor/model_runner_components/load_model_utils.py", "quantization"),
(
"model_executor/model_runner_components/spec_aux_hidden_state.py",
"speculative_draft_attention_backend",
),
(
"model_executor/model_runner_components/spec_aux_hidden_state.py",
"speculative_draft_model_path",
),
(
"model_executor/model_runner_components/spec_aux_hidden_state.py",
"speculative_draft_model_revision",
),
("model_executor/model_runner_components/startup_weight_load.py", "attn_cp_size"),
(
"model_executor/model_runner_components/startup_weight_load.py",
"cuda_graph_config",
),
(
"model_executor/model_runner_components/startup_weight_load.py",
"custom_weight_loader",
),
("model_executor/model_runner_components/startup_weight_load.py", "device"),
("model_executor/model_runner_components/startup_weight_load.py", "enable_lora"),
("model_executor/model_runner_components/startup_weight_load.py", "lora_paths"),
("model_executor/model_runner_components/startup_weight_load.py", "pp_size"),
(
"model_executor/model_runner_components/startup_weight_load.py",
"speculative_algorithm",
),
(
"model_executor/runner_backend/tc_piecewise_cuda_graph_backend.py",
"cuda_graph_config",
),
("multimodal/processors/base_processor.py", "image_processor_backend"),
("observability/metrics_collector.py", "disaggregation_mode"),
("observability/metrics_collector.py", "prefill_delayer_max_delay_passes"),
("observability/metrics_collector.py", "served_model_name"),
("parser/template_detection.py", "model_path"),
("speculative/adaptive_spec_params.py", "speculative_algorithm"),
("speculative/adaptive_spec_params.py", "speculative_eagle_topk"),
("speculative/dflash_worker_v2.py", "speculative_draft_window_size"),
("speculative/dflash_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/draft_worker_common.py", "speculative_draft_attention_backend"),
("speculative/dspark_components/dspark_config.py", "speculative_draft_model_path"),
(
"speculative/dspark_components/dspark_config.py",
"speculative_draft_model_revision",
),
("speculative/dspark_components/dspark_worker_v2.py", "disaggregation_mode"),
(
"speculative/dspark_components/dspark_worker_v2.py",
"speculative_num_draft_tokens",
),
("speculative/eagle_worker_v2.py", "device"),
("speculative/eagle_worker_v2.py", "speculative_adaptive"),
("speculative/eagle_worker_v2.py", "speculative_algorithm"),
("speculative/eagle_worker_v2.py", "speculative_eagle_topk"),
("speculative/eagle_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/eagle_worker_v2.py", "speculative_num_steps"),
("speculative/frozen_kv_mtp_worker_v2.py", "device"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_adaptive"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_algorithm"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_eagle_topk"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_num_steps"),
("speculative/multi_layer_eagle_worker_v2.py", "device"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_algorithm"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_eagle_topk"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_num_steps"),
("speculative/ngram_worker.py", "device"),
("speculative/ngram_worker.py", "disable_overlap_schedule"),
("speculative/ngram_worker.py", "speculative_eagle_topk"),
("speculative/ngram_worker.py", "speculative_num_draft_tokens"),
("speculative/ngram_worker.py", "speculative_num_steps"),
("speculative/spec_info.py", "enable_multi_layer_eagle"),
("speculative/spec_registry.py", "disable_overlap_schedule"),
("speculative/standalone_worker_v2.py", "device"),
("speculative/standalone_worker_v2.py", "speculative_algorithm"),
("speculative/standalone_worker_v2.py", "speculative_eagle_topk"),
("speculative/standalone_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/standalone_worker_v2.py", "speculative_num_steps"),
("utils/common.py", "speculative_num_draft_tokens"),
("utils/common.py", "speculative_num_steps"),
("utils/cuda_vmm_transport_utils.py", "mm_feature_transport"),
@@ -367,56 +218,19 @@ _EXPOSED_CUDA_ONLY: frozenset = frozenset()
# some code overrides post-publish. Each needs an ordering judgment, not a blanket
# conversion; the list exists so a new one is a decision made when it is written.
_OVERRIDDEN_AND_READ = {
("dllm/config.py", "model_path"),
("entrypoints/engine.py", "reasoning_parser"),
("entrypoints/engine.py", "tool_call_parser"),
("configs/model_config.py", "dtype"),
("configs/model_config.py", "model_path"),
("disaggregation/decode_kvcache_offload_manager.py", "hicache_storage_backend"),
(
"disaggregation/decode_kvcache_offload_manager.py",
"hicache_storage_backend_extra_config",
),
(
"distributed/device_communicators/mooncake_transfer_engine.py",
"hicache_storage_backend",
),
("dllm/config.py", "model_path"),
("elastic_ep/expert_backup_manager.py", "load_format"),
("kv_canary/api.py", "speculative_num_steps"),
("kv_canary/capacities.py", "speculative_num_draft_tokens"),
("managers/scheduler.py", "hicache_storage_backend"),
("managers/tp_worker.py", "model_path"),
("mem_cache/hiradix_cache.py", "hicache_storage_backend"),
("mem_cache/hiradix_cache.py", "hicache_storage_backend_extra_config"),
("mem_cache/hiradix_cache.py", "hicache_storage_prefetch_policy"),
("mem_cache/hiradix_cache.py", "hicache_write_policy"),
("mem_cache/hybrid_cache/hybrid_pool_assembler.py", "hicache_storage_backend"),
("mem_cache/hybrid_cache/hybrid_pool_assembler.py", "hicache_write_policy"),
("mem_cache/kv_cache_builder.py", "hicache_storage_backend"),
("mem_cache/pool_host/common.py", "hicache_storage_backend"),
("mem_cache/pool_host/common.py", "hicache_storage_backend_extra_config"),
("mem_cache/radix_cache_cpp.py", "hicache_write_policy"),
("mem_cache/unified_radix_cache.py", "hicache_storage_backend"),
("mem_cache/unified_radix_cache.py", "hicache_storage_backend_extra_config"),
("mem_cache/unified_radix_cache.py", "hicache_storage_prefetch_policy"),
("mem_cache/unified_radix_cache.py", "hicache_write_policy"),
("model_executor/model_runner_components/load_model_utils.py", "load_format"),
("parser/template_detection.py", "model_path"),
("speculative/dflash_worker_v2.py", "speculative_num_draft_tokens"),
(
"speculative/dspark_components/dspark_worker_v2.py",
"speculative_num_draft_tokens",
),
("speculative/eagle_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/eagle_worker_v2.py", "speculative_num_steps"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/frozen_kv_mtp_worker_v2.py", "speculative_num_steps"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/multi_layer_eagle_worker_v2.py", "speculative_num_steps"),
("speculative/ngram_worker.py", "speculative_num_draft_tokens"),
("speculative/ngram_worker.py", "speculative_num_steps"),
("speculative/standalone_worker_v2.py", "speculative_num_draft_tokens"),
("speculative/standalone_worker_v2.py", "speculative_num_steps"),
("utils/common.py", "speculative_num_draft_tokens"),
("utils/common.py", "speculative_num_steps"),
}