Files
sglang/python/sglang/srt/arg_groups/fields/disagg.py
T
Cheng Wan ed82def55f [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.
2026-09-06 21:40:31 -07:00

168 lines
7.2 KiB
Python

"""Config fields of the ``disagg`` namespace.
One class per namespace. The class *is* the namespace: a field declared here
lands in the ``disagg`` bag, which is what ``get_disagg()`` returns, so a reader
spells it exactly as before. ``ServerArgs`` composes these classes, so the
record stays one flat object -- the split moves where declarations live, not
how config is shaped at runtime.
"""
from __future__ import annotations
import dataclasses
from typing import (
List,
Literal,
Optional,
)
from sglang.srt.arg_groups.arg_utils import (
A,
Arg,
)
from sglang.srt.arg_groups.choices import DISAGG_TRANSFER_BACKEND_CHOICES
from sglang.srt.utils.common import json_list_type
@dataclasses.dataclass
class Disagg:
"""Namespace ``disagg``."""
_NS_PATH = "disagg"
# Decoupled speculative decoding: draft and verify run as
# separate engines, currently connected by a ZMQ IPC mesh.
decoupled_spec_bind_endpoint: A[
Optional[str],
"ZMQ endpoint this engine binds for its inbound channel in decoupled "
"speculative decoding (verifier: result PULL; drafter: control PULL).",
] = None
decoupled_spec_connect_endpoints: A[
Optional[List[str]],
Arg(
help="Peer inbound (bind) endpoints to connect to, ordered by peer "
"rank, for decoupled speculative decoding.",
type_parser=json_list_type,
),
] = None
decoupled_spec_rank: A[
Optional[int],
"This engine's rank within its own role space (verifier-rank or "
"drafter-rank) for decoupled speculative decoding.",
] = None
decoupled_spec_role: A[
Literal["null", "verifier", "drafter"],
"Role in decoupled speculative decoding: 'null' disables it, 'verifier' "
"runs the target/verify half, 'drafter' runs the draft half.",
] = "null"
# -------------------------------------------------------------------------
# PD disaggregation
# -------------------------------------------------------------------------
disaggregation_mode: A[
Literal["null", "prefill", "decode"],
'Only used for PD disaggregation. "prefill" for prefill-only server, and "decode" for decode-only server. If not specified, it is not PD disaggregated',
] = "null"
disaggregation_transfer_backend: A[
str,
Arg(
help="The backend for disaggregation transfer. Default is mooncake.",
choices=DISAGG_TRANSFER_BACKEND_CHOICES,
),
] = "mooncake"
disaggregation_bootstrap_port: A[
int, "Bootstrap server port on the prefill server. Default is 8998."
] = 8998
disaggregation_ib_device: A[
Optional[str],
'The InfiniBand devices for disaggregation transfer. Supports a single device (e.g., --disaggregation-ib-device mlx5_0), a shared comma-separated list (e.g., --disaggregation-ib-device mlx5_0,mlx5_1), a per-GPU JSON mapping (e.g., --disaggregation-ib-device \'{"0": "mlx5_0,mlx5_1", "1": "mlx5_2"}\'), or a path to a JSON file containing that mapping. Default is None, which triggers automatic device detection when mooncake backend is enabled.',
] = None
disaggregation_decode_enable_radix_cache: A[
bool,
"Enable radix cache on decode server (PD mode). Caches KV prefixes to avoid redundant transfers. Incompatible with --enable-hisparse, speculative decoding, and --disaggregation-transfer-backend fake.",
] = False
disaggregation_decode_enable_offload_kvcache: A[
bool, "Enable async KV cache offloading on decode server (PD mode)."
] = False
disaggregation_decode_retraction_backup: A[
Optional[str],
Arg(
help=(
"Storage backend for KV preserved across PD decode retraction. "
"'cpu_tensor' uses per-request CPU tensors. 'host_pool' uses "
"a reserved HiCache pool and does not fall back on exhaustion. "
"If omitted, the backend is inferred from the decode KV pool."
),
choices=["cpu_tensor", "host_pool"],
),
] = None
num_reserved_decode_tokens: A[
int,
"Number of decode tokens that will have memory reserved when adding new request to the running batch.",
] = 512
disaggregation_decode_extra_slots: A[
Optional[int],
"Number of extra decode req_to_token slots pre-allocated for in-transfer requests (PD mode). If unset, defaults to 0 (or 2x the per-worker running batch for small batches).",
] = None
disaggregation_decode_polling_interval: A[
int,
"The interval to poll requests in decode server. Can be set to >1 to reduce the overhead of this.",
] = 1
optimistic_prefill_attempts: A[
int, "Number of optimistic prefill forward passes that skip the bootstrap wait."
] = 0
# -------------------------------------------------------------------------
# Encode prefill disaggregation
# -------------------------------------------------------------------------
encoder_only: A[
bool,
"For MLLM with an encoder, launch an encoder-only server",
] = False
language_only: A[
bool,
"For VLM, load weights for the language model only.",
] = False
language_model_only: A[
bool,
"Skip the multimodal encoder entirely: its weights are never loaded and the "
"tower is never built, freeing that GPU memory for KV cache. Multimodal "
"requests are rejected. Unlike --language-only this is a standalone mode, "
"not part of encoder/decoder disaggregation.",
] = False
encoder_transfer_backend: A[
str,
Arg(
help="The backend for encoder disaggregation transfer. Auto selects a model- and TP-aware backend.",
choices=["auto", "zmq_to_scheduler", "zmq_to_tokenizer", "mooncake"],
),
] = "auto"
encoder_urls: A[List[str], "List of encoder server urls."] = dataclasses.field(
default_factory=list
)
encoder_bootstrap_port: A[
int,
"Port for the EncoderBootstrapServer that runs in the language-only tokenizer manager process. Encoders register here, and language-only receivers fetch the current URL list from here.",
] = 8997
encoder_register_urls: A[
List[str],
"One or more EncoderBootstrapServer URLs to register this encoder with on startup, for dynamic encoder discovery. Example: --encoder-register-urls http://prefill0:8997 http://prefill1:8997. Used with --encoder-only servers.",
] = dataclasses.field(default_factory=list)
enable_adaptive_dispatch_to_encoder: A[
bool,
"When enabled, adaptively dispatch: multi-image requests go to encoder in language_only epd mode, single-image requests are processed locally.",
] = False
# -------------------------------------------------------------------------
# PD-Multiplexing
# -------------------------------------------------------------------------
enable_pdmux: A[
bool,
"Enable PD-Multiplexing, PD running on greenctx stream.",
] = False
pdmux_config_path: A[
Optional[str],
"The path of the PD-Multiplexing config file.",
] = None
sm_group_num: A[int, "Number of sm partition groups."] = 8