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)
|
ServerArgs.add_cli_args(parser)
|
||||||
args = [
|
args = [
|
||||||
"--model-path=Qwen/Qwen2-VL-2B",
|
"--model-path=Qwen/Qwen2-VL-2B",
|
||||||
|
"--skip-tokenizer-init",
|
||||||
]
|
]
|
||||||
args = parser.parse_args(args=args)
|
args = parser.parse_args(args=args)
|
||||||
server_args = ServerArgs.from_cli_args(args)
|
server_args = ServerArgs.from_cli_args(args)
|
||||||
server_args.skip_tokenizer_init = True
|
|
||||||
token_in_out_example(server_args)
|
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
|
# 0 when CVD is pinned to one GPU, else the absolute id. rank=0, so
|
||||||
# MMEncoder runs set_device(base_gpu_id).
|
# MMEncoder runs set_device(base_gpu_id).
|
||||||
args = copy.deepcopy(server_args)
|
args = copy.deepcopy(server_args)
|
||||||
args.base_gpu_id = gpu_id
|
# The copy is already resolved (read-only); route the per-worker
|
||||||
args.tp_size = 1
|
# 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)
|
enc = MMEncoder(args, dist_init_method=f"tcp://127.0.0.1:{get_free_port()}", rank=0)
|
||||||
|
|
||||||
global encoder_metrics_collector
|
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():
|
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.")
|
rank0_log("Defaulting SM100 GDN prefill backend to FlashInfer.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ class RuntimeContext:
|
|||||||
"parallel",
|
"parallel",
|
||||||
"_server_args",
|
"_server_args",
|
||||||
"_config_bags",
|
"_config_bags",
|
||||||
|
"_overrides_log",
|
||||||
"flags",
|
"flags",
|
||||||
"resources",
|
"resources",
|
||||||
"forward",
|
"forward",
|
||||||
@@ -664,6 +665,7 @@ class RuntimeContext:
|
|||||||
self.parallel = parallel
|
self.parallel = parallel
|
||||||
self._server_args: ServerArgs | None = None
|
self._server_args: ServerArgs | None = None
|
||||||
self._config_bags: dict | None = None
|
self._config_bags: dict | None = None
|
||||||
|
self._overrides_log: list = []
|
||||||
self.flags = Flags()
|
self.flags = Flags()
|
||||||
self.resources = Resources()
|
self.resources = Resources()
|
||||||
self.forward = ForwardFlags()
|
self.forward = ForwardFlags()
|
||||||
@@ -725,6 +727,8 @@ class RuntimeContext:
|
|||||||
# truth for config reads). Driven by NS(...) metadata; a mock/partial
|
# truth for config reads). Driven by NS(...) metadata; a mock/partial
|
||||||
# config with no NS markers yields an empty tree (no bags projected).
|
# config with no NS markers yields an empty tree (no bags projected).
|
||||||
self._config_bags = _build_config_bags(server_args)
|
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:
|
def config_bag(self, name: str) -> _ConfigBag:
|
||||||
"""Return the top-level config namespace bag (``device`` / ``model`` /
|
"""Return the top-level config namespace bag (``device`` / ``model`` /
|
||||||
@@ -736,6 +740,55 @@ class RuntimeContext:
|
|||||||
raise ValueError(f"config namespace {name!r} not published")
|
raise ValueError(f"config namespace {name!r} not published")
|
||||||
return bags[name]
|
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:
|
def override_server_args(self, **fields) -> _ServerArgsOverride:
|
||||||
"""Test-only scoped override for the config tier — the sibling of
|
"""Test-only scoped override for the config tier — the sibling of
|
||||||
``get_parallel().override()`` and the flag groups' ``override()``:
|
``get_parallel().override()`` and the flag groups' ``override()``:
|
||||||
@@ -927,6 +980,7 @@ def reset_context() -> None:
|
|||||||
"""
|
"""
|
||||||
_CONTEXT._server_args = None
|
_CONTEXT._server_args = None
|
||||||
_CONTEXT._config_bags = None
|
_CONTEXT._config_bags = None
|
||||||
|
_CONTEXT._overrides_log = []
|
||||||
_CONTEXT.flags = Flags()
|
_CONTEXT.flags = Flags()
|
||||||
_CONTEXT.resources = Resources()
|
_CONTEXT.resources = Resources()
|
||||||
_CONTEXT.forward = ForwardFlags()
|
_CONTEXT.forward = ForwardFlags()
|
||||||
|
|||||||
@@ -7877,20 +7877,21 @@ class ServerArgs:
|
|||||||
object.__setattr__(self, "_in_override", False)
|
object.__setattr__(self, "_in_override", False)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name, value):
|
||||||
# After materialization the fields are the resolved configuration:
|
# after materialization the fields are the resolved startup
|
||||||
# under the strict test harness, a bare assignment outside
|
# configuration -- the pristine, READ-ONLY record. A bare assignment
|
||||||
# ServerArgs.override() (and the resolution pipeline itself) raises.
|
# 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 (
|
if (
|
||||||
not name.startswith("_")
|
not name.startswith("_")
|
||||||
and getattr(self, "_declarations_materialized", False)
|
and getattr(self, "_declarations_materialized", False)
|
||||||
and not getattr(self, "_in_override", False)
|
and not getattr(self, "_in_override", False)
|
||||||
):
|
):
|
||||||
from sglang.srt.environ import envs
|
|
||||||
|
|
||||||
if envs.SGLANG_STRICT_CONFIG_MUTATION.get():
|
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f"server_args.{name} assigned after resolution; use "
|
f"server_args.{name} assigned after resolution; server_args is "
|
||||||
"server_args.override(source, ...) instead."
|
"read-only -- use get_context().override(source, ...) to change "
|
||||||
|
"resolved config."
|
||||||
)
|
)
|
||||||
object.__setattr__(self, name, value)
|
object.__setattr__(self, name, value)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ def make_runner(
|
|||||||
for name, value in arg_overrides.items():
|
for name, value in arg_overrides.items():
|
||||||
setattr(args, name, value)
|
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(
|
return SimpleNamespace(
|
||||||
server_args=args,
|
server_args=args,
|
||||||
model_config=SimpleNamespace(),
|
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