config: record resolution writes in a declaration stash (#35905)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6218d6ce3f
commit
0e22777572
@@ -0,0 +1,381 @@
|
||||
"""Resolution writes are recorded, not just applied.
|
||||
|
||||
The projection that replaces field materialization reads the declaration stash,
|
||||
so a resolution write that only assigns the field is invisible to it. Every
|
||||
resolver declares now -- the record's handlers through `self._declare`, the
|
||||
hooks and hardware defaults through `declare_resolution` -- and that is pinned
|
||||
two ways: no bare assignment to a field survives anywhere a ServerArgs instance
|
||||
is in reach, and after resolution every declared field agrees with what the
|
||||
stash says. The second check is the one that keeps the transition honest --
|
||||
while a declaration still writes the field immediately, a stash entry and a
|
||||
field can only disagree if something assigned the field behind the stash's
|
||||
back. A third check runs the other way: every field resolution moved has to
|
||||
be explained by the stash, which covers the spellings a source scan cannot
|
||||
see.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import sglang
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
|
||||
|
||||
_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt"
|
||||
|
||||
# Every field of the record: resolution has no bare-assignment writer left, so
|
||||
# the scan states that as a whole rather than a converted-so-far list.
|
||||
_RESOLVED_FIELDS = frozenset(field.name for field in dataclasses.fields(ServerArgs))
|
||||
|
||||
# Shapes the agreement check runs on. Each needs a real config.json:
|
||||
# `model_path="dummy"` takes the pipeline's early return.
|
||||
_MINI_CONFIG = {
|
||||
"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,
|
||||
}
|
||||
|
||||
_SHAPES = (
|
||||
{"tp_size": 2, "dwdp_size": 2},
|
||||
{"random_seed": None},
|
||||
{"enable_deterministic_inference": True},
|
||||
{"enable_return_hidden_states": True},
|
||||
{
|
||||
"speculative_algorithm": "EAGLE",
|
||||
"speculative_num_steps": 3,
|
||||
"speculative_eagle_topk": 1,
|
||||
"speculative_num_draft_tokens": 4,
|
||||
},
|
||||
{"dp_size": 2, "tp_size": 2, "enable_dp_attention": True},
|
||||
{"enable_hierarchical_cache": True},
|
||||
{"disaggregation_mode": "prefill"},
|
||||
{"enable_lora": True, "max_lora_rank": 16},
|
||||
{"kv_cache_dtype": "fp8_e4m3", "page_size": 64},
|
||||
# A pass and a handler both decide this one: waterfill forces `deepep`
|
||||
# and the ascend handler wants `none`. Without this shape nothing in
|
||||
# the set reaches a field two writers disagree about.
|
||||
{"enable_waterfill": True, "moe_a2a_backend": "ascend_tp"},
|
||||
)
|
||||
|
||||
# Which converted fields the shapes above reach; the rest need a device or an
|
||||
# architecture no CPU fixture has, and the source scan covers those. Pinned so
|
||||
# a shape that stops reaching a field fails here. Add to it when adding a shape.
|
||||
_REACHED_BY_SHAPES = frozenset(
|
||||
{
|
||||
"_speculative_draft_quantization_explicitly_set",
|
||||
"allowed_media_domains",
|
||||
"attention_backend",
|
||||
"chunked_prefill_size",
|
||||
"cuda_graph_config",
|
||||
"custom_weight_loader",
|
||||
"device",
|
||||
"disable_cuda_graph",
|
||||
"disaggregation_ib_device",
|
||||
"dp_size",
|
||||
"enable_dp_attention",
|
||||
"enable_dp_attention_local_control_broadcast",
|
||||
"enable_dp_lm_head",
|
||||
"enable_flashinfer_allreduce_fusion",
|
||||
"encoder_transfer_backend",
|
||||
"enforce_disable_flashinfer_allreduce_fusion",
|
||||
"ep_size",
|
||||
"expert_distribution_recorder_buffer_size",
|
||||
"flashinfer_allreduce_fusion_backend",
|
||||
"grammar_backend",
|
||||
"hicache_ratio",
|
||||
"keep_mm_feature_on_device",
|
||||
"load_balance_method",
|
||||
"max_running_requests",
|
||||
"mem_fraction_static",
|
||||
"mm_feature_transport",
|
||||
"mm_process_config",
|
||||
"moe_a2a_backend",
|
||||
"moe_dense_tp_size",
|
||||
"moe_dp_size",
|
||||
"page_size",
|
||||
"random_seed",
|
||||
"return_hidden_states_mode",
|
||||
"sampling_backend",
|
||||
"schedule_conservativeness",
|
||||
"served_model_name",
|
||||
"speculative_algorithm",
|
||||
"speculative_draft_model_quantization",
|
||||
"tokenizer_path",
|
||||
"uses_mamba_radix_cache",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _server_args_writers(tree, path):
|
||||
"""Assignment targets that land on a ServerArgs instance.
|
||||
|
||||
Two mechanisms reach the same instance during resolution: a handler writing
|
||||
`self.<field>`, and a helper elsewhere in the tree writing through a
|
||||
`ServerArgs`-annotated parameter -- `set_default_server_args(args)` is
|
||||
called from the pipeline and writes `args.<field>`. Both bypass the
|
||||
declaration stash, so both have to be scanned; scanning only the handlers
|
||||
would let a field look converted while a second writer still assigns it.
|
||||
"""
|
||||
names = {"self"} if path.name == "server_args.py" else set()
|
||||
# A parameter *named* `server_args` counts with or without the annotation.
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
args = node.args
|
||||
for arg in args.posonlyargs + args.args + args.kwonlyargs:
|
||||
annotation = arg.annotation
|
||||
if isinstance(annotation, ast.Constant):
|
||||
text = annotation.value
|
||||
elif isinstance(annotation, ast.Name):
|
||||
text = annotation.id
|
||||
elif isinstance(annotation, ast.Attribute):
|
||||
text = annotation.attr
|
||||
else:
|
||||
continue
|
||||
if text == "ServerArgs":
|
||||
names.add(arg.arg)
|
||||
names |= {
|
||||
arg.arg for arg in args.posonlyargs + args.args if arg.arg == "server_args"
|
||||
}
|
||||
return names
|
||||
|
||||
|
||||
def _bare_assignments():
|
||||
"""Assignments to a converted field that never reach the stash."""
|
||||
found = []
|
||||
for path in sorted(_SRT.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
names = _server_args_writers(tree, path)
|
||||
if not names:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = node.targets
|
||||
elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
|
||||
targets = [node.target]
|
||||
else:
|
||||
continue
|
||||
# Destructured targets count: `(sa.a, sa.b) = f()` writes two
|
||||
# fields and is not an `ast.Attribute` at the top level.
|
||||
flat = []
|
||||
for target in targets:
|
||||
if isinstance(target, (ast.Tuple, ast.List)):
|
||||
flat.extend(target.elts)
|
||||
else:
|
||||
flat.append(target)
|
||||
for target in flat:
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id in names
|
||||
and target.attr in _RESOLVED_FIELDS
|
||||
):
|
||||
found.append(
|
||||
f"{path.relative_to(_SRT)}:{node.lineno} "
|
||||
f"{target.value.id}.{target.attr}"
|
||||
)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _stash_overlay(server_args):
|
||||
"""What the declarations say, last writer wins -- the projection's input."""
|
||||
overlay = {}
|
||||
for _source, declared in getattr(server_args, "_resolved_overrides", None) or ():
|
||||
overlay.update(declared)
|
||||
return overlay
|
||||
|
||||
|
||||
class TestResolutionDeclarations(CustomTestCase):
|
||||
def setUp(self):
|
||||
# Resolution writes environment variables, and those outlive the
|
||||
# record that set them.
|
||||
super().setUp()
|
||||
environment = dict(os.environ)
|
||||
|
||||
def restore():
|
||||
os.environ.clear()
|
||||
os.environ.update(environment)
|
||||
|
||||
self.addCleanup(restore)
|
||||
|
||||
def _resolve(self, extra):
|
||||
"""A fully-resolved config: a real config.json, so the pipeline runs
|
||||
past its dummy-model early return."""
|
||||
path = tempfile.mkdtemp(prefix="declarations_")
|
||||
self.addCleanup(shutil.rmtree, path, ignore_errors=True)
|
||||
with open(os.path.join(path, "config.json"), "w") as handle:
|
||||
json.dump(_MINI_CONFIG, handle)
|
||||
fields = {"random_seed": 42}
|
||||
fields.update(extra)
|
||||
return ServerArgs(model_path=path, device="cuda", **fields)
|
||||
|
||||
def test_converted_fields_are_not_assigned_bare(self):
|
||||
bare = _bare_assignments()
|
||||
self.assertEqual(
|
||||
bare,
|
||||
[],
|
||||
"a converted field is assigned directly, so the projection would "
|
||||
"not see this write:\n " + "\n ".join(bare),
|
||||
)
|
||||
|
||||
def test_the_stash_accounts_for_every_change_resolution_made(self):
|
||||
"""The other direction: a field resolution moved is in the stash.
|
||||
|
||||
The source scan states that no *assignment* escapes, which leaves the
|
||||
spellings a source scan cannot see -- a computed name, a write through
|
||||
a helper the scan does not recognize as holding the record. This
|
||||
compares the resolved value against what the caller supplied (or the
|
||||
field's default) and asks the stash to explain every difference, which
|
||||
is what the projection has to be able to do.
|
||||
"""
|
||||
unexplained = []
|
||||
for shape in _SHAPES:
|
||||
supplied = {"random_seed": 42, **shape}
|
||||
server_args = self._resolve(shape)
|
||||
overlay = _stash_overlay(server_args)
|
||||
for field in dataclasses.fields(server_args):
|
||||
if field.name in ("model_path", "device") or field.name in overlay:
|
||||
continue
|
||||
if field.name in supplied:
|
||||
before = supplied[field.name]
|
||||
elif field.default is not dataclasses.MISSING:
|
||||
before = field.default
|
||||
elif field.default_factory is not dataclasses.MISSING:
|
||||
before = field.default_factory()
|
||||
else:
|
||||
continue
|
||||
after = getattr(server_args, field.name, None)
|
||||
if after != before:
|
||||
unexplained.append(
|
||||
f"{shape} -> {field.name}: {before!r} -> {after!r}"
|
||||
)
|
||||
self.assertEqual(
|
||||
unexplained,
|
||||
[],
|
||||
"resolution moved these fields without declaring them, so the "
|
||||
"projection would answer with the unresolved value:\n "
|
||||
+ "\n ".join(unexplained),
|
||||
)
|
||||
|
||||
def test_the_stash_agrees_with_the_fields_it_declared(self):
|
||||
mismatches = []
|
||||
for shape in _SHAPES:
|
||||
server_args = self._resolve(shape)
|
||||
overlay = _stash_overlay(server_args)
|
||||
for field, declared in overlay.items():
|
||||
if field not in _RESOLVED_FIELDS:
|
||||
continue
|
||||
actual = getattr(server_args, field)
|
||||
if actual != declared:
|
||||
mismatches.append(
|
||||
f"{shape} -> {field}: field={actual!r} stash={declared!r}"
|
||||
)
|
||||
self.assertEqual(
|
||||
mismatches,
|
||||
[],
|
||||
"a declared field and its stash entry disagree, so something "
|
||||
"assigned the field behind the declaration:\n " + "\n ".join(mismatches),
|
||||
)
|
||||
|
||||
def test_no_immediate_writer_overrides_a_deferred_one(self):
|
||||
"""A handler must not declare over a value a pass already decided.
|
||||
|
||||
Routing a bare write into the stash changed which writer wins: the
|
||||
appended entry is replayed last, so a handler now beats a pass or a
|
||||
registry entry that ran earlier -- where before, the pass's declaration
|
||||
was applied on top of the handler's bare write. One handler was found
|
||||
that way (it gated on the raw field while its neighbours read the
|
||||
resolving view, so `--enable-waterfill --moe-a2a-backend ascend_tp`
|
||||
silently stopped forcing `deepep`).
|
||||
|
||||
This is the invariant rather than that instance: walk the stash in
|
||||
order and fail when an immediate writer declares a field whose previous
|
||||
entry came from a deferred writer with a *different* value. The
|
||||
deferred sources are derived from the live registries and the constant
|
||||
override table, so a new pass is covered without being listed.
|
||||
"""
|
||||
from sglang.srt.arg_groups import overrides
|
||||
|
||||
deferred = {
|
||||
getattr(fn, "__qualname__", getattr(fn, "__name__", ""))
|
||||
for fn in overrides.POST_PROCESS_PASSES
|
||||
}
|
||||
deferred |= {
|
||||
getattr(fn, "__qualname__", getattr(fn, "__name__", ""))
|
||||
for fns in overrides._MODEL_OVERRIDE_FNS.values()
|
||||
for fn in fns
|
||||
}
|
||||
deferred |= {
|
||||
getattr(fn, "__qualname__", getattr(fn, "__name__", ""))
|
||||
for _predicate, fn in overrides._PREDICATE_OVERRIDE_FNS
|
||||
}
|
||||
# The constant arch -> {field: value} table is a deferred writer too --
|
||||
# it has no callable, and its stash source is spelled by the collector
|
||||
# (`MODEL_OVERRIDES[<arch>]`).
|
||||
deferred |= {f"MODEL_OVERRIDES[{arch!r}]" for arch in overrides.MODEL_OVERRIDES}
|
||||
self.assertGreater(
|
||||
len(deferred), 40, "the deferred-writer set collapsed; nothing to compare"
|
||||
)
|
||||
|
||||
inversions = []
|
||||
for shape in _SHAPES:
|
||||
server_args = self._resolve(shape)
|
||||
decided_by = {}
|
||||
for source, declared in getattr(server_args, "_resolved_overrides", []):
|
||||
for field, value in declared.items():
|
||||
previous = decided_by.get(field)
|
||||
if (
|
||||
previous is not None
|
||||
and previous[0] in deferred
|
||||
and source not in deferred
|
||||
and previous[1] != value
|
||||
):
|
||||
inversions.append(
|
||||
f"{shape} -> {field}: {previous[0]} decided "
|
||||
f"{previous[1]!r}, then {source} declared {value!r}"
|
||||
)
|
||||
decided_by[field] = (source, value)
|
||||
self.assertEqual(
|
||||
inversions,
|
||||
[],
|
||||
"a handler declared over a value a pass or a registry entry had "
|
||||
"already decided; if the handler is meant to win, say so, and if "
|
||||
"it is gating on the field, it has to read the resolving view:\n "
|
||||
+ "\n ".join(inversions),
|
||||
)
|
||||
|
||||
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()
|
||||
for shape in _SHAPES:
|
||||
declared |= set(_stash_overlay(self._resolve(shape))) & _RESOLVED_FIELDS
|
||||
missing = sorted(_REACHED_BY_SHAPES - declared)
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
"the shapes no longer reach these converted fields, so the "
|
||||
"agreement check silently stopped covering them:\n "
|
||||
+ "\n ".join(missing),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -253,15 +253,19 @@ class TestPublishInstallsSlot(_IsolatedPublish):
|
||||
"""Publish wiring: set_server_args installs the already-resolved object
|
||||
into the context-owned slot (no transformation at publish time)."""
|
||||
|
||||
def test_dummy_fixture_has_empty_stash_and_publishes_cleanly(self):
|
||||
def test_dummy_fixture_publishes_the_object_it_resolved(self):
|
||||
from sglang.srt.server_args import (
|
||||
ServerArgs,
|
||||
set_global_server_args_for_scheduler,
|
||||
)
|
||||
|
||||
sa = ServerArgs(model_path="dummy") # __post_init__ early-returns
|
||||
# The stash is created before the dummy short-circuit and stays empty.
|
||||
self.assertEqual(sa._resolved_overrides, [])
|
||||
# A dummy path short-circuits the pipeline, but the handlers ahead of
|
||||
# that point still declare; whatever they left in the stash is on the
|
||||
# object by the time publish sees it.
|
||||
for source, declared in sa._resolved_overrides:
|
||||
for field, value in declared.items():
|
||||
self.assertEqual(getattr(sa, field), value, f"{source}: {field}")
|
||||
set_global_server_args_for_scheduler(sa)
|
||||
self.assertIs(get_server_args(), sa)
|
||||
|
||||
|
||||
@@ -663,14 +663,22 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
`speculative_draft_attention_backend`, and no entry ran it) showed
|
||||
that a family nobody listed leaves its readers unpinned. The hook
|
||||
modules under `arg_groups/` are the resolution pipeline's extension
|
||||
points, and their assignment surface (`server_args.field = ...`) is
|
||||
the may-write set, family-blind by construction. Collected like the
|
||||
points -- along with the NPU default helper, which the pipeline calls
|
||||
the same way -- and their write surface is the may-write set,
|
||||
family-blind by
|
||||
construction. A hook writes two ways: `server_args.field = ...`, and
|
||||
`declare_resolution(server_args, source, field=...)`, which records
|
||||
the write in the declaration stash on its way to the field. Counting
|
||||
only the assignment would read a hook's conversion to a declaration as
|
||||
the field having stopped being written. Collected like the
|
||||
late-resolution keywords: statically, failing loudly on an
|
||||
unparsable module. Underscore-prefixed targets are pipeline
|
||||
bookkeeping, not config leaves.
|
||||
"""
|
||||
targets = set()
|
||||
for path in sorted((_PACKAGE_ROOT / "arg_groups").glob("*.py")):
|
||||
modules = sorted((_PACKAGE_ROOT / "arg_groups").glob("*.py"))
|
||||
modules.append(_PACKAGE_ROOT / "hardware_backend/npu/utils.py")
|
||||
for path in modules:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
except SyntaxError:
|
||||
@@ -680,6 +688,17 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
tgts = node.targets
|
||||
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = [node.target]
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "declare_resolution"
|
||||
):
|
||||
targets |= {
|
||||
kw.arg
|
||||
for kw in node.keywords
|
||||
if kw.arg and not kw.arg.startswith("_")
|
||||
}
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
for tgt in tgts:
|
||||
@@ -702,6 +721,12 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
A write site that can never fire is a dead branch to delete upstream,
|
||||
not a census exemption. Only names that are declared dataclass fields
|
||||
count; underscore bookkeeping does not.
|
||||
|
||||
Two spellings write: an assignment, and ``self._declare(source,
|
||||
field=value)``, which records the write in the declaration stash on
|
||||
its way to the field. Counting only assignments would read a handler's
|
||||
conversion to a declaration as the field having stopped being written,
|
||||
which would quietly retire every pinned pair that reads it.
|
||||
"""
|
||||
tree = ast.parse(
|
||||
(_PACKAGE_ROOT / "server_args.py").read_text(encoding="utf-8-sig")
|
||||
@@ -722,6 +747,17 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
tgts = node.targets
|
||||
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = [node.target]
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "_declare"
|
||||
):
|
||||
targets |= {
|
||||
kw.arg
|
||||
for kw in node.keywords
|
||||
if kw.arg in declared and not kw.arg.startswith("_")
|
||||
}
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
for tgt in tgts:
|
||||
@@ -733,27 +769,24 @@ class TestSuppliedInstanceExposure(CustomTestCase):
|
||||
and not tgt.attr.startswith("_")
|
||||
):
|
||||
targets.add(tgt.attr)
|
||||
# The deprecated-alias normalization loop writes through a *name
|
||||
# tuple* (`for attr in (...): setattr(self, attr, "dsv4")`), which no
|
||||
# assignment scan sees; its field set is pinned here with a drift
|
||||
# guard on the tuple itself.
|
||||
# The deprecated-alias normalization declares through `**renamed`, so
|
||||
# the keyword scan sees no names; its field set is pinned here.
|
||||
alias_fields = {
|
||||
"attention_backend",
|
||||
"decode_attention_backend",
|
||||
"prefill_attention_backend",
|
||||
"speculative_draft_attention_backend",
|
||||
}
|
||||
deprecated = next(
|
||||
node
|
||||
for node in ast.walk(sa_class)
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_handle_deprecated_args"
|
||||
)
|
||||
found_tuples = [
|
||||
{elt.value for elt in node.iter.elts if isinstance(elt, ast.Constant)}
|
||||
for node in ast.walk(sa_class)
|
||||
if isinstance(node, ast.For)
|
||||
and isinstance(node.iter, ast.Tuple)
|
||||
and any(
|
||||
isinstance(inner, ast.Call)
|
||||
and isinstance(inner.func, ast.Name)
|
||||
and inner.func.id == "setattr"
|
||||
for inner in ast.walk(node)
|
||||
)
|
||||
for node in ast.walk(deprecated)
|
||||
if isinstance(node, ast.For) and isinstance(node.iter, ast.Tuple)
|
||||
]
|
||||
self.assertIn(
|
||||
alias_fields,
|
||||
|
||||
Reference in New Issue
Block a user