diff --git a/examples/runtime/token_in_token_out/token_in_token_out_vlm_engine.py b/examples/runtime/token_in_token_out/token_in_token_out_vlm_engine.py index bf6f19719..7f610af1a 100644 --- a/examples/runtime/token_in_token_out/token_in_token_out_vlm_engine.py +++ b/examples/runtime/token_in_token_out/token_in_token_out_vlm_engine.py @@ -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) diff --git a/python/sglang/srt/disaggregation/encode_server.py b/python/sglang/srt/disaggregation/encode_server.py index a3bd73a98..626b75a1b 100644 --- a/python/sglang/srt/disaggregation/encode_server.py +++ b/python/sglang/srt/disaggregation/encode_server.py @@ -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 diff --git a/python/sglang/srt/layers/attention/linear/gdn_backend.py b/python/sglang/srt/layers/attention/linear/gdn_backend.py index b535666d6..681583c0f 100644 --- a/python/sglang/srt/layers/attention/linear/gdn_backend.py +++ b/python/sglang/srt/layers/attention/linear/gdn_backend.py @@ -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.") diff --git a/python/sglang/srt/runtime_context.py b/python/sglang/srt/runtime_context.py index c891efa19..05c83fd6d 100644 --- a/python/sglang/srt/runtime_context.py +++ b/python/sglang/srt/runtime_context.py @@ -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() diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 6ceaea6c2..b5ec57619 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -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): diff --git a/test/registered/unit/layers/attention/test_gdn_prefill_backend_policy.py b/test/registered/unit/layers/attention/test_gdn_prefill_backend_policy.py index f8fdd0bae..1381981b2 100644 --- a/test/registered/unit/layers/attention/test_gdn_prefill_backend_policy.py +++ b/test/registered/unit/layers/attention/test_gdn_prefill_backend_policy.py @@ -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(), diff --git a/test/registered/unit/test_runtime_context_override.py b/test/registered/unit/test_runtime_context_override.py new file mode 100644 index 000000000..6a6b51e17 --- /dev/null +++ b/test/registered/unit/test_runtime_context_override.py @@ -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()