diff --git a/python/sglang/srt/arg_groups/arg_utils.py b/python/sglang/srt/arg_groups/arg_utils.py index a9b091e5f..bc209aa27 100644 --- a/python/sglang/srt/arg_groups/arg_utils.py +++ b/python/sglang/srt/arg_groups/arg_utils.py @@ -119,6 +119,14 @@ def namespace_of(cls) -> dict: return out +@functools.lru_cache(maxsize=None) +def field_names(cls) -> frozenset: + """Names of ``cls`` dataclass fields — what a declaration may name.""" + if not dataclasses.is_dataclass(cls): + return frozenset() + return frozenset(field.name for field in dataclasses.fields(cls)) + + @functools.lru_cache(maxsize=None) def resolvable_fields(cls) -> frozenset: """Names of ``cls`` dataclass fields whose ``Arg`` metadata declares diff --git a/python/sglang/srt/arg_groups/deepseek_v4_hook.py b/python/sglang/srt/arg_groups/deepseek_v4_hook.py index 4569c9b62..c2c78f46f 100644 --- a/python/sglang/srt/arg_groups/deepseek_v4_hook.py +++ b/python/sglang/srt/arg_groups/deepseek_v4_hook.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING +from sglang.srt.arg_groups.overrides import declare_resolution from sglang.srt.environ import envs if TYPE_CHECKING: @@ -136,7 +137,11 @@ def apply_deepseek_v4_defaults(server_args: ServerArgs, model_arch: str) -> None run_post_process_pass(server_args, _deepseek_v4_kv_cache_dtype) if server_args.max_running_requests is None: - server_args.max_running_requests = 256 + declare_resolution( + server_args, + "apply_deepseek_v4_defaults", + max_running_requests=256, + ) logger.warning( f"Setting max_running_requests to {server_args.max_running_requests} for {model_arch}." ) @@ -163,12 +168,36 @@ def validate_deepseek_v4_cp(server_args: ServerArgs) -> None: f"got {server_args.cp_strategy}" ) - server_args.enable_dsa_prefill_context_parallel = True - server_args.enable_prefill_context_parallel = False - server_args.dsa_prefill_cp_mode = "round-robin-split" - server_args.enable_dp_attention = True - server_args.moe_dense_tp_size = 1 - server_args.attn_cp_size = server_args.tp_size // server_args.dp_size + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + enable_dsa_prefill_context_parallel=True, + ) + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + enable_prefill_context_parallel=False, + ) + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + dsa_prefill_cp_mode="round-robin-split", + ) + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + enable_dp_attention=True, + ) + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + moe_dense_tp_size=1, + ) + declare_resolution( + server_args, + "validate_deepseek_v4_cp", + attn_cp_size=server_args.tp_size // server_args.dp_size, + ) assert ( server_args.dp_size == 1 ), "For round-robin split mode, dp attention is not supported." diff --git a/python/sglang/srt/arg_groups/kimi_k3_hook.py b/python/sglang/srt/arg_groups/kimi_k3_hook.py index 7fe32a90c..9b42917f8 100644 --- a/python/sglang/srt/arg_groups/kimi_k3_hook.py +++ b/python/sglang/srt/arg_groups/kimi_k3_hook.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING +from sglang.srt.arg_groups.overrides import declare_resolution + if TYPE_CHECKING: from sglang.srt.server_args import ServerArgs @@ -20,7 +22,11 @@ def apply_kimi_k3_spec_backend_defaults(server_args: ServerArgs) -> None: # Decode is left free (its bf16-ssm SM100+ flashinfer default is fine -- the # target only verifies under spec); the verify backend is pinned directly. if server_args.linear_attn_verify_backend is None: - server_args.linear_attn_verify_backend = "nv_cutedsl" + declare_resolution( + server_args, + "apply_kimi_k3_spec_backend_defaults", + linear_attn_verify_backend="nv_cutedsl", + ) logger.info( "Kimi hybrid model with speculative decoding: pinning " "--linear-attn-verify-backend to nv_cutedsl (uses the fused " @@ -34,7 +40,11 @@ def apply_kimi_k3_spec_backend_defaults(server_args: ServerArgs) -> None: and server_args.speculative_draft_attention_backend is None and is_sm100_supported() ): - server_args.speculative_draft_attention_backend = "trtllm_mha" + declare_resolution( + server_args, + "apply_kimi_k3_spec_backend_defaults", + speculative_draft_attention_backend="trtllm_mha", + ) logger.info( "Kimi hybrid DSPARK: defaulting " "--speculative-draft-attention-backend to trtllm_mha." @@ -72,7 +82,11 @@ def disable_kimi_k3_symm_mem(server_args: ServerArgs) -> None: and graph.prefill.backend == Backend.DISABLED ): return - server_args.enable_symm_mem = False + declare_resolution( + server_args, + "disable_kimi_k3_symm_mem", + enable_symm_mem=False, + ) logger.warning( "Kimi hybrid model: ignoring --enable-symm-mem because CUDA graphs are on. " "The symmetric-memory pool's per-forward allocations are not valid for the " @@ -96,7 +110,11 @@ def apply_kimi_k3_linear_attn_defaults(server_args: ServerArgs) -> None: and server_args.mamba_ssm_dtype == "bfloat16" and is_sm100_supported() ): - server_args.linear_attn_decode_backend = "triton" + declare_resolution( + server_args, + "apply_kimi_k3_linear_attn_defaults", + linear_attn_decode_backend="triton", + ) logger.info( "Kimi hybrid model with bf16 SSM state: defaulting " "--linear-attn-decode-backend to triton." diff --git a/python/sglang/srt/arg_groups/mega_moe_hook.py b/python/sglang/srt/arg_groups/mega_moe_hook.py index 89382c728..0c1806e69 100644 --- a/python/sglang/srt/arg_groups/mega_moe_hook.py +++ b/python/sglang/srt/arg_groups/mega_moe_hook.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from sglang.srt.server_args import ServerArgs +from sglang.srt.arg_groups.overrides import declare_resolution + logger = logging.getLogger(__name__) @@ -26,8 +28,12 @@ def handle_moe_runner_backend_alias(server_args: ServerArgs) -> None: "--moe-a2a-backend %s.", server_args.moe_a2a_backend, ) - server_args.moe_runner_backend = "auto" - server_args.moe_a2a_backend = "megamoe" + declare_resolution( + server_args, + "handle_moe_runner_backend_alias", + moe_runner_backend="auto", + moe_a2a_backend="megamoe", + ) def handle_w4a4_mxfp4_megamoe_env(server_args: ServerArgs) -> None: diff --git a/python/sglang/srt/arg_groups/overrides.py b/python/sglang/srt/arg_groups/overrides.py index 167959763..45236737e 100644 --- a/python/sglang/srt/arg_groups/overrides.py +++ b/python/sglang/srt/arg_groups/overrides.py @@ -35,7 +35,7 @@ import json import logging from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple -from sglang.srt.arg_groups.arg_utils import resolvable_fields +from sglang.srt.arg_groups.arg_utils import field_names, resolvable_fields from sglang.srt.environ import envs from sglang.srt.hardware_backend.mlx.runtime import use_mlx from sglang.srt.model_executor.cuda_graph_config import Backend @@ -167,9 +167,12 @@ def register_post_process(fn: Callable[..., dict]) -> Callable[..., dict]: def _declaration_overlay(server_args: Any) -> Dict[str, Any]: - """Accumulated declared values: declarations never mutate - ``server_args``, so mid-resolution readers overlay them from the - declaration stash (last writer wins, like the gate).""" + """What the declarations say so far, last writer wins. + + Passes declare without touching the fields until + ``materialize_declarations``, so a mid-resolution reader needs this to see + them; handlers and hooks write as they declare, and for those the overlay + repeats what the field already holds.""" overlay: Dict[str, Any] = {} for _source, declared in getattr(server_args, "_resolved_overrides", None) or (): overlay.update(declared) @@ -219,6 +222,44 @@ def _apply_fields(server_args: Any, fields: Dict[str, Any]) -> None: object.__setattr__(server_args, "_internal_write", False) +def declare_resolution(server_args: Any, source: str, **fields: Any) -> None: + """Record a resolution write in the declaration stash, and apply it now. + + The stash is what the projection reads, so a resolver that only assigns + the field leaves that write invisible to it. The immediate write keeps the + resolver's successors seeing the value where they read the field directly. + + What it does change is which writer wins. A declaration is appended and + replayed last, so a resolver that declares a field a *deferred* writer (a + post-process pass, a registry entry) also decides now beats it, where its + bare assignment used to be overwritten by that writer's declaration. A + resolver that gates on such a field has to read the resolving view rather + than the raw field, or it decides from a value that is already stale. + + For resolvers inside ``__post_init__``: the handlers on ``ServerArgs`` + (through ``self._declare``) and the ``arg_groups`` hooks and hardware + defaults they call. Resolution that has to wait for the launcher stage + goes through ``declare_late_resolution`` instead. + + Names arrive as keyword arguments, which accept anything; a misspelled one + would otherwise become a new attribute that nothing ever reads, so it is + rejected here. This is not the model-override whitelist: that one limits + which fields a *registry entry* may reach, while a resolver writing the + field it owns is the pipeline resolving by construction. + """ + if dataclasses.is_dataclass(type(server_args)): + unknown = sorted(set(fields) - field_names(type(server_args))) + if unknown: + raise AttributeError(f"{source}: {unknown} are not ServerArgs fields") + stash = getattr(server_args, "_resolved_overrides", None) + if stash is None: + stash = [] + object.__setattr__(server_args, "_resolved_overrides", stash) + stash.append((source, dict(fields))) + for name, value in fields.items(): + setattr(server_args, name, value) + + def declare_late_resolution(server_args: Any, source: str, **fields: Any) -> None: """Resolve fields on a config that is **not published yet**. diff --git a/python/sglang/srt/arg_groups/pd_disaggregation_hook.py b/python/sglang/srt/arg_groups/pd_disaggregation_hook.py index 65be40b5f..ff2257d61 100644 --- a/python/sglang/srt/arg_groups/pd_disaggregation_hook.py +++ b/python/sglang/srt/arg_groups/pd_disaggregation_hook.py @@ -5,6 +5,7 @@ import logging import os from typing import TYPE_CHECKING +from sglang.srt.arg_groups.overrides import declare_resolution from sglang.srt.environ import envs if TYPE_CHECKING: @@ -20,8 +21,16 @@ def handle_pd_disaggregation(server_args: ServerArgs) -> None: # mooncake, and skip RDMA HCA selection. Must run before backend-name checks. if server_args.disaggregation_transfer_backend == "mooncake_tcp": os.environ.setdefault("MC_FORCE_TCP", "1") - server_args.disaggregation_transfer_backend = "mooncake" - server_args.disaggregation_ib_device = None + declare_resolution( + server_args, + "handle_pd_disaggregation", + disaggregation_transfer_backend="mooncake", + ) + declare_resolution( + server_args, + "handle_pd_disaggregation", + disaggregation_ib_device=None, + ) logger.info( "disaggregation transfer backend 'mooncake_tcp' -> mooncake " "with MC_FORCE_TCP=1 (TCP transport, no RDMA)" @@ -83,10 +92,18 @@ def handle_pd_disaggregation(server_args: ServerArgs) -> None: "EXPERIMENTAL: Decode radix cache with DP attention. " "Requires prefix-aware DP rank routing for optimal cache hits." ) - server_args.disable_radix_cache = False + declare_resolution( + server_args, + "handle_pd_disaggregation", + disable_radix_cache=False, + ) logger.warning("EXPERIMENTAL: Radix cache is enabled for decode server") else: - server_args.disable_radix_cache = True + declare_resolution( + server_args, + "handle_pd_disaggregation", + disable_radix_cache=True, + ) logger.warning("KV cache is forced as chunk cache for decode server") # Default the number of *extra* decode req_to_token slots reserved for @@ -101,7 +118,11 @@ def handle_pd_disaggregation(server_args: ServerArgs) -> None: ) if per_worker <= 32: extra_slots = per_worker * 2 - server_args.disaggregation_decode_extra_slots = extra_slots + declare_resolution( + server_args, + "handle_pd_disaggregation", + disaggregation_decode_extra_slots=extra_slots, + ) elif server_args.disaggregation_mode == "prefill": assert ( @@ -153,4 +174,8 @@ def _alias_bootstrap_port_to_api_port(server_args: ServerArgs) -> None: server_args.disaggregation_bootstrap_port, server_args.port, ) - server_args.disaggregation_bootstrap_port = server_args.port + declare_resolution( + server_args, + "_alias_bootstrap_port_to_api_port", + disaggregation_bootstrap_port=server_args.port, + ) diff --git a/python/sglang/srt/arg_groups/speculative_hook.py b/python/sglang/srt/arg_groups/speculative_hook.py index c10348325..deb0b7d9c 100644 --- a/python/sglang/srt/arg_groups/speculative_hook.py +++ b/python/sglang/srt/arg_groups/speculative_hook.py @@ -5,6 +5,8 @@ import logging import os from typing import TYPE_CHECKING, Optional +from sglang.srt.arg_groups.overrides import declare_resolution + if TYPE_CHECKING: from sglang.srt.server_args import ServerArgs @@ -15,7 +17,11 @@ def _disable_overlap_schedule_for_cpu(server_args: ServerArgs) -> None: if server_args.device != "cpu" or server_args.disable_overlap_schedule: return - server_args.disable_overlap_schedule = True + declare_resolution( + server_args, + "_disable_overlap_schedule_for_cpu", + disable_overlap_schedule=True, + ) logger.warning( "Overlap schedule is not implemented for speculative decoding on CPU." ) @@ -66,7 +72,11 @@ def handle_speculative_decoding(server_args: ServerArgs) -> None: server_args.speculative_draft_model_path is not None and server_args.speculative_draft_model_revision is None ): - server_args.speculative_draft_model_revision = "main" + declare_resolution( + server_args, + "handle_speculative_decoding", + speculative_draft_model_revision="main", + ) # Moved to the resolution pipeline (arg_groups/overrides.py: # _speculative_moe_runner_default), invoked here at its legacy slot. @@ -78,7 +88,11 @@ def handle_speculative_decoding(server_args: ServerArgs) -> None: run_post_process_pass(server_args, _speculative_moe_runner_default) if server_args.speculative_algorithm is not None: - server_args.speculative_algorithm = server_args.speculative_algorithm.upper() + declare_resolution( + server_args, + "handle_speculative_decoding", + speculative_algorithm=server_args.speculative_algorithm.upper(), + ) # Removal notice for the retired env var; raw os.getenv on purpose -- the # Envs descriptor is gone. Drop this check after one release. @@ -95,11 +109,15 @@ def handle_speculative_decoding(server_args: ServerArgs) -> None: if override_config_file and override_config_file.strip(): kwargs["_configuration_file"] = override_config_file.strip() - server_args.speculative_algorithm = _resolve_speculative_algorithm_alias( - server_args.speculative_algorithm, - server_args.speculative_draft_model_path, - trust_remote_code=server_args.trust_remote_code, - kwargs=kwargs, + declare_resolution( + server_args, + "handle_speculative_decoding", + speculative_algorithm=_resolve_speculative_algorithm_alias( + server_args.speculative_algorithm, + server_args.speculative_draft_model_path, + trust_remote_code=server_args.trust_remote_code, + kwargs=kwargs, + ), ) # Validate --speculative-draft-window-size once, regardless of algorithm. @@ -110,7 +128,11 @@ def handle_speculative_decoding(server_args: ServerArgs) -> None: raise ValueError( f"--speculative-draft-window-size must be positive, got {window_size}." ) - server_args.speculative_draft_window_size = window_size + declare_resolution( + server_args, + "handle_speculative_decoding", + speculative_draft_window_size=window_size, + ) if server_args.speculative_algorithm not in ("EAGLE3", "DFLASH"): logger.warning( "--speculative-draft-window-size has no effect with " @@ -173,22 +195,38 @@ def _handle_dflash(server_args: ServerArgs) -> None: # # For DFlash, the natural unit is `block_size` (verify window length). if server_args.speculative_num_steps is None: - server_args.speculative_num_steps = 1 + declare_resolution( + server_args, + "_handle_dflash", + speculative_num_steps=1, + ) elif int(server_args.speculative_num_steps) != 1: logger.warning( "DFLASH only supports speculative_num_steps == 1; overriding speculative_num_steps=%s to 1.", server_args.speculative_num_steps, ) - server_args.speculative_num_steps = 1 + declare_resolution( + server_args, + "_handle_dflash", + speculative_num_steps=1, + ) if server_args.speculative_eagle_topk is None: - server_args.speculative_eagle_topk = 1 + declare_resolution( + server_args, + "_handle_dflash", + speculative_eagle_topk=1, + ) elif int(server_args.speculative_eagle_topk) != 1: logger.warning( "DFLASH only supports speculative_eagle_topk == 1; overriding speculative_eagle_topk=%s to 1.", server_args.speculative_eagle_topk, ) - server_args.speculative_eagle_topk = 1 + declare_resolution( + server_args, + "_handle_dflash", + speculative_eagle_topk=1, + ) if server_args.speculative_dflash_block_size is not None: if int(server_args.speculative_dflash_block_size) <= 0: @@ -205,8 +243,10 @@ def _handle_dflash(server_args: ServerArgs) -> None: f"speculative_num_draft_tokens={server_args.speculative_num_draft_tokens}, " f"speculative_dflash_block_size={server_args.speculative_dflash_block_size}." ) - server_args.speculative_num_draft_tokens = int( - server_args.speculative_dflash_block_size + declare_resolution( + server_args, + "_handle_dflash", + speculative_num_draft_tokens=int(server_args.speculative_dflash_block_size), ) if server_args.speculative_num_draft_tokens is None: @@ -241,7 +281,11 @@ def _handle_dflash(server_args: ServerArgs) -> None: "speculative_num_draft_tokens is not set; defaulting to %d for DFLASH.", inferred_block_size, ) - server_args.speculative_num_draft_tokens = inferred_block_size + declare_resolution( + server_args, + "_handle_dflash", + speculative_num_draft_tokens=inferred_block_size, + ) if server_args.speculative_draft_window_size is not None: draft_tokens = int(server_args.speculative_num_draft_tokens) @@ -255,13 +299,21 @@ def _handle_dflash(server_args: ServerArgs) -> None: _resolve_dflash_draft_attention_backend(server_args) if server_args.max_running_requests is None: - server_args.max_running_requests = 48 + declare_resolution( + server_args, + "_handle_dflash", + max_running_requests=48, + ) logger.warning( "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) if server_args.enable_mixed_chunk: - server_args.enable_mixed_chunk = False + declare_resolution( + server_args, + "_handle_dflash", + enable_mixed_chunk=False, + ) logger.warning( "Mixed chunked prefill is disabled because of using dflash speculative decoding." ) @@ -327,8 +379,16 @@ def _handle_dspark(server_args: ServerArgs) -> None: if server_args.speculative_draft_model_path is None: if _target_checkpoint_bundles_dspark_draft(server_args): - server_args.speculative_draft_model_path = server_args.model_path - server_args.speculative_draft_model_revision = server_args.revision + declare_resolution( + server_args, + "_handle_dspark", + speculative_draft_model_path=server_args.model_path, + ) + declare_resolution( + server_args, + "_handle_dspark", + speculative_draft_model_revision=server_args.revision, + ) logger.info( "DSpark draft weights are bundled in the target checkpoint; " "defaulting --speculative-draft-model-path to --model-path (%s).", @@ -341,22 +401,38 @@ def _handle_dspark(server_args: ServerArgs) -> None: ) if server_args.speculative_num_steps is None: - server_args.speculative_num_steps = 1 + declare_resolution( + server_args, + "_handle_dspark", + speculative_num_steps=1, + ) elif int(server_args.speculative_num_steps) != 1: logger.warning( "DSpark only supports speculative_num_steps == 1; overriding speculative_num_steps=%s to 1.", server_args.speculative_num_steps, ) - server_args.speculative_num_steps = 1 + declare_resolution( + server_args, + "_handle_dspark", + speculative_num_steps=1, + ) if server_args.speculative_eagle_topk is None: - server_args.speculative_eagle_topk = 1 + declare_resolution( + server_args, + "_handle_dspark", + speculative_eagle_topk=1, + ) elif int(server_args.speculative_eagle_topk) != 1: logger.warning( "DSpark only supports speculative_eagle_topk == 1; overriding speculative_eagle_topk=%s to 1.", server_args.speculative_eagle_topk, ) - server_args.speculative_eagle_topk = 1 + declare_resolution( + server_args, + "_handle_dspark", + speculative_eagle_topk=1, + ) gamma: Optional[int] = None if server_args.speculative_dspark_block_size is not None: @@ -398,7 +474,11 @@ def _handle_dspark(server_args: ServerArgs) -> None: f"(= {verify_window} for gamma={gamma}), but got " f"speculative_num_draft_tokens={server_args.speculative_num_draft_tokens}." ) - server_args.speculative_num_draft_tokens = verify_window + declare_resolution( + server_args, + "_handle_dspark", + speculative_num_draft_tokens=verify_window, + ) if server_args.speculative_num_draft_tokens is None: raise ValueError( @@ -412,13 +492,21 @@ def _handle_dspark(server_args: ServerArgs) -> None: ) if server_args.max_running_requests is None: - server_args.max_running_requests = 48 + declare_resolution( + server_args, + "_handle_dspark", + max_running_requests=48, + ) logger.warning( "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) if server_args.enable_mixed_chunk: - server_args.enable_mixed_chunk = False + declare_resolution( + server_args, + "_handle_dspark", + enable_mixed_chunk=False, + ) logger.warning( "Mixed chunked prefill is disabled because of using dspark speculative decoding." ) @@ -522,18 +610,30 @@ def _resolve_dflash_draft_attention_backend(server_args: ServerArgs) -> None: draft_backend = fallback_backend # FIXME: avoid overriding server args directly; pass the resolved draft # backend to the draft worker explicitly instead. - server_args.speculative_draft_attention_backend = draft_backend + declare_resolution( + server_args, + "_resolve_dflash_draft_attention_backend", + speculative_draft_attention_backend=draft_backend, + ) def _handle_frozen_kv_mtp(server_args: ServerArgs) -> None: if server_args.max_running_requests is None: - server_args.max_running_requests = 48 + declare_resolution( + server_args, + "_handle_frozen_kv_mtp", + max_running_requests=48, + ) logger.warning( "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) if server_args.enable_mixed_chunk: - server_args.enable_mixed_chunk = False + declare_resolution( + server_args, + "_handle_frozen_kv_mtp", + enable_mixed_chunk=False, + ) logger.warning( "Mixed chunked prefill is disabled because of using " "Frozen-KV MTP speculative decoding." @@ -556,7 +656,11 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: ) if server_args.max_running_requests is None: - server_args.max_running_requests = 48 + declare_resolution( + server_args, + "_handle_eagle_family", + max_running_requests=48, + ) logger.warning( "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) @@ -570,7 +674,11 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: ) if server_args.enable_mixed_chunk: - server_args.enable_mixed_chunk = False + declare_resolution( + server_args, + "_handle_eagle_family", + enable_mixed_chunk=False, + ) logger.warning( "Mixed chunked prefill is disabled because of using " "eagle speculative decoding." @@ -592,8 +700,16 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: "HYV3ForCausalLM", ]: if server_args.speculative_draft_model_path is None: - server_args.speculative_draft_model_path = server_args.model_path - server_args.speculative_draft_model_revision = server_args.revision + declare_resolution( + server_args, + "_handle_eagle_family", + speculative_draft_model_path=server_args.model_path, + ) + declare_resolution( + server_args, + "_handle_eagle_family", + speculative_draft_model_revision=server_args.revision, + ) else: if model_arch not in [ "MistralLarge3ForCausalLM", @@ -612,11 +728,16 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: and server_args.speculative_num_draft_tokens is None ) - ( - server_args.speculative_num_steps, - server_args.speculative_eagle_topk, - server_args.speculative_num_draft_tokens, - ) = _auto_choose_speculative_params(server_args, model_arch) + steps, topk, draft_tokens = _auto_choose_speculative_params( + server_args, model_arch + ) + declare_resolution( + server_args, + "_handle_eagle_family.auto_params", + speculative_num_steps=steps, + speculative_eagle_topk=topk, + speculative_num_draft_tokens=draft_tokens, + ) if "trtllm_mha" in attention_backends_of(resolved_view(server_args)): if server_args.speculative_eagle_topk > 1: @@ -680,7 +801,11 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: logger.warning( "speculative_num_draft_tokens is adjusted to speculative_num_steps + 1 when speculative_eagle_topk == 1" ) - server_args.speculative_num_draft_tokens = server_args.speculative_num_steps + 1 + declare_resolution( + server_args, + "_handle_eagle_family", + speculative_num_draft_tokens=server_args.speculative_num_steps + 1, + ) # topk > 1 + page_size > 1 needs the two-pass cascade draft-decode (shared prefix # pass + per-branch expand pass with prefix-tail dup). Only these backends implement @@ -708,23 +833,41 @@ def _handle_ngram(server_args: ServerArgs) -> None: _disable_overlap_schedule_for_cpu(server_args) if server_args.max_running_requests is None: - server_args.max_running_requests = 48 + declare_resolution( + server_args, + "_handle_ngram", + max_running_requests=48, + ) logger.warning( "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) - server_args.enable_mixed_chunk = False - server_args.speculative_eagle_topk = server_args.speculative_ngram_max_bfs_breadth + declare_resolution( + server_args, + "_handle_ngram", + enable_mixed_chunk=False, + ) + declare_resolution( + server_args, + "_handle_ngram", + speculative_eagle_topk=server_args.speculative_ngram_max_bfs_breadth, + ) if server_args.speculative_num_draft_tokens is None: - server_args.speculative_num_draft_tokens = 12 + declare_resolution( + server_args, + "_handle_ngram", + speculative_num_draft_tokens=12, + ) logger.warning( "speculative_num_draft_tokens is set to 12 by default for ngram speculative decoding. " "You can override this by explicitly setting --speculative-num-draft-tokens." ) if server_args.speculative_num_steps is None: - server_args.speculative_num_steps = ( - server_args.speculative_num_draft_tokens - // server_args.speculative_eagle_topk + declare_resolution( + server_args, + "_handle_ngram", + speculative_num_steps=server_args.speculative_num_draft_tokens + // server_args.speculative_eagle_topk, ) if server_args.speculative_ngram_external_corpus_path is not None: if server_args.speculative_ngram_external_sam_budget <= 0: @@ -782,7 +925,11 @@ def _maybe_disable_adaptive(server_args: ServerArgs) -> None: f"speculative_adaptive disabled: {reason}. " "Falling back to static speculative params." ) - server_args.speculative_adaptive = False + declare_resolution( + server_args, + "_maybe_disable_adaptive", + speculative_adaptive=False, + ) def _init_adaptive_speculative_params(server_args: ServerArgs) -> None: @@ -795,10 +942,18 @@ def _init_adaptive_speculative_params(server_args: ServerArgs) -> None: ) if server_args.speculative_eagle_topk is None: - server_args.speculative_eagle_topk = 1 + declare_resolution( + server_args, + "_init_adaptive_speculative_params", + speculative_eagle_topk=1, + ) if server_args.speculative_num_steps is None: - server_args.speculative_num_steps = candidate_steps[len(candidate_steps) // 2] + declare_resolution( + server_args, + "_init_adaptive_speculative_params", + speculative_num_steps=candidate_steps[len(candidate_steps) // 2], + ) if server_args.speculative_num_steps not in candidate_steps: raise ValueError( @@ -807,7 +962,11 @@ def _init_adaptive_speculative_params(server_args: ServerArgs) -> None: "Pass one of those values." ) - server_args.speculative_num_draft_tokens = server_args.speculative_num_steps + 1 + declare_resolution( + server_args, + "_init_adaptive_speculative_params", + speculative_num_draft_tokens=server_args.speculative_num_steps + 1, + ) def _auto_choose_speculative_params(server_args: ServerArgs, model_arch: str) -> tuple: diff --git a/python/sglang/srt/hardware_backend/npu/utils.py b/python/sglang/srt/hardware_backend/npu/utils.py index b4a6968f4..3a285f728 100644 --- a/python/sglang/srt/hardware_backend/npu/utils.py +++ b/python/sglang/srt/hardware_backend/npu/utils.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Callable import torch +from sglang.srt.arg_groups.overrides import declare_resolution from sglang.srt.environ import envs from sglang.srt.utils import get_npu_memory_capacity, is_npu @@ -44,11 +45,27 @@ def set_default_server_args(args: "ServerArgs"): """ # NPU only works with "ascend" attention backend for now - args.attention_backend = "ascend" - args.prefill_attention_backend = "ascend" - args.decode_attention_backend = "ascend" + declare_resolution( + args, + "set_default_server_args", + attention_backend="ascend", + ) + declare_resolution( + args, + "set_default_server_args", + prefill_attention_backend="ascend", + ) + declare_resolution( + args, + "set_default_server_args", + decode_attention_backend="ascend", + ) if args.page_size is None: - args.page_size = 128 + declare_resolution( + args, + "set_default_server_args", + page_size=128, + ) # NPU memory settings decode = args.cuda_graph_config.decode @@ -57,7 +74,11 @@ def set_default_server_args(args: "ServerArgs"): # Ascend 910B4,910B4_1 # (chunked_prefill_size 4k, max_bs 16 if tp < 4 else 64) if args.chunked_prefill_size is None: - args.chunked_prefill_size = 4 * 1024 + declare_resolution( + args, + "set_default_server_args", + chunked_prefill_size=4 * 1024, + ) if decode.max_bs is None: if args.tp_size < 4: decode.max_bs = 16 @@ -67,7 +88,11 @@ def set_default_server_args(args: "ServerArgs"): # Ascend 910B1,910B2,910B2C,910B3,910_9391,910_9392,910_9381,910_9382,910_9372,910_9362 # (chunked_prefill_size 8k, max_bs 64 if tp < 4 else 256) if args.chunked_prefill_size is None: - args.chunked_prefill_size = 8 * 1024 + declare_resolution( + args, + "set_default_server_args", + chunked_prefill_size=8 * 1024, + ) if decode.max_bs is None: if args.tp_size < 4: decode.max_bs = 64 @@ -75,15 +100,31 @@ def set_default_server_args(args: "ServerArgs"): decode.max_bs = 256 # NPU does not support CustomAllReduce - args.disable_custom_all_reduce = True + declare_resolution( + args, + "set_default_server_args", + disable_custom_all_reduce=True, + ) # handles hierarchical cache configs if args.enable_hierarchical_cache: - args.hicache_io_backend = "kernel_ascend" + declare_resolution( + args, + "set_default_server_args", + hicache_io_backend="kernel_ascend", + ) if args.use_mla_backend(): - args.hicache_mem_layout = "page_first_kv_split" + declare_resolution( + args, + "set_default_server_args", + hicache_mem_layout="page_first_kv_split", + ) else: - args.hicache_mem_layout = "page_first_direct" + declare_resolution( + args, + "set_default_server_args", + hicache_mem_layout="page_first_direct", + ) @_call_once diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 5a933dc67..a7b2ddade 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -3643,6 +3643,16 @@ class ServerArgs: def __post_init__(self): self._run_resolution_pipeline() + def _declare(self, source: str, **fields: Any) -> None: + """This record's handlers declaring their resolution writes. + + See ``arg_groups.overrides.declare_resolution``, which the hooks these + handlers call reach directly. + """ + from sglang.srt.arg_groups.overrides import declare_resolution + + declare_resolution(self, source, **fields) + def _run_resolution_pipeline(self): """ Orchestrates the handling of various server arguments, ensuring proper configuration and validation. @@ -3859,9 +3869,15 @@ class ServerArgs: ) if self.return_hidden_states_mode is None: if self.enable_return_hidden_states: - self.return_hidden_states_mode = "full" + self._declare( + "_handle_return_hidden_states_mode", + return_hidden_states_mode="full", + ) else: - self.enable_return_hidden_states = True + self._declare( + "_handle_return_hidden_states_mode", + enable_return_hidden_states=True, + ) def _handle_model_capability_adjustments(self): if parse_connector_type(self.model_path) == ConnectorType.INSTANCE: @@ -3887,9 +3903,18 @@ class ServerArgs: # bidirectional-attention forcing and silently produce junk output. if is_hrm_text and getattr(hf_config, "prefix_lm", True): run_post_process_pass(self, _hrm_text_attention_force) - self.chunked_prefill_size = -1 - self.disable_radix_cache = True - self.disable_cuda_graph = True + self._declare( + "_handle_model_capability_adjustments", + chunked_prefill_size=-1, + ) + self._declare( + "_handle_model_capability_adjustments", + disable_radix_cache=True, + ) + self._declare( + "_handle_model_capability_adjustments", + disable_cuda_graph=True, + ) # cuda_graph_config was already parsed from the legacy boolean, so # flipping the boolean alone would not stop graph capture. self.cuda_graph_config.decode.backend = Backend.DISABLED @@ -3921,7 +3946,10 @@ class ServerArgs: and embedding_model_spec.auto_enable_embedding and not self.is_embedding ): - self.is_embedding = True + self._declare( + "_handle_model_capability_adjustments", + is_embedding=True, + ) logger.info( "Embedding architecture detected: enabling embedding mode automatically." ) @@ -3937,13 +3965,25 @@ class ServerArgs: # named Gemma3TextModel. Marking it as embedding mode enables the # FlashAttention raw-K/V fast path, which does not write or read # the paged KV cache during its single prefill forward. - self.is_embedding = True - self.disable_radix_cache = True - self.chunked_prefill_size = -1 + self._declare( + "_handle_model_capability_adjustments", + is_embedding=True, + ) + self._declare( + "_handle_model_capability_adjustments", + disable_radix_cache=True, + ) + self._declare( + "_handle_model_capability_adjustments", + chunked_prefill_size=-1, + ) # Submit a list-valued embeddings request atomically so BCG can # replay its full prefill batch instead of starting item zero # while the remaining texts are still being tokenized. - self.enable_tokenizer_batch_encode = True + self._declare( + "_handle_model_capability_adjustments", + enable_tokenizer_batch_encode=True, + ) requested_prefill_backend = ( self.prefill_attention_backend or self.attention_backend ) @@ -3956,7 +3996,10 @@ class ServerArgs: # tensors for a single embedding prefill. Enable its no-KV # pool path before memory-pool sizing; an explicit non-FA # backend retains the existing paged-KV behavior. - self.prefill_only_disable_kv_cache = True + self._declare( + "_handle_model_capability_adjustments", + prefill_only_disable_kv_cache=True, + ) self._validate_prefill_only_disable_kv_cache_args() self.cuda_graph_config.decode.backend = Backend.DISABLED if is_cuda() and self.cuda_graph_config.prefill.backend != Backend.DISABLED: @@ -4000,7 +4043,10 @@ class ServerArgs: model_config.is_multimodal and not model_config.is_multimodal_chunked_prefill_supported ): - self.chunked_prefill_size = -1 + self._declare( + "_handle_model_capability_adjustments", + chunked_prefill_size=-1, + ) logger.info( f"Automatically turn off --chunked-prefill-size as it is not supported for " f"{hf_config.model_type}" @@ -4074,10 +4120,13 @@ class ServerArgs: # - non-PD: round_robin # - PD prefill: follow_bootstrap_room # - PD decode: round_robin - self.load_balance_method = ( - "follow_bootstrap_room" - if self.disaggregation_mode == "prefill" - else "round_robin" + self._declare( + "_handle_load_balance_method", + load_balance_method=( + "follow_bootstrap_room" + if self.disaggregation_mode == "prefill" + else "round_robin" + ), ) return @@ -4168,9 +4217,12 @@ class ServerArgs: def _handle_media_url_security(self): """Normalize and publish the media URL policy before workers start.""" - self.allowed_media_domains = configure_media_url_security( - self.allowed_media_domains, - self.media_url_max_file_size_mb, + self._declare( + "_handle_media_url_security", + allowed_media_domains=configure_media_url_security( + self.allowed_media_domains, + self.media_url_max_file_size_mb, + ), ) def _handle_deprecated_args(self): @@ -4184,7 +4236,7 @@ class ServerArgs: "--disable-fast-image-processor is deprecated; use " "--image-processor-backend=pil instead." ) - self.image_processor_backend = "pil" + self._declare("_handle_deprecated_args", image_processor_backend="pil") # Handle deprecated tool call parsers deprecated_tool_call_parsers = {"qwen25": "qwen", "glm45": "glm"} @@ -4192,7 +4244,10 @@ class ServerArgs: logger.warning( f"The tool_call_parser '{self.tool_call_parser}' is deprecated. Please use '{deprecated_tool_call_parsers[self.tool_call_parser]}' instead." ) - self.tool_call_parser = deprecated_tool_call_parsers[self.tool_call_parser] + self._declare( + "_handle_deprecated_args", + tool_call_parser=deprecated_tool_call_parsers[self.tool_call_parser], + ) # When user passes --enable-flashinfer-allreduce-fusion, enable with auto backend if ( @@ -4203,9 +4258,16 @@ class ServerArgs: "--enable-flashinfer-allreduce-fusion is deprecated. " "Please use --flashinfer-allreduce-fusion-backend=auto instead." ) - self.flashinfer_allreduce_fusion_backend = "auto" - self.enable_flashinfer_allreduce_fusion = False + self._declare( + "_handle_deprecated_args", + flashinfer_allreduce_fusion_backend="auto", + ) + self._declare( + "_handle_deprecated_args", + enable_flashinfer_allreduce_fusion=False, + ) # Deprecated attention-backend alias: "compressed" -> "dsv4". + renamed = {} for attr in ( "attention_backend", "decode_attention_backend", @@ -4217,7 +4279,9 @@ class ServerArgs: "--%s=compressed is deprecated; use 'dsv4' instead.", attr.replace("_", "-"), ) - setattr(self, attr, "dsv4") + renamed[attr] = "dsv4" + if renamed: + self._declare("_handle_deprecated_args", **renamed) # --grpc-mode is a deprecated alias for --smg-grpc-mode. if self.grpc_mode and not self.smg_grpc_mode: @@ -4226,7 +4290,10 @@ class ServerArgs: "version. Use --smg-grpc-mode for the legacy SMG gRPC server, " "or --grpc-port for the native gRPC server." ) - self.smg_grpc_mode = True + self._declare( + "_handle_deprecated_args", + smg_grpc_mode=True, + ) # Native gRPC tuning knob is env-only; --grpc-port (CLI) enables the # native server, falling back to SGLANG_GRPC_PORT. @@ -4234,13 +4301,19 @@ class ServerArgs: grpc_port_env = envs.SGLANG_GRPC_PORT.get() if self.grpc_port is None and grpc_port_env is not None: - self.grpc_port = grpc_port_env + self._declare( + "_handle_deprecated_args", + grpc_port=grpc_port_env, + ) # Legacy SMG defaults its port to --port + 10000. Derive/validate only # when gRPC is in use, so HTTP-only high ports don't fail validation. legacy_grpc = self.smg_grpc_mode or self.grpc_mode if legacy_grpc and self.grpc_port is None: - self.grpc_port = self.port + 10000 + self._declare( + "_handle_deprecated_args", + grpc_port=self.port + 10000, + ) if self.grpc_port is not None: if not (1 <= self.grpc_port <= 65535): @@ -4298,25 +4371,49 @@ class ServerArgs: def _handle_prefill_delayer_env_compat(self): if envs.SGLANG_SCHEDULER_DECREASE_PREFILL_IDLE.get(): - self.enable_prefill_delayer = True + self._declare( + "_handle_prefill_delayer_env_compat", + enable_prefill_delayer=True, + ) if x := envs.SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES.get(): - self.prefill_delayer_max_delay_passes = x + self._declare( + "_handle_prefill_delayer_env_compat", + prefill_delayer_max_delay_passes=x, + ) if x := envs.SGLANG_PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK.get(): - self.prefill_delayer_token_usage_low_watermark = x + self._declare( + "_handle_prefill_delayer_env_compat", + prefill_delayer_token_usage_low_watermark=x, + ) def _handle_missing_default_values(self): if self.tokenizer_path is None: - self.tokenizer_path = self.model_path + self._declare( + "_handle_missing_default_values", + tokenizer_path=self.model_path, + ) if self.served_model_name is None: - self.served_model_name = self.model_path + self._declare( + "_handle_missing_default_values", + served_model_name=self.model_path, + ) if self.device is None: - self.device = get_device() + self._declare( + "_handle_missing_default_values", + device=get_device(), + ) # strip device index from user if any (e.g. "cuda:0" -> "cuda") - self.device = self.device.split(":")[0] + self._declare( + "_handle_missing_default_values", + device=self.device.split(":")[0], + ) if self.random_seed is None: - self.random_seed = random.randint(0, 1 << 30) + self._declare( + "_handle_missing_default_values", + random_seed=random.randint(0, 1 << 30), + ) if self.mm_process_config is None: - self.mm_process_config = {} + self._declare("_handle_missing_default_values", mm_process_config={}) # Handle ModelScope model downloads if envs.SGLANG_USE_MODELSCOPE.get(): @@ -4326,22 +4423,33 @@ class ServerArgs: # - If `speculative_draft_model_quantization` is specified, the draft model uses this quantization method. # - Otherwise, the draft model defaults to the same quantization as the target model. if self._speculative_draft_quantization_explicitly_set is None: - self._speculative_draft_quantization_explicitly_set = ( - self.speculative_draft_model_quantization is not None + self._declare( + "_handle_missing_default_values", + _speculative_draft_quantization_explicitly_set=self.speculative_draft_model_quantization + is not None, ) if self.speculative_draft_model_quantization is None: - self.speculative_draft_model_quantization = self.quantization + self._declare( + "_handle_missing_default_values", + speculative_draft_model_quantization=self.quantization, + ) # Resolve --quantization unquant before model config validation. Record # the explicit opt-out so later auto-detection does not re-enable # quantization. if self.quantization == "unquant": - self.quantization = None + self._declare( + "_handle_missing_default_values", + quantization=None, + ) self._quantization_explicitly_unset = True else: self._quantization_explicitly_unset = False if self.speculative_draft_model_quantization == "unquant": - self.speculative_draft_model_quantization = None + self._declare( + "_handle_missing_default_values", + speculative_draft_model_quantization=None, + ) def _handle_modelscope_paths(self): """Resolve model / tokenizer / speculative-draft paths from the local @@ -4395,30 +4503,51 @@ class ServerArgs: **({"ignore_patterns": ignore_patterns} if ignore_patterns else {}), ) - self.model_path = _resolve_or_download(self.model_path, revision=self.revision) - self.tokenizer_path = _resolve_or_download( - self.tokenizer_path, - ignore_patterns=["*.bin", "*.safetensors"], - revision=self.revision, + self._declare( + "_handle_modelscope_paths", + model_path=_resolve_or_download(self.model_path, revision=self.revision), + ) + self._declare( + "_handle_modelscope_paths", + tokenizer_path=_resolve_or_download( + self.tokenizer_path, + ignore_patterns=["*.bin", "*.safetensors"], + revision=self.revision, + ), ) if self.speculative_draft_model_path: - self.speculative_draft_model_path = _resolve_or_download( - self.speculative_draft_model_path, - revision=self.speculative_draft_model_revision or "main", + self._declare( + "_handle_modelscope_paths", + speculative_draft_model_path=_resolve_or_download( + self.speculative_draft_model_path, + revision=self.speculative_draft_model_revision or "main", + ), ) def _handle_hpu_backends(self): if self.device == "hpu": - self.attention_backend = "torch_native" - self.sampling_backend = "pytorch" + self._declare( + "_handle_hpu_backends", + attention_backend="torch_native", + ) + self._declare( + "_handle_hpu_backends", + sampling_backend="pytorch", + ) def _handle_cpu_backends(self): if self.device == "cpu": if self.attention_backend is None: - self.attention_backend = ( - "torch_native" if is_host_cpu_arm64() else "intel_amx" + self._declare( + "_handle_cpu_backends", + attention_backend=( + "torch_native" if is_host_cpu_arm64() else "intel_amx" + ), ) - self.sampling_backend = "pytorch" + self._declare( + "_handle_cpu_backends", + sampling_backend="pytorch", + ) def _handle_hardware_runtime_validation(self): # This is intentionally independent of self.device: setting @@ -4444,7 +4573,10 @@ class ServerArgs: def _handle_mps_backends(self): if self.device == "mps": if not use_mlx(): - self.disable_overlap_schedule = True + self._declare( + "_handle_mps_backends", + disable_overlap_schedule=True, + ) def _handle_xpu_backends(self): if self.device == "xpu": @@ -4484,7 +4616,10 @@ class ServerArgs: "InklingForConditionalGeneration", "InklingForConditionalGenerationMTP", ): - self.cuda_graph_backend_prefill = Backend.FULL + self._declare( + "_apply_inkling_prefill_cuda_graph_default", + cuda_graph_backend_prefill=Backend.FULL, + ) def _apply_muse_glimmer_prefill_cuda_graph_max_bs_default(self): if ( @@ -4494,7 +4629,10 @@ class ServerArgs: return arch = self.get_model_config().hf_config.architectures[0] if arch in ("MuseGlimmerForCausalLM", "MuseGlimmerForConditionalGeneration"): - self.cuda_graph_max_bs_prefill = 512 + self._declare( + "_apply_muse_glimmer_prefill_cuda_graph_max_bs_default", + cuda_graph_max_bs_prefill=512, + ) def _handle_cuda_graph_config(self): from sglang.srt.arg_groups.kimi_k3_hook import disable_kimi_k3_symm_mem @@ -4600,7 +4738,10 @@ class ServerArgs: for key, value in phase_config.items(): _set(phase, key, value) - self.cuda_graph_config = config + self._declare( + "_parse_cuda_graph_config", + cuda_graph_config=config, + ) self._cuda_graph_config_locked = locked def _apply_cuda_graph_compatibility(self): @@ -4844,11 +4985,17 @@ class ServerArgs: if not self.disable_radix_cache: logger.warning("Radix cache is disabled because --enable-mis is set.") - self.disable_radix_cache = True + self._declare( + "_handle_multi_item_scoring", + disable_radix_cache=True, + ) if self.chunked_prefill_size != -1: logger.warning("Chunked prefill is disabled because --enable-mis is set.") - self.chunked_prefill_size = -1 + self._declare( + "_handle_multi_item_scoring", + chunked_prefill_size=-1, + ) prefill_backend, decode_backend = self._resolved_attention_backends() assert prefill_backend == "flashinfer" and decode_backend == "flashinfer", ( @@ -4889,14 +5036,20 @@ class ServerArgs: # T4, 4080 # (chunked_prefill_size 2k, max_bs 8) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 2048 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=2048, + ) if decode_cuda_graph_config.max_bs is None: decode_cuda_graph_config.max_bs = 8 elif gpu_mem < 35 * 1024: # A10, 4090, 5090 # (chunked_prefill_size 2k, max_bs 24 if tp < 4 else 80) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 2048 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=2048, + ) if decode_cuda_graph_config.max_bs is None: if self.tp_size < 4: decode_cuda_graph_config.max_bs = 24 @@ -4906,7 +5059,10 @@ class ServerArgs: # A100 (40GB), L40, # (chunked_prefill_size 4k, max_bs 32 if tp < 4 else 160) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 4096 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=4096, + ) if decode_cuda_graph_config.max_bs is None: if self.tp_size < 4: decode_cuda_graph_config.max_bs = 32 @@ -4916,7 +5072,10 @@ class ServerArgs: # H100, A100 # (chunked_prefill_size 8k, max_bs 256 if tp < 4 else 512) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 8192 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=8192, + ) if decode_cuda_graph_config.max_bs is None: if self.tp_size < 4: decode_cuda_graph_config.max_bs = 256 @@ -4926,7 +5085,10 @@ class ServerArgs: # H20, H200 # (chunked_prefill_size 8k, max_bs 256 if tp < 4 else 512) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 8192 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=8192, + ) if decode_cuda_graph_config.max_bs is None: if self.tp_size < 4: decode_cuda_graph_config.max_bs = 256 @@ -4936,13 +5098,19 @@ class ServerArgs: # B200, MI300 # (chunked_prefill_size 16k, max_bs 512) if self.chunked_prefill_size is None: - self.chunked_prefill_size = 16384 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=16384, + ) if decode_cuda_graph_config.max_bs is None: decode_cuda_graph_config.max_bs = 512 else: # Fallback defaults when gpu_mem is None if self.chunked_prefill_size is None: - self.chunked_prefill_size = 4096 + self._declare( + "_handle_gpu_memory_settings", + chunked_prefill_size=4096, + ) if decode_cuda_graph_config.max_bs is None: decode_cuda_graph_config.max_bs = 160 @@ -4960,12 +5128,17 @@ class ServerArgs: # Reuse decode_cuda_graph_config.bs for cpu graph and use torch_compile_max_bs for cpu graph batch size limit, # as cpu graph is based on torch.compile if decode_cuda_graph_config.bs is not None: - self.torch_compile_max_bs = max(decode_cuda_graph_config.bs) + self._declare( + "_handle_gpu_memory_settings", + torch_compile_max_bs=max(decode_cuda_graph_config.bs), + ) else: # If decode_cuda_graph_config.bs is not set, we will preferentially use torch_compile_max_bs # to generate decode_cuda_graph_config.bs - self.torch_compile_max_bs = ( - self.torch_compile_max_bs or decode_cuda_graph_config.max_bs + self._declare( + "_handle_gpu_memory_settings", + torch_compile_max_bs=self.torch_compile_max_bs + or decode_cuda_graph_config.max_bs, ) decode_cuda_graph_config.bs = self._generate_cpu_graph_batch_sizes() @@ -5034,10 +5207,13 @@ class ServerArgs: # Reserve headroom for DeepEP all-to-all buffers on top of the floor. reserved_mem += self.reserve_for_deepep_a2a_mb() - self.mem_fraction_static = ( - round((gpu_mem - reserved_mem) / gpu_mem, 3) - if gpu_mem is not None - else 0.88 + self._declare( + "_handle_gpu_memory_settings", + mem_fraction_static=( + round((gpu_mem - reserved_mem) / gpu_mem, 3) + if gpu_mem is not None + else 0.88 + ), ) # Multimodal models need more memory for the image processing, @@ -5297,11 +5473,18 @@ class ServerArgs: ) if self.enable_deterministic_inference: - self.enforce_disable_flashinfer_allreduce_fusion = True + self._declare( + "_handle_model_specific_adjustments", + enforce_disable_flashinfer_allreduce_fusion=True, + ) - self.uses_mamba_radix_cache = False + self._declare( + "_handle_model_specific_adjustments", + uses_mamba_radix_cache=False, + ) if parse_connector_type(self.model_path) == ConnectorType.INSTANCE: - self._resolved_overrides = [] + # No model overrides for an instance connector: no hf_config to + # key them on. return model_config = self.get_model_config() @@ -5352,10 +5535,11 @@ class ServerArgs: validate_declarations, ) - self._resolved_overrides = collect_model_override_declarations( + model_overrides = collect_model_override_declarations( model_arch, self, hf_config ) - validate_declarations(self, self._resolved_overrides) + validate_declarations(self, model_overrides) + self._resolved_overrides.extend(model_overrides) if model_arch in ( "KimiLinearForCausalLM", @@ -6033,7 +6217,10 @@ class ServerArgs: in (model_config.hf_config.architectures or []) ): logger.info("Radix cache is disabled for Whisper") - self.disable_radix_cache = True + self._declare( + "_handle_attention_backend_compatibility", + disable_radix_cache=True, + ) # Major NVIDIA platforms backends: the page-size snaps of this family # moved to the resolution pipeline (arg_groups/overrides.py: @@ -6104,7 +6291,10 @@ class ServerArgs: # AMD platforms backends if resolved_view(self).attention_backend == "aiter": if model_config.context_len > 8192: - self.mem_fraction_static *= 0.85 + self._declare( + "_handle_attention_backend_compatibility", + mem_fraction_static=self.mem_fraction_static * 0.85, + ) # Other platforms backends run_post_process_pass(self, _attention_backend_platform_fallbacks) @@ -6123,8 +6313,14 @@ class ServerArgs: logger.warning( "Mixed chunk and radix cache are disabled when using dual-chunk flash attention backend" ) - self.enable_mixed_chunk = False - self.disable_radix_cache = True + self._declare( + "_handle_attention_backend_compatibility", + enable_mixed_chunk=False, + ) + self._declare( + "_handle_attention_backend_compatibility", + disable_radix_cache=True, + ) def _handle_mxfp8_kv_cache_compatibility(self): """MXFP8 KV cache uses operands available only on SM100+ (Blackwell).""" @@ -6221,7 +6417,7 @@ class ServerArgs: def _handle_amd_specifics(self): if is_hip(): - self.triton_attention_num_kv_splits = 16 + self._declare("_handle_amd_specifics", triton_attention_num_kv_splits=16) def _handle_nccl_pre_warm(self): # pre_warm_nccl is only used with CUDA or HIP hardware or NPU hardware @@ -6230,11 +6426,11 @@ class ServerArgs: "pre_warm_nccl is only applicable for CUDA or HIP hardware or NPU hardware. " "Ignoring pre_warm_nccl setting on current hardware." ) - self.pre_warm_nccl = False + self._declare("_handle_nccl_pre_warm", pre_warm_nccl=False) def _handle_grammar_backend(self): if self.grammar_backend is None: - self.grammar_backend = "xgrammar" + self._declare("_handle_grammar_backend", grammar_backend="xgrammar") def _handle_mamba_backend(self): if self.mamba_cache_philox_rounds < 0: @@ -6329,7 +6525,10 @@ class ServerArgs: # state correctly — the unified-memory skip is no longer needed (the # page-major gate now allows flashinfer for linear-attn decode). ): - self.linear_attn_decode_backend = "flashinfer" + self._declare( + "_handle_linear_attn_backend", + linear_attn_decode_backend="flashinfer", + ) logger.info( "SM100+ detected with mamba-ssm-dtype=bfloat16, " "defaulting --linear-attn-decode-backend to flashinfer." @@ -6350,7 +6549,10 @@ class ServerArgs: "FlashKDA is prefill-only. Use " "--linear-attn-prefill-backend flashkda (decode stays on triton)." ) - self.linear_attn_decode_backend = "triton" + self._declare( + "_handle_linear_attn_backend", + linear_attn_decode_backend="triton", + ) decode = "triton" logger.info( "FlashKDA is prefill-only; using triton for KDA decode " @@ -6520,7 +6722,10 @@ class ServerArgs: "float32 (the closed-loop exact fold keeps the SSM checkpoint " "bit-identical to the recurrent baseline)." ) - self.mamba_ssm_dtype = "float32" + self._declare( + "_handle_linear_attn_backend", + mamba_ssm_dtype="float32", + ) elif self.mamba_ssm_dtype != "float32": logger.warning( "--enable-linear-replayssm-spec with --mamba-ssm-dtype=%s: the " @@ -6545,12 +6750,21 @@ class ServerArgs: self.enable_prefill_context_parallel or self.enable_dsa_prefill_context_parallel ): - self.enable_prefill_cp = True + self._declare( + "_handle_legacy_cp_arguments", + enable_prefill_cp=True, + ) if self.enable_prefill_context_parallel and self.cp_strategy is None: - self.cp_strategy = legacy_mode_to_strategy[self.prefill_cp_mode] + self._declare( + "_handle_legacy_cp_arguments", + cp_strategy=legacy_mode_to_strategy[self.prefill_cp_mode], + ) if self.enable_dsa_prefill_context_parallel and self.cp_strategy is None: - self.cp_strategy = legacy_mode_to_strategy[self.dsa_prefill_cp_mode] + self._declare( + "_handle_legacy_cp_arguments", + cp_strategy=legacy_mode_to_strategy[self.dsa_prefill_cp_mode], + ) if ( self.enable_prefill_context_parallel @@ -6566,12 +6780,27 @@ class ServerArgs: self._resolved(), "attention_backend", None ) in ("dsa", "dsv4") if use_dsa_legacy_aliases: - self.enable_dsa_prefill_context_parallel = True - self.enable_prefill_context_parallel = False + self._declare( + "_handle_legacy_cp_arguments", + enable_dsa_prefill_context_parallel=True, + ) + self._declare( + "_handle_legacy_cp_arguments", + enable_prefill_context_parallel=False, + ) else: - self.enable_prefill_context_parallel = True - self.dsa_prefill_cp_mode = mode - self.prefill_cp_mode = mode + self._declare( + "_handle_legacy_cp_arguments", + enable_prefill_context_parallel=True, + ) + self._declare( + "_handle_legacy_cp_arguments", + dsa_prefill_cp_mode=mode, + ) + self._declare( + "_handle_legacy_cp_arguments", + prefill_cp_mode=mode, + ) def _handle_context_parallelism(self): if parse_connector_type(self.model_path) != ConnectorType.INSTANCE: @@ -6701,19 +6930,43 @@ class ServerArgs: "recommended only with --disaggregation-mode prefill." ) - self.dp_size = self.dwdp_size - self.enable_dp_attention = True - self.enable_dp_attention_local_control_broadcast = True - self.enable_dp_lm_head = True - self.moe_dense_tp_size = 1 - self.ep_size = self.dwdp_size + self._declare( + "_handle_dwdp", + dp_size=self.dwdp_size, + ) + self._declare( + "_handle_dwdp", + enable_dp_attention=True, + ) + self._declare("_handle_dwdp", enable_dp_attention_local_control_broadcast=True) + self._declare( + "_handle_dwdp", + enable_dp_lm_head=True, + ) + self._declare( + "_handle_dwdp", + moe_dense_tp_size=1, + ) + self._declare( + "_handle_dwdp", + ep_size=self.dwdp_size, + ) self.moe_ep_size = self.dwdp_size - self.moe_dp_size = 1 - self.moe_a2a_backend = "none" + self._declare( + "_handle_dwdp", + moe_dp_size=1, + ) + self._declare( + "_handle_dwdp", + moe_a2a_backend="none", + ) envs.SGLANG_SCHEDULER_SKIP_ALL_GATHER.set(True) - self.disable_cuda_graph = True + self._declare( + "_handle_dwdp", + disable_cuda_graph=True, + ) logger.info( f"DWDP enabled: dwdp_size={self.dwdp_size}, " @@ -6753,10 +7006,16 @@ class ServerArgs: ) if self._resolved().enable_dp_attention: - self.schedule_conservativeness = self.schedule_conservativeness * 0.3 + self._declare( + "_handle_data_parallelism", + schedule_conservativeness=self.schedule_conservativeness * 0.3, + ) assert self.tp_size % self.dp_size == 0 original_chunked_prefill_size = self.chunked_prefill_size - self.chunked_prefill_size = self.chunked_prefill_size // self.dp_size + self._declare( + "_handle_data_parallelism", + chunked_prefill_size=self.chunked_prefill_size // self.dp_size, + ) logger.warning( f"DP attention is enabled. chunked prefill size is adjusted " f"from {original_chunked_prefill_size} to {self.chunked_prefill_size}." @@ -6974,13 +7233,16 @@ class ServerArgs: a2a_backend = resolved_view(self).moe_a2a_backend if self.enable_waterfill: - self.enforce_shared_experts_fusion = True + self._declare("_handle_a2a_moe", enforce_shared_experts_fusion=True) logger.info(f"Waterfill is enabled with moe_a2a_backend='{a2a_backend}'.") if a2a_backend == "deepep": if self.moe_runner_backend == "flashinfer_cutedsl": if self.deepep_mode == "auto": - self.deepep_mode = "low_latency" + self._declare( + "_handle_a2a_moe", + deepep_mode="low_latency", + ) logger.warning( "Forcing --deepep-mode low_latency: flashinfer_cutedsl " "FP4 MoE has no DeepEP normal-dispatch handler, so " @@ -6999,11 +7261,15 @@ class ServerArgs: self.cuda_graph_config.decode.backend = Backend.DISABLED self.cuda_graph_config.prefill.backend = Backend.DISABLED - if ( - self.moe_a2a_backend == "none" and is_npu() - ) or self.moe_a2a_backend == "ascend_tp": + # The resolving view, not the field: `_a2a_backend_overrides` may have + # moved this already (waterfill forces `deepep`). + a2a_now = resolved_view(self).moe_a2a_backend + if (a2a_now == "none" and is_npu()) or a2a_now == "ascend_tp": # FIXME (OrangeRedeng): for some reasons if pass "ascend_tp" accuracy drops to zero - self.moe_a2a_backend = "none" + self._declare( + "_handle_a2a_moe", + moe_a2a_backend="none", + ) if self.moe_a2a_backend == "flashinfer": assert ( @@ -7027,7 +7293,10 @@ class ServerArgs: if a2a_backend == "mori": if self.deepep_mode == "auto": - self.deepep_mode = "normal" + self._declare( + "_handle_a2a_moe", + deepep_mode="normal", + ) logger.warning("auto set deepep_mode=`normal` for MORI EP") # Check chunked prefill for mori @@ -7049,7 +7318,10 @@ class ServerArgs: "set --deepep-mode to 'low_latency' or 'auto'." ) if self.deepep_mode == "auto": - self.deepep_mode = "low_latency" + self._declare( + "_handle_a2a_moe", + deepep_mode="low_latency", + ) logger.warning("auto set deepep_mode=`low_latency` for PPLX EP") # pplx-kernels' AllToAll needs numDPGroups (== attention dp_size) > 1; # without DP attention numDPGroups == 1 and construction fails deep in @@ -7067,7 +7339,10 @@ class ServerArgs: "deep_gemm (or auto)." ) if self.moe_runner_backend == "auto": - self.moe_runner_backend = "deep_gemm" + self._declare( + "_handle_a2a_moe", + moe_runner_backend="deep_gemm", + ) logger.warning("auto set moe_runner_backend=`deep_gemm` for PPLX EP") # Check per-rank dispatch tokens for pplx @@ -7095,7 +7370,10 @@ class ServerArgs: def _handle_eplb_and_dispatch(self): if self.enable_eplb and (self.expert_distribution_recorder_mode is None): - self.expert_distribution_recorder_mode = "stat" + self._declare( + "_handle_eplb_and_dispatch", + expert_distribution_recorder_mode="stat", + ) logger.warning( "EPLB is enabled. The expert_distribution_recorder_mode is automatically set." ) @@ -7107,8 +7385,11 @@ class ServerArgs: if (self.enable_eplb or (self.init_expert_location != "trivial")) and ( self.ep_dispatch_algorithm is None ): - self.ep_dispatch_algorithm = ( - "dynamic" if needs_rank_invariant_dispatch else "static" + self._declare( + "_handle_eplb_and_dispatch", + ep_dispatch_algorithm=( + "dynamic" if needs_rank_invariant_dispatch else "static" + ), ) # `dynamic` / `fake` switch to the row-index pick; `static` reads a @@ -7133,7 +7414,10 @@ class ServerArgs: logger.warning( "--elastic-ep-rejoin is deprecated, use --elastic-ep-join-mode recover instead." ) - self.ep_join_mode = "recover" + self._declare( + "_handle_elastic_ep", + ep_join_mode="recover", + ) else: assert self.ep_join_mode == "recover", ( "--elastic-ep-rejoin (deprecated) conflicts with " @@ -7142,7 +7426,10 @@ class ServerArgs: if self.elastic_ep_backend is not None: if self.enable_eplb: if self.eplb_algorithm == "auto": - self.eplb_algorithm = "elasticity_aware" + self._declare( + "_handle_elastic_ep", + eplb_algorithm="elasticity_aware", + ) assert self.eplb_algorithm in [ "elasticity_aware", "elasticity_aware_hierarchical", @@ -7151,8 +7438,11 @@ class ServerArgs: assert self.pp_size == 1, "PP size should be set to 1 under elastic EP" if self.elastic_ep_backend == "mooncake": - self.mooncake_ib_device = self._validate_ib_devices( - self.mooncake_ib_device + self._declare( + "_handle_elastic_ep", + mooncake_ib_device=self._validate_ib_devices( + self.mooncake_ib_device + ), ) if self.ep_join_mode is not None: assert ( @@ -7208,7 +7498,10 @@ class ServerArgs: "Elastic EP runtime scale-up does not support " "--enable-elastic-expert-backup." ) - self.enable_dp_attention_local_control_broadcast = True + self._declare( + "_handle_elastic_ep", + enable_dp_attention_local_control_broadcast=True, + ) if self.ep_join_mode == "scale": assert self.elastic_ep_initial_size is not None, ( "Elastic EP scale joiners require --elastic-ep-initial-size " @@ -7231,7 +7524,10 @@ class ServerArgs: ) else: if self.elastic_ep_initial_size is None: - self.elastic_ep_initial_size = self.tp_size + self._declare( + "_handle_elastic_ep", + elastic_ep_initial_size=self.tp_size, + ) assert self.elastic_ep_initial_size == self.tp_size, ( "The primary --elastic-ep-initial-size must equal its " f"launch-time TP size ({self.tp_size})." @@ -7317,13 +7613,22 @@ class ServerArgs: if self.should_report_expert_balancedness() and ( self.expert_distribution_recorder_mode is None ): - self.expert_distribution_recorder_mode = "stat" + self._declare( + "_handle_expert_distribution_metrics", + expert_distribution_recorder_mode="stat", + ) if self.expert_distribution_recorder_buffer_size is None: if (x := self.eplb_rebalance_num_iterations) is not None: - self.expert_distribution_recorder_buffer_size = x + self._declare( + "_handle_expert_distribution_metrics", + expert_distribution_recorder_buffer_size=x, + ) elif self.expert_distribution_recorder_mode is not None: - self.expert_distribution_recorder_buffer_size = 1000 + self._declare( + "_handle_expert_distribution_metrics", + expert_distribution_recorder_buffer_size=1000, + ) def _handle_pipeline_parallelism(self): # Moved to the resolution pipeline (arg_groups/overrides.py: @@ -7448,8 +7753,11 @@ class ServerArgs: it against the retraction-backup backend (1.0 for host_pool, else 2.0). """ if self.hicache_ratio is None and self.disaggregation_mode != "decode": - self.hicache_ratio = ( - 1.2 if self.hicache_host_memory_mode == "buffer_only" else 2.0 + self._declare( + "_handle_hicache_ratio_default", + hicache_ratio=( + 1.2 if self.hicache_host_memory_mode == "buffer_only" else 2.0 + ), ) def _handle_hicache(self): @@ -7571,7 +7879,10 @@ class ServerArgs: self.hicache_mem_layout == "page_first_direct" and self.hicache_io_backend == "kernel" ): - self.hicache_io_backend = "direct" + self._declare( + "_resolve_layout_io_compatibility", + hicache_io_backend="direct", + ) logger.warning( "Kernel io backend does not support page first direct layout, switching to direct io backend" ) @@ -7580,7 +7891,10 @@ class ServerArgs: self.hicache_mem_layout == "page_first" and self.hicache_io_backend == "direct" ): - self.hicache_mem_layout = "page_first_direct" + self._declare( + "_resolve_layout_io_compatibility", + hicache_mem_layout="page_first_direct", + ) logger.warning( "Page first layout is not supported with direct IO backend, switching to page first direct layout" ) @@ -7600,7 +7914,10 @@ class ServerArgs: # Keep current behavior for unknown backends (e.g., kernel_ascend). new_layout = self.hicache_mem_layout - self.hicache_mem_layout = new_layout + self._declare( + "_resolve_storage_layout_compatibility", + hicache_mem_layout=new_layout, + ) logger.warning( f"Mooncake storage backend does not support layer_first layout, " f"switching to {new_layout} layout for {self.hicache_io_backend} io backend" @@ -7614,8 +7931,14 @@ class ServerArgs: if resolved is not None: logger.info("Resolved GGUF %s -> %s", self.model_path, resolved) if self.tokenizer_path == self.model_path: - self.tokenizer_path = resolved - self.model_path = resolved + self._declare( + "_resolve_hf_gguf_model_path", + tokenizer_path=resolved, + ) + self._declare( + "_resolve_hf_gguf_model_path", + model_path=resolved, + ) # A speculative draft can be a .gguf too, and it is loaded by path, so it # needs the same Hub-reference resolution as the target. @@ -7630,7 +7953,10 @@ class ServerArgs: self.speculative_draft_model_path, resolved_draft, ) - self.speculative_draft_model_path = resolved_draft + self._declare( + "_resolve_hf_gguf_model_path", + speculative_draft_model_path=resolved_draft, + ) def _handle_load_format(self): # The quantization side of the gguf coupling moved to the pipeline @@ -7645,28 +7971,43 @@ class ServerArgs: if ( self.load_format == "auto" or self.load_format == "gguf" ) and check_gguf_file(self.model_path): - self.load_format = "gguf" + self._declare( + "_handle_load_format", + load_format="gguf", + ) if self.load_format == "auto" and self._is_mistral_native_format(): - self.load_format = "mistral" + self._declare( + "_handle_load_format", + load_format="mistral", + ) logger.info( "Detected Mistral native format checkpoint, setting load_format='mistral'" ) if is_runai_obj_uri(self.model_path): - self.load_format = "runai_streamer" + self._declare( + "_handle_load_format", + load_format="runai_streamer", + ) elif is_remote_url(self.model_path): - self.load_format = "remote" + self._declare( + "_handle_load_format", + load_format="remote", + ) if ( self.speculative_draft_model_path is not None and is_runai_obj_uri(self.speculative_draft_model_path) and self.speculative_draft_load_format is None ): - self.speculative_draft_load_format = "runai_streamer" + self._declare( + "_handle_load_format", + speculative_draft_load_format="runai_streamer", + ) if self.custom_weight_loader is None: - self.custom_weight_loader = [] + self._declare("_handle_load_format", custom_weight_loader=[]) if self.load_format == "remote_instance": if self.remote_instance_weight_loader_backend != "modelexpress" and ( @@ -7676,7 +8017,10 @@ class ServerArgs: logger.warning( "Fallback load_format to 'auto' due to incomplete remote instance weight loader settings." ) - self.load_format = "auto" + self._declare( + "_handle_load_format", + load_format="auto", + ) elif ( self.remote_instance_weight_loader_send_weights_group_ports is None and self.remote_instance_weight_loader_backend == "nccl" @@ -7684,7 +8028,10 @@ class ServerArgs: logger.warning( "Fallback load_format to 'auto' due to incomplete remote instance weight loader NCCL group ports settings." ) - self.load_format = "auto" + self._declare( + "_handle_load_format", + load_format="auto", + ) elif ( self.remote_instance_weight_loader_backend == "transfer_engine" and not self.validate_transfer_engine() @@ -7692,12 +8039,16 @@ class ServerArgs: logger.warning( "Fallback load_format to 'auto' due to 'transfer_engine' backend is not supported." ) - self.load_format = "auto" + self._declare( + "_handle_load_format", + load_format="auto", + ) # Check whether TransferEngine can be used when users want to start seed service that supports TransferEngine backend. if self.remote_instance_weight_loader_start_seed_via_transfer_engine: - self.remote_instance_weight_loader_start_seed_via_transfer_engine = ( - self.validate_transfer_engine() + self._declare( + "_handle_load_format", + remote_instance_weight_loader_start_seed_via_transfer_engine=self.validate_transfer_engine(), ) # "ipc_cache" is an internal-only load format: ModelRunner sets it @@ -7842,16 +8193,22 @@ class ServerArgs: self.disaggregation_transfer_backend == "mooncake" and self.disaggregation_mode in ("prefill", "decode") ) or self.encoder_transfer_backend == "mooncake": - self.disaggregation_ib_device = self._validate_ib_devices( - self.disaggregation_ib_device + self._declare( + "_handle_encoder_disaggregation", + disaggregation_ib_device=self._validate_ib_devices( + self.disaggregation_ib_device + ), ) # Validate model type for encoder disaggregation hf_config = self.get_model_config().hf_config model_arch = hf_config.architectures[0] if self.encoder_transfer_backend == "auto": - self.encoder_transfer_backend = resolve_encoder_transfer_backend( - self.encoder_transfer_backend, model_arch, self.tp_size + self._declare( + "_handle_encoder_disaggregation", + encoder_transfer_backend=resolve_encoder_transfer_backend( + self.encoder_transfer_backend, model_arch, self.tp_size + ), ) if self.encoder_only or self.language_only: logger.info( @@ -7973,19 +8330,25 @@ class ServerArgs: "skip_tokenizer_init=True leaves no decode work for detokenizer workers; " f"forcing detokenizer_worker_num=1 (requested {self.detokenizer_worker_num})." ) - self.detokenizer_worker_num = 1 + self._declare("_handle_tokenizer_batching", detokenizer_worker_num=1) if self.enable_tokenizer_batch_encode: logger.warning( "skip_tokenizer_init=True ignores --enable-tokenizer-batch-encode; disabling it." ) - self.enable_tokenizer_batch_encode = False + self._declare( + "_handle_tokenizer_batching", + enable_tokenizer_batch_encode=False, + ) if self.enable_dynamic_batch_tokenizer: logger.warning( "skip_tokenizer_init=True ignores --enable-dynamic-batch-tokenizer; disabling it." ) - self.enable_dynamic_batch_tokenizer = False + self._declare( + "_handle_tokenizer_batching", + enable_dynamic_batch_tokenizer=False, + ) logger.info( "skip_tokenizer_init=True: string-based stop conditions (stop, stop_regex) " @@ -8152,10 +8515,16 @@ class ServerArgs: ), ) - self.mm_feature_transport = requested_transport + self._declare( + "_handle_multimodal_feature_transport", + mm_feature_transport=requested_transport, + ) # The bounded IPC pool owns device residency. Do not retain unpooled # tensors after a pool miss, which would make HBM use request-dependent. - self.keep_mm_feature_on_device = False + self._declare( + "_handle_multimodal_feature_transport", + keep_mm_feature_on_device=False, + ) envs.SGLANG_USE_CUDA_IPC_TRANSPORT.set( "1" if requested_transport == "cuda_ipc" else "0" ) @@ -8179,7 +8548,7 @@ class ServerArgs: "--debug-cuda-graph is not supported on non CUDA/HIP devices. " "Disabling breakable CUDA graph." ) - self.debug_cuda_graph = False + self._declare("_handle_environment_variables", debug_cuda_graph=False) else: envs.SGLANG_USE_BREAKABLE_CUDA_GRAPH.set("1") logger.warning( @@ -8277,7 +8646,10 @@ class ServerArgs: logger.warning( "Enable deterministic inference because of rl_on_policy_target." ) - self.enable_deterministic_inference = True + self._declare( + "_handle_deterministic_inference", + enable_deterministic_inference=True, + ) # For VLM envs.SGLANG_VLM_CACHE_SIZE_MB.set(0) @@ -8289,7 +8661,10 @@ class ServerArgs: logger.warning( "Disable --enable-aiter-allreduce-fusion because deterministic inference is enabled." ) - self.enable_aiter_allreduce_fusion = False + self._declare( + "_handle_deterministic_inference", + enable_aiter_allreduce_fusion=False, + ) # Moved to the resolution pipeline (arg_groups/overrides.py: # _deterministic_allreduce_fusion_disable), invoked here at its @@ -8350,7 +8725,10 @@ class ServerArgs: if attention_backend not in RADIX_SUPPORTED_DETERMINISTIC_ATTENTION_BACKEND: # Currently, only certain backends support radix cache. Support for other backends is in progress - self.disable_radix_cache = True + self._declare( + "_handle_deterministic_inference", + disable_radix_cache=True, + ) logger.warning( f"Currently radix cache is not compatible with {attention_backend} attention backend for deterministic inference. It will be supported in the future." ) @@ -8366,11 +8744,20 @@ class ServerArgs: else: # CUDA: use NCCL tree algorithm os.environ["NCCL_ALGO"] = "allreduce:tree" - self.disable_custom_all_reduce = True + # Not declared: set_default_server_args() writes this field + # too, through its `args` parameter, so a declaration here + # would be a second source for one field. + self._declare( + "_handle_deterministic_inference", + disable_custom_all_reduce=True, + ) # should_torch_symm_mem_allreduce() takes the # symmetric-memory path only below a byte threshold, so # which reduce runs would follow the token count. - self.enable_torch_symm_mem = False + self._declare( + "_handle_deterministic_inference", + enable_torch_symm_mem=False, + ) # Each channel carries a differently shaped tree and the # channel count is picked from the message size, so a # token's reduction order would follow the token count. @@ -8464,7 +8851,10 @@ class ServerArgs: # enabling it implies --enable-page-major-kv-layout — routing it through the # single page-major path + stride-aware Triton asserts (set before the guard). if self.enable_unified_memory: - self.enable_page_major_kv_layout = True + self._declare( + "_handle_page_major_kv_layout", + enable_page_major_kv_layout=True, + ) if not self.enable_page_major_kv_layout: return # Only the Triton attention kernels read the strided 4-D envelope K/V @@ -8577,41 +8967,53 @@ class ServerArgs: logger.warning( "Hierarchical cache is disabled because of using diffusion LLM inference" ) - self.enable_hierarchical_cache = False + self._declare( + "_handle_dllm_inference", + enable_hierarchical_cache=False, + ) if self.enable_lmcache: logger.warning( "LMCache is disabled because of using diffusion LLM inference" ) - self.enable_lmcache = False + self._declare("_handle_dllm_inference", enable_lmcache=False) if self.enable_flexkv: logger.warning( "FlexKV is disabled because of using diffusion LLM inference" ) - self.enable_flexkv = False + self._declare("_handle_dllm_inference", enable_flexkv=False) if self.pp_size > 1: logger.warning( "Pipeline parallelism is disabled because of using diffusion LLM inference" ) - self.pp_size = 1 + self._declare( + "_handle_dllm_inference", + pp_size=1, + ) if self.enable_lora: logger.warning( "Currently LoRA is not supported by diffusion LLM inference." ) - self.enable_lora = False + self._declare("_handle_dllm_inference", enable_lora=False) if self.disaggregation_mode != "null": logger.warning( "Currently disaggregation is not supported by diffusion LLM inference." ) - self.disaggregation_mode = "null" + self._declare( + "_handle_dllm_inference", + disaggregation_mode="null", + ) if self.enable_mixed_chunk: logger.warning( "Mixed chunked prefill is disabled because of using diffusion LLM inference." ) - self.enable_mixed_chunk = False + self._declare( + "_handle_dllm_inference", + enable_mixed_chunk=False, + ) def _handle_asr_validation(self): """Validate transcription/ASR-specific server args.""" @@ -8645,7 +9047,10 @@ class ServerArgs: ): if self.pp_size > 1: logger.warning("Optimistic prefill does not support pp_size > 1") - self.optimistic_prefill_attempts = 0 + self._declare( + "_handle_other_validations", + optimistic_prefill_attempts=0, + ) elif self.enable_hierarchical_cache and ( self.hicache_storage_backend is not None or self.hicache_write_policy != "write_back" @@ -8654,13 +9059,19 @@ class ServerArgs: "Optimistic prefill only supports L2 hierarchical cache " "with write-back policy" ) - self.optimistic_prefill_attempts = 0 + self._declare( + "_handle_other_validations", + optimistic_prefill_attempts=0, + ) elif resolved_view(self).uses_mamba_radix_cache: logger.warning( "Optimistic prefill does not support models that use " "mamba radix cache." ) - self.optimistic_prefill_attempts = 0 + self._declare( + "_handle_other_validations", + optimistic_prefill_attempts=0, + ) # Handle model inference tensor dump. if self.debug_tensor_dump_output_folder is not None: @@ -8669,7 +9080,7 @@ class ServerArgs: ) self.cuda_graph_config.decode.backend = Backend.DISABLED self.cuda_graph_config.prefill.backend = Backend.DISABLED - self.skip_server_warmup = True + self._declare("_handle_other_validations", skip_server_warmup=True) if self.msprobe_dump_config is not None: logger.warning( @@ -8679,13 +9090,16 @@ class ServerArgs: ) self.cuda_graph_config.decode.backend = Backend.DISABLED self.cuda_graph_config.prefill.backend = Backend.DISABLED - self.skip_server_warmup = True + self._declare("_handle_other_validations", skip_server_warmup=True) # Validate limit_mm_per_prompt modalities if self.limit_mm_data_per_request: if isinstance(self.limit_mm_data_per_request, str): - self.limit_mm_data_per_request = json.loads( - self.limit_mm_data_per_request + self._declare( + "_handle_other_validations", + limit_mm_data_per_request=json.loads( + self.limit_mm_data_per_request + ), ) if isinstance(self.limit_mm_data_per_request, dict): @@ -8700,8 +9114,11 @@ class ServerArgs: # Validate preferred_sampling_params if self.preferred_sampling_params: if isinstance(self.preferred_sampling_params, str): - self.preferred_sampling_params = json.loads( - self.preferred_sampling_params + self._declare( + "_handle_other_validations", + preferred_sampling_params=json.loads( + self.preferred_sampling_params + ), ) # Validate preferred_sampling_params doesn't use tokenizer-dependent features @@ -8755,7 +9172,7 @@ class ServerArgs: def _handle_debug_utils(self): if is_in_ci() and self.soft_watchdog_timeout is None: logger.info("Set soft_watchdog_timeout since in CI") - self.soft_watchdog_timeout = 300 + self._declare("_handle_debug_utils", soft_watchdog_timeout=300) @staticmethod def add_cli_args(parser: argparse.ArgumentParser): @@ -9716,8 +10133,9 @@ class ServerArgs: final_overall_factor = ( base_mem_fraction_reduction_ratio * dynamic_adjustment_factor ) - self.mem_fraction_static = ( - original_server_arg_mem_fraction * final_overall_factor + self._declare( + "adjust_mem_fraction_for_vlm", + mem_fraction_static=original_server_arg_mem_fraction * final_overall_factor, ) def validate_transfer_engine(self): diff --git a/test/registered/unit/server_args/test_resolution_declarations.py b/test/registered/unit/server_args/test_resolution_declarations.py new file mode 100644 index 000000000..40690da86 --- /dev/null +++ b/test/registered/unit/server_args/test_resolution_declarations.py @@ -0,0 +1,381 @@ +"""Resolution writes are recorded, not just applied. + +The projection that replaces field materialization reads the declaration stash, +so a resolution write that only assigns the field is invisible to it. Every +resolver declares now -- the record's handlers through `self._declare`, the +hooks and hardware defaults through `declare_resolution` -- and that is pinned +two ways: no bare assignment to a field survives anywhere a ServerArgs instance +is in reach, and after resolution every declared field agrees with what the +stash says. The second check is the one that keeps the transition honest -- +while a declaration still writes the field immediately, a stash entry and a +field can only disagree if something assigned the field behind the stash's +back. A third check runs the other way: every field resolution moved has to +be explained by the stash, which covers the spellings a source scan cannot +see. +""" + +import ast +import dataclasses +import json +import os +import pathlib +import shutil +import tempfile +import unittest + +import sglang +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=10, suite="base-a-test-cpu") + +_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt" + +# Every field of the record: resolution has no bare-assignment writer left, so +# the scan states that as a whole rather than a converted-so-far list. +_RESOLVED_FIELDS = frozenset(field.name for field in dataclasses.fields(ServerArgs)) + +# Shapes the agreement check runs on. Each needs a real config.json: +# `model_path="dummy"` takes the pipeline's early return. +_MINI_CONFIG = { + "architectures": ["LlamaForCausalLM"], + "model_type": "llama", + "hidden_size": 16, + "intermediate_size": 32, + "num_attention_heads": 2, + "num_key_value_heads": 2, + "num_hidden_layers": 2, + "vocab_size": 128, + "max_position_embeddings": 2048, +} + +_SHAPES = ( + {"tp_size": 2, "dwdp_size": 2}, + {"random_seed": None}, + {"enable_deterministic_inference": True}, + {"enable_return_hidden_states": True}, + { + "speculative_algorithm": "EAGLE", + "speculative_num_steps": 3, + "speculative_eagle_topk": 1, + "speculative_num_draft_tokens": 4, + }, + {"dp_size": 2, "tp_size": 2, "enable_dp_attention": True}, + {"enable_hierarchical_cache": True}, + {"disaggregation_mode": "prefill"}, + {"enable_lora": True, "max_lora_rank": 16}, + {"kv_cache_dtype": "fp8_e4m3", "page_size": 64}, + # A pass and a handler both decide this one: waterfill forces `deepep` + # and the ascend handler wants `none`. Without this shape nothing in + # the set reaches a field two writers disagree about. + {"enable_waterfill": True, "moe_a2a_backend": "ascend_tp"}, +) + +# Which converted fields the shapes above reach; the rest need a device or an +# architecture no CPU fixture has, and the source scan covers those. Pinned so +# a shape that stops reaching a field fails here. Add to it when adding a shape. +_REACHED_BY_SHAPES = frozenset( + { + "_speculative_draft_quantization_explicitly_set", + "allowed_media_domains", + "attention_backend", + "chunked_prefill_size", + "cuda_graph_config", + "custom_weight_loader", + "device", + "disable_cuda_graph", + "disaggregation_ib_device", + "dp_size", + "enable_dp_attention", + "enable_dp_attention_local_control_broadcast", + "enable_dp_lm_head", + "enable_flashinfer_allreduce_fusion", + "encoder_transfer_backend", + "enforce_disable_flashinfer_allreduce_fusion", + "ep_size", + "expert_distribution_recorder_buffer_size", + "flashinfer_allreduce_fusion_backend", + "grammar_backend", + "hicache_ratio", + "keep_mm_feature_on_device", + "load_balance_method", + "max_running_requests", + "mem_fraction_static", + "mm_feature_transport", + "mm_process_config", + "moe_a2a_backend", + "moe_dense_tp_size", + "moe_dp_size", + "page_size", + "random_seed", + "return_hidden_states_mode", + "sampling_backend", + "schedule_conservativeness", + "served_model_name", + "speculative_algorithm", + "speculative_draft_model_quantization", + "tokenizer_path", + "uses_mamba_radix_cache", + } +) + + +def _server_args_writers(tree, path): + """Assignment targets that land on a ServerArgs instance. + + Two mechanisms reach the same instance during resolution: a handler writing + `self.`, and a helper elsewhere in the tree writing through a + `ServerArgs`-annotated parameter -- `set_default_server_args(args)` is + called from the pipeline and writes `args.`. Both bypass the + declaration stash, so both have to be scanned; scanning only the handlers + would let a field look converted while a second writer still assigns it. + """ + names = {"self"} if path.name == "server_args.py" else set() + # A parameter *named* `server_args` counts with or without the annotation. + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + args = node.args + for arg in args.posonlyargs + args.args + args.kwonlyargs: + annotation = arg.annotation + if isinstance(annotation, ast.Constant): + text = annotation.value + elif isinstance(annotation, ast.Name): + text = annotation.id + elif isinstance(annotation, ast.Attribute): + text = annotation.attr + else: + continue + if text == "ServerArgs": + names.add(arg.arg) + names |= { + arg.arg for arg in args.posonlyargs + args.args if arg.arg == "server_args" + } + return names + + +def _bare_assignments(): + """Assignments to a converted field that never reach the stash.""" + found = [] + for path in sorted(_SRT.rglob("*.py")): + try: + tree = ast.parse(path.read_text(encoding="utf-8-sig")) + except SyntaxError: + continue + names = _server_args_writers(tree, path) + if not names: + continue + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, (ast.AugAssign, ast.AnnAssign)): + targets = [node.target] + else: + continue + # Destructured targets count: `(sa.a, sa.b) = f()` writes two + # fields and is not an `ast.Attribute` at the top level. + flat = [] + for target in targets: + if isinstance(target, (ast.Tuple, ast.List)): + flat.extend(target.elts) + else: + flat.append(target) + for target in flat: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id in names + and target.attr in _RESOLVED_FIELDS + ): + found.append( + f"{path.relative_to(_SRT)}:{node.lineno} " + f"{target.value.id}.{target.attr}" + ) + return sorted(found) + + +def _stash_overlay(server_args): + """What the declarations say, last writer wins -- the projection's input.""" + overlay = {} + for _source, declared in getattr(server_args, "_resolved_overrides", None) or (): + overlay.update(declared) + return overlay + + +class TestResolutionDeclarations(CustomTestCase): + def setUp(self): + # Resolution writes environment variables, and those outlive the + # record that set them. + super().setUp() + environment = dict(os.environ) + + def restore(): + os.environ.clear() + os.environ.update(environment) + + self.addCleanup(restore) + + def _resolve(self, extra): + """A fully-resolved config: a real config.json, so the pipeline runs + past its dummy-model early return.""" + path = tempfile.mkdtemp(prefix="declarations_") + self.addCleanup(shutil.rmtree, path, ignore_errors=True) + with open(os.path.join(path, "config.json"), "w") as handle: + json.dump(_MINI_CONFIG, handle) + fields = {"random_seed": 42} + fields.update(extra) + return ServerArgs(model_path=path, device="cuda", **fields) + + def test_converted_fields_are_not_assigned_bare(self): + bare = _bare_assignments() + self.assertEqual( + bare, + [], + "a converted field is assigned directly, so the projection would " + "not see this write:\n " + "\n ".join(bare), + ) + + def test_the_stash_accounts_for_every_change_resolution_made(self): + """The other direction: a field resolution moved is in the stash. + + The source scan states that no *assignment* escapes, which leaves the + spellings a source scan cannot see -- a computed name, a write through + a helper the scan does not recognize as holding the record. This + compares the resolved value against what the caller supplied (or the + field's default) and asks the stash to explain every difference, which + is what the projection has to be able to do. + """ + unexplained = [] + for shape in _SHAPES: + supplied = {"random_seed": 42, **shape} + server_args = self._resolve(shape) + overlay = _stash_overlay(server_args) + for field in dataclasses.fields(server_args): + if field.name in ("model_path", "device") or field.name in overlay: + continue + if field.name in supplied: + before = supplied[field.name] + elif field.default is not dataclasses.MISSING: + before = field.default + elif field.default_factory is not dataclasses.MISSING: + before = field.default_factory() + else: + continue + after = getattr(server_args, field.name, None) + if after != before: + unexplained.append( + f"{shape} -> {field.name}: {before!r} -> {after!r}" + ) + self.assertEqual( + unexplained, + [], + "resolution moved these fields without declaring them, so the " + "projection would answer with the unresolved value:\n " + + "\n ".join(unexplained), + ) + + def test_the_stash_agrees_with_the_fields_it_declared(self): + mismatches = [] + for shape in _SHAPES: + server_args = self._resolve(shape) + overlay = _stash_overlay(server_args) + for field, declared in overlay.items(): + if field not in _RESOLVED_FIELDS: + continue + actual = getattr(server_args, field) + if actual != declared: + mismatches.append( + f"{shape} -> {field}: field={actual!r} stash={declared!r}" + ) + self.assertEqual( + mismatches, + [], + "a declared field and its stash entry disagree, so something " + "assigned the field behind the declaration:\n " + "\n ".join(mismatches), + ) + + def test_no_immediate_writer_overrides_a_deferred_one(self): + """A handler must not declare over a value a pass already decided. + + Routing a bare write into the stash changed which writer wins: the + appended entry is replayed last, so a handler now beats a pass or a + registry entry that ran earlier -- where before, the pass's declaration + was applied on top of the handler's bare write. One handler was found + that way (it gated on the raw field while its neighbours read the + resolving view, so `--enable-waterfill --moe-a2a-backend ascend_tp` + silently stopped forcing `deepep`). + + This is the invariant rather than that instance: walk the stash in + order and fail when an immediate writer declares a field whose previous + entry came from a deferred writer with a *different* value. The + deferred sources are derived from the live registries and the constant + override table, so a new pass is covered without being listed. + """ + from sglang.srt.arg_groups import overrides + + deferred = { + getattr(fn, "__qualname__", getattr(fn, "__name__", "")) + for fn in overrides.POST_PROCESS_PASSES + } + deferred |= { + getattr(fn, "__qualname__", getattr(fn, "__name__", "")) + for fns in overrides._MODEL_OVERRIDE_FNS.values() + for fn in fns + } + deferred |= { + getattr(fn, "__qualname__", getattr(fn, "__name__", "")) + for _predicate, fn in overrides._PREDICATE_OVERRIDE_FNS + } + # The constant arch -> {field: value} table is a deferred writer too -- + # it has no callable, and its stash source is spelled by the collector + # (`MODEL_OVERRIDES[]`). + deferred |= {f"MODEL_OVERRIDES[{arch!r}]" for arch in overrides.MODEL_OVERRIDES} + self.assertGreater( + len(deferred), 40, "the deferred-writer set collapsed; nothing to compare" + ) + + inversions = [] + for shape in _SHAPES: + server_args = self._resolve(shape) + decided_by = {} + for source, declared in getattr(server_args, "_resolved_overrides", []): + for field, value in declared.items(): + previous = decided_by.get(field) + if ( + previous is not None + and previous[0] in deferred + and source not in deferred + and previous[1] != value + ): + inversions.append( + f"{shape} -> {field}: {previous[0]} decided " + f"{previous[1]!r}, then {source} declared {value!r}" + ) + decided_by[field] = (source, value) + self.assertEqual( + inversions, + [], + "a handler declared over a value a pass or a registry entry had " + "already decided; if the handler is meant to win, say so, and if " + "it is gating on the field, it has to read the resolving view:\n " + + "\n ".join(inversions), + ) + + def test_the_shapes_reach_the_fields_they_are_meant_to(self): + """A green agreement check over an empty stash would prove nothing.""" + declared = set() + for shape in _SHAPES: + declared |= set(_stash_overlay(self._resolve(shape))) & _RESOLVED_FIELDS + missing = sorted(_REACHED_BY_SHAPES - declared) + self.assertEqual( + missing, + [], + "the shapes no longer reach these converted fields, so the " + "agreement check silently stopped covering them:\n " + + "\n ".join(missing), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/test_model_overrides.py b/test/registered/unit/test_model_overrides.py index 95a286784..00ae9b256 100644 --- a/test/registered/unit/test_model_overrides.py +++ b/test/registered/unit/test_model_overrides.py @@ -253,15 +253,19 @@ class TestPublishInstallsSlot(_IsolatedPublish): """Publish wiring: set_server_args installs the already-resolved object into the context-owned slot (no transformation at publish time).""" - def test_dummy_fixture_has_empty_stash_and_publishes_cleanly(self): + def test_dummy_fixture_publishes_the_object_it_resolved(self): from sglang.srt.server_args import ( ServerArgs, set_global_server_args_for_scheduler, ) sa = ServerArgs(model_path="dummy") # __post_init__ early-returns - # The stash is created before the dummy short-circuit and stays empty. - self.assertEqual(sa._resolved_overrides, []) + # A dummy path short-circuits the pipeline, but the handlers ahead of + # that point still declare; whatever they left in the stash is on the + # object by the time publish sees it. + for source, declared in sa._resolved_overrides: + for field, value in declared.items(): + self.assertEqual(getattr(sa, field), value, f"{source}: {field}") set_global_server_args_for_scheduler(sa) self.assertIs(get_server_args(), sa) diff --git a/test/registered/unit/test_supplied_instance_exposure_ratchet.py b/test/registered/unit/test_supplied_instance_exposure_ratchet.py index 921145946..038677b62 100644 --- a/test/registered/unit/test_supplied_instance_exposure_ratchet.py +++ b/test/registered/unit/test_supplied_instance_exposure_ratchet.py @@ -663,14 +663,22 @@ class TestSuppliedInstanceExposure(CustomTestCase): `speculative_draft_attention_backend`, and no entry ran it) showed that a family nobody listed leaves its readers unpinned. The hook modules under `arg_groups/` are the resolution pipeline's extension - points, and their assignment surface (`server_args.field = ...`) is - the may-write set, family-blind by construction. Collected like the + points -- along with the NPU default helper, which the pipeline calls + the same way -- and their write surface is the may-write set, + family-blind by + construction. A hook writes two ways: `server_args.field = ...`, and + `declare_resolution(server_args, source, field=...)`, which records + the write in the declaration stash on its way to the field. Counting + only the assignment would read a hook's conversion to a declaration as + the field having stopped being written. Collected like the late-resolution keywords: statically, failing loudly on an unparsable module. Underscore-prefixed targets are pipeline bookkeeping, not config leaves. """ targets = set() - for path in sorted((_PACKAGE_ROOT / "arg_groups").glob("*.py")): + modules = sorted((_PACKAGE_ROOT / "arg_groups").glob("*.py")) + modules.append(_PACKAGE_ROOT / "hardware_backend/npu/utils.py") + for path in modules: try: tree = ast.parse(path.read_text(encoding="utf-8-sig")) except SyntaxError: @@ -680,6 +688,17 @@ class TestSuppliedInstanceExposure(CustomTestCase): tgts = node.targets elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): tgts = [node.target] + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "declare_resolution" + ): + targets |= { + kw.arg + for kw in node.keywords + if kw.arg and not kw.arg.startswith("_") + } + continue else: continue for tgt in tgts: @@ -702,6 +721,12 @@ class TestSuppliedInstanceExposure(CustomTestCase): A write site that can never fire is a dead branch to delete upstream, not a census exemption. Only names that are declared dataclass fields count; underscore bookkeeping does not. + + Two spellings write: an assignment, and ``self._declare(source, + field=value)``, which records the write in the declaration stash on + its way to the field. Counting only assignments would read a handler's + conversion to a declaration as the field having stopped being written, + which would quietly retire every pinned pair that reads it. """ tree = ast.parse( (_PACKAGE_ROOT / "server_args.py").read_text(encoding="utf-8-sig") @@ -722,6 +747,17 @@ class TestSuppliedInstanceExposure(CustomTestCase): tgts = node.targets elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): tgts = [node.target] + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "_declare" + ): + targets |= { + kw.arg + for kw in node.keywords + if kw.arg in declared and not kw.arg.startswith("_") + } + continue else: continue for tgt in tgts: @@ -733,27 +769,24 @@ class TestSuppliedInstanceExposure(CustomTestCase): and not tgt.attr.startswith("_") ): targets.add(tgt.attr) - # The deprecated-alias normalization loop writes through a *name - # tuple* (`for attr in (...): setattr(self, attr, "dsv4")`), which no - # assignment scan sees; its field set is pinned here with a drift - # guard on the tuple itself. + # The deprecated-alias normalization declares through `**renamed`, so + # the keyword scan sees no names; its field set is pinned here. alias_fields = { "attention_backend", "decode_attention_backend", "prefill_attention_backend", "speculative_draft_attention_backend", } + deprecated = next( + node + for node in ast.walk(sa_class) + if isinstance(node, ast.FunctionDef) + and node.name == "_handle_deprecated_args" + ) found_tuples = [ {elt.value for elt in node.iter.elts if isinstance(elt, ast.Constant)} - for node in ast.walk(sa_class) - if isinstance(node, ast.For) - and isinstance(node.iter, ast.Tuple) - and any( - isinstance(inner, ast.Call) - and isinstance(inner.func, ast.Name) - and inner.func.id == "setattr" - for inner in ast.walk(node) - ) + for node in ast.walk(deprecated) + if isinstance(node, ast.For) and isinstance(node.iter, ast.Tuple) ] self.assertIn( alias_fields,