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:
Cheng Wan
2026-08-05 19:30:53 -07:00
committed by GitHub
parent d33ab39ebc
commit 99cfc90658
22 changed files with 411 additions and 279 deletions
@@ -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")),
+4 -3
View File
@@ -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()