[Test] Consolidate test cleanup and CI taxonomy (net -11.4K lines) (#37436)
Co-authored-by: Mick Qian <mickqian@users.noreply.github.com>
This commit is contained in:
co-authored by
Mick Qian
parent
6a1ff90f2d
commit
4d23a4fa6d
@@ -1,765 +0,0 @@
|
||||
"""`ModelConfig` is built from values resolution has already decided.
|
||||
|
||||
Resolution builds a `ModelConfig` partway through and keys later decisions off
|
||||
it, so the pipeline reads its own output through that object. The loop is only
|
||||
benign while every field `ModelConfig.from_server_args` reads has been resolved
|
||||
by the time it is built -- otherwise the model configuration describes a
|
||||
half-resolved input, and every handler downstream of it inherits that.
|
||||
|
||||
Nothing enforces the ordering today; it holds because the path and quantization
|
||||
handlers happen to run early. So this derives both sides from the source -- the
|
||||
fields the constructor reads, and the step each is declared at -- and pins the
|
||||
one field that is deliberately read before resolution touches it.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import functools
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import sglang
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=17, suite="base-a-test-cpu")
|
||||
|
||||
_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt"
|
||||
|
||||
# Read for what the caller asked for: the constructor passes it through and
|
||||
# never stores it, while resolution declares the value the architecture implies.
|
||||
# Two quantities sharing one name.
|
||||
_READ_BEFORE_RESOLUTION = frozenset({"is_embedding"})
|
||||
|
||||
# Declared after the first `model_config_of()`, so the cached configuration
|
||||
# holds the earlier value. Nothing reads the stale copy today (its one consumer
|
||||
# is on the `is_draft_model` branch, built after resolution), and fixing it
|
||||
# means moving the build or the hook. Pinned so a second field in this position
|
||||
# has to be looked at.
|
||||
_STALE_IN_THE_MODEL_CONFIG = frozenset({"speculative_algorithm"})
|
||||
|
||||
# Behind the expert-pack build. `expert_pack_hook.handle_expert_pack` builds a
|
||||
# model configuration, and it always did -- the walk stopped at the record's
|
||||
# file and never saw it, so these three read as decided before the first build.
|
||||
# The call sits behind `load_format != "expert_pack": return`, so it is the
|
||||
# first build only on an expert-pack launch. Pre-existing; named rather than
|
||||
# fixed, because fixing it means moving the build or the hook.
|
||||
_STALE_BEHIND_THE_EXPERT_PACK_BUILD = frozenset(
|
||||
{
|
||||
"_speculative_draft_quantization_explicitly_set",
|
||||
"model_path",
|
||||
"speculative_draft_model_quantization",
|
||||
}
|
||||
)
|
||||
|
||||
# The same staleness through the registries: `_handle_model_specific_adjustments`
|
||||
# builds the model configuration and *then* collects the override declarations,
|
||||
# both inside one handler body. Named rather than fixed (that means moving the
|
||||
# build or the collection), so a fifth field here has to be looked at -- and so
|
||||
# does fixing the ordering.
|
||||
_STALE_FROM_THE_REGISTRIES = frozenset(
|
||||
{
|
||||
"disable_hybrid_swa_memory",
|
||||
"dtype",
|
||||
"enable_multi_layer_eagle",
|
||||
"quantization",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _parsed(path):
|
||||
return ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _declared_resolution_fields(path):
|
||||
fields = set()
|
||||
for node in ast.walk(_parsed(path)):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "declare_resolution"
|
||||
):
|
||||
fields |= {kw.arg for kw in node.keywords if kw.arg}
|
||||
return frozenset(fields)
|
||||
|
||||
|
||||
def _registry_declared_fields():
|
||||
"""What the live registries and passes declare.
|
||||
|
||||
Imported from the chain ratchet by path instead of re-derived: two
|
||||
derivations of the same set drift, and the one that drifts narrower makes
|
||||
this check quietly vacuous. Keying on `self._declare(...)` alone is what
|
||||
hid these four -- 26 of the providers register through a helper call, and
|
||||
none of them spell a keyword this file can see.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
ratchet = (
|
||||
pathlib.Path(__file__).resolve().parent.parent / "test_chain_read_ratchet.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("_chain_ratchet_for_pin", ratchet)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module._declared_by_registry_and_passes()
|
||||
|
||||
|
||||
def _registry_collection_is_after_the_build():
|
||||
"""(collection line, first build line) inside the model-specific handler.
|
||||
|
||||
Handler-local ordering only -- the caller still has to compare against the
|
||||
pipeline-wide first build, which sits in an *earlier* step: hoisting the
|
||||
collection above this handler's own `model_config_of()` call does not move
|
||||
it above the configuration another handler already cached.
|
||||
"""
|
||||
handler = None
|
||||
for source, wanted in (
|
||||
(_SRT / "server_args.py", "_handle_model_specific_adjustments"),
|
||||
*(
|
||||
(path, "handle_model_specific_adjustments")
|
||||
for path in sorted((_SRT / "arg_groups").glob("*.py"))
|
||||
),
|
||||
):
|
||||
for node in ast.walk(_parsed(source)):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == wanted:
|
||||
if any(
|
||||
isinstance(child, ast.Call)
|
||||
and getattr(child.func, "attr", getattr(child.func, "id", None))
|
||||
== "collect_model_override_declarations"
|
||||
for child in ast.walk(node)
|
||||
):
|
||||
handler = node
|
||||
break
|
||||
if handler is not None:
|
||||
break
|
||||
assert handler is not None, "the model-specific handler was not found"
|
||||
build = collect = None
|
||||
for node in ast.walk(handler):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
# Both spellings: an Attribute call and a bare Name call.
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
name = func.attr
|
||||
elif isinstance(func, ast.Name):
|
||||
name = func.id
|
||||
else:
|
||||
continue
|
||||
if name == "model_config_of" and build is None:
|
||||
build = node.lineno
|
||||
if name == "collect_model_override_declarations" and collect is None:
|
||||
collect = node.lineno
|
||||
return collect, build
|
||||
|
||||
|
||||
def _server_args_names(tree, path):
|
||||
"""Every local that names the record, including the read views over it.
|
||||
|
||||
A resolution-time reader reads through `resolving_view(server_args)` (the
|
||||
declaration stash over the fields): declaration-only resolvers write no
|
||||
field, so a field read there answers with the raw input. `cfg.dtype` after `cfg = resolving_view(sa)` is
|
||||
the same read this scan is looking for, so the local it binds counts.
|
||||
"""
|
||||
names = {"self"} if path.name == "server_args.py" else {"server_args"}
|
||||
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)
|
||||
# `cfg = resolving_view(server_args)` / `resolved_view(server_args)`
|
||||
for _ in range(2): # a view over a view-holding local is still one
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
value = node.value
|
||||
bare = (
|
||||
isinstance(value, ast.Call)
|
||||
and isinstance(value.func, ast.Name)
|
||||
and value.func.id in ("resolving_view", "resolved_view")
|
||||
and value.args
|
||||
and isinstance(value.args[0], ast.Name)
|
||||
and value.args[0].id in names
|
||||
)
|
||||
# `resolved = self._resolved()` is the same view, spelled as the
|
||||
# resolution vocabulary.
|
||||
member = (
|
||||
isinstance(value, ast.Call)
|
||||
and isinstance(value.func, ast.Attribute)
|
||||
and isinstance(value.func, ast.Name)
|
||||
and value.func.id == "resolved_view"
|
||||
and isinstance(value.func.value, ast.Name)
|
||||
and value.func.value.id in names
|
||||
)
|
||||
if not (bare or member):
|
||||
continue
|
||||
names |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
return names
|
||||
|
||||
|
||||
def _constructor_reads():
|
||||
"""Fields `ModelConfig.from_server_args` takes off the record."""
|
||||
path = _SRT / "configs/model_config.py"
|
||||
tree = _parsed(path)
|
||||
constructor = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "from_server_args"
|
||||
)
|
||||
names = _server_args_names(tree, path)
|
||||
reads = {
|
||||
node.attr
|
||||
for node in ast.walk(constructor)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id in names
|
||||
and isinstance(node.ctx, ast.Load)
|
||||
}
|
||||
# `getattr(server_args, "field", default)` is the normal spelling for an
|
||||
# optional input and is a `Call`, not an `Attribute`.
|
||||
for node in ast.walk(constructor):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "getattr"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[0], ast.Name)
|
||||
and node.args[0].id in names
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and isinstance(node.args[1].value, str)
|
||||
):
|
||||
reads.add(node.args[1].value)
|
||||
return reads
|
||||
|
||||
|
||||
def _late_resolution_fields():
|
||||
"""Fields written through `_late_resolution` / `declare_late_resolution`.
|
||||
|
||||
All of them land after the model configuration is built: the launcher's
|
||||
validation stage runs long after `__post_init__`.
|
||||
"""
|
||||
fields = set()
|
||||
for name in (
|
||||
"server_args.py",
|
||||
"arg_groups/overrides.py",
|
||||
"parser/template_detection.py",
|
||||
):
|
||||
path = _SRT / name
|
||||
# A named file that moved away has to be loud; skipping it silently
|
||||
# leaves the scan believing it read a module it never opened.
|
||||
assert path.exists(), f"{name} is not where this scan looks for it"
|
||||
tree = _parsed(path)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
called = (
|
||||
node.func.attr
|
||||
if isinstance(node.func, ast.Attribute)
|
||||
else getattr(node.func, "id", "")
|
||||
)
|
||||
if called == "declare_late_resolution":
|
||||
fields |= {kw.arg for kw in node.keywords if kw.arg}
|
||||
return fields
|
||||
|
||||
|
||||
def _hook_declarations(dispatch, source_module):
|
||||
"""{field: dispatcher line} for hooks the dispatch calls on other objects.
|
||||
|
||||
`handle_speculative_decoding(self)` is not a `self.<handler>()` call, so a
|
||||
scan of the dispatcher's own method calls never reaches its
|
||||
`declare_resolution` sites -- and the speculative hooks decide
|
||||
`speculative_algorithm`, which the model configuration reads.
|
||||
|
||||
The platform hook is *not* covered here: it reaches the pipeline as a
|
||||
callback argument, so there is no call node to follow and its writes live
|
||||
outside this tree. Its position is pinned instead --
|
||||
`test_every_opaque_callback_is_still_late`.
|
||||
"""
|
||||
imported = {}
|
||||
for node in ast.walk(_parsed(source_module)):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
for alias in node.names:
|
||||
imported[alias.asname or alias.name] = node.module
|
||||
|
||||
out = {}
|
||||
for node in ast.walk(dispatch):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = (
|
||||
node.func.id
|
||||
if isinstance(node.func, ast.Name)
|
||||
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
|
||||
)
|
||||
module = imported.get(name)
|
||||
if not module or not module.startswith("sglang.srt."):
|
||||
continue
|
||||
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
|
||||
if not path.exists():
|
||||
continue
|
||||
for field in _declared_resolution_fields(path):
|
||||
out[field] = max(out.get(field, 0), node.lineno)
|
||||
return out
|
||||
|
||||
|
||||
# The dispatcher's own file: its imports are what map a bare-name call in it
|
||||
# to the family that defines the callable.
|
||||
_DISPATCH_MODULE = _SRT / "arg_groups" / "pipeline.py"
|
||||
|
||||
|
||||
def _hook_functions():
|
||||
"""Module-level resolution functions under `arg_groups/`.
|
||||
|
||||
A handler that moved out of the record leaves a slot behind that imports
|
||||
one of these and calls it. Without following that hop the scan stops at
|
||||
the slot and silently loses everything the handler does.
|
||||
"""
|
||||
functions = {}
|
||||
for path in sorted((_SRT / "arg_groups").glob("*.py")):
|
||||
for node in _parsed(path).body:
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
functions.setdefault(node.name, node)
|
||||
return functions
|
||||
|
||||
|
||||
def _pipeline():
|
||||
"""(ordered steps, {step: methods it reaches}) for the resolution dispatch."""
|
||||
tree = _parsed(_SRT / "server_args.py")
|
||||
record = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
|
||||
)
|
||||
methods = {
|
||||
node.name: node for node in record.body if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
# The dispatcher calls its hooks by bare name, so the walk resolves those
|
||||
# against `arg_groups/` alongside the record's own methods.
|
||||
hooks = _hook_functions()
|
||||
methods.update({name: node for name, node in hooks.items() if name not in methods})
|
||||
dispatch = methods["run_resolution_pipeline"]
|
||||
# A step is either a record method (`self._x()`) or a bare-name hook call.
|
||||
steps = [
|
||||
name
|
||||
for _line, name in sorted(
|
||||
(
|
||||
node.lineno,
|
||||
(
|
||||
node.func.attr
|
||||
if isinstance(node.func, ast.Attribute)
|
||||
else node.func.id
|
||||
),
|
||||
)
|
||||
for node in ast.walk(dispatch)
|
||||
if isinstance(node, ast.Call)
|
||||
and (
|
||||
(
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "self"
|
||||
)
|
||||
or (isinstance(node.func, ast.Name) and node.func.id in hooks)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
def reaches(name, seen=None):
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen or name not in methods:
|
||||
return seen
|
||||
seen.add(name)
|
||||
for node in ast.walk(methods[name]):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "self"
|
||||
and node.func.attr in methods
|
||||
):
|
||||
reaches(node.func.attr, seen)
|
||||
elif isinstance(node.func, ast.Name) and node.func.id in hooks:
|
||||
reaches(node.func.id, seen)
|
||||
return seen
|
||||
|
||||
step_lines = {}
|
||||
for node in ast.walk(dispatch):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "self"
|
||||
):
|
||||
step_lines.setdefault(node.func.attr, node.lineno)
|
||||
elif isinstance(node.func, ast.Name) and node.func.id in hooks:
|
||||
step_lines.setdefault(node.func.id, node.lineno)
|
||||
return steps, methods, {name: reaches(name) for name in steps}, step_lines
|
||||
|
||||
|
||||
def _opaque_callback_positions(dispatch, source_module):
|
||||
"""{callback spelling: dispatcher line} for every resolver handed in.
|
||||
|
||||
`declare_direct_writes(record, source, callback)` runs a callable instead of
|
||||
code in this tree -- a platform plugin, a registered speculative algorithm.
|
||||
Which fields such a callback writes is not a static question; only *when* it
|
||||
runs is, so the position is what gets pinned.
|
||||
|
||||
Two spellings reach the pipeline: the dispatcher wraps a callback itself, or
|
||||
it calls a hook in this tree that wraps one. The line recorded is always the
|
||||
dispatcher's, because that is where the ordering against the build is
|
||||
decided -- a hook body sits further down its own file and says nothing about
|
||||
it.
|
||||
"""
|
||||
imported = {}
|
||||
for node in ast.walk(_parsed(source_module)):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
for alias in node.names:
|
||||
imported[alias.asname or alias.name] = node.module
|
||||
|
||||
def callbacks_in(tree):
|
||||
found = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = (
|
||||
node.func.id
|
||||
if isinstance(node.func, ast.Name)
|
||||
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
|
||||
)
|
||||
if name == "declare_direct_writes" and len(node.args) > 2:
|
||||
found.append(ast.unparse(node.args[2]))
|
||||
return found
|
||||
|
||||
positions = {}
|
||||
for spelling in callbacks_in(dispatch):
|
||||
positions[spelling] = min(
|
||||
positions.get(spelling, 10**9),
|
||||
next(
|
||||
node.lineno
|
||||
for node in ast.walk(dispatch)
|
||||
if isinstance(node, ast.Call)
|
||||
and getattr(node.func, "id", None) == "declare_direct_writes"
|
||||
),
|
||||
)
|
||||
for node in ast.walk(dispatch):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = (
|
||||
node.func.id
|
||||
if isinstance(node.func, ast.Name)
|
||||
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
|
||||
)
|
||||
module = imported.get(name)
|
||||
if not module or not module.startswith("sglang.srt."):
|
||||
continue
|
||||
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
|
||||
if not path.exists():
|
||||
continue
|
||||
for spelling in callbacks_in(_parsed(path)):
|
||||
positions[spelling] = min(positions.get(spelling, 10**9), node.lineno)
|
||||
return positions
|
||||
|
||||
|
||||
def _declaration_positions():
|
||||
"""({field: position}, first_build) over the fields the constructor reads.
|
||||
|
||||
A position is `(step index, rank)`, and `rank` is 0 only for a declaration
|
||||
that sits *above* the build in the very method that builds: a declaration
|
||||
applies where it is written, so one statement earlier in the same body is
|
||||
genuinely earlier. Everything else in the build's step gets rank 1 and
|
||||
counts as late -- line numbers say nothing across two method bodies, since
|
||||
a handler sits further down the file than the dispatcher that calls it.
|
||||
|
||||
One derivation, two callers: the check below asks which fields land after
|
||||
the build, and the pin check asks whether an exempted field is still one
|
||||
of them. Two derivations of that answer drift apart.
|
||||
"""
|
||||
steps, methods, reached, step_lines = _pipeline()
|
||||
wanted = _constructor_reads()
|
||||
|
||||
def build_site():
|
||||
"""(step index, method name, line) of the first `model_config_of()`."""
|
||||
for index, step in enumerate(steps):
|
||||
for method in reached[step]:
|
||||
for node in ast.walk(methods[method]):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "model_config_of"
|
||||
):
|
||||
return index, step, method, node.lineno
|
||||
return None
|
||||
|
||||
site = build_site()
|
||||
if site is None:
|
||||
return {}, None
|
||||
build_index, build_step, build_method, build_line_in_body = site
|
||||
first_build = (build_index, build_step)
|
||||
|
||||
source_module = _DISPATCH_MODULE
|
||||
imported = {}
|
||||
for node in ast.walk(_parsed(source_module)):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
for alias in node.names:
|
||||
imported[alias.asname or alias.name] = node.module
|
||||
|
||||
def _hook_declared_fields(name):
|
||||
"""Fields a hook imported from `sglang.srt` declares, by callable name."""
|
||||
module = imported.get(name)
|
||||
if not module or not module.startswith("sglang.srt."):
|
||||
return frozenset()
|
||||
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
|
||||
if not path.exists():
|
||||
return frozenset()
|
||||
return _declared_resolution_fields(path)
|
||||
|
||||
declared_at = {}
|
||||
for index, step in enumerate(steps):
|
||||
for method in reached[step]:
|
||||
for node in ast.walk(methods[method]):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
same_body = index == build_index and method == build_method
|
||||
rank = 0 if same_body and node.lineno < build_line_in_body else 1
|
||||
if (
|
||||
isinstance(node.func, ast.Name)
|
||||
and node.func.id == "declare_resolution"
|
||||
):
|
||||
fields = {kw.arg for kw in node.keywords if kw.arg}
|
||||
# A handler that calls an imported hook (the Kimi and DeepSeek
|
||||
# defaults live in arg_groups modules) declares through it, and
|
||||
# the hook can sit below the build inside the same handler.
|
||||
elif isinstance(node.func, ast.Name):
|
||||
fields = _hook_declared_fields(node.func.id)
|
||||
else:
|
||||
continue
|
||||
for field in fields:
|
||||
if field in wanted:
|
||||
# The *last* declaration is the one that has to precede
|
||||
# the build.
|
||||
declared_at[field] = max(
|
||||
declared_at.get(field, (index, rank)), (index, rank)
|
||||
)
|
||||
|
||||
# Hooks the dispatch calls on other objects declare too, and a hook below
|
||||
# the first build is late by definition. Both positions are read *inside
|
||||
# the dispatcher*: a handler body sits further down the file than the
|
||||
# dispatcher that calls it, so a line number taken from one scope says
|
||||
# nothing about ordering against the other.
|
||||
dispatch = methods["run_resolution_pipeline"]
|
||||
build_line = step_lines[first_build[1]]
|
||||
for field, line in _hook_declarations(dispatch, _DISPATCH_MODULE).items():
|
||||
if field in wanted and line > build_line:
|
||||
declared_at[field] = max(
|
||||
declared_at.get(field, (build_index, 1)), (10**6, 1)
|
||||
)
|
||||
|
||||
# Late resolution is the other channel that can decide a field the
|
||||
# constructor reads, and it runs after every build.
|
||||
for field in _late_resolution_fields():
|
||||
if field in wanted:
|
||||
declared_at[field] = (10**6, 1)
|
||||
return declared_at, first_build
|
||||
|
||||
|
||||
class TestModelConfigReadsResolvedInput(CustomTestCase):
|
||||
def test_every_field_it_reads_is_resolved_before_it_is_built(self):
|
||||
declared_at, first_build = _declaration_positions()
|
||||
self.assertIsNotNone(
|
||||
first_build, "no handler builds a ModelConfig; the scan broke"
|
||||
)
|
||||
|
||||
known = (
|
||||
_READ_BEFORE_RESOLUTION
|
||||
| _STALE_IN_THE_MODEL_CONFIG
|
||||
| _STALE_FROM_THE_REGISTRIES
|
||||
| _STALE_BEHIND_THE_EXPERT_PACK_BUILD
|
||||
)
|
||||
late = sorted(
|
||||
field
|
||||
for field, position in declared_at.items()
|
||||
if position >= (first_build[0], 1) and field not in known
|
||||
)
|
||||
self.assertEqual(
|
||||
late,
|
||||
[],
|
||||
"resolution decides these after it builds the ModelConfig that reads "
|
||||
f"them, so the model configuration describes a half-resolved input "
|
||||
f"(first build: step {first_build[0]}, {first_build[1]}): {late}",
|
||||
)
|
||||
|
||||
def test_the_registry_stale_set_is_exactly_what_is_late(self):
|
||||
"""Equality, not membership.
|
||||
|
||||
A fifth field the registries decide after the build fails here, and so
|
||||
does fixing the ordering -- either way someone has to come back and
|
||||
read this. The earlier version of this file derived declarations only
|
||||
from `self._declare(...)` keywords, so it passed while these four were
|
||||
already stale.
|
||||
"""
|
||||
collect_line, build_line = _registry_collection_is_after_the_build()
|
||||
self.assertIsNotNone(
|
||||
collect_line, "the handler no longer collects registry declarations"
|
||||
)
|
||||
reads = _constructor_reads()
|
||||
registry = _registry_declared_fields()
|
||||
self.assertGreater(
|
||||
len(registry), 20, "the registry-declared set collapsed; nothing to compare"
|
||||
)
|
||||
# Late against the *pipeline-wide* first build, not only the build in
|
||||
# the collection's own handler: `_handle_gpu_memory_settings` builds
|
||||
# the configuration many steps earlier, so hoisting the collection
|
||||
# above the local build still leaves that cache describing raw input.
|
||||
steps, methods, reached, _step_lines = _pipeline()
|
||||
_declared_at, first_build = _declaration_positions()
|
||||
self.assertIsNotNone(
|
||||
first_build, "no handler builds a ModelConfig; the scan broke"
|
||||
)
|
||||
collecting_steps = [
|
||||
index
|
||||
for index, step in enumerate(steps)
|
||||
for method in reached[step]
|
||||
if any(
|
||||
isinstance(node, ast.Call)
|
||||
and (
|
||||
node.func.attr
|
||||
if isinstance(node.func, ast.Attribute)
|
||||
else getattr(node.func, "id", None)
|
||||
)
|
||||
== "collect_model_override_declarations"
|
||||
for node in ast.walk(methods[method])
|
||||
)
|
||||
]
|
||||
self.assertTrue(collecting_steps, "no pipeline step collects the registry")
|
||||
collection_is_late = min(collecting_steps) > first_build[0] or (
|
||||
build_line is not None and collect_line > build_line
|
||||
)
|
||||
late = frozenset(reads & registry) if collection_is_late else frozenset()
|
||||
self.assertEqual(
|
||||
sorted(late),
|
||||
sorted(_STALE_FROM_THE_REGISTRIES),
|
||||
"the set of ModelConfig-read fields the registries decide after the "
|
||||
f"build changed (collection at line {collect_line}, build at line "
|
||||
f"{build_line}); read the comment on _STALE_FROM_THE_REGISTRIES "
|
||||
"before editing it",
|
||||
)
|
||||
|
||||
def test_the_pinned_stale_field_is_still_stale(self):
|
||||
"""If the ordering gets fixed, this pin has to be retired, not kept.
|
||||
|
||||
A pin that outlives the defect it describes is worse than none: it
|
||||
documents a hazard that no longer exists and hides the day one appears.
|
||||
"""
|
||||
steps, methods, reached, step_lines = _pipeline()
|
||||
dispatch = methods["run_resolution_pipeline"]
|
||||
hooks = _hook_declarations(dispatch, _DISPATCH_MODULE)
|
||||
build_line = min(
|
||||
step_lines[step]
|
||||
for step in steps
|
||||
for method in reached[step]
|
||||
if any(
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "model_config_of"
|
||||
for node in ast.walk(methods[method])
|
||||
)
|
||||
)
|
||||
for field in _STALE_IN_THE_MODEL_CONFIG:
|
||||
self.assertIn(
|
||||
field,
|
||||
hooks,
|
||||
f"{field} is pinned as decided after the build, but no hook "
|
||||
"declares it any more; retire the pin",
|
||||
)
|
||||
self.assertGreater(
|
||||
hooks[field],
|
||||
build_line,
|
||||
f"{field} is now decided before the model configuration is "
|
||||
"built; retire the pin",
|
||||
)
|
||||
|
||||
def test_every_opaque_callback_is_still_late(self):
|
||||
"""The opaque resolvers all run after the model configuration is built.
|
||||
|
||||
A plugin that rewrites `dtype` or `model_path` in one of them is
|
||||
invisible to the configuration already cached, and no scan can say
|
||||
whether it does: the implementations are out of tree. So the positions
|
||||
are the pin, and the set of callbacks is pinned with them -- a new one
|
||||
has to be placed against the build by whoever adds it. Moving them all
|
||||
above the build fixes the hazard and fails this test; retire the pin
|
||||
then, rather than keeping a note about a hazard that is gone.
|
||||
"""
|
||||
steps, methods, reached, step_lines = _pipeline()
|
||||
dispatch = methods["run_resolution_pipeline"]
|
||||
positions = _opaque_callback_positions(dispatch, _DISPATCH_MODULE)
|
||||
self.assertEqual(
|
||||
sorted(positions),
|
||||
[
|
||||
"algo.handle_server_args",
|
||||
"algo.validate_server_args",
|
||||
"current_platform.apply_server_args_defaults",
|
||||
],
|
||||
"the set of resolvers handed to declare_direct_writes changed; each "
|
||||
"one needs its position against the ModelConfig build looked at",
|
||||
)
|
||||
_declared_at, first_build = _declaration_positions()
|
||||
self.assertIsNotNone(
|
||||
first_build, "no handler builds a ModelConfig; the scan broke"
|
||||
)
|
||||
build_line = step_lines[first_build[1]]
|
||||
for spelling, line in sorted(positions.items()):
|
||||
self.assertGreater(
|
||||
line,
|
||||
build_line,
|
||||
f"{spelling} now runs before the model configuration is built, "
|
||||
"so a plugin's writes reach it; retire the pin",
|
||||
)
|
||||
|
||||
def test_the_documented_exception_is_still_the_only_one(self):
|
||||
"""A field pinned as read-before-resolution has to still be all three.
|
||||
|
||||
Read by the constructor, written by resolution, and written *after* the
|
||||
build -- the last one is what makes the exemption load-bearing. Without
|
||||
it, moving the declaration earlier leaves the name sitting in the
|
||||
exempt set with nothing to exempt, and the next field that lands in
|
||||
this position gets waved through by a pin nobody re-read.
|
||||
"""
|
||||
wanted = _constructor_reads()
|
||||
declared_at, first_build = _declaration_positions()
|
||||
self.assertIsNotNone(
|
||||
first_build, "no handler builds a ModelConfig; the scan broke"
|
||||
)
|
||||
for field in sorted(_READ_BEFORE_RESOLUTION):
|
||||
self.assertIn(
|
||||
field,
|
||||
wanted,
|
||||
f"{field} is pinned as read before resolution, but the "
|
||||
"constructor no longer reads it; retire the pin",
|
||||
)
|
||||
self.assertIn(
|
||||
field,
|
||||
declared_at,
|
||||
f"{field} is pinned as read before resolution, but resolution "
|
||||
"no longer writes it; retire the pin",
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
declared_at[field],
|
||||
(first_build[0], 1),
|
||||
f"{field} is now decided before the model configuration is "
|
||||
"built, so the exemption covers nothing; retire the pin",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,86 +0,0 @@
|
||||
"""The record grows no attribute the projection cannot see.
|
||||
|
||||
A publicly-named attribute that is not a dataclass field is invisible to every
|
||||
other guard here: the namespace coverage walks fields, the projection walks
|
||||
fields, and the read ratchets watch field reads. Three of them accumulated that
|
||||
way -- a `ModelConfig` cache, an `moe_ep_size` that only a log line read, and an
|
||||
env-derived `grpc_worker_threads` that one entry point read across the boundary.
|
||||
|
||||
Leading-underscore names are the record's own bookkeeping and stay: the
|
||||
read-only guard classifies writability by that spelling, so a private name is
|
||||
already outside the config tier by construction.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import dataclasses
|
||||
import pathlib
|
||||
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")
|
||||
|
||||
|
||||
def _self_written_attributes() -> set:
|
||||
"""Names `ServerArgs` writes on itself, by either spelling."""
|
||||
source = (
|
||||
pathlib.Path(next(iter(sglang.__path__))) / "srt" / "server_args.py"
|
||||
).read_text(encoding="utf-8-sig")
|
||||
tree = ast.parse(source)
|
||||
cls = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
|
||||
)
|
||||
written = set()
|
||||
for node in ast.walk(cls):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "self"
|
||||
):
|
||||
written.add(target.attr)
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and getattr(node.func, "attr", None) == "__setattr__"
|
||||
and getattr(getattr(node.func, "value", None), "id", None) == "object"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
):
|
||||
written.add(node.args[1].value)
|
||||
return written
|
||||
|
||||
|
||||
class TestNoPublicNonFieldSlot(CustomTestCase):
|
||||
def test_every_public_attribute_is_a_field(self):
|
||||
written = _self_written_attributes()
|
||||
# Anchor on a name, not a count: the count falls every time a derived
|
||||
# read leaves the record, so a floor erodes with what it measures.
|
||||
self.assertIn(
|
||||
"_resolution_finished",
|
||||
written,
|
||||
f"the scan did not find the resolution flag the record sets on "
|
||||
f"itself, so it is the scan that is broken, not the record: "
|
||||
f"{sorted(written)}",
|
||||
)
|
||||
fields = {field.name for field in dataclasses.fields(ServerArgs)}
|
||||
stray = sorted(
|
||||
name for name in written if not name.startswith("_") and name not in fields
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
stray,
|
||||
"these are written on the record under a public name but are not "
|
||||
"fields, so the projection cannot see them and no other guard "
|
||||
"watches them: make each a field, or give it the leading underscore "
|
||||
f"that says it is the record's own bookkeeping: {stray}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Every `server_args.<name>()` in the tree names something the record has.
|
||||
|
||||
Removing a member from `ServerArgs` means rewriting its callers, and the ones
|
||||
inside `server_args.py` are the ones you fix by reflex. The cross-file caller is
|
||||
what bites: `ServerArgs.ssl_verify()` moved to `serving_hook.ssl_verify_of()` and
|
||||
one call site kept the old spelling as `self.server_args.ssl_verify()` -- a grep
|
||||
for `server_args.ssl_verify()` does not find that, and nothing else looks. Every
|
||||
`HttpServerEngineAdapter` request raised `AttributeError` before sending.
|
||||
|
||||
So this resolves the call sites instead of grepping for them: every attribute
|
||||
*called* on something statically known to be a record has to exist on the record.
|
||||
It is deliberately not limited to methods the refactor touched -- the next
|
||||
removal gets the same check for free.
|
||||
|
||||
`multimodal_gen` carries a different, same-named class outside this contract, as
|
||||
the other record ratchets also record.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=19, suite="base-a-test-cpu")
|
||||
|
||||
import ast
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import sglang
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
_ROOTS = (
|
||||
pathlib.Path(next(iter(sglang.__path__))) / "srt",
|
||||
pathlib.Path(__file__).resolve().parents[3], # test/
|
||||
)
|
||||
_EXCLUDED = ("multimodal_gen",)
|
||||
|
||||
# Attribute names that hold a `ServerArgs`. `resolving_view` and `resolved_view`
|
||||
# proxy the record but answer for names it does not carry, so they are not here.
|
||||
_RECORD_NAMES = ("server_args", "_server_args")
|
||||
|
||||
|
||||
def _is_record(node) -> bool:
|
||||
"""`server_args`, `self.server_args`, `self._server_args`, `cls.server_args`."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id in _RECORD_NAMES
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr in _RECORD_NAMES
|
||||
return False
|
||||
|
||||
|
||||
def _rebound_locally(tree) -> set:
|
||||
"""Names assigned something that is plainly not a record.
|
||||
|
||||
`server_args` is also a natural name for a dict of CLI flags or a list of
|
||||
argv strings in test helpers, and those legitimately answer `.update()` and
|
||||
`.items()`. A function that assigns one of those to the name is not talking
|
||||
about the record in that scope.
|
||||
"""
|
||||
literal = (ast.Dict, ast.List, ast.DictComp, ast.ListComp)
|
||||
builders = {"dict", "list", "tuple", "set"}
|
||||
|
||||
def _not_a_record(value) -> bool:
|
||||
if isinstance(value, literal):
|
||||
return True
|
||||
# `dict(...)` / `list(...)`, and an annotated `server_args: list[str] = [...]`
|
||||
return (
|
||||
isinstance(value, ast.Call) and getattr(value.func, "id", None) in builders
|
||||
)
|
||||
|
||||
rebound = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
targets, value = [node.target], node.value
|
||||
elif isinstance(node, ast.Assign):
|
||||
targets, value = node.targets, node.value
|
||||
else:
|
||||
continue
|
||||
if value is None or not _not_a_record(value):
|
||||
continue
|
||||
for target in targets:
|
||||
if isinstance(target, ast.Name) and target.id in _RECORD_NAMES:
|
||||
rebound.add(target.id)
|
||||
return rebound
|
||||
|
||||
|
||||
def _called_members():
|
||||
"""{name: [file:line]} for every `<record>.<name>(...)` in the tree."""
|
||||
found: dict[str, list[str]] = {}
|
||||
for root in _ROOTS:
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
text = path.as_posix()
|
||||
if any(part in text for part in _EXCLUDED):
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if "server_args" not in source:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
rebound = _rebound_locally(tree)
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and _is_record(node.func.value)
|
||||
and getattr(node.func.value, "id", None) not in rebound
|
||||
):
|
||||
found.setdefault(node.func.attr, []).append(
|
||||
f"{path.name}:{node.lineno}"
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
class TestRecordMemberCallsResolve(CustomTestCase):
|
||||
def test_every_called_member_exists_on_the_record(self):
|
||||
called = _called_members()
|
||||
self.assertGreater(
|
||||
len(called),
|
||||
5,
|
||||
f"only {len(called)} members called on a record; the scan is broken, "
|
||||
"not the tree",
|
||||
)
|
||||
available = set(dir(ServerArgs)) | {
|
||||
field.name for field in dataclasses.fields(ServerArgs)
|
||||
}
|
||||
missing = {
|
||||
name: sites
|
||||
for name, sites in sorted(called.items())
|
||||
if name not in available
|
||||
}
|
||||
self.assertEqual(
|
||||
{},
|
||||
missing,
|
||||
"these are called on a ServerArgs but the record has no such member -- "
|
||||
"each one raises AttributeError at the call. A member that moved out of "
|
||||
"the record has to be rewritten at every call site, including the ones "
|
||||
f"reached through `self.server_args`: {missing}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,15 +1,10 @@
|
||||
"""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 `resolution_result` answers for every declared
|
||||
field with what the stash holds. The second check is what the stash is measured
|
||||
against: the two can disagree only if something wrote 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.
|
||||
so a resolution write that bypasses the stash is invisible to it. These tests
|
||||
compare the raw input, resolved record, declaration result, and published bags
|
||||
across representative configurations. A field that moves without a declaration
|
||||
or is projected into the wrong namespace therefore fails on observed state.
|
||||
"""
|
||||
|
||||
import ast
|
||||
@@ -122,114 +117,6 @@ _REACHED_BY_SHAPES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _late_resolvers():
|
||||
"""Callables that reach `declare_late_resolution`, derived per module."""
|
||||
found = set()
|
||||
for relative in ("server_args.py", "parser/template_detection.py"):
|
||||
tree = ast.parse((_SRT / relative).read_text(encoding="utf-8-sig"))
|
||||
functions = {
|
||||
node.name: node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
def reaches(name, seen=None):
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen or name not in functions:
|
||||
return False
|
||||
seen.add(name)
|
||||
for node in ast.walk(functions[name]):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
called = (
|
||||
node.func.attr
|
||||
if isinstance(node.func, ast.Attribute)
|
||||
else getattr(node.func, "id", None)
|
||||
)
|
||||
if called == "declare_late_resolution":
|
||||
return True
|
||||
if called and reaches(called, seen):
|
||||
return True
|
||||
return False
|
||||
|
||||
found |= {name for name in functions if reaches(name)}
|
||||
return found
|
||||
|
||||
|
||||
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 shape_key(shape):
|
||||
"""A shape rendered short enough for a failure message."""
|
||||
return ",".join(f"{k}={v}" for k, v in sorted(shape.items())) or "defaults"
|
||||
@@ -299,15 +186,6 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
server_args.resolve_once()
|
||||
return server_args
|
||||
|
||||
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.
|
||||
|
||||
@@ -419,10 +297,7 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
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.
|
||||
a field is assigned to the *wrong* group.
|
||||
"""
|
||||
import sglang.srt.runtime_context as runtime_context
|
||||
from sglang.srt.arg_groups.arg_utils import namespace_of
|
||||
@@ -643,54 +518,6 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
"normalization is a declaration; the record keeps what was passed",
|
||||
)
|
||||
|
||||
def test_the_launcher_finishes_resolving_before_it_publishes(self):
|
||||
"""Every late resolver runs above the publish, in the source.
|
||||
|
||||
A published record refuses to be written, so a late resolver below the
|
||||
publish raises at startup rather than at test time -- and only for the
|
||||
configuration that reaches it, which is why the LoRA path can break
|
||||
while every other launch stays green. Both sides are derived: which
|
||||
callables reach `declare_late_resolution`, and where the launcher calls
|
||||
them.
|
||||
"""
|
||||
launcher = _SRT / "entrypoints/engine.py"
|
||||
late = {"check_server_args", "resolve_auto_parsers"} | _late_resolvers()
|
||||
tree = ast.parse(launcher.read_text(encoding="utf-8-sig"))
|
||||
function = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "_launch_subprocesses"
|
||||
)
|
||||
published_at = [
|
||||
node.lineno
|
||||
for node in ast.walk(function)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "publish"
|
||||
]
|
||||
self.assertEqual(len(published_at), 1, "the launcher publishes once")
|
||||
too_late = sorted(
|
||||
f"{name}() at line {node.lineno}"
|
||||
for node in ast.walk(function)
|
||||
if isinstance(node, ast.Call)
|
||||
for name in [
|
||||
(
|
||||
node.func.attr
|
||||
if isinstance(node.func, ast.Attribute)
|
||||
else getattr(node.func, "id", None)
|
||||
)
|
||||
]
|
||||
if name in late and node.lineno > published_at[0]
|
||||
)
|
||||
self.assertEqual(
|
||||
too_late,
|
||||
[],
|
||||
f"these resolve after the launcher publishes at line "
|
||||
f"{published_at[0]}, and a published record refuses to be "
|
||||
f"written:\n " + "\n ".join(too_late),
|
||||
)
|
||||
|
||||
def test_an_undeclared_field_still_holds_the_raw_input(self):
|
||||
"""Nothing writes a field behind the stash's back.
|
||||
|
||||
@@ -832,48 +659,6 @@ class TestResolutionDeclarations(CustomTestCase):
|
||||
"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 / "arg_groups" / "pipeline.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()
|
||||
|
||||
@@ -35,7 +35,6 @@ import unittest.mock
|
||||
|
||||
import torch
|
||||
|
||||
import sglang
|
||||
from sglang.srt.arg_groups.overrides import model_config_of, resolution_result
|
||||
from sglang.srt.environ import EnvField, envs
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
@@ -481,274 +480,6 @@ class TestResolutionIsReproducible(_RestoresProcessState, CustomTestCase):
|
||||
self.assertEqual(getattr(first, "_resolved_overrides", None), first_provenance)
|
||||
|
||||
|
||||
class TestProgramsResolveBeforeReadingResolution(CustomTestCase):
|
||||
"""A program that builds its own record resolves it before reading what
|
||||
resolution decides.
|
||||
|
||||
Construction is inert, so a program that builds a record and then reads a
|
||||
resolution-written field reads the CLI default. Two of these shipped past
|
||||
the earlier censuses because those are rooted at the `sglang` package: the
|
||||
model gateway's launcher sized its worker plan from a raw `dp_size`
|
||||
(`--dwdp-size 4` launched one server instead of four) and a speculative
|
||||
benchmark forwarded `--mem-fraction-static None` to the server it spawns.
|
||||
So the universe here is the *repository*, not the package.
|
||||
"""
|
||||
|
||||
# Entries that hand the record on instead of reading it. Reason required.
|
||||
_EXEMPT: dict = {}
|
||||
|
||||
def _repo_root(self):
|
||||
# <repo>/python/sglang/__init__.py -> <repo>
|
||||
root = pathlib.Path(next(iter(sglang.__path__))).resolve().parents[1]
|
||||
if root.name == "python":
|
||||
root = root.parent
|
||||
return root
|
||||
|
||||
def _written_fields(self):
|
||||
"""Fields resolution declares, read out of the pipeline's own source.
|
||||
|
||||
Deliberately local: the chain ratchet has a wider derivation (it also
|
||||
walks the model-override registries), but it arrives later in this
|
||||
series, and a check that imports it would fail at this PR's boundary.
|
||||
Coarser is fine here -- what this needs is the fields the entries below
|
||||
actually read -- and the floor keeps it from drifting narrower.
|
||||
"""
|
||||
import ast
|
||||
import dataclasses as _dataclasses
|
||||
|
||||
from sglang.srt.server_args import ServerArgs as _ServerArgs
|
||||
|
||||
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
|
||||
declarers = {"declare_resolution", "declare_late_resolution"}
|
||||
fields = set()
|
||||
field_names = {field.name for field in _dataclasses.fields(_ServerArgs)}
|
||||
# The record plus every module under `arg_groups/`: a handler declares
|
||||
# from whichever of the two it lives in.
|
||||
sources = [srt / "server_args.py", *sorted((srt / "arg_groups").rglob("*.py"))]
|
||||
for source in sources:
|
||||
tree = ast.parse(source.read_text(encoding="utf-8-sig"))
|
||||
for node in ast.walk(tree):
|
||||
# Registry data: provider dict keys are field names as
|
||||
# *data*, invisible to the keyword scan below. Filtered
|
||||
# against the real field set.
|
||||
if isinstance(node, ast.Dict):
|
||||
fields |= {
|
||||
key.value
|
||||
for key in node.keys
|
||||
if isinstance(key, ast.Constant)
|
||||
and isinstance(key.value, str)
|
||||
and key.value in field_names
|
||||
}
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
called = (
|
||||
func.attr
|
||||
if isinstance(func, ast.Attribute)
|
||||
else getattr(func, "id", "")
|
||||
)
|
||||
if called in declarers or called == "update":
|
||||
fields |= {
|
||||
kw.arg
|
||||
for kw in node.keywords
|
||||
if kw.arg and (called != "update" or kw.arg in field_names)
|
||||
}
|
||||
return fields
|
||||
|
||||
def _candidates(self, root):
|
||||
"""Source files that build a record, with the names they bind it to."""
|
||||
import ast
|
||||
|
||||
skip = {".git", "build", "dist", "node_modules", ".venv", "target"}
|
||||
found = {}
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
parts = set(path.relative_to(root).parts)
|
||||
if parts & skip:
|
||||
continue
|
||||
rel = path.relative_to(root).as_posix()
|
||||
# Tests build raw records on purpose.
|
||||
if rel.startswith("test/") or "/test/" in rel or "/tests/" in rel:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
# Which local names are *the srt record*, by import source: the
|
||||
# diffusion runtime has a same-spelled `ServerArgs` with no
|
||||
# resolution, so the spelling alone is not enough.
|
||||
record_classes, record_helpers = set(), set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
for alias in node.names:
|
||||
bound = alias.asname or alias.name
|
||||
if node.module == "sglang" and alias.name == "ServerArgs":
|
||||
record_classes.add(bound)
|
||||
if node.module == "sglang.srt.server_args":
|
||||
if alias.name == "ServerArgs":
|
||||
record_classes.add(bound)
|
||||
if alias.name == "prepare_server_args":
|
||||
record_helpers.add(bound)
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = node.targets
|
||||
elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)):
|
||||
# `x: ServerArgs = ...` is an AnnAssign, not an Assign.
|
||||
targets = [node.target]
|
||||
else:
|
||||
continue
|
||||
call = getattr(node, "value", None)
|
||||
if not isinstance(call, ast.Call):
|
||||
continue
|
||||
func = call.func
|
||||
# `prepare_server_args(argv)` is the CLI launcher's way.
|
||||
builds = (
|
||||
isinstance(func, ast.Name)
|
||||
and func.id in (record_classes | record_helpers)
|
||||
) or (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "from_cli_args"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id in record_classes
|
||||
)
|
||||
if builds:
|
||||
names |= {t.id for t in targets if isinstance(t, ast.Name)}
|
||||
if names:
|
||||
found[rel] = (tree, names, path)
|
||||
return found
|
||||
|
||||
def test_every_program_that_builds_a_record_resolves_it(self):
|
||||
import ast
|
||||
|
||||
root = self._repo_root()
|
||||
candidates = self._candidates(root)
|
||||
self.assertGreater(
|
||||
len(candidates),
|
||||
10,
|
||||
f"only {len(candidates)} files build a record under {root}; either "
|
||||
"this is not a source checkout or the scan broke",
|
||||
)
|
||||
written = self._written_fields()
|
||||
self.assertGreater(len(written), 50, "the written-field set collapsed")
|
||||
# What the escaped entries actually read: a narrower derivation goes
|
||||
# quiet on exactly those.
|
||||
for field in ("dp_size", "mem_fraction_static"):
|
||||
self.assertIn(field, written)
|
||||
|
||||
offenders = []
|
||||
for rel, (tree, names, path) in sorted(candidates.items()):
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if "resolve_once(" in source or "publish(" in source:
|
||||
continue
|
||||
reads = sorted(
|
||||
{
|
||||
f"{node.attr}:{node.lineno}"
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id in names
|
||||
and node.attr in written
|
||||
}
|
||||
)
|
||||
if reads and rel not in self._EXEMPT:
|
||||
offenders.append(f"{rel} reads {', '.join(reads[:4])}")
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"a program builds its own record and reads what resolution decides "
|
||||
"without resolving it, so it reads the CLI default:\n "
|
||||
+ "\n ".join(offenders),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(set(self._EXEMPT) - set(candidates)),
|
||||
[],
|
||||
"an exemption names a file that no longer builds a record",
|
||||
)
|
||||
|
||||
|
||||
class TestForksResolveFirst(CustomTestCase):
|
||||
"""A process that forks a child to run the record resolves it first.
|
||||
|
||||
The pipeline probes the device (the default attention backend reads the CUDA
|
||||
capability), and a forked child cannot initialize CUDA once its parent has.
|
||||
Construction used to resolve, so the probe always happened in whoever built
|
||||
the record; now it happens at the gate, and the gate must not be reached for
|
||||
the first time inside a fork.
|
||||
"""
|
||||
|
||||
# Sites inside the launcher: `_launch_subprocesses` resolves at its top, so
|
||||
# every fork below it already has a resolved record.
|
||||
_AFTER_LAUNCHER_RESOLVE = {
|
||||
"srt/entrypoints/engine.py",
|
||||
"srt/managers/data_parallel_controller.py",
|
||||
"srt/disaggregation/encoder/grpc_server.py",
|
||||
"srt/disaggregation/encoder/runtime.py",
|
||||
"srt/elastic_ep/expert_backup_manager.py",
|
||||
}
|
||||
|
||||
def test_every_fork_of_a_record_has_a_resolved_one(self):
|
||||
import ast
|
||||
|
||||
package_root = pathlib.Path(next(iter(sglang.__path__))).resolve()
|
||||
offenders, examined = [], 0
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
rel = path.relative_to(package_root).as_posix()
|
||||
if rel.startswith("test/") or "/test/" in rel:
|
||||
continue
|
||||
# The diffusion runtime has its own record with no gate.
|
||||
if rel.startswith("multimodal_gen/"):
|
||||
continue
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
if "Process" not in source:
|
||||
continue
|
||||
tree = ast.parse(source)
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
forks = [
|
||||
call
|
||||
for call in ast.walk(node)
|
||||
if isinstance(call, ast.Call)
|
||||
and (
|
||||
(
|
||||
isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "Process"
|
||||
)
|
||||
or (
|
||||
isinstance(call.func, ast.Name)
|
||||
and call.func.id == "Process"
|
||||
)
|
||||
)
|
||||
and "server_args" in (ast.get_source_segment(source, call) or "")
|
||||
]
|
||||
if not forks:
|
||||
continue
|
||||
body = ast.get_source_segment(source, node) or ""
|
||||
examined += 1
|
||||
# `spawn` starts a fresh interpreter, so the child may probe.
|
||||
if 'get_context("spawn")' in body or "'spawn'" in body:
|
||||
continue
|
||||
if "resolve_once(" in body or "publish(" in body:
|
||||
continue
|
||||
if rel in self._AFTER_LAUNCHER_RESOLVE:
|
||||
continue
|
||||
offenders.append(f"{rel}:{forks[0].lineno} {node.name}")
|
||||
self.assertGreater(
|
||||
examined, 5, f"only {examined} fork sites found; the scan broke"
|
||||
)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"these fork a child that will resolve the record, without resolving "
|
||||
"it first -- the child cannot initialize CUDA if this process "
|
||||
f"already has:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
"""A resolved record copied with `dataclasses.replace` loses what makes it
|
||||
resolved, and the next publish resolves it a second time -- over values it
|
||||
@@ -876,215 +607,6 @@ class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
|
||||
"the parent's resolution decided",
|
||||
)
|
||||
|
||||
def test_no_bare_replace_of_a_record_outside_the_helper(self):
|
||||
"""`dataclasses.replace` on a record is the helper's job now.
|
||||
|
||||
Derived, not listed: any `dataclasses.replace` whose first argument is
|
||||
named for a record. The helper's own call is the positive control -- if
|
||||
the scan stops seeing it, the scan broke rather than the tree.
|
||||
"""
|
||||
import ast
|
||||
|
||||
# The repository, not the package: the gateway is outside `sglang/`.
|
||||
package_root = pathlib.Path(next(iter(sglang.__path__))).resolve().parents[1]
|
||||
if package_root.name == "python":
|
||||
package_root = package_root.parent
|
||||
helper = "python/sglang/srt/server_args.py"
|
||||
bare, inside_helper = [], 0
|
||||
|
||||
def replaces_a_record(node, record_names):
|
||||
if not (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "replace"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "dataclasses"
|
||||
and node.args
|
||||
):
|
||||
return False
|
||||
first = node.args[0]
|
||||
name = (
|
||||
first.id if isinstance(first, ast.Name) else getattr(first, "attr", "")
|
||||
)
|
||||
return name in record_names or "server_args" in name
|
||||
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
rel = path.relative_to(package_root).as_posix()
|
||||
# `self` is a record only inside the record's own class body.
|
||||
in_record_class = [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
|
||||
]
|
||||
for scope, record_names in [(tree, set())] + [
|
||||
(klass, {"self"}) for klass in in_record_class
|
||||
]:
|
||||
for node in ast.walk(scope):
|
||||
if not replaces_a_record(node, record_names):
|
||||
continue
|
||||
if rel == helper and record_names:
|
||||
inside_helper += 1
|
||||
elif not record_names:
|
||||
bare.append(f"{rel}:{node.lineno}")
|
||||
self.assertEqual(
|
||||
inside_helper,
|
||||
1,
|
||||
"the scan no longer finds `replace_resolved`'s own call; it broke",
|
||||
)
|
||||
self.assertEqual(
|
||||
bare,
|
||||
[],
|
||||
"a record is copied with a bare `dataclasses.replace`, so the copy "
|
||||
"loses the parent's resolution and the next publish resolves it "
|
||||
"again: " + ", ".join(bare),
|
||||
)
|
||||
|
||||
|
||||
class TestTheResolutionSeamHasOneCaller(CustomTestCase):
|
||||
"""The pipeline is entered from exactly one place, and that place decides
|
||||
whether it runs at all.
|
||||
|
||||
``resolve_once`` is the gate: the handlers are not written to survive a
|
||||
second pass over their own output, so a record must go through the pipeline
|
||||
at most once. Keeping the pipeline itself down to a single caller is what
|
||||
makes that gate impossible to bypass -- and what keeps the remaining move
|
||||
(construction time to publish time) a matter of who calls the gate.
|
||||
"""
|
||||
|
||||
def test_only_the_gate_runs_the_pipeline(self):
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import sglang
|
||||
|
||||
package_root = Path(next(iter(sglang.__path__)))
|
||||
callers = []
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
try:
|
||||
source = path.read_text()
|
||||
if "run_resolution_pipeline" not in source:
|
||||
continue
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
# The full (class, function, ...) scope chain, so the assertion can
|
||||
# say "the one caller is ServerArgs.__post_init__" -- not merely
|
||||
# that nothing outside a function named __post_init__ calls it.
|
||||
scopes = {}
|
||||
for node in ast.walk(tree):
|
||||
own = scopes.get(id(node), ())
|
||||
if isinstance(
|
||||
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
||||
):
|
||||
own = own + (node.name,)
|
||||
for child in ast.iter_child_nodes(node):
|
||||
scopes[id(child)] = own
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "run_resolution_pipeline"
|
||||
):
|
||||
rel = path.relative_to(package_root).as_posix()
|
||||
callers.append((rel, ".".join(scopes.get(id(node), ()))))
|
||||
# Every call, compared whole: a removed call, a duplicate inside
|
||||
# __post_init__, or another class growing a same-named __post_init__
|
||||
# all show up here.
|
||||
self.assertEqual(
|
||||
[("srt/server_args.py", "ServerArgs.resolve_once")],
|
||||
callers,
|
||||
"the resolution pipeline must be entered exactly once, from "
|
||||
f"ServerArgs.resolve_once; found: {callers}",
|
||||
)
|
||||
|
||||
def test_the_gate_is_reached_from_the_launcher_and_from_publish(self):
|
||||
"""Both entries go through the gate, so neither can resolve twice.
|
||||
|
||||
The launcher resolves the engine's record before reading any resolved
|
||||
value from it; every publishing process asks the gate on the way in and
|
||||
finds nothing left to do when the record arrived resolved.
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import sglang
|
||||
|
||||
package_root = Path(next(iter(sglang.__path__)))
|
||||
callers = []
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
try:
|
||||
source = path.read_text()
|
||||
if "resolve_once" not in source:
|
||||
continue
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
# `self.resolve_once()` at construction; publish looks the
|
||||
# attribute up first, so it appears as a bare name call.
|
||||
called = (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "resolve_once"
|
||||
) or (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "resolve_once"
|
||||
)
|
||||
if called:
|
||||
callers.append(path.relative_to(package_root).as_posix())
|
||||
machinery = {"srt/entrypoints/engine.py", "srt/runtime_context.py"}
|
||||
self.assertEqual(
|
||||
[
|
||||
# Program entries: each builds a record from its own
|
||||
# arguments and then reads effective configuration, or hands it
|
||||
# to a fork that must not be the first to probe the device.
|
||||
"benchmark/endpoint.py",
|
||||
"benchmark/offline_throughput.py",
|
||||
"benchmark/one_batch.py",
|
||||
"benchmark/one_batch_server.py",
|
||||
"compile_deep_gemm.py",
|
||||
"lang/backend/runtime_endpoint.py",
|
||||
"launch_server.py",
|
||||
# The mechanism.
|
||||
"srt/entrypoints/engine.py",
|
||||
"srt/entrypoints/http_server_engine.py",
|
||||
"srt/runtime_context.py",
|
||||
],
|
||||
sorted(set(callers)),
|
||||
f"the resolution gate grew or lost a caller: {sorted(set(callers))}",
|
||||
)
|
||||
# The rule the list stands for: a caller that is not the mechanism
|
||||
# resolves a record it built itself from argv. Anything else was handed
|
||||
# one someone already resolved, or should publish.
|
||||
for caller in sorted(set(callers) - machinery):
|
||||
source = (package_root / caller).read_text()
|
||||
# Either the module turned argv into the record -- the dataclass,
|
||||
# the CLI classmethod, or the argv helper `launch_server.py` uses
|
||||
# -- or it hands the record to a fork, which has to resolve first:
|
||||
# the pipeline probes the device and a forked child cannot
|
||||
# re-initialize CUDA. A worker handed a resolved record is neither.
|
||||
builds_its_own = any(
|
||||
spelling in source
|
||||
for spelling in (
|
||||
"ServerArgs(",
|
||||
".from_cli_args(",
|
||||
"prepare_server_args(",
|
||||
)
|
||||
) or ("Process(" in source and "server_args" in source)
|
||||
# `assertTrue`, not `assertIn`: the container is a whole module.
|
||||
self.assertTrue(
|
||||
builds_its_own,
|
||||
f"{caller} calls the resolution gate but does not build the "
|
||||
"record it resolves; a record it was handed is already "
|
||||
"resolved by whoever built it, and publish resolves what it "
|
||||
"is handed",
|
||||
)
|
||||
|
||||
|
||||
class TestResolutionStaysLazy(CustomTestCase):
|
||||
"""Resolving a dummy model must not load the families it never reaches.
|
||||
@@ -1096,92 +618,6 @@ class TestResolutionStaysLazy(CustomTestCase):
|
||||
`override_server_args` in the test suite.
|
||||
"""
|
||||
|
||||
def test_no_hook_module_imports_another_at_module_scope(self):
|
||||
import ast
|
||||
|
||||
import sglang
|
||||
|
||||
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
|
||||
offenders = []
|
||||
for path in sorted((srt / "arg_groups").glob("*.py")):
|
||||
for node in ast.parse(path.read_text(encoding="utf-8-sig")).body:
|
||||
if (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module
|
||||
and node.module.startswith("sglang.srt.arg_groups")
|
||||
and node.module.endswith("_hook")
|
||||
):
|
||||
offenders.append(f"{path.name}:{node.lineno} -> {node.module}")
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"a hook module imports another at module scope, so loading one "
|
||||
"family drags in a family it may never call. Import it inside the "
|
||||
"function that calls it:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
def test_no_family_is_imported_before_the_step_that_calls_it(self):
|
||||
"""Source-level, so it holds whatever else the process has imported.
|
||||
|
||||
Every hook import inside the dispatcher must come after the imports of
|
||||
the families reached earlier and before its own first call -- what an
|
||||
eager block at the top of the function breaks, and what a `sys.modules`
|
||||
diff cannot see once another test has loaded those modules.
|
||||
"""
|
||||
import ast
|
||||
|
||||
import sglang
|
||||
|
||||
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
|
||||
tree = ast.parse(
|
||||
(srt / "arg_groups" / "pipeline.py").read_text(encoding="utf-8-sig")
|
||||
)
|
||||
dispatch = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "run_resolution_pipeline"
|
||||
)
|
||||
early_return = min(
|
||||
(
|
||||
n.lineno
|
||||
for n in ast.walk(dispatch)
|
||||
if isinstance(n, ast.Return) and n.value is None
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
self.assertIsNotNone(early_return, "the dummy short circuit is gone")
|
||||
|
||||
imported_early, called_early = set(), set()
|
||||
for node in ast.walk(dispatch):
|
||||
if (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module
|
||||
and node.module.endswith("_hook")
|
||||
and node.lineno < early_return
|
||||
):
|
||||
imported_early.add(node.module.rsplit(".", 1)[-1])
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.lineno < early_return
|
||||
):
|
||||
called_early.add(node.func.id)
|
||||
|
||||
hooks = {}
|
||||
for path in sorted((srt / "arg_groups").glob("*_hook.py")):
|
||||
for node in ast.parse(path.read_text(encoding="utf-8-sig")).body:
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
hooks[node.name] = path.stem
|
||||
needed_early = {hooks[name] for name in called_early if name in hooks}
|
||||
self.assertEqual(
|
||||
imported_early - needed_early,
|
||||
set(),
|
||||
"the dispatcher imports a hook family before the dummy short "
|
||||
"circuit without calling it there, so every dummy resolution pays "
|
||||
"for a family it never reaches",
|
||||
)
|
||||
|
||||
def test_a_dummy_resolution_loads_only_what_it_reaches(self):
|
||||
"""The same claim measured, in an interpreter of its own.
|
||||
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"""Resolution does not read the config bags, because they do not exist yet.
|
||||
|
||||
The bags are projected from what resolution decides, so anything the pipeline
|
||||
calls has to read the resolving state instead — `resolved_view(server_args)`,
|
||||
or the view a handler already holds. A bag read reached from resolution raises
|
||||
`config namespace ... not published`, and only on the branch that reaches it:
|
||||
the diffusion-LM page-size pass needed one model family, the Marlin LoRA
|
||||
validation needed one MoE runner backend. Both were written, merged into a
|
||||
branch, and stayed green for everything except the configuration that triggers
|
||||
them.
|
||||
|
||||
`test_publish_precedes_bag_reads.py` is the same worry from the other side, but
|
||||
it walks the *process entries* — it cannot see a helper the pipeline calls, and
|
||||
neither of the two above appeared in it.
|
||||
|
||||
The walk starts from three places: the symbols the pipeline imports, the live
|
||||
resolution registries (every pass and override provider, taken from the
|
||||
registries themselves rather than from the decorator that put it there -- most
|
||||
providers register through a helper call), and the passes named at a
|
||||
`run_post_process_pass(sa, fn)` call site. From there it follows calls
|
||||
in-module, one hop out, and matches an accessor whether it is spelled bare or
|
||||
through an object.
|
||||
|
||||
What this still cannot see: a bag read reached through a method rather than a
|
||||
module-level function, one behind an import the walk does not follow, and one
|
||||
in a callable that reaches the pipeline through a variable no call site names.
|
||||
It is a ratchet, not a proof.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import functools
|
||||
import inspect
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import sglang
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=22, suite="base-a-test-cpu")
|
||||
|
||||
_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt"
|
||||
|
||||
|
||||
def _accessor_names():
|
||||
"""Every bag accessor `runtime_context` exports, read from the module.
|
||||
|
||||
Listing them by hand is how this went stale once already: the list had
|
||||
eighteen names while the module exported twenty-five, so a resolution-time
|
||||
`get_flags().x` or `get_resources().y` would have walked straight past.
|
||||
"""
|
||||
tree = ast.parse((_SRT / "runtime_context.py").read_text(encoding="utf-8-sig"))
|
||||
names = {
|
||||
node.name
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name.startswith("get_")
|
||||
}
|
||||
# Two that are not bags: the context object itself, and the platform facts.
|
||||
# Both answer before anything is published.
|
||||
return frozenset(names - {"get_context", "get_platform"})
|
||||
|
||||
|
||||
_BAG_ACCESSORS = _accessor_names()
|
||||
|
||||
# `get_device` also names the device-string utility and the platform method,
|
||||
# so only the bare spelling is the accessor.
|
||||
_ATTRIBUTE_SPELLED = _BAG_ACCESSORS - {"get_device"}
|
||||
|
||||
# The pipeline itself and the mechanism it publishes through: `runtime_context`
|
||||
# defines the accessors, and `arg_groups` is the pipeline's own code.
|
||||
_OWN = ("server_args.py", "runtime_context.py")
|
||||
|
||||
|
||||
def _pipeline_sources():
|
||||
"""The record plus every module under `arg_groups/`.
|
||||
|
||||
A handler that moved out of the record takes its imports with it, so
|
||||
seeding the walk from two files would stop covering it.
|
||||
"""
|
||||
return [_SRT / "server_args.py", *sorted((_SRT / "arg_groups").rglob("*.py"))]
|
||||
|
||||
|
||||
def _module_of(name):
|
||||
"""`sglang.srt.a.b` -> the file, if it is one of ours."""
|
||||
if not name or not name.startswith("sglang.srt."):
|
||||
return None
|
||||
rel = name[len("sglang.srt.") :].replace(".", "/")
|
||||
for candidate in (_SRT / f"{rel}.py", _SRT / rel / "__init__.py"):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _imported_symbols(paths):
|
||||
"""{module file: {symbol names imported from it}} across the given sources."""
|
||||
out = {}
|
||||
for path in paths:
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8-sig"))):
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
target = _module_of(node.module)
|
||||
if target is None or target.name in _OWN:
|
||||
continue
|
||||
out.setdefault(target, set()).update(alias.name for alias in node.names)
|
||||
return out
|
||||
|
||||
|
||||
def _registry_functions():
|
||||
"""Every callable the resolution registries will call, from the registries.
|
||||
|
||||
Not from decorator syntax: most model-override providers register through
|
||||
a `_register_for(...)` helper rather than a decorator, so a scan keyed on
|
||||
the decorator name walked past all of them -- 39 entries found where the
|
||||
registries hold 65. However a provider registers, it is in the registry
|
||||
once its module is imported, and `inspect` says where it came from.
|
||||
"""
|
||||
from sglang.srt.arg_groups import overrides
|
||||
|
||||
functions = list(overrides.POST_PROCESS_PASSES)
|
||||
functions += [fn for fns in overrides._MODEL_OVERRIDE_FNS.values() for fn in fns]
|
||||
functions += [fn for _predicate, fn in overrides._PREDICATE_OVERRIDE_FNS]
|
||||
return functions
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _registered_entries():
|
||||
"""Entries the import map cannot reach: passes and override providers.
|
||||
|
||||
A pass arrives at the pipeline as a value, and the registry calls its
|
||||
providers by dictionary lookup. Both run during resolution, so a bag read
|
||||
inside one raises exactly like a bag read in a handler -- and neither is
|
||||
named by an import the walk can follow.
|
||||
"""
|
||||
entries = set()
|
||||
for fn in _registry_functions():
|
||||
target = inspect.unwrap(fn)
|
||||
name = getattr(target, "__name__", "")
|
||||
if not name or name == "<lambda>":
|
||||
continue
|
||||
source = inspect.getsourcefile(target)
|
||||
if source is None:
|
||||
continue
|
||||
path = pathlib.Path(source).resolve()
|
||||
if _SRT in path.parents:
|
||||
entries.add((path, name))
|
||||
# A pass handed over by value is in no registry, so its call sites are read
|
||||
# from the source. The entry carries the *defining* file: `_reaches_a_bag`
|
||||
# walks functions in the entry's file, so a call-site key walks nothing.
|
||||
by_value = set()
|
||||
sources = {
|
||||
path: path.read_text(encoding="utf-8-sig")
|
||||
for path in sorted(_SRT.rglob("*.py"))
|
||||
}
|
||||
for path, source in sources.items():
|
||||
if "run_post_process_pass" not in source:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "run_post_process_pass"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[1], ast.Name)
|
||||
):
|
||||
by_value.add(node.args[1].id)
|
||||
trees = {}
|
||||
for path, source in sources.items():
|
||||
if not any(name in source for name in by_value):
|
||||
continue
|
||||
try:
|
||||
trees[path] = ast.parse(source)
|
||||
except SyntaxError:
|
||||
continue
|
||||
for name in sorted(by_value):
|
||||
defined_in = [
|
||||
path
|
||||
for path, tree in trees.items()
|
||||
if any(
|
||||
isinstance(node, ast.FunctionDef) and node.name == name
|
||||
for node in tree.body
|
||||
)
|
||||
]
|
||||
if not defined_in:
|
||||
raise AssertionError(
|
||||
f"pass {name!r} is handed to run_post_process_pass by value "
|
||||
"but defined in no scanned module; the walk cannot see it"
|
||||
)
|
||||
for path in defined_in:
|
||||
entries.add((path, name))
|
||||
return entries
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _functions_in(path):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
return {
|
||||
node.name: node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
|
||||
def _locally_shadowed_accessors(path):
|
||||
"""Accessor names this file imports from somewhere that is not the context.
|
||||
|
||||
`get_device` is both the `device` bag accessor and the hardware probe in
|
||||
`utils.common`. Matching the bare name would report the probe as a bag read,
|
||||
so a name imported from elsewhere in this file is not the accessor.
|
||||
"""
|
||||
shadowed = set()
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8-sig"))):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.endswith("runtime_context"):
|
||||
continue
|
||||
for alias in node.names:
|
||||
name = alias.asname or alias.name
|
||||
if name in _BAG_ACCESSORS:
|
||||
shadowed.add(name)
|
||||
return shadowed
|
||||
|
||||
|
||||
def _reaches_a_bag(path, entry):
|
||||
"""Does `entry` in `path` reach a bag accessor, following calls in-module?"""
|
||||
functions = _functions_in(path)
|
||||
shadowed = _locally_shadowed_accessors(path)
|
||||
seen = set()
|
||||
|
||||
def walk(name):
|
||||
if name in seen or name not in functions:
|
||||
return None
|
||||
seen.add(name)
|
||||
for node in ast.walk(functions[name]):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
# `rc.get_exec()`, `self.get_schedule()`: the same accessor
|
||||
# reached through a module alias or an object.
|
||||
if node.func.attr in _ATTRIBUTE_SPELLED:
|
||||
return node.lineno
|
||||
continue
|
||||
if not isinstance(node.func, ast.Name):
|
||||
continue
|
||||
if node.func.id in _BAG_ACCESSORS and node.func.id not in shadowed:
|
||||
return node.lineno
|
||||
found = walk(node.func.id)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
return walk(entry)
|
||||
|
||||
|
||||
class TestResolutionReadsNoBag(CustomTestCase):
|
||||
def test_the_accessor_set_is_derived_and_whole(self):
|
||||
"""A shrunken accessor set would make every other check pass quietly."""
|
||||
self.assertGreaterEqual(
|
||||
len(_BAG_ACCESSORS),
|
||||
15,
|
||||
f"only {len(_BAG_ACCESSORS)} accessors were derived from "
|
||||
"runtime_context; the derivation broke",
|
||||
)
|
||||
# Spelled out so a rename that drops one fails here.
|
||||
for name in ("get_exec", "get_flags", "get_parallel", "get_resources"):
|
||||
self.assertIn(name, _BAG_ACCESSORS)
|
||||
|
||||
def test_the_walk_finds_something_to_walk(self):
|
||||
"""A collapsed import map would make the pin vacuous."""
|
||||
imported = _imported_symbols(_pipeline_sources())
|
||||
self.assertGreater(
|
||||
len(imported),
|
||||
20,
|
||||
f"the pipeline only imports from {len(imported)} of our modules; "
|
||||
"the scan broke",
|
||||
)
|
||||
|
||||
def test_the_registered_entries_are_found(self):
|
||||
"""The passes and providers are the half the import map cannot see."""
|
||||
entries = _registered_entries()
|
||||
self.assertGreater(
|
||||
len(entries),
|
||||
60,
|
||||
f"only {len(entries)} passes and providers were found; the scan broke",
|
||||
)
|
||||
# Every registered callable that lives in our tree has to appear: the
|
||||
# derivation reads the registries through `inspect`, so an interpreter
|
||||
# that imported a *different* checkout would resolve them outside
|
||||
# `_SRT` and quietly leave the walk with nothing to walk.
|
||||
missing = sorted(
|
||||
name
|
||||
for name in (
|
||||
getattr(inspect.unwrap(fn), "__name__", "")
|
||||
for fn in _registry_functions()
|
||||
)
|
||||
if name
|
||||
and name != "<lambda>"
|
||||
and name not in {entry for _path, entry in entries}
|
||||
)
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
"a registered pass or provider did not resolve to a file under "
|
||||
f"{_SRT}; the entry set is narrower than the registries:\n "
|
||||
+ "\n ".join(missing),
|
||||
)
|
||||
|
||||
def test_nothing_the_pipeline_calls_reads_a_bag(self):
|
||||
imported = _imported_symbols(_pipeline_sources())
|
||||
reachable = {
|
||||
(path, symbol) for path, symbols in imported.items() for symbol in symbols
|
||||
} | _registered_entries()
|
||||
found = []
|
||||
for path, symbol in sorted(reachable):
|
||||
line = _reaches_a_bag(path, symbol)
|
||||
if line is not None:
|
||||
found.append(
|
||||
f"{path.relative_to(_SRT)}:{line} reached from "
|
||||
f"{symbol}(), which resolution calls"
|
||||
)
|
||||
self.assertEqual(
|
||||
found,
|
||||
[],
|
||||
"resolution reaches a config-bag read, which raises on whichever "
|
||||
"branch gets there first; read the resolving state instead:\n "
|
||||
+ "\n ".join(found),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user