[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:
Cheng Wan
2026-09-06 21:40:31 -07:00
committed by GitHub
parent 45c24444b1
commit ed82def55f
18 changed files with 4442 additions and 3624 deletions
+18 -16
View File
@@ -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:]: