[Config] Round 6.2: the field declarations move to their namespaces, and the record is assembled from them (#38047)
Second of four; stacked on #38046. Mechanical relocation plus one design change that the relocation makes possible. **Review by checking the identity proofs at the bottom** -- nothing here is meant to change behaviour. ## The declarations move `ServerArgs` carried all 487 declarations in one 4,462-line file, each tagged with an `NS("...")` marker naming the namespace it belongs to -- structure supplied by annotation, in a file a namespace away from the `arg_groups/*_hook.py` that resolves it. They move to `arg_groups/fields/`: one module per top-level namespace, one class per leaf namespace (21 of them, `exec.moe` becomes `exec_.py::ExecMoe`). The class carries the `_NS_PATH` it stands for, so the module a field is declared in *is* its namespace and the marker is redundant -- `namespace_of` reads the declaring class instead. `NS` stays for the one case a class cannot express: a single ad-hoc dataclass whose fields span namespaces, which is what the config-bag tests build. Two things travel with the declarations. The `*_CHOICES` lists and the `add_*_choices` adders that extend them move to `arg_groups/choices.py`, since the fields naming them can no longer import from `server_args` without a cycle; `server_args` re-exports all of them, because out-of-tree plugins have always reached them there. And five fields whose only annotation element was the namespace marker become plain annotations -- `A` is `Annotated`, which needs two arguments, so stripping the marker would have left them invalid. `server_args.py` goes from 4,458 lines to about 1,000. ## The record is assembled, not inherited Inheriting the namespace classes would make the record's contents a property of which classes happen to appear in a base list. That is correct only while every namespace declares nothing but operator input, and it stops being correct the moment a derived field is declared: `attn_tp_size` belongs in `parallel.py` next to the leaves it is derived from, and inheriting `Parallel` would put it on the record -- where it is neither input nor safe, since the record is what crosses a process boundary and a derived width pickled to a subprocess is a stamp that elastic scale-up will not refresh. `collect_input_fields` takes the classes that declare input and returns their annotations, defaults and namespaces. Each source's annotations are resolved in its own module and handed on as type objects; carried across as text they would be re-evaluated where they land, and the composing module deliberately imports none of the names the declarations use. A namespace can now declare both halves side by side, and which half reaches the record is one readable call rather than an invariant spread across a base-class list. Nothing is registered on the derived side yet -- this is what makes it possible. `ServerArgs` is still one flat dataclass with 494 attributes, so `server_args.tp_size`, `ServerArgs(model_path=..., tp_size=8)`, pickling to a subprocess and every existing call site are untouched. ### Field order is a contract, so it is written down A dataclass turns field order into a positional constructor signature, and collecting whole namespaces groups fields that used to be interleaved. Keeping `model_path` first is not enough: `ServerArgs("dummy", "/tmp/tokenizer")` would set `load_format="/tmp/tokenizer"` and leave `tokenizer_path=None`, which then selects an invalid model loader -- silently, at a call site that did not change. So `arg_groups/field_order.py` records the order the record had before the split, and `collect_input_fields` orders what it collects by it. A field the record declares that the frozen order does not name goes after it, in declaration order -- the only backward-compatible place for a new field anyway, so a new declaration needs no edit there. The list is a compatibility record and nothing else reads it; the namespace a field belongs to is still the module it is declared in. ## Verification Four ways, all against the base commit: | check | result | |---|---| | `namespace_of` map, field by field | 494 / 494, **0 differences** | | CLI surface (options, defaults, choices, actions) | 507 / 507, **0 differences** | | field order, name by name | 494 / 494, **identical to the base** | | resolution result, 24 launch shapes x 489 fields | **0 differences** | | names importable from `sglang.srt.server_args` | nothing lost | Plus a full registered-unit sweep (648 files) against the stack's merge-base: 19 failures on both sides, the same 19, none of them config.
This commit is contained in:
@@ -33,8 +33,8 @@ shims over this slot).
|
||||
``get_model()`` / ``get_spec()`` / ``get_lora()`` / ``get_mm()`` /
|
||||
``get_disagg()`` / ``get_serving()`` / ``get_observability()`` return the
|
||||
resolved **config namespace bags** — the single source of truth for config,
|
||||
snapshotted from ``server_args`` at publish and driven by the ``NS(...)``
|
||||
metadata on each field (multi-level under ``exec.*``). Reads are attribute
|
||||
snapshotted from ``server_args`` at publish, one bag per namespace class in
|
||||
``arg_groups/fields/`` (multi-level under ``exec.*``). Reads are attribute
|
||||
chains (``get_exec().moe.moe_runner_backend``); bags are read-only by bare
|
||||
assignment (written via ``override``).
|
||||
|
||||
@@ -796,14 +796,16 @@ class _ConfigBag:
|
||||
|
||||
|
||||
def _build_config_bags(server_args: Any) -> dict:
|
||||
"""Snapshot the resolution result into the namespace bag tree, driven by
|
||||
the ``NS(...)`` metadata on the dataclass fields. Each leaf comes from
|
||||
"""Snapshot the resolution result into the namespace bag tree.
|
||||
|
||||
The tree is ``namespace_of``: each field is placed by the namespace class
|
||||
that declares it (``arg_groups/fields/``). Each leaf comes from
|
||||
``resolution_result`` -- the declaration if resolution made one, else what
|
||||
the caller supplied. Returns
|
||||
``{top_level_name: _ConfigBag}``, arbitrarily nested (``exec.moe.eplb.…``).
|
||||
Only dataclass fields carry ``NS`` markers, so derived properties/methods are
|
||||
naturally excluded (they stay on the bag). A name used as both a leaf and a
|
||||
subgroup at the same level is a hard error — no silent shadowing."""
|
||||
the caller supplied. Returns ``{top_level_name: _ConfigBag}``, arbitrarily
|
||||
nested (``exec.moe.eplb.…``). Only dataclass fields are placed, so derived
|
||||
properties and methods are naturally excluded (they stay on the bag). A
|
||||
name used as both a leaf and a subgroup at the same level is a hard error
|
||||
— no silent shadowing."""
|
||||
from sglang.srt.arg_groups.arg_utils import namespace_of
|
||||
from sglang.srt.arg_groups.overrides import resolution_result
|
||||
|
||||
@@ -812,12 +814,12 @@ def _build_config_bags(server_args: Any) -> dict:
|
||||
for field, path in namespace_of(type(server_args)).items():
|
||||
value = resolution_result(server_args, field, _MISSING)
|
||||
if value is _MISSING:
|
||||
# Every NS-declared field is a dataclass field, so a resolved config
|
||||
# Every placed field is a dataclass field, so a resolved config
|
||||
# always carries it; a miss means a malformed/partial config object
|
||||
# was published. Fail loud here rather than silently omitting the
|
||||
# leaf (which surfaces later as a confusing "not a published leaf").
|
||||
raise AttributeError(
|
||||
f"config field {field!r} is declared NS({path!r}) but absent from "
|
||||
f"config field {field!r} belongs to namespace {path!r} but is absent from "
|
||||
f"the published {type(server_args).__name__}; cannot project its bag leaf"
|
||||
)
|
||||
parts = path.split(".")
|
||||
@@ -953,8 +955,8 @@ class RuntimeContext:
|
||||
)
|
||||
self._server_args = server_args
|
||||
# Snapshot resolved config into the namespace bags (the single source of
|
||||
# truth for config reads). Driven by NS(...) metadata; a mock/partial
|
||||
# config with no NS markers yields an empty tree (no bags projected).
|
||||
# truth for config reads). Placed by `namespace_of`; a mock/partial
|
||||
# config that declares no namespace yields an empty tree (no bags).
|
||||
self._config_bags = _build_config_bags(server_args)
|
||||
spec = self._config_bags.get("spec")
|
||||
if spec is not None:
|
||||
@@ -1024,7 +1026,7 @@ class RuntimeContext:
|
||||
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
|
||||
Each flat field name is routed to its bag by ``namespace_of`` (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.
|
||||
@@ -1042,7 +1044,7 @@ class RuntimeContext:
|
||||
path = nsmap.get(name)
|
||||
if path is None:
|
||||
raise ValueError(
|
||||
f"override: unknown config field {name!r} (no NS namespace) — "
|
||||
f"override: unknown config field {name!r} (no namespace) — "
|
||||
"not a resolved config leaf"
|
||||
)
|
||||
parts = path.split(".")
|
||||
@@ -1076,7 +1078,7 @@ class RuntimeContext:
|
||||
|
||||
path = namespace_of(type(self._server_args)).get(name)
|
||||
if path is None:
|
||||
raise ValueError(f"{name!r} is not a config leaf (no NS namespace)")
|
||||
raise ValueError(f"{name!r} is not a config leaf (no namespace)")
|
||||
parts = path.split(".")
|
||||
bag = self.config_bag(parts[0])
|
||||
for seg in parts[1:]:
|
||||
|
||||
Reference in New Issue
Block a user