config: retire ServerArgs.override in favour of derive()
`ServerArgs.override(source, **fields)` was the last way to change a resolved
`ServerArgs` in place. Every remaining call-site was one of two things, and
neither wanted an in-place write:
- **A config for someone else.** A draft worker's context length, an encode
worker's device, the compile script's watchdog, the client's port pick, a test
fixture's backends. These already deepcopied first — the write was on the copy.
- **A launcher-stage resolution.** `resolve_auto_parsers` detected the chat
template's parsers and wrote them back, to be inherited by the schedulers it
spawns.
Both are "one config becomes another", so `derive(source, **fields)` returns the
variant and leaves the receiver — and any bags projected from it — untouched. It
deliberately is not `dataclasses.replace`: resolution does not re-run, because
the values being set are decided after it, from inputs it never had. Provenance
and the resolvable-field stash work as before, on the copy.
`resolve_auto_parsers` now computes the parsers and returns the config to launch
with; the detection helpers stop taking a config to mutate. `HiMambaRadixCache`
re-applied a HiCache layout normalization `__post_init__` already performs (the
same duplicate removed from `UnifiedRadixCache` in ebb1c88d23) and just goes.
With no in-place mutation left, `ServerArgs.__setattr__` raising after
resolution *is* the guarantee, so the textual writer ratchet retires and
`test_server_args_derive.py` pins the contract instead: the receiver survives
deriving, the published instance still refuses assignment, and deriving does not
publish. `SGLANG_STRICT_CONFIG_MUTATION` was already unused — the guard has been
unconditional since the mutation sweep — and goes with it.
The detection tests drop their `SimpleNamespace` stand-in for a real
`ServerArgs`; the test kit and the MLA chunk-metadata fixture publish a derived
variant instead of writing the runner's published config.
This commit is contained in:
+5
-4
@@ -45,13 +45,14 @@ class _ChunkKVMLARunner(MockMLAModelRunner):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# The fixture's config is already published; adjust it through the
|
||||
# audited entry point (bare writes raise under the strict guard).
|
||||
self.server_args.override(
|
||||
source="attention-unittest",
|
||||
from sglang.srt.runtime_context import get_context
|
||||
|
||||
self.server_args = self.server_args.derive(
|
||||
"attention-unittest",
|
||||
disable_chunked_prefix_cache=False,
|
||||
flashinfer_mla_disable_ragged=False,
|
||||
)
|
||||
get_context().set_server_args(self.server_args)
|
||||
|
||||
|
||||
def _make_case() -> MLAAttentionCase:
|
||||
|
||||
@@ -13,6 +13,7 @@ from sglang.srt.parser.template_detection import (
|
||||
detect_tool_call_parser,
|
||||
resolve_auto_parsers,
|
||||
)
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=2.0, suite="base-a-test-cpu")
|
||||
@@ -769,21 +770,15 @@ class TestResolveAutoParsers(unittest.TestCase):
|
||||
|
||||
qwen3_template = "{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}"
|
||||
|
||||
class _Args(SimpleNamespace):
|
||||
# Write-through override, per the runtime-context testing idiom:
|
||||
# production adjusts parsers through override(source, ...), so the
|
||||
# stand-in needs the method (a bare SimpleNamespace would raise).
|
||||
def override(self, source, **fields):
|
||||
for key, value in fields.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def _make_server_args(
|
||||
self, reasoning_parser=None, tool_call_parser=None, chat_template=None
|
||||
):
|
||||
return self._Args(
|
||||
# The dummy model path skips resolution; the tokenizer / HF-config
|
||||
# loads that detection performs are patched per test.
|
||||
return ServerArgs(
|
||||
model_path="dummy",
|
||||
reasoning_parser=reasoning_parser,
|
||||
tool_call_parser=tool_call_parser,
|
||||
model_path="Qwen/Qwen3-0.6B",
|
||||
trust_remote_code=False,
|
||||
chat_template=chat_template,
|
||||
)
|
||||
@@ -826,7 +821,11 @@ class TestResolveAutoParsers(unittest.TestCase):
|
||||
|
||||
def test_nonexistent_model_disables_both_parsers(self):
|
||||
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
|
||||
args.model_path = "nonexistent/model-does-not-exist-xyz"
|
||||
args = self._make_server_args(
|
||||
reasoning_parser="auto",
|
||||
tool_call_parser="auto",
|
||||
)
|
||||
object.__setattr__(args, "model_path", "nonexistent/model-does-not-exist-xyz")
|
||||
with _patch_hf_transformers_utils(
|
||||
Mock(side_effect=RuntimeError("tokenizer unavailable")),
|
||||
Mock(side_effect=RuntimeError("config unavailable")),
|
||||
|
||||
@@ -299,12 +299,13 @@ class TestServerArgsScopedOverride(_IsolatedServerArgs):
|
||||
|
||||
def test_installed_config_arms_the_strict_guard(self):
|
||||
# The published dummy must behave like a resolved config: bare writes
|
||||
# raise under the strict harness; override() stays the entry point.
|
||||
# raise, and a differing value lives on a derived variant.
|
||||
published = get_context().override_server_args(tp_size=2).install()
|
||||
with self.assertRaises(AttributeError):
|
||||
published.tp_size = 4
|
||||
published.override(source="test", tp_size=4)
|
||||
self.assertEqual(published.tp_size, 4)
|
||||
variant = published.derive("test", tp_size=4)
|
||||
self.assertEqual(variant.tp_size, 4)
|
||||
self.assertEqual(published.tp_size, 2)
|
||||
|
||||
def test_restore_resets_the_capture_seed(self):
|
||||
# install() seeds flags.capture from the published dummy; restore()
|
||||
|
||||
@@ -102,8 +102,8 @@ class TestContextOverride(CustomTestCase):
|
||||
self.assertEqual(sa.kv_cache_dtype, raw)
|
||||
|
||||
def test_bare_server_args_write_raises_after_resolution(self):
|
||||
# server_args is read-only after resolution regardless of the
|
||||
# SGLANG_STRICT_CONFIG_MUTATION env; write via override instead.
|
||||
# server_args is read-only after resolution: resolved config changes go
|
||||
# to the bags, a per-runner config to a derived variant.
|
||||
sa = ServerArgs(model_path="dummy")
|
||||
object.__setattr__(sa, "_declarations_materialized", True)
|
||||
with self.assertRaises(AttributeError):
|
||||
@@ -118,8 +118,9 @@ class TestContextOverride(CustomTestCase):
|
||||
rc.get_context().override(
|
||||
"ModelRunner.configure_kv_cache_dtype", kv_cache_dtype="fp8_e4m3"
|
||||
)
|
||||
draft = ServerArgs(model_path="dummy")
|
||||
draft.override(source="draft-build", kv_cache_dtype="bf16")
|
||||
draft = ServerArgs(model_path="dummy").derive(
|
||||
"draft-build", kv_cache_dtype="bf16"
|
||||
)
|
||||
with rc.get_context().preserve_config():
|
||||
rc.get_context().set_server_args(draft)
|
||||
# Inside the scope the draft's bags are live...
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""``ServerArgs.derive`` is the only way one config becomes another.
|
||||
|
||||
After resolution the instance is the process's read-only startup record and the
|
||||
object the config bags were projected from, so it cannot be mutated: a change to
|
||||
resolved config goes to the bags (``get_context().override``), and a config that
|
||||
differs for one runner or worker — a draft's context length, an encode worker's
|
||||
device — is a second object.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.srt.runtime_context import get_context, reset_context
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
|
||||
class TestServerArgsDerive(CustomTestCase):
|
||||
def tearDown(self):
|
||||
reset_context()
|
||||
|
||||
def _resolved(self) -> ServerArgs:
|
||||
"""A config in the post-resolution state, without a real model."""
|
||||
server_args = ServerArgs(model_path="dummy")
|
||||
object.__setattr__(server_args, "_declarations_materialized", True)
|
||||
return server_args
|
||||
|
||||
def test_the_receiver_is_untouched(self):
|
||||
server_args = self._resolved()
|
||||
variant = server_args.derive("draft_worker.copy", context_length=1024)
|
||||
|
||||
self.assertEqual(variant.context_length, 1024)
|
||||
self.assertIsNot(variant, server_args)
|
||||
self.assertIsNone(server_args.context_length)
|
||||
|
||||
def test_a_published_config_rejects_assignment_but_still_derives(self):
|
||||
override = get_context().override_server_args(tp_size=2)
|
||||
published = override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
with self.assertRaises(AttributeError):
|
||||
published.tp_size = 4
|
||||
|
||||
self.assertEqual(published.derive("worker", tp_size=4).tp_size, 4)
|
||||
self.assertEqual(published.tp_size, 2)
|
||||
|
||||
def test_the_variant_is_not_published_by_deriving(self):
|
||||
override = get_context().override_server_args(tp_size=2)
|
||||
published = override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
published.derive("worker", tp_size=4)
|
||||
|
||||
self.assertIs(get_context().server_args, published)
|
||||
|
||||
def test_the_variant_is_frozen_too(self):
|
||||
variant = self._resolved().derive("worker", context_length=1024)
|
||||
with self.assertRaises(AttributeError):
|
||||
variant.context_length = 2048
|
||||
|
||||
def test_provenance_is_recorded(self):
|
||||
variant = self._resolved().derive("draft_worker.copy", watchdog_timeout=1.0)
|
||||
self.assertIn(
|
||||
("draft_worker.copy", {"watchdog_timeout": 1.0}),
|
||||
getattr(variant, "_runtime_mutations", []),
|
||||
)
|
||||
|
||||
def test_a_resolvable_field_joins_the_declaration_stash(self):
|
||||
"""So a re-resolution of the variant keeps the derived value."""
|
||||
variant = self._resolved().derive("draft_worker.build", kv_cache_dtype="bf16")
|
||||
stash = getattr(variant, "_resolved_overrides", [])
|
||||
self.assertIn(("draft_worker.build", {"kv_cache_dtype": "bf16"}), stash)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,13 +8,13 @@ configuration; the resolution pipeline (``server_args.py`` and
|
||||
an exact pin: new mutations must not appear, and removals must lower the
|
||||
baseline to lock in the progress.
|
||||
|
||||
Every audited runtime adjustment goes through ``ServerArgs.override(source,
|
||||
**fields)`` — the single mutation entry point, which records provenance and
|
||||
keeps whitelisted fields consistent with the declaration stash. The baseline
|
||||
is therefore zero. The registered test harness additionally runs with
|
||||
``SGLANG_STRICT_CONFIG_MUTATION=1``, under which a bare assignment after
|
||||
resolution raises at runtime; this ratchet catches sites the tests never
|
||||
execute.
|
||||
There is no post-resolution mutation entry point on the instance any more:
|
||||
resolved config changes go to the context bags via
|
||||
``get_context().override(source, **fields)``, and a config that differs for one
|
||||
runner or worker is a separate object built with ``ServerArgs.derive(source,
|
||||
**fields)``. The baseline is therefore zero. ``ServerArgs.__setattr__`` raises
|
||||
on a bare assignment after resolution; this ratchet catches the sites the tests
|
||||
never execute.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
@@ -69,8 +69,9 @@ class TestServerArgsMutationRatchet(CustomTestCase):
|
||||
f"server_args mutations outside the resolution pipeline grew: "
|
||||
f"{count} > baseline {_BASELINE}. Configuration is resolved in "
|
||||
"ServerArgs.__post_init__; declare through the pipeline "
|
||||
"(passes / declare_load_time_override) or go through "
|
||||
"ServerArgs.override(source, ...) instead of assigning fields."
|
||||
"(passes / declare_load_time_override), change resolved config "
|
||||
"with get_context().override(source, ...), or build a variant "
|
||||
"with server_args.derive(source, ...) — do not assign fields."
|
||||
)
|
||||
if count < _BASELINE:
|
||||
self.fail(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""``ServerArgs`` has no in-place mutation entry, and nothing calls one.
|
||||
|
||||
``ServerArgs.override(source, **fields)`` used to mutate a resolved instance;
|
||||
after resolution the fields are the record the config bags were projected from,
|
||||
so such a write desyncs every namespace reader. The method is gone and the two
|
||||
sanctioned replacements are ``get_context().override`` (post-publish, writes the
|
||||
bags) and ``ServerArgs.derive`` (a variant for another runner / process). Late
|
||||
launcher-stage resolution writes in place through
|
||||
``arg_groups.overrides.declare_late_resolution``, which refuses the published
|
||||
instance.
|
||||
|
||||
The textual half of this guard matters because the resolution pipeline's own file
|
||||
is exempt from the mutation ratchet: a ``self.override(...)`` there — exactly
|
||||
what the LoRA normalization used — is invisible to it.
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import sglang
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
_SGLANG_ROOT = Path(next(iter(sglang.__path__)))
|
||||
|
||||
# ``x.override(`` on anything that is a ServerArgs by name, including the
|
||||
# pipeline's own ``self.override(`` inside server_args.py.
|
||||
_PATTERNS = [
|
||||
re.compile(r"\bself\.override\("),
|
||||
re.compile(r"\bserver_args\.override\("),
|
||||
re.compile(r"\bsa\.override\("),
|
||||
re.compile(r"\bargs\.override\("),
|
||||
]
|
||||
|
||||
_EXCLUDED = ("multimodal_gen",)
|
||||
|
||||
|
||||
class TestNoServerArgsMutationEntry(CustomTestCase):
|
||||
def test_the_method_is_gone(self):
|
||||
self.assertFalse(
|
||||
hasattr(ServerArgs, "override"),
|
||||
"ServerArgs.override is back; post-publish changes belong on the bags "
|
||||
"(get_context().override) and per-runner values on a derive() variant.",
|
||||
)
|
||||
|
||||
def test_nothing_calls_an_instance_override(self):
|
||||
offenders = []
|
||||
for path in sorted(_SGLANG_ROOT.rglob("*.py")):
|
||||
rel = path.relative_to(_SGLANG_ROOT).as_posix()
|
||||
if rel.startswith(_EXCLUDED):
|
||||
continue
|
||||
source = path.read_text()
|
||||
for pattern in _PATTERNS:
|
||||
for match in pattern.finditer(source):
|
||||
line = source.count("\n", 0, match.start()) + 1
|
||||
offenders.append(f"{rel}:{line}: {match.group(0)}")
|
||||
if offenders:
|
||||
self.fail(
|
||||
"in-place ServerArgs mutation call-sites:\n"
|
||||
+ "\n".join(offenders)
|
||||
+ "\n\nUse get_context().override(source, ...) for resolved config, "
|
||||
"server_args.derive(source, ...) for a per-runner variant, or "
|
||||
"declare_late_resolution(...) for pre-publish launcher resolution."
|
||||
)
|
||||
|
||||
def test_late_resolution_refuses_the_published_config(self):
|
||||
from sglang.srt.arg_groups.overrides import declare_late_resolution
|
||||
from sglang.srt.runtime_context import get_context
|
||||
|
||||
override = get_context().override_server_args(tp_size=2)
|
||||
published = override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
declare_late_resolution(published, "test", tp_size=4)
|
||||
self.assertEqual(published.tp_size, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Ratchet guard: ``ServerArgs.override`` call-sites may only decrease.
|
||||
|
||||
``ServerArgs.override(source, **fields)`` mutates a ``ServerArgs`` *instance*
|
||||
only — the resolved-config bags on the runtime context never see the write, so
|
||||
any consumer reading the namespace accessors (``get_exec()`` / ``get_memory()``
|
||||
/ …) desyncs from the writer. The migration end-state removes this primitive
|
||||
entirely: post-publish, process-global config changes go through
|
||||
``get_context().override(source, **fields)`` (which writes the bags), and
|
||||
per-runner resolved values live on the runner object rather than on a
|
||||
``ServerArgs`` copy.
|
||||
|
||||
Until every call-site is rerouted together with its readers, this exact pin
|
||||
keeps the writer surface from growing unwatched: new writers must use
|
||||
``get_context().override``, and each rerouted batch lowers the baseline to
|
||||
lock in the progress. (The count is textual and includes docstring mentions
|
||||
and the test-kit's private-config use — the pin tracks growth, not the exact
|
||||
production-writer census.)
|
||||
"""
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import sglang
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
_SGLANG_ROOT = Path(next(iter(sglang.__path__)))
|
||||
|
||||
# ``server_args.override(`` also matches ``self.server_args.override(``,
|
||||
# ``<obj>.server_args.override(``, and the ``draft_server_args`` /
|
||||
# ``dp_server_args`` copies; ``args`` / ``sa`` are the aliases a few call-sites
|
||||
# bind first.
|
||||
_WRITER_PATTERNS = [
|
||||
re.compile(r"server_args\.override\("),
|
||||
re.compile(r"\bargs\.override\("),
|
||||
re.compile(r"\bsa\.override\("),
|
||||
]
|
||||
|
||||
# The resolution pipeline itself (its declare face forwards through
|
||||
# ``override`` by design) and multimodal_gen, whose ServerArgs is a different
|
||||
# class outside this contract.
|
||||
_EXCLUDED = (
|
||||
"srt/server_args.py",
|
||||
"srt/arg_groups",
|
||||
"multimodal_gen",
|
||||
)
|
||||
|
||||
_BASELINE = 15
|
||||
|
||||
|
||||
class TestServerArgsWriterRatchet(CustomTestCase):
|
||||
def test_server_args_override_call_sites_match_the_baseline(self):
|
||||
count = 0
|
||||
for path in sorted(_SGLANG_ROOT.rglob("*.py")):
|
||||
rel = path.relative_to(_SGLANG_ROOT).as_posix()
|
||||
if rel.startswith(_EXCLUDED):
|
||||
continue
|
||||
source = path.read_text()
|
||||
count += sum(len(p.findall(source)) for p in _WRITER_PATTERNS)
|
||||
if count > _BASELINE:
|
||||
self.fail(
|
||||
f"ServerArgs.override call-sites grew: {count} > baseline "
|
||||
f"{_BASELINE}. Instance writes never reach the resolved-config "
|
||||
"bags, so namespace readers desync from the writer. Post-publish "
|
||||
"process-global changes go through get_context().override(...); "
|
||||
"per-runner resolved values belong on the runner object."
|
||||
)
|
||||
if count < _BASELINE:
|
||||
self.fail(
|
||||
f"ServerArgs.override call-sites shrank: {count} < baseline "
|
||||
f"{_BASELINE}. Lower the baseline in this file to lock in the "
|
||||
"progress."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user