config: make ServerArgs read-only with a single audited mutation entry (#31811)
This commit is contained in:
@@ -67,8 +67,8 @@ if __name__ == "__main__":
|
||||
ServerArgs.add_cli_args(parser)
|
||||
args = [
|
||||
"--model-path=Qwen/Qwen2-VL-2B",
|
||||
"--skip-tokenizer-init",
|
||||
]
|
||||
args = parser.parse_args(args=args)
|
||||
server_args = ServerArgs.from_cli_args(args)
|
||||
server_args.skip_tokenizer_init = True
|
||||
token_in_out_example(server_args)
|
||||
|
||||
@@ -3331,8 +3331,9 @@ async def run_dp_worker(
|
||||
# 0 when CVD is pinned to one GPU, else the absolute id. rank=0, so
|
||||
# MMEncoder runs set_device(base_gpu_id).
|
||||
args = copy.deepcopy(server_args)
|
||||
args.base_gpu_id = gpu_id
|
||||
args.tp_size = 1
|
||||
# The copy is already resolved (read-only); route the per-worker
|
||||
# specialization through the audited mutation entry.
|
||||
args.override("encode_server.dp_worker", base_gpu_id=gpu_id, tp_size=1)
|
||||
enc = MMEncoder(args, dist_init_method=f"tcp://127.0.0.1:{get_free_port()}", rank=0)
|
||||
|
||||
global encoder_metrics_collector
|
||||
|
||||
@@ -93,7 +93,12 @@ def maybe_set_default_flashinfer_gdn_prefill(model_runner: ModelRunner) -> None:
|
||||
)
|
||||
|
||||
if is_flashinfer_gdn_prefill_available():
|
||||
args.linear_attn_prefill_backend = "flashinfer"
|
||||
# server_args is resolved (read-only) by the time backends initialize;
|
||||
# route this load-time default through the audited mutation entry.
|
||||
args.override(
|
||||
"gdn_backend.sm100_flashinfer_default",
|
||||
linear_attn_prefill_backend="flashinfer",
|
||||
)
|
||||
rank0_log("Defaulting SM100 GDN prefill backend to FlashInfer.")
|
||||
|
||||
|
||||
|
||||
@@ -655,6 +655,7 @@ class RuntimeContext:
|
||||
"parallel",
|
||||
"_server_args",
|
||||
"_config_bags",
|
||||
"_overrides_log",
|
||||
"flags",
|
||||
"resources",
|
||||
"forward",
|
||||
@@ -664,6 +665,7 @@ class RuntimeContext:
|
||||
self.parallel = parallel
|
||||
self._server_args: ServerArgs | None = None
|
||||
self._config_bags: dict | None = None
|
||||
self._overrides_log: list = []
|
||||
self.flags = Flags()
|
||||
self.resources = Resources()
|
||||
self.forward = ForwardFlags()
|
||||
@@ -725,6 +727,8 @@ class RuntimeContext:
|
||||
# truth for config reads). Driven by NS(...) metadata; a mock/partial
|
||||
# config with no NS markers yields an empty tree (no bags projected).
|
||||
self._config_bags = _build_config_bags(server_args)
|
||||
# Fresh config lifecycle: prior override provenance no longer applies.
|
||||
self._overrides_log = []
|
||||
|
||||
def config_bag(self, name: str) -> _ConfigBag:
|
||||
"""Return the top-level config namespace bag (``device`` / ``model`` /
|
||||
@@ -736,6 +740,55 @@ class RuntimeContext:
|
||||
raise ValueError(f"config namespace {name!r} not published")
|
||||
return bags[name]
|
||||
|
||||
def override(self, source: str, **fields) -> None:
|
||||
"""The business mutation entry: write resolved config
|
||||
leaves onto the namespace bags — the single source of truth. It does
|
||||
**not** touch ``server_args`` (the pristine startup record) and there is
|
||||
no write-through, so the old "wrote one store, read another" desync class
|
||||
cannot occur.
|
||||
|
||||
Each flat field name is routed to its bag by the ``NS`` metadata (flat
|
||||
names are unique across namespaces). Validation is all-or-nothing: an
|
||||
unknown / unprojected field aborts before any write. ``source`` is
|
||||
recorded for provenance / reproduction.
|
||||
"""
|
||||
if not fields:
|
||||
return
|
||||
bags = self._config_bags
|
||||
if bags is None:
|
||||
raise ValueError("config not published; cannot override")
|
||||
from sglang.srt.arg_groups.arg_utils import namespace_of
|
||||
|
||||
nsmap = namespace_of(type(self._server_args))
|
||||
targets = [] # (bag, leaf, value) — resolved before any write
|
||||
for name, value in fields.items():
|
||||
path = nsmap.get(name)
|
||||
if path is None:
|
||||
raise ValueError(
|
||||
f"override: unknown config field {name!r} (no NS namespace) — "
|
||||
"not a resolved config leaf"
|
||||
)
|
||||
parts = path.split(".")
|
||||
bag = bags.get(parts[0])
|
||||
if bag is None:
|
||||
raise ValueError(f"override: namespace {parts[0]!r} not published")
|
||||
for seg in parts[1:]:
|
||||
bag = object.__getattribute__(bag, "_subs").get(seg)
|
||||
if bag is None:
|
||||
raise ValueError(
|
||||
f"override: subgroup {seg!r} missing under {path!r}"
|
||||
)
|
||||
if name not in bag:
|
||||
raise ValueError(f"override: field {name!r} not projected on {path!r}")
|
||||
targets.append((bag, name, value))
|
||||
for bag, name, value in targets:
|
||||
bag._set(name, value)
|
||||
self._overrides_log.append((source, dict(fields)))
|
||||
|
||||
def overrides_log(self) -> list:
|
||||
"""Provenance of post-publish ``override`` calls: ``[(source, {field: value})]``."""
|
||||
return list(self._overrides_log)
|
||||
|
||||
def override_server_args(self, **fields) -> _ServerArgsOverride:
|
||||
"""Test-only scoped override for the config tier — the sibling of
|
||||
``get_parallel().override()`` and the flag groups' ``override()``:
|
||||
@@ -927,6 +980,7 @@ def reset_context() -> None:
|
||||
"""
|
||||
_CONTEXT._server_args = None
|
||||
_CONTEXT._config_bags = None
|
||||
_CONTEXT._overrides_log = []
|
||||
_CONTEXT.flags = Flags()
|
||||
_CONTEXT.resources = Resources()
|
||||
_CONTEXT.forward = ForwardFlags()
|
||||
|
||||
@@ -7877,21 +7877,22 @@ class ServerArgs:
|
||||
object.__setattr__(self, "_in_override", False)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
# After materialization the fields are the resolved configuration:
|
||||
# under the strict test harness, a bare assignment outside
|
||||
# ServerArgs.override() (and the resolution pipeline itself) raises.
|
||||
# after materialization the fields are the resolved startup
|
||||
# configuration -- the pristine, READ-ONLY record. A bare assignment
|
||||
# outside ServerArgs.override() (and the resolution pipeline, which runs
|
||||
# before materialization) always raises; resolved config is mutated on
|
||||
# the context bags via get_context().override(...), not here. (Formerly
|
||||
# gated on SGLANG_STRICT_CONFIG_MUTATION; now unconditional.)
|
||||
if (
|
||||
not name.startswith("_")
|
||||
and getattr(self, "_declarations_materialized", False)
|
||||
and not getattr(self, "_in_override", False)
|
||||
):
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
if envs.SGLANG_STRICT_CONFIG_MUTATION.get():
|
||||
raise AttributeError(
|
||||
f"server_args.{name} assigned after resolution; use "
|
||||
"server_args.override(source, ...) instead."
|
||||
)
|
||||
raise AttributeError(
|
||||
f"server_args.{name} assigned after resolution; server_args is "
|
||||
"read-only -- use get_context().override(source, ...) to change "
|
||||
"resolved config."
|
||||
)
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def _resolved_attention_backends(self):
|
||||
|
||||
@@ -35,6 +35,14 @@ def make_runner(
|
||||
for name, value in arg_overrides.items():
|
||||
setattr(args, name, value)
|
||||
|
||||
# The policy routes its load-time default through the audited mutation entry
|
||||
# (server_args.override); mirror that on the stub so the write lands.
|
||||
def _override(source, **fields):
|
||||
for _field, _value in fields.items():
|
||||
setattr(args, _field, _value)
|
||||
|
||||
args.override = _override
|
||||
|
||||
return SimpleNamespace(
|
||||
server_args=args,
|
||||
model_config=SimpleNamespace(),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Context-first mutation.
|
||||
|
||||
``get_context().override(source, **fields)`` is the business mutation entry: it
|
||||
writes the resolved config bags (the single source of truth) and never touches
|
||||
``server_args`` (the pristine startup record). Routing is by NS metadata; a bad
|
||||
field aborts before any write; provenance is recorded.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.srt import runtime_context as rc
|
||||
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=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestContextOverride(CustomTestCase):
|
||||
def setUp(self):
|
||||
rc.reset_context()
|
||||
|
||||
def tearDown(self):
|
||||
rc.reset_context()
|
||||
|
||||
def _publish(self):
|
||||
sa = ServerArgs(model_path="dummy")
|
||||
rc.get_context().set_server_args(sa)
|
||||
return sa
|
||||
|
||||
def test_override_writes_bag_not_server_args(self):
|
||||
sa = self._publish()
|
||||
before = sa.hicache_ratio
|
||||
rc.get_context().override("test", hicache_ratio=before + 1.0)
|
||||
self.assertEqual(rc.get_memory().hicache_ratio, before + 1.0)
|
||||
# server_args stays the pristine startup record.
|
||||
self.assertEqual(sa.hicache_ratio, before)
|
||||
|
||||
def test_override_routes_across_namespaces(self):
|
||||
self._publish()
|
||||
rc.get_context().override(
|
||||
"test",
|
||||
moe_runner_backend="triton",
|
||||
page_size=64,
|
||||
disaggregation_mode="decode",
|
||||
)
|
||||
self.assertEqual(rc.get_exec().moe.moe_runner_backend, "triton")
|
||||
self.assertEqual(rc.get_schedule().page_size, 64)
|
||||
self.assertEqual(rc.get_disagg().disaggregation_mode, "decode")
|
||||
|
||||
def test_override_unknown_field_raises_and_is_atomic(self):
|
||||
self._publish()
|
||||
before = rc.get_memory().hicache_ratio
|
||||
with self.assertRaises(ValueError):
|
||||
rc.get_context().override(
|
||||
"test", hicache_ratio=before + 5.0, not_a_real_field=1
|
||||
)
|
||||
# No partial write: the valid field was not applied.
|
||||
self.assertEqual(rc.get_memory().hicache_ratio, before)
|
||||
|
||||
def test_override_before_publish_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
rc.get_context().override("test", page_size=32)
|
||||
|
||||
def test_override_provenance_recorded(self):
|
||||
self._publish()
|
||||
rc.get_context().override("srcA", page_size=16)
|
||||
log = rc.get_context().overrides_log()
|
||||
self.assertEqual(log[-1], ("srcA", {"page_size": 16}))
|
||||
|
||||
def test_republish_resets_provenance(self):
|
||||
self._publish()
|
||||
rc.get_context().override("srcA", page_size=16)
|
||||
self.assertTrue(rc.get_context().overrides_log())
|
||||
self._publish()
|
||||
self.assertEqual(rc.get_context().overrides_log(), [])
|
||||
|
||||
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.
|
||||
sa = ServerArgs(model_path="dummy")
|
||||
object.__setattr__(sa, "_declarations_materialized", True)
|
||||
with self.assertRaises(AttributeError):
|
||||
sa.page_size = 999
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user