From c861896721cf020b9c143e1b4e7a4d2a751c5f98 Mon Sep 17 00:00:00 2001 From: Cheng Wan <54331508+ch-wan@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:04:44 -0700 Subject: [PATCH] [refactor] Resolve config declarations onto server_args at the end of __post_init__ (#30297) --- python/sglang/srt/arg_groups/hisparse_hook.py | 29 +- python/sglang/srt/arg_groups/overrides.py | 275 ++++++++-- .../sglang/srt/arg_groups/speculative_hook.py | 48 +- python/sglang/srt/server_args.py | 504 ++++++++++-------- .../srt/speculative/adaptive_spec_params.py | 6 +- .../mock_model/test_self_unit_install.py | 4 + .../model_executor/test_pool_configurator.py | 4 + .../test_deepseek_v4_shared_expert_fusion.py | 2 +- .../unit/server_args/test_server_args.py | 21 +- test/registered/unit/test_model_overrides.py | 190 ++++--- test/registered/unit/test_runtime_context.py | 32 +- 11 files changed, 722 insertions(+), 393 deletions(-) diff --git a/python/sglang/srt/arg_groups/hisparse_hook.py b/python/sglang/srt/arg_groups/hisparse_hook.py index 4ff8caf46..d6c437bdb 100644 --- a/python/sglang/srt/arg_groups/hisparse_hook.py +++ b/python/sglang/srt/arg_groups/hisparse_hook.py @@ -43,21 +43,30 @@ def _hisparse_allowed_backends(kv_cache_dtype: str) -> set[str]: def validate_hisparse_dsa_backend( server_args: ServerArgs, attr: str, label: str ) -> None: - backend = getattr(server_args, attr) - allowed_backends = _hisparse_allowed_backends(server_args.kv_cache_dtype) + from sglang.srt.arg_groups.overrides import resolved_view + + # Invoked after the DSA kv-cache-dtype / split-backend declarations: + # read the resolving state through the view. + view = resolved_view(server_args) + backend = getattr(view, attr) + kv_cache_dtype = view.kv_cache_dtype + allowed_backends = _hisparse_allowed_backends(kv_cache_dtype) if backend is not None and backend not in allowed_backends: raise ValueError( f"HiSparse supports DSA {label} backend(s) {sorted(allowed_backends)} " - f"on this platform with --kv-cache-dtype={server_args.kv_cache_dtype}, " + f"on this platform with --kv-cache-dtype={kv_cache_dtype}, " f"but got --dsa-{label}-backend={backend}. " f"Please use --dsa-{label}-backend=" - f"{_hisparse_default_backend(server_args.kv_cache_dtype)} " + f"{_hisparse_default_backend(kv_cache_dtype)} " "or omit it." ) def validate_hisparse_kv_cache_dtype(server_args: ServerArgs) -> None: - if server_args.kv_cache_dtype in HISPARSE_KV_CACHE_DTYPES: + from sglang.srt.arg_groups.overrides import resolved_view + + kv_cache_dtype = resolved_view(server_args).kv_cache_dtype + if kv_cache_dtype in HISPARSE_KV_CACHE_DTYPES: return choices = " or ".join( @@ -65,7 +74,7 @@ def validate_hisparse_kv_cache_dtype(server_args: ServerArgs) -> None: ) raise ValueError( f"HiSparse requires one of {HISPARSE_KV_CACHE_DTYPES} KV cache dtypes, " - f"but got --kv-cache-dtype={server_args.kv_cache_dtype}. Please use {choices}." + f"but got --kv-cache-dtype={kv_cache_dtype}. Please use {choices}." ) @@ -112,7 +121,13 @@ def validate_hisparse(server_args: ServerArgs) -> None: ) return - if server_args.kv_cache_dtype not in ("bfloat16", "auto", "fp8_e4m3"): + from sglang.srt.arg_groups.overrides import resolved_view + + if resolved_view(server_args).kv_cache_dtype not in ( + "bfloat16", + "auto", + "fp8_e4m3", + ): validate_hisparse_kv_cache_dtype(server_args) for attr, label in [ diff --git a/python/sglang/srt/arg_groups/overrides.py b/python/sglang/srt/arg_groups/overrides.py index e94ac8ee0..9439b6da4 100644 --- a/python/sglang/srt/arg_groups/overrides.py +++ b/python/sglang/srt/arg_groups/overrides.py @@ -117,14 +117,10 @@ def _invoke_provider( class ResolvedView: """Read-only view of the resolving configuration handed to post-process - passes. - - During the dual-apply transition the view forwards every read to the live - ``server_args`` — the pristine input plus the declarations replayed so far - plus any residual imperative writes — which is exactly the state the - legacy handler at the same slot observed. In the end state (dual-apply - retired) the same type overlays the accumulated declarations on the - pristine object. Writes are rejected: passes return declarations. + passes: the accumulated declarations overlaid on the pristine + ``server_args`` (residual imperative writes of non-resolved fields show + through the fallthrough) — exactly the state the legacy handler at the + same slot observed. Writes are rejected: passes return declarations. """ __slots__ = ("_server_args", "_overlay") @@ -162,14 +158,27 @@ def register_post_process(fn: Callable[..., dict]) -> Callable[..., dict]: return fn -def run_post_process_pass(server_args: Any, fn: Callable[..., dict]) -> None: - """Transition-period invocation of one pass at its legacy handler slot. +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).""" + overlay: Dict[str, Any] = {} + for _source, declared in getattr(server_args, "_resolved_overrides", None) or (): + overlay.update(declared) + return overlay - Evaluates the pass on the live state (through a read-only view), appends - its declaration to the declaration stash, and dual-applies it in place — - byte-identical to the imperative handler write this replaces. + +def run_post_process_pass(server_args: Any, fn: Callable[..., dict]) -> None: + """Invoke one pass at its legacy handler slot. + + Evaluates the pass on the resolving state (a read-only view with the + accumulated declarations overlaid from the stash) and appends its + declaration to the stash. During ``__post_init__`` the fields stay + untouched — ``materialize_declarations`` applies the whole stash once at + the end of resolution; a pass invoked after materialization (a post-init + slot) writes through immediately. """ - declared = fn(ResolvedView(server_args)) + declared = fn(ResolvedView(server_args, overlay=_declaration_overlay(server_args))) if not isinstance(declared, dict): raise TypeError( f"post-process pass {fn.__qualname__} must return a dict, " @@ -186,19 +195,70 @@ def run_post_process_pass(server_args: Any, fn: Callable[..., dict]) -> None: # must sit at or after it in __post_init__ order. stash = server_args._resolved_overrides = [] stash.append(entry) - apply_declarations_to_server_args(server_args, [entry]) + validate_declarations(server_args, [entry]) + if getattr(server_args, "_declarations_materialized", False): + for field, value in declared.items(): + setattr(server_args, field, value) + + +def materialize_declarations(server_args: Any) -> None: + """Apply the accumulated declarations onto ``server_args`` once, at the + end of ``__post_init__`` (gate order: last writer wins). After this the + fields carry the resolved configuration — every post-init reader, in any + process, reads them directly; ``resolved_view`` remains an internal + helper for mid-resolution code only.""" + for _source, declared in getattr(server_args, "_resolved_overrides", None) or (): + for field, value in declared.items(): + setattr(server_args, field, value) + server_args._declarations_materialized = True + + +def resolved_view(server_args: Any) -> ResolvedView: + """Read-only view of the resolving configuration for mid-resolution code + that is not a pass (``__post_init__`` handlers and hooks). Internal to + the resolution pipeline: after ``materialize_declarations`` runs, the + fields themselves carry the resolved values — read them directly.""" + return ResolvedView(server_args, overlay=_declaration_overlay(server_args)) + + +def attention_backends_of(cfg: Any) -> tuple: + """(prefill, decode) attention backends of a config-shaped object (a + ResolvedView mid-resolution, or pristine server_args at dispatch time): + split fields fall back to the base backend.""" + prefill = ( + cfg.prefill_attention_backend + if cfg.prefill_attention_backend + else cfg.attention_backend + ) + decode = ( + cfg.decode_attention_backend + if cfg.decode_attention_backend + else cfg.attention_backend + ) + return prefill, decode + + +def mamba_extra_buffer_of(cfg: Any) -> bool: + """Mid-resolution equivalent of runtime_context.mamba_extra_buffer_enabled: + reads the (possibly overlaid) strategy from a config-shaped object.""" + return cfg.disable_radix_cache is False and cfg.mamba_radix_cache_strategy in ( + "extra_buffer", + "extra_buffer_lazy", + ) def declare_load_time_override(source: str, declared: Dict[str, Any]) -> None: - """Transition helper for load-time resolved fields (model-file config - overrides, weight-resolved dtypes): dual-apply the declaration onto the - published ``server_args`` — byte-identical to the imperative write this - replaces — and record it into the flags tier through the runtime gate.""" + """Declare a load-time resolved field (model-file config overrides, + weight-resolved dtypes): apply it onto the published ``server_args`` — + resolution has already materialized, so post-init declarations write + through — and record it into the flags tier through the runtime gate.""" from sglang.srt.runtime_context import get_context ctx = get_context() entry = (source, dict(declared)) - apply_declarations_to_server_args(ctx.server_args, [entry]) + validate_declarations(ctx.server_args, [entry]) + for field, value in declared.items(): + setattr(ctx.server_args, field, value) ctx.record_runtime_overrides([entry]) @@ -741,8 +801,11 @@ def _qwen3_5_hybrid_overrides(server_args: Any, hf_config: Any) -> dict: use_mla_backend=server_args.use_mla_backend(), model_config=server_args.get_model_config(), ) + # The mamba radix-cache pass runs before this dispatch: read the + # declared strategy through the view (the legacy branch observed the + # already-written field here). if default_attn_backend == "trtllm_mha" and not ( - not server_args.enable_mamba_extra_buffer() + not mamba_extra_buffer_of(resolved_view(server_args)) and not server_args.disable_radix_cache and server_args.speculative_algorithm is None ): @@ -1519,12 +1582,57 @@ def _mla_backend_page_constraints(view: Any) -> dict: return {} +@register_post_process +def _mla_kv_cache_dtype_checks(view: Any) -> dict: + """Read-only validation pass in the attention-backend compatibility + handler: the TRT-LLM and tokenspeed MLA backends constrain the resolved + kv-cache dtype (declarations never reach the field, so the checks read + the view).""" + if ( + view.attention_backend == "trtllm_mla" + or view.decode_attention_backend == "trtllm_mla" + ): + if not is_blackwell_supported(): + raise ValueError( + "TRTLLM MLA backend is only supported on Blackwell GPUs (SM100/SM12x). Please use a different backend." + ) + if view.kv_cache_dtype not in ["fp8_e4m3", "fp4_e2m1", "bf16", "auto"]: + raise ValueError( + "TensorRT-LLM MLA backend only supports kv-cache-dtype of fp8_e4m3, fp4_e2m1, bf16, or auto." + ) + if ( + view.attention_backend == "tokenspeed_mla" + or view.decode_attention_backend == "tokenspeed_mla" + ): + if not is_blackwell_supported(): + raise ValueError( + "tokenspeed_mla backend is only supported on Blackwell GPUs (SM100/SM12x)." + ) + if view.kv_cache_dtype not in ["fp8_e4m3"]: + raise ValueError( + "tokenspeed_mla backend requires kv-cache-dtype=fp8_e4m3, " + f"got {view.kv_cache_dtype}." + ) + return {} + + +@register_post_process +def _hisparse_validation(view: Any) -> dict: + """Read-only validation pass: --enable-hisparse constraints (model class, + radix cache, kv dtype, DSA backends) read the resolved values through the + view.""" + from sglang.srt.arg_groups.hisparse_hook import validate_hisparse + + validate_hisparse(view) + return {} + + @register_post_process def _cutedsl_prefill_backend_fill(view: Any) -> dict: """Slot pass in the attention-backend compatibility handler: CuteDSL MLA is decode-only, so validate the combination and default the prefill side to trtllm_mla. The trtllm_mha check that follows at the legacy slot reads - the dual-applied value.""" + the resolved value through the view.""" if not ( view.attention_backend == "cutedsl_mla" or view.decode_attention_backend == "cutedsl_mla" @@ -1610,7 +1718,7 @@ def _attention_backend_platform_fallbacks(view: Any) -> dict: @register_post_process def _intel_xpu_page_constraint(view: Any) -> dict: - _, decode_backend = view.get_attention_backends() + _, decode_backend = attention_backends_of(view) if decode_backend == "intel_xpu": if view.use_mla_backend(): supported_page_sizes = [16, 32, 64, 128] @@ -1674,6 +1782,17 @@ def _data_parallelism_defaults(view: Any) -> dict: return {} +@register_post_process +def _dp_lm_head_validation(view: Any) -> dict: + """Read-only validation pass: dp-attention is a prerequisite for the + dp LM head. Reads the mid-resolution values through the view.""" + if view.enable_dp_lm_head: + assert ( + view.enable_dp_attention + ), "Please enable dp attention when setting enable_dp_lm_head. " + return {} + + @register_post_process def _moe_runner_backend_quant_constraints(view: Any) -> dict: """The quantization-driven moe_runner_backend resolutions at the head of @@ -1728,6 +1847,49 @@ def _moe_runner_backend_quant_constraints(view: Any) -> dict: @register_post_process +def _moe_runner_fusion_disable(view: Any) -> dict: + """FlashInfer CuteDSL / TRT-LLM / TRT-LLM-routed MoE runners require the + shared-experts fusion disabled; declared at the legacy write slots in + _handle_moe_kernel_config (before the deprecated cutlass env override, so + the runner value observed is the pre-override one).""" + runner = view.moe_runner_backend + if runner == "flashinfer_cutedsl": + logger.warning( + "FlashInfer CuteDSL MoE is enabled. --disable-shared-experts-fusion is automatically set." + ) + return {"disable_shared_experts_fusion": True} + if runner in ("flashinfer_trtllm", "experimental_sgl_trtllm"): + logger.warning( + "FlashInfer TRTLLM MoE is enabled. --disable-shared-experts-fusion is automatically set." + ) + return {"disable_shared_experts_fusion": True} + if runner == "flashinfer_trtllm_routed": + logger.warning( + "FlashInfer TRTLLM routed MoE is enabled. --disable-shared-experts-fusion is automatically set." + ) + return {"disable_shared_experts_fusion": True} + return {} + + +def _a2a_fusion_adjustments(view: Any) -> dict: + """A2A-backend-driven shared-experts fusion adjustments, declared at the + legacy write slots in _handle_a2a_moe: DeepEP Waterfill requires the + fusion enabled; FlashInfer A2A requires it disabled.""" + if view.moe_a2a_backend == "deepep" and view.enable_deepep_waterfill: + if view.disable_shared_experts_fusion: + logger.warning( + "disable_shared_experts_fusion is overridden to False because DeepEP Waterfill requires shared expert fusion." + ) + return {"disable_shared_experts_fusion": False} + return {} + if view.moe_a2a_backend == "flashinfer": + logger.warning( + "Flashinfer MoE A2A is enabled. --disable-shared-experts-fusion is automatically set." + ) + return {"disable_shared_experts_fusion": True} + return {} + + def _cutlass_moe_env_override(view: Any) -> dict: from sglang.srt.environ import envs @@ -1846,16 +2008,25 @@ def _dllm_overlap_disable(view: Any) -> dict: @register_post_process def _dllm_page_size(view: Any) -> dict: - if view.dllm_algorithm is None or view.disable_radix_cache: + if view.dllm_algorithm is None: return {} from sglang.srt.dllm.config import DllmConfig config = DllmConfig.from_server_args(view) - if view.page_size % config.block_size != 0: + if not view.disable_radix_cache and view.page_size % config.block_size != 0: logger.warning( f"Setting page size to {config.block_size} for diffusion LLM inference" ) return {"page_size": config.block_size} + if view.page_size > config.block_size: + # Legacy scheduler-init fallback, folded into the pass: the page + # size must not exceed the dllm block size. + logger.warning( + "WARNING: " + f"The page size {view.page_size} should not be larger than dllm block size {config.block_size}." + f"Page size now falls back to {config.block_size}" + ) + return {"page_size": config.block_size} return {} @@ -1937,45 +2108,35 @@ def apply_model_overrides( return records -def apply_declarations_to_server_args( +def validate_declarations( server_args: Any, declarations: Sequence[Tuple[str, Dict[str, Any]]], - *, - terminal: Sequence[Tuple[str, Dict[str, Any]]] = (), ) -> None: - """Transition-period dual-apply: replay declarations onto ``server_args`` - in gate order, byte-identical to the legacy imperative writes. - - Retired per field once that field's readers have all flipped to the flags - tier (at which point the server_args field returns to pristine). - - Validates against the same whitelist as the publish gate BEFORE any write: - a registry typo or a not-yet-resolvable field must fail fast here, not - mutate ``server_args`` and only be rejected at publish time. + """Fail-fast whitelist check at declaration time: a registry typo or a + not-yet-resolvable field must be rejected at its slot, not only at + publish time. Declarations never mutate ``server_args``. """ # Non-dataclass fixtures carry no Arg metadata (mirrors the # resolvable_fields escape); only real ServerArgs is validated. - if dataclasses.is_dataclass(type(server_args)): - whitelist = resolvable_fields(type(server_args)) - for source, decl in list(declarations) + list(terminal): - unknown = set(decl) - whitelist - if unknown: - raise ValueError( - f"{source}: {sorted(unknown)} not model-overridable; the " - "transition dual-apply refuses fields the publish gate " - "would reject." - ) - for _source, decl in list(declarations) + list(terminal): - for field, value in decl.items(): - setattr(server_args, field, value) + if not dataclasses.is_dataclass(type(server_args)): + return + whitelist = resolvable_fields(type(server_args)) + for source, decl in declarations: + unknown = set(decl) - whitelist + if unknown: + raise ValueError( + f"{source}: {sorted(unknown)} not model-overridable; " + "declarations are limited to the fields the publish gate " + "accepts." + ) def refresh_declared_fields(server_args: Any, fields: Iterable[str]) -> None: - """Transition helper for legacy code that overwrites a resolved field - AFTER the override collection in ``__post_init__`` (e.g. - ``ModelRunner.model_specific_adjustment`` forcing ``attention_backend`` - for HRM-Text). Redeclares the live value so publish parity holds and the - flags tier materializes the adjusted end state. + """Helper for legacy code that overwrites a resolved field AFTER + materialization (e.g. ``ModelRunner.model_specific_adjustment`` forcing + ``attention_backend`` for HRM-Text). Redeclares the live value so the + publish parity holds and the flags tier materializes the adjusted end + state. """ _missing = object() declarations = server_args._resolved_overrides @@ -1998,8 +2159,8 @@ def assert_flag_parity( *, leaf_map: Optional[Dict[str, str]] = None, ) -> None: - """Dual-apply drift guard: each migrated field's flag leaf must equal the - (dual-applied) ``server_args`` value.""" + """Drift guard: each declared field's flag leaf must equal the + (materialized) ``server_args`` value.""" mismatches = [] for field in fields: owner, leaf = resolve_flag_leaf(flags, field, leaf_map=leaf_map) diff --git a/python/sglang/srt/arg_groups/speculative_hook.py b/python/sglang/srt/arg_groups/speculative_hook.py index bd2cc9dd4..143249b95 100644 --- a/python/sglang/srt/arg_groups/speculative_hook.py +++ b/python/sglang/srt/arg_groups/speculative_hook.py @@ -135,7 +135,9 @@ def handle_speculative_decoding(server_args: ServerArgs) -> None: def _handle_dflash(server_args: ServerArgs) -> None: - if server_args.enable_dp_attention: + from sglang.srt.arg_groups.overrides import resolved_view + + if resolved_view(server_args).enable_dp_attention: raise ValueError( "Currently DFLASH speculative decoding does not support dp attention." ) @@ -264,7 +266,12 @@ def _resolve_dflash_draft_attention_backend(server_args: ServerArgs) -> None: draft_backend = server_args.speculative_draft_attention_backend if draft_backend is None: - draft_backend, _ = server_args.get_attention_backends() + from sglang.srt.arg_groups.overrides import ( + attention_backends_of, + resolved_view, + ) + + draft_backend, _ = attention_backends_of(resolved_view(server_args)) if draft_backend is None: draft_backend = fallback_backend elif draft_backend == "trtllm_mha": @@ -305,9 +312,14 @@ def _handle_frozen_kv_mtp(server_args: ServerArgs) -> None: def _handle_eagle_family(server_args: ServerArgs) -> None: + from sglang.srt.arg_groups.overrides import ( + attention_backends_of, + resolved_view, + ) + if ( server_args.speculative_algorithm == "STANDALONE" - and server_args.enable_dp_attention + and resolved_view(server_args).enable_dp_attention ): # TODO: support dp attention for standalone speculative decoding raise ValueError( @@ -320,7 +332,7 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: "Max running requests is reset to 48 for speculative decoding. You can override this by explicitly setting --max-running-requests." ) - if server_args.disable_overlap_schedule: + if resolved_view(server_args).disable_overlap_schedule: logger.warning( "Non-overlap (synchronous) spec v2 is used for eagle/eagle3/standalone " "speculative decoding." @@ -375,11 +387,7 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: server_args.speculative_num_draft_tokens, ) = _auto_choose_speculative_params(server_args, model_arch) - if ( - server_args.attention_backend == "trtllm_mha" - or server_args.decode_attention_backend == "trtllm_mha" - or server_args.prefill_attention_backend == "trtllm_mha" - ): + if "trtllm_mha" in attention_backends_of(resolved_view(server_args)): if server_args.speculative_eagle_topk > 1: raise ValueError( "trtllm_mha backend only supports topk = 1 for speculative decoding." @@ -416,7 +424,9 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: "--enable-deterministic-inference; the sampling kernel draws " "coins from the global RNG and is not batch-invariant." ) - if server_args.enable_multi_layer_eagle: + from sglang.srt.arg_groups.overrides import resolved_view + + if resolved_view(server_args).enable_multi_layer_eagle: raise NotImplementedError( "--speculative-use-rejection-sampling is not supported with " "multi-layer EAGLE (--enable-multi-layer-eagle)." @@ -440,15 +450,16 @@ def _handle_eagle_family(server_args: ServerArgs) -> None: # pass + per-branch expand pass with prefix-tail dup). Only these backends implement # it; flashmla / trtllm_mla / cutlass_mla can't express the per-branch tree, so reject. _PAGE_TREE_SPEC_BACKENDS = ("flashinfer", "fa3", "triton") + view = resolved_view(server_args) if ( server_args.speculative_eagle_topk > 1 - and server_args.page_size > 1 - and server_args.attention_backend not in _PAGE_TREE_SPEC_BACKENDS + and view.page_size > 1 + and view.attention_backend not in _PAGE_TREE_SPEC_BACKENDS ): raise ValueError( f"speculative_eagle_topk > 1 with page_size > 1 is only supported on " f"{_PAGE_TREE_SPEC_BACKENDS}; got attention_backend=" - f"{server_args.attention_backend!r}. Use page_size == 1 or one of those backends." + f"{view.attention_backend!r}. Use page_size == 1 or one of those backends." ) @@ -499,18 +510,21 @@ def _handle_ngram(server_args: ServerArgs) -> None: "using ngram speculative decoding." ) + from sglang.srt.arg_groups.overrides import resolved_view + + view = resolved_view(server_args) if ( server_args.speculative_eagle_topk > 1 - and server_args.page_size > 1 - and server_args.attention_backend != "flashinfer" + and view.page_size > 1 + and view.attention_backend != "flashinfer" ): raise ValueError( f"speculative_eagle_topk({server_args.speculative_eagle_topk}) > 1 " - f"with page_size({server_args.page_size}) > 1 is unstable " + f"with page_size({view.page_size}) > 1 is unstable " "and produces incorrect results for paged attention backends. " "This combination is only supported for the 'flashinfer' backend." ) - if server_args.enable_dp_attention: + if view.enable_dp_attention: # TODO: support dp attention for ngram speculative decoding raise ValueError( "Currently ngram speculative decoding does not support dp attention." diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 884ba1877..ed1ee9627 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -69,7 +69,6 @@ from sglang.srt.utils.common import ( get_int_env_var, get_quantization_config, human_readable_int, - is_blackwell_supported, is_cpu, is_cuda, is_flashinfer_available, @@ -2787,6 +2786,14 @@ class ServerArgs: # Handle any other necessary validations. self._handle_other_validations() + # End of resolution: apply the accumulated declarations onto the + # fields once (gate order). From here on server_args carries the + # resolved configuration — post-init readers, in any process, read + # the fields directly. + from sglang.srt.arg_groups.overrides import materialize_declarations + + materialize_declarations(self) + def _handle_model_source_paths(self): """Resolve model/tokenizer paths backed by remote object stores.""" if is_runai_obj_uri(self.model_path): @@ -3233,6 +3240,8 @@ class ServerArgs: self._disable_breakable_cudagraph_if_incompatible() def _disable_tc_piecewise_cudagraph_if_incompatible(self): + from sglang.srt.arg_groups.overrides import resolved_view as _resolved_view + """TcPiecewise (torch.compile + piecewise) is incompatible with these configurations. Most are torch.compile / dynamo limitations. """ @@ -3242,7 +3251,7 @@ class ServerArgs: "model-arch blacklist", lambda: self.get_model_config().is_piecewise_cuda_graph_disabled_model, ), - ("DP attention", lambda: self.enable_dp_attention), + ("DP attention", lambda: self._resolved().enable_dp_attention), ("full torch.compile mode", lambda: self.enable_torch_compile), ("pipeline parallelism (pp_size > 1)", lambda: self.pp_size > 1), ( @@ -3254,7 +3263,10 @@ class ServerArgs: lambda: current_platform.is_out_of_tree() and not current_platform.support_piecewise_cuda_graph(), ), - ("MoE A2A backend", lambda: self.moe_a2a_backend != "none"), + ( + "MoE A2A backend", + lambda: _resolved_view(self).moe_a2a_backend != "none", + ), ("LoRA", lambda: bool(self.lora_paths) or self.enable_lora), ( "multimodal model", @@ -3264,7 +3276,7 @@ class ServerArgs: ( "GGUF quantization", lambda: self.load_format == "gguf" - or self.quantization == "gguf" + or _resolved_view(self).quantization == "gguf" or check_gguf_file(self.model_path), ), ("DLLM (diffusion LLM)", lambda: self.dllm_algorithm is not None), @@ -3283,7 +3295,10 @@ class ServerArgs: lambda: self.enable_eplb or self.expert_distribution_recorder_mode is not None, ), - ("context parallel (attn_cp_size > 1)", lambda: self.attn_cp_size > 1), + ( + "context parallel (attn_cp_size > 1)", + lambda: self._resolved().attn_cp_size > 1, + ), ("CUDA graph debug mode", lambda: self.debug_cuda_graph), ( "DSA prefill context parallelism", @@ -3295,6 +3310,8 @@ class ServerArgs: self.cuda_graph_config.prefill.backend = Backend.DISABLED def _disable_breakable_cudagraph_if_incompatible(self): + from sglang.srt.arg_groups.overrides import resolved_view as _resolved_view + """Breakable (segmented capture, no torch.compile). Breakable enforces memory-saver rejection in its own __init__; config-time rules can be added here as they're discovered. @@ -3311,13 +3328,19 @@ class ServerArgs: lambda: is_deepseek_v4(self.get_model_config().hf_config), ), # CP all_gather replay size mismatch under BCG. - ("context parallel (attn_cp_size > 1)", lambda: self.attn_cp_size > 1), + ( + "context parallel (attn_cp_size > 1)", + lambda: self._resolved().attn_cp_size > 1, + ), # BCG capture + LoRA adapter weights exceed host RAM headroom. ("LoRA", lambda: bool(self.lora_paths) or bool(self.enable_lora)), # BCG bucket sizes exceed FlashInfer MoE A2A's dispatch cap. - ("MoE A2A backend", lambda: self.moe_a2a_backend != "none"), + ( + "MoE A2A backend", + lambda: _resolved_view(self).moe_a2a_backend != "none", + ), # DP-attn × BCG capture/replay not yet validated. - ("DP attention", lambda: self.enable_dp_attention), + ("DP attention", lambda: self._resolved().enable_dp_attention), # Multimodal prefill replay faults under BCG. ("multimodal model", lambda: self.get_model_config().is_multimodal), ] @@ -3347,7 +3370,7 @@ class ServerArgs: not in self.get_model_config().hf_config.architectures ): return - prefill_attention_backend, _ = self.get_attention_backends() + prefill_attention_backend, _ = self._resolved_attention_backends() if prefill_attention_backend != "trtllm_mla": return logger.warning( @@ -3394,7 +3417,7 @@ class ServerArgs: logger.warning("Chunked prefill is disabled because --enable-mis is set.") self.chunked_prefill_size = -1 - prefill_backend, decode_backend = self.get_attention_backends() + prefill_backend, decode_backend = self._resolved_attention_backends() assert prefill_backend == "flashinfer" and decode_backend == "flashinfer", ( "Multi-item scoring requires flashinfer attention backend for custom attention mask support. " f"Please set --attention-backend flashinfer when using --enable-mis. " @@ -3579,7 +3602,10 @@ class ServerArgs: # Some adjustments for large parallel size reserved_mem += self.tp_size * self.pp_size / 8 * 1024 - if self.enable_dp_attention and self.disaggregation_mode != "prefill": + if ( + self._resolved().enable_dp_attention + and self.disaggregation_mode != "prefill" + ): # DP attention needs more padding for some operations reserved_mem += decode_cuda_graph_config.max_bs * self.dp_size * 3 @@ -3606,10 +3632,12 @@ class ServerArgs: # DeepEP all-to-all buffers captured in the decode graph are real # extra allocations, so reserve them on top of the floor. + from sglang.srt.arg_groups.overrides import resolved_view + if ( self.disaggregation_mode != "prefill" and decode_cuda_graph_config.backend != Backend.DISABLED - and self.moe_a2a_backend == "deepep" + and resolved_view(self).moe_a2a_backend == "deepep" ): reserved_mem += 2 * 1024 @@ -3757,20 +3785,20 @@ class ServerArgs: self._handle_mamba_radix_cache(model_arch=model_arch) # Collect the declarative model overrides (registry) on the - # pristine config and stash them for publish-time flags resolution. - # Transition dual-apply: the same declarations are applied to - # server_args right here, byte-identical to the imperative arch - # branches this dispatch gradually replaces (dual-apply is retired - # per field once that field's readers migrate to the flags tier). + # pristine config and stash them for publish-time flags resolution; + # server_args is never mutated — mid-resolution readers see the + # declared values through resolved_view, runtime readers through the + # flags tier. from sglang.srt.arg_groups.overrides import ( - apply_declarations_to_server_args, collect_model_override_declarations, + resolved_view, + validate_declarations, ) self._resolved_overrides = collect_model_override_declarations( model_arch, self, hf_config ) - apply_declarations_to_server_args(self, self._resolved_overrides) + validate_declarations(self, self._resolved_overrides) if model_arch in [ "DeepseekV4ForCausalLM", @@ -3842,7 +3870,9 @@ class ServerArgs: import torch major, _ = torch.cuda.get_device_capability() - self._set_default_dsa_kv_cache_dtype(major, self.quantization) + self._set_default_dsa_kv_cache_dtype( + major, resolved_view(self).quantization + ) self._set_default_dsa_backends(self.kv_cache_dtype, major) if self.enable_prefill_cp: @@ -3877,7 +3907,7 @@ class ServerArgs: run_post_process_pass(self, _deepseek_moe_quant_resolution) if is_hip(): - if not self.enable_dp_attention and self.nnodes == 1: + if not self._resolved().enable_dp_attention and self.nnodes == 1: # TODO (Hubert): Put this back later # self.enable_aiter_allreduce_fusion = True logger.info( @@ -3945,7 +3975,9 @@ class ServerArgs: "intel_xpu", "aiter", ] - prefill_attn_backend, decode_attn_backend = self.get_attention_backends() + prefill_attn_backend, decode_attn_backend = ( + self._resolved_attention_backends() + ) assert ( prefill_attn_backend in supported_backends and decode_attn_backend in supported_backends @@ -3957,7 +3989,11 @@ class ServerArgs: quant_method = get_quantization_config(hf_config) is_mxfp4_quant_format = quant_method == "mxfp4" - if not self.enable_dp_attention and self.nnodes == 1 and is_hip(): + if ( + not self._resolved().enable_dp_attention + and self.nnodes == 1 + and is_hip() + ): # TODO (Hubert): Put this back later # self.enable_aiter_allreduce_fusion = True logger.info("Enable Aiter AllReduce Fusion for GptOssForCausalLM") @@ -3972,9 +4008,9 @@ class ServerArgs: # The moe_runner_backend selection moved to the override registry # (arg_groups/overrides.py: _gpt_oss_overrides). - if self.moe_runner_backend == "triton_kernel": + if resolved_view(self).moe_runner_backend == "triton_kernel": assert ( - self.ep_size == 1 + self._resolved().ep_size == 1 ), "Triton kernel MoE is only supported when ep_size == 1" elif model_arch in ("MiMoV2ForCausalLM", "MiMoV2FlashForCausalLM"): @@ -3982,9 +4018,10 @@ class ServerArgs: expected_attn_tp_size = get_mimo_v2_fused_qkv_expected_tp_size( hf_config ) - attn_dp_size = self.dp_size if self.enable_dp_attention else 1 + view = self._resolved() + attn_dp_size = self.dp_size if view.enable_dp_attention else 1 effective_attn_tp_size = ( - self.tp_size // attn_dp_size // self.attn_cp_size + self.tp_size // attn_dp_size // view.attn_cp_size ) if ( expected_attn_tp_size is not None @@ -3997,8 +4034,8 @@ class ServerArgs: f"TP={expected_attn_tp_size}-interleaved; got " f"{effective_attn_tp_size} " f"(tp_size={self.tp_size}, dp_size={self.dp_size}, " - f"enable_dp_attention={self.enable_dp_attention}, " - f"attn_cp_size={self.attn_cp_size}). " + f"enable_dp_attention={view.enable_dp_attention}, " + f"attn_cp_size={view.attn_cp_size}). " "Set --tp, --dp, --enable-dp-attention, and " "--attention-context-parallel-size so the effective " f"attention TP size is {expected_attn_tp_size}." @@ -4032,14 +4069,15 @@ class ServerArgs: ): # Attention backend auto-select moved to the override registry # (arg_groups/overrides.py: _llama4_overrides). - assert self.attention_backend in { + attention_backend = resolved_view(self).attention_backend + assert attention_backend in { "fa3", "aiter", "triton", "ascend", "trtllm_mha", "intel_xpu", - }, f"fa3, aiter, triton, ascend, trtllm_mha or intel_xpu is required for Llama4 model but got {self.attention_backend}" + }, f"fa3, aiter, triton, ascend, trtllm_mha or intel_xpu is required for Llama4 model but got {attention_backend}" # The moe_runner_backend selection moved to the override registry # (arg_groups/overrides.py: _llama4_overrides). # Gemma2/Gemma3 (disable_hybrid_swa_memory) moved to the override registry @@ -4051,7 +4089,7 @@ class ServerArgs: ): # Default attention backend selection moved to the override registry # (arg_groups/overrides.py: _gemma4_overrides). - prefill_backend, decode_backend = self.get_attention_backends() + prefill_backend, decode_backend = self._resolved_attention_backends() accepted_backends = ("trtllm_mha", "triton", "ascend", "intel_xpu") assert ( prefill_backend in accepted_backends @@ -4073,9 +4111,10 @@ class ServerArgs: # (arg_groups/overrides.py: _exaone_overrides). # https://docs.sglang.ai/advanced_features/attention_backend.html accepted_backends = ["fa3", "triton", "trtllm_mha"] + attention_backend = resolved_view(self).attention_backend assert ( - self.attention_backend in accepted_backends - ), f"One of the attention backends in {accepted_backends} is required for {model_arch}, but got {self.attention_backend}" + attention_backend in accepted_backends + ), f"One of the attention backends in {accepted_backends} is required for {model_arch}, but got {attention_backend}" elif model_arch in ["Olmo2ForCausalLM"]: # disable_hybrid_swa_memory + attention backend selection moved to # the override registry (arg_groups/overrides.py: _olmo2_overrides). @@ -4083,18 +4122,19 @@ class ServerArgs: # Flashinfer appears to degrade performance when sliding window attention # is used for the Olmo2 architecture. Olmo2 does not use sliding window attention # but Olmo3 does. + attention_backend = resolved_view(self).attention_backend assert ( - self.attention_backend != "flashinfer" + attention_backend != "flashinfer" ), "FlashInfer backend can significantly degrade the performance of Olmo3 models." logger.info( - f"Using {self.attention_backend} as attention backend for {model_arch}." + f"Using {attention_backend} as attention backend for {model_arch}." ) elif model_arch in ["NemotronHForCausalLM", "NemotronHPuzzleForCausalLM"]: # Quantization / MoE runner / attention backend defaults moved to # the override registry (arg_groups/overrides.py: # _nemotron_h_overrides). - assert self.attention_backend != "triton", ( + assert resolved_view(self).attention_backend != "triton", ( "NemotronHForCausalLM does not support triton attention backend," "as the first layer might not be an attention layer" ) @@ -4121,7 +4161,7 @@ class ServerArgs: elif model_arch in ["Lfm2ForCausalLM"]: # Attention backend selection moved to the override registry # (arg_groups/overrides.py: _lfm2_overrides). - assert self.attention_backend != "triton", ( + assert resolved_view(self).attention_backend != "triton", ( f"{model_arch} does not support triton attention backend, " "as the first layer might not be an attention layer" ) @@ -4166,29 +4206,31 @@ class ServerArgs: return supports_mamba_cache_extra_buffer(self, model_arch) - def _validate_mamba_no_buffer(self, model_arch: str): - assert self.page_size in (1, None), "no_buffer only supports page_size=1." + def _validate_mamba_no_buffer(self, view, model_arch: str): + assert view.page_size in (1, None), "no_buffer only supports page_size=1." assert ( - self.disable_overlap_schedule + view.disable_overlap_schedule ), "no_buffer do not support overlap schedule. Try to set disable_overlap_schedule=True." assert ( - self.attention_backend != "trtllm_mha" + view.attention_backend != "trtllm_mha" ), "no_buffer do not support trtllm_mha attention backend." - def _validate_mamba_extra_buffer(self, model_arch: str): - assert self._support_mamba_cache_extra_buffer( - model_arch + def _validate_mamba_extra_buffer(self, view, model_arch: str): + from sglang.srt.arg_groups.overrides import supports_mamba_cache_extra_buffer + + assert supports_mamba_cache_extra_buffer( + view, model_arch ), f"extra_buffer is not supported for {model_arch}; use no_buffer." assert ( is_cuda() or is_musa() or is_npu() ), "extra_buffer needs CUDA/MUSA/NPU (FLA)." - if self.speculative_num_draft_tokens is not None: + if view.speculative_num_draft_tokens is not None: assert ( - not self.enable_mamba_extra_buffer_lazy() + view.mamba_radix_cache_strategy != "extra_buffer_lazy" ), "extra_buffer_lazy unsupported with spec." - assert self.mamba_track_interval >= self.speculative_num_draft_tokens - if self.page_size is not None: - assert self.mamba_track_interval % self.page_size == 0 + assert view.mamba_track_interval >= view.speculative_num_draft_tokens + if view.page_size is not None: + assert view.mamba_track_interval % view.page_size == 0 assert self.mamba_cache_chunk_size is not None def _handle_mamba_radix_cache(self, model_arch: str): @@ -4197,17 +4239,20 @@ class ServerArgs: # slot; this handler keeps the validation. from sglang.srt.arg_groups.overrides import ( _mamba_radix_cache_resolution, + mamba_extra_buffer_of, + resolved_view, run_post_process_pass, ) run_post_process_pass(self, _mamba_radix_cache_resolution) - if not self.uses_mamba_radix_cache: + view = resolved_view(self) + if not view.uses_mamba_radix_cache: return - if self.enable_mamba_extra_buffer(): - self._validate_mamba_extra_buffer(model_arch) + if mamba_extra_buffer_of(view): + self._validate_mamba_extra_buffer(view, model_arch) else: - self._validate_mamba_no_buffer(model_arch) + self._validate_mamba_no_buffer(view, model_arch) def _handle_sampling_backend(self): # Moved to the resolution pipeline (arg_groups/overrides.py: @@ -4245,14 +4290,18 @@ class ServerArgs: if not use_mla_backend: # MHA architecture - if is_hopper_with_cuda_12_3() and is_no_spec_infer_or_topk_one(self): + from sglang.srt.arg_groups.overrides import resolved_view + + if is_hopper_with_cuda_12_3() and is_no_spec_infer_or_topk_one( + resolved_view(self) + ): # Note: flashinfer 0.6.1 caused performance regression on Hopper attention kernel # Before the kernel is fixed, we choose fa3 as the default backend on Hopper MHA # ref: https://github.com/sgl-project/sglang/issues/17411 return "fa3" elif ( is_sm100_supported() - and is_no_spec_infer_or_topk_one(self) + and is_no_spec_infer_or_topk_one(resolved_view(self)) and ( self.speculative_algorithm is None or self.speculative_eagle_topk is not None @@ -4300,6 +4349,7 @@ class ServerArgs: _fa4_page_constraint, _intel_xpu_page_constraint, _mla_backend_page_constraints, + resolved_view, run_post_process_pass, ) @@ -4307,14 +4357,15 @@ class ServerArgs: run_post_process_pass(self, _attention_backend_default) # Torch native and flex attention backends - if self.attention_backend == "torch_native": + attention_backend = resolved_view(self).attention_backend + if attention_backend == "torch_native": logger.warning( "Cuda graph is disabled because of using torch native attention backend" ) self.cuda_graph_config.decode.backend = Backend.DISABLED self.cuda_graph_config.prefill.backend = Backend.DISABLED - if self.attention_backend == "flex_attention": + if attention_backend == "flex_attention": logger.warning( "Cuda graph is disabled because of using torch Flex Attention backend" ) @@ -4341,33 +4392,12 @@ class ServerArgs: # fallback stay below. run_post_process_pass(self, _mla_backend_page_constraints) - if ( - self.attention_backend == "trtllm_mla" - or self.decode_attention_backend == "trtllm_mla" - ): - if not is_blackwell_supported(): - raise ValueError( - "TRTLLM MLA backend is only supported on Blackwell GPUs (SM100/SM12x). Please use a different backend." - ) + # The TRT-LLM / tokenspeed MLA kv-dtype validations moved to the + # resolution pipeline (arg_groups/overrides.py: + # _mla_kv_cache_dtype_checks), invoked here at their legacy slot. + from sglang.srt.arg_groups.overrides import _mla_kv_cache_dtype_checks - if self.kv_cache_dtype not in ["fp8_e4m3", "fp4_e2m1", "bf16", "auto"]: - raise ValueError( - "TensorRT-LLM MLA backend only supports kv-cache-dtype of fp8_e4m3, fp4_e2m1, bf16, or auto." - ) - - if ( - self.attention_backend == "tokenspeed_mla" - or self.decode_attention_backend == "tokenspeed_mla" - ): - if not is_blackwell_supported(): - raise ValueError( - "tokenspeed_mla backend is only supported on Blackwell GPUs (SM100/SM12x)." - ) - if self.kv_cache_dtype not in ["fp8_e4m3"]: - raise ValueError( - "tokenspeed_mla backend requires kv-cache-dtype=fp8_e4m3, " - f"got {self.kv_cache_dtype}." - ) + run_post_process_pass(self, _mla_kv_cache_dtype_checks) # The CuteDSL MLA validation + prefill fill moved to the resolution # pipeline (arg_groups/overrides.py: _cutedsl_prefill_backend_fill), @@ -4376,28 +4406,12 @@ class ServerArgs: run_post_process_pass(self, _cutedsl_prefill_backend_fill) - if ( - self.attention_backend == "trtllm_mha" - or self.decode_attention_backend == "trtllm_mha" - or self.prefill_attention_backend == "trtllm_mha" - ): - # Check prefill backend - prefill_backend = ( - self.prefill_attention_backend - if self.prefill_attention_backend is not None - else self.attention_backend - ) + prefill_backend, decode_backend = self._resolved_attention_backends() + if "trtllm_mha" in (prefill_backend, decode_backend): if prefill_backend == "trtllm_mha" and not is_sm100_supported(): raise ValueError( "TRTLLM MHA backend for prefill is only supported on Blackwell GPUs (SM100). Please use a different prefill backend." ) - - # Check decode backend - decode_backend = ( - self.decode_attention_backend - if self.decode_attention_backend is not None - else self.attention_backend - ) if decode_backend == "trtllm_mha" and not ( is_sm90_supported() or is_sm100_supported() or is_sm120_supported() ): @@ -4410,14 +4424,14 @@ class ServerArgs: run_post_process_pass(self, _fa4_page_constraint) # AMD platforms backends - if self.attention_backend == "aiter": + if resolved_view(self).attention_backend == "aiter": if model_config.context_len > 8192: self.mem_fraction_static *= 0.85 # Other platforms backends run_post_process_pass(self, _attention_backend_platform_fallbacks) - prefill_backend, decode_backend = self.get_attention_backends() + prefill_backend, decode_backend = self._resolved_attention_backends() if self.use_mla_backend() and prefill_backend == "intel_xpu": raise ValueError( "intel_xpu backend is only supported on decode for MLA models, please set --decode-attention-backend to intel_xpu and do not set --attention-backend or --prefill-attention-backend to intel_xpu for prefill instead use triton." @@ -4427,7 +4441,7 @@ class ServerArgs: # Dual chunk flash attention backend run_post_process_pass(self, _attention_backend_dual_chunk) - if self.attention_backend == "dual_chunk_flash_attn": + if resolved_view(self).attention_backend == "dual_chunk_flash_attn": logger.warning( "Mixed chunk and radix cache are disabled when using dual-chunk flash attention backend" ) @@ -4436,39 +4450,35 @@ class ServerArgs: def _handle_kv4_compatibility(self): """Check FP4 KV cache compatibility with the attention backend""" + from sglang.srt.arg_groups.overrides import resolved_view + if self.kv_cache_dtype != "fp4_e2m1": return use_mla_backend = self.use_mla_backend() - # self.attention_backend didn't overwrite self.prefill/decode_attention_backend yet - self.prefill_attention_backend_str, self.decode_attention_backend_str = ( - self.get_attention_backends() - ) + prefill_backend, decode_backend = self._resolved_attention_backends() + attention_backend = resolved_view(self).attention_backend if is_cuda(): if ( - self.prefill_attention_backend_str != self.decode_attention_backend_str - and self.prefill_attention_backend_str != "fa4" + prefill_backend != decode_backend and prefill_backend != "fa4" ): # Take care of prefill=fa4 later logger.warning( - f"Attention: Using KV4 with PREFILL = {self.prefill_attention_backend_str} " - f"and DECODE = {self.decode_attention_backend_str}. " + f"Attention: Using KV4 with PREFILL = {prefill_backend} " + f"and DECODE = {decode_backend}. " f"Compatibility issues are unlikely, but may occur in rare edge cases." ) else: - if self.prefill_attention_backend_str == "fa4": + if prefill_backend == "fa4": if use_mla_backend: # FA4 + MLA KV4_FA4_MLA_BACKEND_CHOICES = [ "cutlass_mla", "flashinfer", "trtllm_mla", ] - assert ( - self.decode_attention_backend_str - in KV4_FA4_MLA_BACKEND_CHOICES - ), ( + assert decode_backend in KV4_FA4_MLA_BACKEND_CHOICES, ( f"KV4 FA4 MLA expects decode_attention_backend to be one of " - f"{KV4_FA4_MLA_BACKEND_CHOICES}, but got {self.decode_attention_backend_str}" + f"{KV4_FA4_MLA_BACKEND_CHOICES}, but got {decode_backend}" ) else: # FA4 + MHA KV4_FA4_MHA_BACKEND_CHOICES = [ @@ -4476,12 +4486,9 @@ class ServerArgs: "torch_native", "flex_attention", ] - assert ( - self.decode_attention_backend_str - in KV4_FA4_MHA_BACKEND_CHOICES - ), ( + assert decode_backend in KV4_FA4_MHA_BACKEND_CHOICES, ( f"KV4 FA4 MHA expects decode_attention_backend to be one of " - f"{KV4_FA4_MHA_BACKEND_CHOICES}, but got {self.decode_attention_backend_str}" + f"{KV4_FA4_MHA_BACKEND_CHOICES}, but got {decode_backend}" ) else: if use_mla_backend: # !FA4 + MLA @@ -4491,11 +4498,9 @@ class ServerArgs: "trtllm_mla", "flashmla", ] - assert ( - self.attention_backend in KV4_ATTENTION_MLA_BACKEND_CHOICES - ), ( + assert attention_backend in KV4_ATTENTION_MLA_BACKEND_CHOICES, ( f"KV4 MLA expects attention_backend to be one of " - f"{KV4_ATTENTION_MLA_BACKEND_CHOICES}, but got {self.attention_backend}" + f"{KV4_ATTENTION_MLA_BACKEND_CHOICES}, but got {attention_backend}" ) else: # !FA4 + MHA KV4_ATTENTION_MHA_BACKEND_CHOICES = [ @@ -4504,11 +4509,9 @@ class ServerArgs: "flex_attention", "trtllm_mha", ] - assert ( - self.attention_backend in KV4_ATTENTION_MHA_BACKEND_CHOICES - ), ( + assert attention_backend in KV4_ATTENTION_MHA_BACKEND_CHOICES, ( f"KV4 MHA expects attention_backend to be one of " - f"{KV4_ATTENTION_MHA_BACKEND_CHOICES}, but got {self.attention_backend}" + f"{KV4_ATTENTION_MHA_BACKEND_CHOICES}, but got {attention_backend}" ) else: raise RuntimeError("KV4 is not tested on non-CUDA platforms.") @@ -4699,7 +4702,12 @@ class ServerArgs: "linear-attn decode backend, got " f"--linear-attn-decode-backend={decode!r}." ) - if self.enable_mamba_extra_buffer(): + from sglang.srt.arg_groups.overrides import ( + mamba_extra_buffer_of, + resolved_view, + ) + + if mamba_extra_buffer_of(resolved_view(self)): raise ValueError( "--enable-linear-replayssm requires --mamba-scheduler-strategy " "no_buffer (the default); the extra_buffer ping-pong " @@ -4794,13 +4802,14 @@ class ServerArgs: "(DeepSeek V3/R1, Kimi K2.5) or MHA/GQA-based models." ) - if self.attn_cp_size > 1: + view = self._resolved() + if view.attn_cp_size > 1: # The tp_size is the world size, not the real tensor parallel size assert ( - self.tp_size % self.attn_cp_size == 0 + self.tp_size % view.attn_cp_size == 0 ), "tp_size must be divisible by attn_cp_size" assert ( - self.tp_size % (self.dp_size * self.attn_cp_size) == 0 + self.tp_size % (self.dp_size * view.attn_cp_size) == 0 ), "tp_size must be divisible by dp_size * attn_cp_size" assert ( @@ -4813,20 +4822,20 @@ class ServerArgs: self.tp_size % self.moe_dp_size == 0 ), "tp_size must be divisible by moe_dp_size" assert ( - self.ep_size * self.moe_dp_size <= self.tp_size + view.ep_size * self.moe_dp_size <= self.tp_size ), "ep_size * moe_dp_size must be less than or equal to tp_size" assert self.pp_size == 1, "PP is not supported with context parallelism" - if self.ep_size > 1: + if view.ep_size > 1: assert ( - self.ep_size * self.moe_dp_size == self.tp_size + view.ep_size * self.moe_dp_size == self.tp_size ), "ep_size * moe_dp_size must be equal to tp_size" assert ( not self.enable_aiter_allreduce_fusion ), "Aiter allreduce fusion is not supported with context parallelism" - if self.attn_cp_size != self.moe_dp_size: + if view.attn_cp_size != self.moe_dp_size: assert ( self.moe_dp_size == 1 ), "attn_cp_size != moe_dp_size is only supported when moe_dp_size == 1" @@ -4845,7 +4854,7 @@ class ServerArgs: run_post_process_pass(self, _data_parallelism_defaults) - if self.enable_dp_attention: + if self._resolved().enable_dp_attention: self.schedule_conservativeness = self.schedule_conservativeness * 0.3 assert self.tp_size % self.dp_size == 0 self.chunked_prefill_size = self.chunked_prefill_size // self.dp_size @@ -4853,10 +4862,12 @@ class ServerArgs: f"DP attention is enabled. The chunked prefill size is adjusted to {self.chunked_prefill_size} to avoid MoE kernel issues. " ) - if self.enable_dp_lm_head: - assert ( - self.enable_dp_attention - ), "Please enable dp attention when setting enable_dp_lm_head. " + # The dp-lm-head validation moved to the resolution pipeline + # (arg_groups/overrides.py: _dp_lm_head_validation), invoked here at + # its legacy slot. + from sglang.srt.arg_groups.overrides import _dp_lm_head_validation + + run_post_process_pass(self, _dp_lm_head_validation) def _handle_moe_kernel_config(self): # The quantization-driven runner resolutions moved to the pipeline @@ -4865,47 +4876,46 @@ class ServerArgs: from sglang.srt.arg_groups.overrides import ( _cutlass_moe_env_override, _moe_runner_backend_quant_constraints, + _moe_runner_fusion_disable, + resolved_view, run_post_process_pass, ) run_post_process_pass(self, _moe_runner_backend_quant_constraints) - if self.moe_runner_backend == "flashinfer_cutlass": - assert self.quantization in [ + view = resolved_view(self) + if view.moe_runner_backend == "flashinfer_cutlass": + assert view.quantization in [ "modelopt_fp4", "modelopt_fp8", "modelopt_mixed", None, - ], f"Invalid quantization '{self.quantization}'. \nFlashInfer Cutlass MOE supports only: 'modelopt_fp4', 'modelopt_fp8', 'modelopt_mixed', or bfloat16 (None)." - assert self.ep_size in [ + ], f"Invalid quantization '{view.quantization}'. \nFlashInfer Cutlass MOE supports only: 'modelopt_fp4', 'modelopt_fp8', 'modelopt_mixed', or bfloat16 (None)." + assert view.ep_size in [ 1, self.tp_size, ], "The expert parallel size must be 1 or the same as the tensor parallel size" - if self.moe_runner_backend == "flashinfer_cutedsl": + if view.moe_runner_backend == "flashinfer_cutedsl": assert ( - self.quantization in ["modelopt_fp4"] + view.quantization in ["modelopt_fp4"] or self.get_model_config().nvfp4_moe_meta is not None - ), f"Invalid quantization '{self.quantization}'. \nFlashInfer CuteDSL MOE currently supports only: 'modelopt_fp4' or hybrid NVFP4 models." - assert self.ep_size in [ + ), f"Invalid quantization '{view.quantization}'. \nFlashInfer CuteDSL MOE currently supports only: 'modelopt_fp4' or hybrid NVFP4 models." + assert view.ep_size in [ 1, self.tp_size, ], "The expert parallel size must be 1 or the same as the tensor parallel size" - assert self.moe_a2a_backend in [ + assert view.moe_a2a_backend in [ "none", "deepep", "flashinfer", ], ( f"flashinfer_cutedsl supports moe_a2a_backend='none', 'deepep', or 'flashinfer', " - f"got '{self.moe_a2a_backend}'." - ) - self.disable_shared_experts_fusion = True - logger.warning( - "FlashInfer CuteDSL MoE is enabled. --disable-shared-experts-fusion is automatically set." + f"got '{view.moe_a2a_backend}'." ) - if self.moe_runner_backend in ["flashinfer_trtllm", "experimental_sgl_trtllm"]: - assert self.quantization in [ + if view.moe_runner_backend in ["flashinfer_trtllm", "experimental_sgl_trtllm"]: + assert view.quantization in [ "modelopt_fp4", "nvfp4_online", "fp8", @@ -4914,36 +4924,35 @@ class ServerArgs: "modelopt_mixed", "compressed-tensors", None, - ], f"Invalid quantization '{self.quantization}'. \nFlashInfer TRTLLM MOE supports only: 'modelopt_fp4', 'nvfp4_online', 'fp8', 'modelopt_fp8', 'modelopt_mixed', 'compressed-tensors', or bfloat16 (None)." - self.disable_shared_experts_fusion = True - logger.warning( - "FlashInfer TRTLLM MoE is enabled. --disable-shared-experts-fusion is automatically set." - ) + ], f"Invalid quantization '{view.quantization}'. \nFlashInfer TRTLLM MOE supports only: 'modelopt_fp4', 'nvfp4_online', 'fp8', 'modelopt_fp8', 'modelopt_mixed', 'compressed-tensors', or bfloat16 (None)." - if self.moe_runner_backend == "flashinfer_trtllm_routed": - assert self.quantization in [ + if view.moe_runner_backend == "flashinfer_trtllm_routed": + assert view.quantization in [ "fp8", "mxfp8", "modelopt_fp4", "nvfp4_online", None, - ], f"Invalid quantization '{self.quantization}'. \nFlashInfer TRTLLM routed MOE supports only: 'fp8', 'mxfp8', 'modelopt_fp4', 'nvfp4_online', or bfloat16 (None)." - self.disable_shared_experts_fusion = True - logger.warning( - "FlashInfer TRTLLM routed MoE is enabled. --disable-shared-experts-fusion is automatically set." - ) + ], f"Invalid quantization '{view.quantization}'. \nFlashInfer TRTLLM routed MOE supports only: 'fp8', 'mxfp8', 'modelopt_fp4', 'nvfp4_online', or bfloat16 (None)." + + # The runner-driven shared-experts fusion disables moved to the + # pipeline (arg_groups/overrides.py: _moe_runner_fusion_disable), + # invoked here at the legacy write slots. + run_post_process_pass(self, _moe_runner_fusion_disable) # The deprecated SGLANG_CUTLASS_MOE override moved to the pipeline # (arg_groups/overrides.py: _cutlass_moe_env_override). It sits after # the fusion blocks above on purpose: they must observe the # pre-override runner value, exactly as they did imperatively. run_post_process_pass(self, _cutlass_moe_env_override) - if self.moe_runner_backend == "cutlass" and self.quantization in [ + if resolved_view(self).moe_runner_backend == "cutlass" and resolved_view( + self + ).quantization in [ "fp8", "mxfp8", ]: assert ( - self.ep_size == 1 + resolved_view(self).ep_size == 1 ), "FP8/MXFP8 Cutlass MoE is only supported with ep_size == 1" def cutedsl_moe_max_num_tokens(self) -> int: @@ -4985,9 +4994,12 @@ class ServerArgs: """Fail fast if the FlashInfer A2A dispatcher workspace cannot cover the largest CuteDSL MoE forward. Runs after speculative decoding is resolved so cutedsl_moe_max_num_tokens() sees the final num_tokens_per_bs.""" + from sglang.srt.arg_groups.overrides import resolved_view + + view = resolved_view(self) if not ( - self.moe_a2a_backend == "flashinfer" - and self.moe_runner_backend == "flashinfer_cutedsl" + view.moe_a2a_backend == "flashinfer" + and view.moe_runner_backend == "flashinfer_cutedsl" and self.max_prefill_tokens > 0 and self.disaggregation_mode != "decode" ): @@ -4996,9 +5008,9 @@ class ServerArgs: max_dispatch_tokens_per_rank = get_int_env_var( "SGLANG_FLASHINFER_NUM_MAX_DISPATCH_TOKENS_PER_RANK", 1024 ) - max_cutedsl_tokens = max_dispatch_tokens_per_rank * self.ep_size + max_cutedsl_tokens = max_dispatch_tokens_per_rank * view.ep_size if max_cutedsl_tokens < required_tokens: - required_per_rank = (required_tokens + self.ep_size - 1) // self.ep_size + required_per_rank = (required_tokens + view.ep_size - 1) // view.ep_size raise ValueError( "FlashInfer MoE A2A with flashinfer_cutedsl requires " "SGLANG_FLASHINFER_NUM_MAX_DISPATCH_TOKENS_PER_RANK * " @@ -5008,7 +5020,7 @@ class ServerArgs: "`ValueError: num_tokens (...) exceeds max_num_tokens (...)`. " "Current values: " f"SGLANG_FLASHINFER_NUM_MAX_DISPATCH_TOKENS_PER_RANK=" - f"{max_dispatch_tokens_per_rank}, ep_size={self.ep_size}, " + f"{max_dispatch_tokens_per_rank}, ep_size={view.ep_size}, " f"capacity={max_cutedsl_tokens}, required={required_tokens}. " f"Set `export " f"SGLANG_FLASHINFER_NUM_MAX_DISPATCH_TOKENS_PER_RANK=" @@ -5024,13 +5036,21 @@ class ServerArgs: from sglang.srt.arg_groups.overrides import ( _a2a_backend_overrides, _a2a_ep_size, + _a2a_fusion_adjustments, + resolved_view, run_post_process_pass, ) run_post_process_pass(self, _a2a_backend_overrides) run_post_process_pass(self, _a2a_ep_size) - if self.moe_a2a_backend == "megamoe": + # The a2a-driven shared-experts fusion adjustments moved to the + # pipeline (arg_groups/overrides.py: _a2a_fusion_adjustments), + # invoked here at the legacy write slots. + run_post_process_pass(self, _a2a_fusion_adjustments) + + a2a_backend = resolved_view(self).moe_a2a_backend + if a2a_backend == "megamoe": if not envs.SGLANG_OPT_FIX_MEGA_MOE_MEMORY.is_set(): envs.SGLANG_OPT_FIX_MEGA_MOE_MEMORY.set(True) logger.info( @@ -5038,7 +5058,7 @@ class ServerArgs: f"to be the same as the tensor parallel size[{self.tp_size}]." ) - if self.moe_a2a_backend == "deepep": + if a2a_backend == "deepep": if self.deepep_mode == "normal": logger.warning("Cuda graph is disabled because deepep_mode=`normal`") self.cuda_graph_config.decode.backend = Backend.DISABLED @@ -5047,27 +5067,22 @@ class ServerArgs: f"DeepEP MoE is enabled. The expert parallel size is adjusted to be the same as the tensor parallel size[{self.tp_size}]." ) if self.enable_deepep_waterfill: - if self.disable_shared_experts_fusion: - logger.warning( - "disable_shared_experts_fusion is overridden to False because DeepEP Waterfill requires shared expert fusion." - ) - self.disable_shared_experts_fusion = False self.enforce_shared_experts_fusion = True logger.info( "DeepEP Waterfill is enabled. Shared expert will be dispatched through DeepEP for load balancing." ) - if self.moe_a2a_backend == "mooncake": + if a2a_backend == "mooncake": logger.warning( f"Mooncake MoE is enabled. The expert parallel size is adjusted to be the same as the tensor parallel size[{self.tp_size}]." ) - if self.moe_a2a_backend == "nixl": + if a2a_backend == "nixl": logger.warning( f"Nixl MoE is enabled. The expert parallel size is adjusted to be the same as the tensor parallel size[{self.tp_size}]." ) - if self.moe_a2a_backend == "ascend_fuseep": + if a2a_backend == "ascend_fuseep": logger.warning( f"Ascend fused EP MoE is enabled. The expert parallel size is adjusted to be the same as the tensor parallel size[{self.tp_size}]." ) @@ -5078,36 +5093,32 @@ class ServerArgs: ) elif fuse_mode == 2: assert ( - self.quantization == "modelslim" + resolved_view(self).quantization == "modelslim" ), "When fuse_mode is set to 2, the NPU supports only ModelSlim quantization." - if self.moe_a2a_backend == "flashinfer": + if a2a_backend == "flashinfer": assert ( - self.enable_dp_attention and self.dp_size == self.tp_size + resolved_view(self).enable_dp_attention and self.dp_size == self.tp_size ), "Flashinfer MoE A2A is only supported with dp_size == tp_size and --enable-dp-attention" logger.warning( f"Flashinfer MoE A2A is enabled. The expert parallel size is adjusted to be the same as the tensor parallel size[{self.tp_size}]." ) - self.disable_shared_experts_fusion = True - logger.warning( - "Flashinfer MoE A2A is enabled. --disable-shared-experts-fusion is automatically set." - ) if self.deepep_mode != "auto": logger.warning("--deepep-mode is ignored for Flashinfer MoE A2A") if not envs.SGLANG_MOE_NVFP4_DISPATCH.is_set() and ( - self.quantization == "modelopt_fp4" + resolved_view(self).quantization == "modelopt_fp4" or self.get_model_config().nvfp4_moe_meta is not None ): envs.SGLANG_MOE_NVFP4_DISPATCH.set(True) logger.warning( "SGLANG_MOE_NVFP4_DISPATCH is set to True for Flashinfer MoE A2A" ) - assert self.moe_runner_backend in [ + assert resolved_view(self).moe_runner_backend in [ "flashinfer_cutlass", "flashinfer_cutedsl", "flashinfer_trtllm_routed", ], "Flashinfer MoE A2A is only supported with flashinfer_cutlass, flashinfer_cutedsl or flashinfer_trtllm_routed moe runner backend" - if self.moe_a2a_backend == "mori": + if a2a_backend == "mori": if self.deepep_mode == "auto": self.deepep_mode = "normal" logger.warning("auto set deepep_mode=`normal` for MORI EP") @@ -5144,7 +5155,7 @@ class ServerArgs: self.ep_dispatch_algorithm = "static" if self.enable_eplb: - assert self.ep_size > 1 + assert self._resolved().ep_size > 1 def _handle_elastic_ep(self): if self.elastic_ep_backend is not None: @@ -5236,7 +5247,7 @@ class ServerArgs: # Context-parallel prefill stages K/V through cp_allgather_and_save_kv_cache, # which writes to the pool via set_kv_buffer. NoOpMHATokenToKVPool intentionally # raises on writes, so the engine would boot fine but fail on the first request. - if self.attn_cp_size > 1: + if self._resolved().attn_cp_size > 1: raise ValueError( "--prefill-only-disable-kv-cache is incompatible with --attn-cp-size > 1: " "the context-parallel attention path writes K/V to the pool via set_kv_buffer, " @@ -5263,19 +5274,21 @@ class ServerArgs: Must run after _handle_attention_backend_compatibility() (which fills the default attention_backend if unset) and _handle_multi_item_scoring() (which may further mutate it). The assertion below guards against - accidental call-site reordering: if attention_backend is still None, - backends haven't settled yet and get_attention_backends() would return - a stale (None, None). + accidental call-site reordering: if the resolved attention_backend is + still None, backends haven't settled yet and the resolved (prefill, + decode) pair would be a stale (None, None). """ + from sglang.srt.arg_groups.overrides import resolved_view + if not self.prefill_only_disable_kv_cache: return - assert self.attention_backend is not None, ( + assert resolved_view(self).attention_backend is not None, ( "_handle_prefill_only_disable_kv_cache must run after " "_handle_attention_backend_compatibility() so the prefill backend is resolved." ) - prefill_backend, _ = self.get_attention_backends() + prefill_backend, _ = self._resolved_attention_backends() if prefill_backend not in ("fa3", "fa4"): raise ValueError( "--prefill-only-disable-kv-cache currently requires the FA prefill backend " @@ -5730,7 +5743,10 @@ class ServerArgs: "The argument disaggregation-decode-enable-offload-kvcache is only supported when hicache-storage-backend is provided." ) - if not (0 < self.swa_full_tokens_ratio <= 1.0): + # Validate the effective ratio: model branches may declare a reset + # (e.g. Step3p forces 1.0 under hierarchical cache) that supersedes + # the user input before it ever takes effect. + if not (0 < self._resolved().swa_full_tokens_ratio <= 1.0): raise ValueError("--swa-full-tokens-ratio should be in range (0, 1.0].") def _handle_deterministic_inference(self): @@ -5768,6 +5784,7 @@ class ServerArgs: from sglang.srt.arg_groups.overrides import ( _deterministic_attention_backend, _deterministic_sampling_backend, + resolved_view, run_post_process_pass, ) @@ -5791,20 +5808,18 @@ class ServerArgs: # Check attention backend run_post_process_pass(self, _deterministic_attention_backend) + attention_backend = resolved_view(self).attention_backend if is_deepseek_model: - if self.attention_backend not in ["fa3", "triton"]: + if attention_backend not in ["fa3", "triton"]: raise ValueError( - f"Currently only {RADIX_SUPPORTED_DETERMINISTIC_ATTENTION_BACKEND} attention backends are supported for deterministic inference with DeepSeek models. But you're using {self.attention_backend}." + f"Currently only {RADIX_SUPPORTED_DETERMINISTIC_ATTENTION_BACKEND} attention backends are supported for deterministic inference with DeepSeek models. But you're using {attention_backend}." ) - if ( - self.attention_backend - not in RADIX_SUPPORTED_DETERMINISTIC_ATTENTION_BACKEND - ): + 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 logger.warning( - f"Currently radix cache is not compatible with {self.attention_backend} attention backend for deterministic inference. It will be supported in the future." + f"Currently radix cache is not compatible with {attention_backend} attention backend for deterministic inference. It will be supported in the future." ) # Check TP size @@ -5870,11 +5885,7 @@ class ServerArgs: return # Only the Triton attention kernels read the strided 4-D envelope K/V # views; FA3 / FlashInfer do not. - backends = { - self.attention_backend, - self.prefill_attention_backend, - self.decode_attention_backend, - } + backends = set(self._resolved_attention_backends()) backends.discard(None) assert backends <= {"triton"}, ( "--enable-page-major-kv-layout requires the Triton attention backend " @@ -5923,12 +5934,16 @@ class ServerArgs: run_post_process_pass(self, _dllm_attention_backend) run_post_process_pass(self, _dllm_overlap_disable) - if not self.disable_radix_cache: - # The page_size adjustment moved to the resolution pipeline - # (arg_groups/overrides.py: _dllm_page_size). - from sglang.srt.arg_groups.overrides import _dllm_page_size + # The page-size alignment + block-size cap for dllm moved to the + # resolution pipeline (arg_groups/overrides.py: _dllm_page_size). + # Invoked outside the radix gate: the alignment fill keeps its radix + # gate inside the pass, the block-size cap applies regardless (it + # replaces the unconditional scheduler-init fallback). + from sglang.srt.arg_groups.overrides import _dllm_page_size - run_post_process_pass(self, _dllm_page_size) + run_post_process_pass(self, _dllm_page_size) + + if not self.disable_radix_cache: if self.enable_hierarchical_cache: logger.warning( "Hierarchical cache is disabled because of using diffusion LLM inference" @@ -5978,6 +5993,8 @@ class ServerArgs: ) def _handle_other_validations(self): + from sglang.srt.arg_groups.overrides import resolved_view + # Handle optimistic prefill validation if ( self.optimistic_prefill_retries > 0 @@ -5989,7 +6006,7 @@ class ServerArgs: elif self.enable_hierarchical_cache: logger.warning("Optimistic prefill does not support hierarchical cache") self.optimistic_prefill_retries = 0 - elif getattr(self, "uses_mamba_radix_cache", False): + elif resolved_view(self).uses_mamba_radix_cache: logger.warning( "Optimistic prefill does not support models that use " "mamba radix cache." @@ -6382,6 +6399,23 @@ class ServerArgs: self.model_config = ModelConfig.from_server_args(self) return self.model_config + def _resolved(self): + """Read-only view of the resolving configuration: declared fields + resolve from the declaration stash.""" + from sglang.srt.arg_groups.overrides import resolved_view + + return resolved_view(self) + + def _resolved_attention_backends(self): + """Mid-resolution (prefill, decode) backends: reads through the pass + view so declared fields resolve from the declaration stash.""" + from sglang.srt.arg_groups.overrides import ( + attention_backends_of, + resolved_view, + ) + + return attention_backends_of(resolved_view(self)) + def get_attention_backends(self): prefill_attention_backend_str = ( self.prefill_attention_backend @@ -6445,12 +6479,15 @@ class ServerArgs: # (or mamba_chunk_size if it is defined in the model's config) and page_size. # It is used to determine the caching point in a sequence during prefill. if not hasattr(self, "_mamba_cache_chunk_size"): + from sglang.srt.arg_groups.overrides import resolved_view + hf_config = self.get_model_config().hf_config chunk_size = getattr(hf_config, "mamba_chunk_size", FLA_CHUNK_SIZE) + page_size = resolved_view(self).page_size assert ( - max(chunk_size, self.page_size) % min(chunk_size, self.page_size) == 0 - ), f"For SSM models, either chunk_size or page_size must be divisible by the other, got {chunk_size=}, {self.page_size=}" - self._mamba_cache_chunk_size = max(chunk_size, self.page_size) + max(chunk_size, page_size) % min(chunk_size, page_size) == 0 + ), f"For SSM models, either chunk_size or page_size must be divisible by the other, got {chunk_size=}, {page_size=}" + self._mamba_cache_chunk_size = max(chunk_size, page_size) return self._mamba_cache_chunk_size def _check_two_batch_overlap(self): @@ -6590,9 +6627,14 @@ class ServerArgs: ) # Check hisparse - from sglang.srt.arg_groups.hisparse_hook import validate_hisparse + # Moved to the resolution pipeline (arg_groups/overrides.py: + # _hisparse_validation), invoked here at its legacy slot. + from sglang.srt.arg_groups.overrides import ( + _hisparse_validation, + run_post_process_pass, + ) - validate_hisparse(self) + run_post_process_pass(self, _hisparse_validation) assert ( self.schedule_conservativeness >= 0 diff --git a/python/sglang/srt/speculative/adaptive_spec_params.py b/python/sglang/srt/speculative/adaptive_spec_params.py index 134289299..d8b24dce0 100644 --- a/python/sglang/srt/speculative/adaptive_spec_params.py +++ b/python/sglang/srt/speculative/adaptive_spec_params.py @@ -49,6 +49,8 @@ DEFAULT_ADAPTIVE_CONFIG: dict[str, dict] = { def adaptive_unsupported_reason(server_args: ServerArgs) -> str | None: """Return why adaptive spec cannot run under the given server args, or None if supported.""" + from sglang.srt.arg_groups.overrides import resolved_view + if server_args.speculative_algorithm not in ("EAGLE", "EAGLE3"): return ( f"speculative_algorithm={server_args.speculative_algorithm} " @@ -62,12 +64,12 @@ def adaptive_unsupported_reason(server_args: ServerArgs) -> str | None: f"speculative_eagle_topk={server_args.speculative_eagle_topk} " "(only topk=1 is supported)" ) - if server_args.enable_dp_attention: + if resolved_view(server_args).enable_dp_attention: return ( "enable_dp_attention=True is not supported " "(adaptive tier decisions are not synchronized across DP ranks)" ) - if server_args.enable_multi_layer_eagle: + if resolved_view(server_args).enable_multi_layer_eagle: return ( "enable_multi_layer_eagle=True is not supported " "(MultiLayerEagleWorkerV2 does not implement adaptive)" diff --git a/test/registered/mock_model/test_self_unit_install.py b/test/registered/mock_model/test_self_unit_install.py index 0bcc35feb..9f32cf0e2 100644 --- a/test/registered/mock_model/test_self_unit_install.py +++ b/test/registered/mock_model/test_self_unit_install.py @@ -17,6 +17,10 @@ register_amd_ci(est_time=60, suite="extra-a-test-1-gpu-small-amd") def _make_server_args(*, sampling_backend: str) -> SimpleNamespace: + # The install gate reads the resolved backend from the flags tier. + from sglang.srt.runtime_context import get_flags + + get_flags().sampling_backend = sampling_backend return SimpleNamespace(sampling_backend=sampling_backend) diff --git a/test/registered/unit/model_executor/test_pool_configurator.py b/test/registered/unit/model_executor/test_pool_configurator.py index dbd9c24a4..287c4fd54 100644 --- a/test/registered/unit/model_executor/test_pool_configurator.py +++ b/test/registered/unit/model_executor/test_pool_configurator.py @@ -102,6 +102,10 @@ def _make_model_runner( sa = SimpleNamespace() sa.swa_full_tokens_ratio = swa_full_tokens_ratio + # The configurator reads the resolved ratio from the flags tier. + from sglang.srt.runtime_context import get_flags + + get_flags().swa_full_tokens_ratio = swa_full_tokens_ratio sa.page_size = page_size sa.disable_radix_cache = disable_radix_cache sa.chunked_prefill_size = chunked_prefill_size diff --git a/test/registered/unit/models/test_deepseek_v4_shared_expert_fusion.py b/test/registered/unit/models/test_deepseek_v4_shared_expert_fusion.py index b08f53615..07dcc9fbb 100644 --- a/test/registered/unit/models/test_deepseek_v4_shared_expert_fusion.py +++ b/test/registered/unit/models/test_deepseek_v4_shared_expert_fusion.py @@ -42,7 +42,7 @@ class TestDeepseekV4SharedExpertFusionPolicy(unittest.TestCase): self.assertEqual(model.num_fused_shared_experts, 0) self.assertTrue(get_flags().disable_shared_experts_fusion) - # dual-apply transition: the published config carries the value too + # post-init declaration writes through to the published config self.assertTrue(server_args.disable_shared_experts_fusion) def test_enables_shared_fusion_when_enforced(self): diff --git a/test/registered/unit/server_args/test_server_args.py b/test/registered/unit/server_args/test_server_args.py index 5e63d9895..35f252bb1 100644 --- a/test/registered/unit/server_args/test_server_args.py +++ b/test/registered/unit/server_args/test_server_args.py @@ -354,7 +354,10 @@ class TestFa4PageSizeAutoForce(CustomTestCase): args._handle_attention_backend_compatibility() - self.assertEqual(args.page_size, 128) + from sglang.srt.arg_groups.overrides import resolved_view + + self.assertEqual(args.page_size, 1) # dual-apply retired: pristine + self.assertEqual(resolved_view(args).page_size, 128) @patch("sglang.srt.arg_groups.overrides.is_sm100_supported", return_value=True) @patch("sglang.srt.server_args.ServerArgs.use_mla_backend", return_value=False) @@ -364,7 +367,10 @@ class TestFa4PageSizeAutoForce(CustomTestCase): args._handle_attention_backend_compatibility() - self.assertEqual(args.page_size, 128) + from sglang.srt.arg_groups.overrides import resolved_view + + self.assertEqual(args.page_size, 1) # dual-apply retired: pristine + self.assertEqual(resolved_view(args).page_size, 128) class TestContextParallelServerArgs(CustomTestCase): @@ -1119,7 +1125,11 @@ class TestDeepEPWaterfillArgs(CustomTestCase): # dummy-model path short-circuits __post_init__; invoke the handler directly. server_args._handle_a2a_moe() - self.assertFalse(server_args.disable_shared_experts_fusion) + from sglang.srt.arg_groups.overrides import resolved_view + + # dual-apply retired: the fields stay pristine, the declarations win + self.assertTrue(server_args.disable_shared_experts_fusion) + self.assertFalse(resolved_view(server_args).disable_shared_experts_fusion) self.assertTrue(server_args.enforce_shared_experts_fusion) def test_waterfill_overrides_moe_a2a_backend_to_deepep(self): @@ -1131,7 +1141,10 @@ class TestDeepEPWaterfillArgs(CustomTestCase): # dummy-model path short-circuits __post_init__; invoke the handler directly. server_args._handle_a2a_moe() - self.assertEqual(server_args.moe_a2a_backend, "deepep") + from sglang.srt.arg_groups.overrides import resolved_view + + self.assertEqual(server_args.moe_a2a_backend, "none") # pristine + self.assertEqual(resolved_view(server_args).moe_a2a_backend, "deepep") self.assertTrue(server_args.enforce_shared_experts_fusion) def test_waterfill_supports_deepep_low_latency_mode(self): diff --git a/test/registered/unit/test_model_overrides.py b/test/registered/unit/test_model_overrides.py index 83cf63fae..a8ea95b11 100644 --- a/test/registered/unit/test_model_overrides.py +++ b/test/registered/unit/test_model_overrides.py @@ -19,11 +19,10 @@ from sglang.srt.arg_groups import overrides as overrides_module from sglang.srt.arg_groups.arg_utils import A, Arg, resolvable_fields from sglang.srt.arg_groups.overrides import ( OverrideRecord, - apply_declarations_to_server_args, apply_model_overrides, - assert_flag_parity, collect_model_override_declarations, register_model_override, + validate_declarations, ) from sglang.srt.runtime_context import ( _StaticFlags, @@ -207,7 +206,7 @@ class TestResolvedViewAndPasses(CustomTestCase): self.assertEqual(view.a, 10) self.assertEqual(view.b, 2) - def test_run_pass_appends_stash_and_dual_applies(self): + def test_run_pass_appends_stash_and_stays_pristine(self): from sglang.srt.arg_groups.overrides import run_post_process_pass live = SimpleNamespace(x=None, _resolved_overrides=[]) @@ -216,11 +215,12 @@ class TestResolvedViewAndPasses(CustomTestCase): return {"x": "filled"} if view.x is None else {} run_post_process_pass(live, _fill_x) - self.assertEqual(live.x, "filled") # dual-applied in place + self.assertIsNone(live.x) # never applied in place self.assertEqual( live._resolved_overrides, [(_fill_x.__qualname__, {"x": "filled"})] ) - run_post_process_pass(live, _fill_x) # now a no-op + # the next invocation sees the declared value through the overlay + run_post_process_pass(live, _fill_x) self.assertEqual(len(live._resolved_overrides), 1) def test_run_pass_rejects_non_dict(self): @@ -415,7 +415,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_mistral_large3_forces_bfloat16(self): sa = self._construct("MistralLarge3ForCausalLM", "mistral") - self.assertEqual(sa.dtype, "bfloat16") # dual-apply == legacy write + self.assertEqual(sa.dtype, "bfloat16") # materialized at end of resolution self.assertIn( ("MODEL_OVERRIDES['MistralLarge3ForCausalLM']", {"dtype": "bfloat16"}), sa._resolved_overrides, @@ -424,15 +424,15 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_pixtral_forces_bfloat16(self): sa = self._construct("PixtralForConditionalGeneration", "pixtral") - self.assertEqual(sa.dtype, "bfloat16") + self.assertEqual(sa.dtype, "bfloat16") # materialized self.assertEqual(self._publish(sa).dtype, "bfloat16") def test_user_requested_dtype_is_still_overridden(self): # Legacy fidelity: the arch branch overwrote dtype unconditionally, - # so the declaration must too. The pristine request survives only on - # provenance (and, post-V3, as the un-overridden server_args field). + # so the declaration must too. The pristine request survives on + # provenance; the materialized field carries the override. sa = self._construct("MistralLarge3ForCausalLM", "mistral", dtype="float16") - self.assertEqual(sa.dtype, "bfloat16") + self.assertEqual(sa.dtype, "bfloat16") # materialized self.assertEqual(self._publish(sa).dtype, "bfloat16") def test_control_arch_keeps_pristine_dtype(self): @@ -446,7 +446,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_minimax_m2_enables_tf32_matmul(self): sa = self._construct("MiniMaxM2ForCausalLM", "llama") - self.assertTrue(sa.enable_tf32_matmul) # dual-apply == legacy write + self.assertTrue(sa.enable_tf32_matmul) # materialized self.assertIn( ("_minimax_m2_overrides", {"enable_tf32_matmul": True}), sa._resolved_overrides, @@ -491,7 +491,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): config_extra=config_extra, enable_hierarchical_cache=True, ) - # dual-apply == legacy writes + # materialized at the end of resolution self.assertEqual(sa.swa_full_tokens_ratio, 1.0) self.assertTrue(sa.disable_hybrid_swa_memory) flags = self._publish(sa) @@ -500,7 +500,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_gemma2_disables_hybrid_swa_memory(self): sa = self._construct("Gemma2ForCausalLM", "llama") - self.assertTrue(sa.disable_hybrid_swa_memory) # dual-apply == legacy + self.assertTrue(sa.disable_hybrid_swa_memory) # materialized self.assertIn( ("_gemma2_gemma3_overrides", {"disable_hybrid_swa_memory": True}), sa._resolved_overrides, @@ -509,7 +509,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_olmo2_disables_hybrid_swa_memory(self): sa = self._construct("Olmo2ForCausalLM", "llama") - self.assertTrue(sa.disable_hybrid_swa_memory) + self.assertTrue(sa.disable_hybrid_swa_memory) # materialized self.assertTrue(self._publish(sa).disable_hybrid_swa_memory) def test_exaone_conditional_on_sliding_window_pattern(self): @@ -520,7 +520,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): config_extra={"sliding_window_pattern": "LLLG"}, attention_backend="fa3", ) - self.assertTrue(sa.disable_hybrid_swa_memory) + self.assertTrue(sa.disable_hybrid_swa_memory) # materialized self.assertTrue(self._publish(sa).disable_hybrid_swa_memory) def test_exaone_without_pattern_declares_nothing(self): @@ -543,7 +543,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): "llama", config_extra={"quantization_config": {"quant_method": "mxfp4"}}, ) - self.assertEqual(sa.dtype, "bfloat16") # dual-apply == legacy + self.assertEqual(sa.dtype, "bfloat16") # materialized self.assertEqual(self._publish(sa).dtype, "bfloat16") def test_gpt_oss_without_mxfp4_keeps_pristine_dtype(self): @@ -569,7 +569,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): sa = self._construct("LlamaForCausalLM", "llama") expected = "flashinfer" if is_flashinfer_available() else "pytorch" - self.assertEqual(sa.sampling_backend, expected) + self.assertEqual(sa.sampling_backend, expected) # materialized self.assertIn( ("_sampling_backend_default", {"sampling_backend": expected}), sa._resolved_overrides, @@ -587,20 +587,20 @@ class TestGoldenModelOverrides(_IsolatedPublish): "LlamaForCausalLM", "llama", enable_deterministic_inference=True ) # two pass writers chain: default fill, then the deterministic force — - # last writer wins on the flags leaf and parity holds end-to-end. + # last writer wins; materialization lands the end state on the fields. self.assertEqual(sa.sampling_backend, "pytorch") flags = self._publish(sa) self.assertEqual(flags.sampling_backend, "pytorch") # the deterministic attention fill declared a compatible backend and # the compatibility default-fill then had nothing to do - self.assertIn( - ( - "_deterministic_attention_backend", - {"attention_backend": sa.attention_backend}, - ), - sa._resolved_overrides, - ) - self.assertEqual(flags.attn.backend, sa.attention_backend) + deterministic_fills = [ + decl["attention_backend"] + for source, decl in sa._resolved_overrides + if source == "_deterministic_attention_backend" + ] + self.assertEqual(len(deterministic_fills), 1) + self.assertEqual(sa.attention_backend, deterministic_fills[0]) + self.assertEqual(flags.attn.backend, deterministic_fills[0]) def test_deterministic_incompatible_backend_raises(self): from sglang.srt.arg_groups.overrides import ( @@ -631,13 +631,15 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_dllm_forces_flashinfer_with_cuda_graph(self): # CUDA path: cuda graph enabled by default -> dllm forces flashinfer. + # A real dllm arch: the page pass now runs regardless of the radix + # switch and builds DllmConfig for it. sa = self._construct( - "LlamaForCausalLM", + "SDARForCausalLM", "llama", dllm_algorithm="LowConfidence", disable_radix_cache=True, ) - self.assertEqual(sa.attention_backend, "flashinfer") + self.assertEqual(sa.attention_backend, "flashinfer") # materialized self.assertIn( ("_dllm_attention_backend", {"attention_backend": "flashinfer"}), sa._resolved_overrides, @@ -647,24 +649,35 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_attention_backend_leaf_materializes_end_state(self): # The default-fill pass declares the platform-selected backend; the - # leaf must equal the final server_args value (publish parity). + # leaf must equal the last declared value while the server_args field + # stays pristine (dual-apply retired). sa = self._construct("LlamaForCausalLM", "llama") - declared = {f for _s, d in sa._resolved_overrides for f in d} - self.assertIn("attention_backend", declared) # default fill declared - self.assertEqual(self._publish(sa).attn.backend, sa.attention_backend) + declared_values = [ + d["attention_backend"] + for _s, d in sa._resolved_overrides + if "attention_backend" in d + ] + self.assertTrue(declared_values) # default fill declared + self.assertEqual(sa.attention_backend, declared_values[-1]) # materialized + self.assertEqual(self._publish(sa).attn.backend, declared_values[-1]) - def test_runner_side_adjustment_can_refresh_declaration(self): - from sglang.srt.arg_groups.overrides import refresh_declared_fields + def test_post_materialize_pass_writes_through(self): + from sglang.srt.arg_groups.overrides import run_post_process_pass + # A pass invoked after materialization (a post-init slot, like the + # legacy runner-side adjustments) declares AND writes through, so + # field readers and the publish see the same end state. sa = self._construct("LlamaForCausalLM", "llama") - declared = {f for _s, d in sa._resolved_overrides for f in d} - self.assertIn("attention_backend", declared) - # Simulate a legacy runner-side overwrite between collection and publish - # (model_specific_adjustment forces attention_backend for HRM-Text). - sa.attention_backend = "fa3" if sa.attention_backend != "fa3" else "triton" - with self.assertRaises(AssertionError): - self._publish(sa) # stale declaration breaks parity - refresh_declared_fields(sa, ("attention_backend",)) + resolved_before = sa.attention_backend + + def _force_triton(view): + if view.attention_backend != "triton": + return {"attention_backend": "triton"} + return {} + + run_post_process_pass(sa, _force_triton) + if resolved_before != "triton": + self.assertEqual(sa.attention_backend, "triton") self.assertEqual(self._publish(sa).attn.backend, sa.attention_backend) def test_attention_backend_user_choice_declares_nothing_extra(self): @@ -798,9 +811,41 @@ class TestGoldenModelOverrides(_IsolatedPublish): return_value=SimpleNamespace(block_size=32), ): self.assertEqual(_view() and _dllm_page_size(_view()), {"page_size": 32}) - self.assertEqual(_dllm_page_size(_view(page_size=64)), {}) # aligned + # aligned but larger than the block: the scheduler-init fallback + # (folded into this pass) still caps the page at the block size + self.assertEqual(_dllm_page_size(_view(page_size=64)), {"page_size": 32}) + self.assertEqual(_dllm_page_size(_view(page_size=32)), {}) # equal + # radix disabled skips the alignment fill but keeps the cap + self.assertEqual(_dllm_page_size(_view(disable_radix_cache=True)), {}) + self.assertEqual( + _dllm_page_size(_view(disable_radix_cache=True, page_size=64)), + {"page_size": 32}, + ) self.assertEqual(_dllm_page_size(_view(dllm_algorithm=None)), {}) - self.assertEqual(_dllm_page_size(_view(disable_radix_cache=True)), {}) + + def test_declaration_overlay_mechanics(self): + from sglang.srt.arg_groups.overrides import run_post_process_pass + + live = SimpleNamespace(x="user", y=None, _resolved_overrides=[]) + + def _resolve_x(view): + return {"x": "resolved"} if view.x == "user" else {} + + def _read_x(view): + return {"y": view.x} + + run_post_process_pass(live, _resolve_x) + # declaration recorded, but server_args stays pristine + self.assertEqual( + live._resolved_overrides, [(_resolve_x.__qualname__, {"x": "resolved"})] + ) + self.assertEqual(live.x, "user") + # a later pass sees the resolved value through the view overlay + run_post_process_pass(live, _read_x) + self.assertEqual( + live._resolved_overrides[-1], (_read_x.__qualname__, {"y": "resolved"}) + ) + self.assertIsNone(live.y) # never applied in place def test_overlap_disable_passes(self): from sglang.srt.arg_groups.overrides import ( @@ -1531,9 +1576,12 @@ class TestGoldenModelOverrides(_IsolatedPublish): def test_page_size_leaf_materializes_end_state(self): sa = self._construct("LlamaForCausalLM", "llama") - declared = {f for _s, d in sa._resolved_overrides for f in d} - self.assertIn("page_size", declared) # default fill declared - self.assertEqual(self._publish(sa).page_size, sa.page_size) + declared_values = [ + d["page_size"] for _s, d in sa._resolved_overrides if "page_size" in d + ] + self.assertTrue(declared_values) # default fill declared + self.assertEqual(sa.page_size, declared_values[-1]) # materialized + self.assertEqual(self._publish(sa).page_size, declared_values[-1]) def test_qwen3_5_hybrid_coupled_declaration(self): from sglang.srt.arg_groups.overrides import _qwen3_5_hybrid_overrides @@ -1544,7 +1592,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): _get_default_attn_backend=lambda **_: default_backend, use_mla_backend=lambda: False, get_model_config=lambda: None, - enable_mamba_extra_buffer=lambda: False, + mamba_radix_cache_strategy="auto", disable_radix_cache=False, speculative_algorithm=None, ) @@ -1571,6 +1619,24 @@ class TestGoldenModelOverrides(_IsolatedPublish): ), {}, ) + # the mamba pass ran before this dispatch and stashed the + # extra-buffer strategy: the callable must see it through the + # view (SM100 hybrid keeps trtllm_mha + page 64) + self.assertEqual( + _qwen3_5_hybrid_overrides( + _args( + "trtllm_mha", + _resolved_overrides=[ + ( + "_mamba_radix_cache_declarations", + {"mamba_radix_cache_strategy": "extra_buffer"}, + ) + ], + ), + None, + ), + {"attention_backend": "trtllm_mha", "page_size": 64}, + ) with patch.object(overrides_module, "is_sm100_supported", return_value=False): self.assertEqual(_qwen3_5_hybrid_overrides(_args("fa3"), None), {}) @@ -1738,7 +1804,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): self.assertEqual( _intel_xpu_page_constraint( _view( - get_attention_backends=lambda: (None, "intel_xpu"), + decode_attention_backend="intel_xpu", use_mla_backend=lambda: False, ) ), @@ -1747,7 +1813,7 @@ class TestGoldenModelOverrides(_IsolatedPublish): self.assertEqual( _intel_xpu_page_constraint( _view( - get_attention_backends=lambda: (None, "intel_xpu"), + decode_attention_backend="intel_xpu", use_mla_backend=lambda: True, page_size=16, # MLA decode accepts 16 ) @@ -2089,22 +2155,22 @@ class TestGoldenModelOverrides(_IsolatedPublish): self.assertEqual(_step3p_overrides(_args(), None), {}) -class TestDualApplyParity(CustomTestCase): - def test_dual_apply_replays_and_parity_holds(self): +class TestDeclarationValidation(CustomTestCase): + def test_declarations_never_mutate_server_args(self): flags, args = _FakeFlags(), _FakeArgs() declarations = [("src", {"resolved_by_model": "dsv4", "also_resolved": 7})] apply_model_overrides(flags, args, declarations) - apply_declarations_to_server_args(args, declarations) - self.assertEqual(args.resolved_by_model, "dsv4") - self.assertEqual(args.also_resolved, 7) - assert_flag_parity(flags, args, ["resolved_by_model", "also_resolved"]) + validate_declarations(args, declarations) + # the leaves carry the declared values; the fields stay pristine + self.assertEqual(flags.resolved_by_model, "dsv4") + self.assertEqual(flags.also_resolved, 7) + self.assertEqual(args.resolved_by_model, _FakeArgs.resolved_by_model) + self.assertEqual(args.also_resolved, _FakeArgs.also_resolved) - def test_parity_detects_drift(self): - flags, args = _FakeFlags(), _FakeArgs() - apply_model_overrides(flags, args, [("src", {"resolved_by_model": "x"})]) - # dual-apply skipped -> server_args still pristine -> drift is caught - with self.assertRaises(AssertionError): - assert_flag_parity(flags, args, ["resolved_by_model"]) + def test_validation_rejects_unknown_fields(self): + args = _FakeArgs() + with self.assertRaises(ValueError): + validate_declarations(args, [("src", {"nope": 1})]) if __name__ == "__main__": diff --git a/test/registered/unit/test_runtime_context.py b/test/registered/unit/test_runtime_context.py index 358a19123..f1f132fa4 100644 --- a/test/registered/unit/test_runtime_context.py +++ b/test/registered/unit/test_runtime_context.py @@ -339,15 +339,6 @@ class TestRuntimeResolutionStages(_IsolatedServerArgs): self.assertEqual(get_flags().sampling_backend, "pytorch") self.assertEqual(get_flags().page_size, 64) # earlier stage survives - def test_record_parity_failure_rolls_back(self): - self._publish(page_size=1) - flags_before = get_flags() - with self.assertRaises(AssertionError): - # declared value diverges from the live server_args (no dual-apply) - get_context().record_runtime_overrides([("bad", {"page_size": 64})]) - self.assertIs(get_flags(), flags_before) # previous flags intact - self.assertEqual(get_context()._runtime_overrides, []) # rolled back - def test_record_whitelist_violation_rolls_back(self): self._publish() with self.assertRaises(ValueError): @@ -366,13 +357,15 @@ class TestRuntimeResolutionStages(_IsolatedServerArgs): finally: reset_context() - def test_declare_load_time_override_dual_applies_and_records(self): + def test_declare_load_time_override_applies_and_records(self): from sglang.srt.arg_groups.overrides import declare_load_time_override args = self._publish(page_size=1) declare_load_time_override("model.load_time", {"page_size": 64}) - self.assertEqual(args.page_size, 64) # dual-applied onto server_args - self.assertEqual(get_flags().page_size, 64) # resolved into the leaf + # post-init declaration: written through to the field and resolved + # into the leaf + self.assertEqual(args.page_size, 64) + self.assertEqual(get_flags().page_size, 64) self.assertEqual( get_context()._runtime_overrides, [("model.load_time", {"page_size": 64})], @@ -415,6 +408,21 @@ class TestRuntimeResolutionStages(_IsolatedServerArgs): finally: reset_context() + def test_declared_leaf_wins_over_stale_field(self): + # A stash entry always drives the leaf at publish, even if the field + # value diverged (e.g. a fixture that skipped materialization). + @dataclasses.dataclass + class _Args: + enable_dp_lm_head: A[bool, Arg(help="d", resolvable=True)] = True + _resolved_overrides: list = dataclasses.field(default_factory=list) + + args = _Args() + args._resolved_overrides = [("dp", {"enable_dp_lm_head": False})] + args._declarations_materialized = True + args.enable_dp_lm_head = False + get_context().set_server_args(args) + self.assertFalse(get_flags().enable_dp_lm_head) + def test_bare_dataclass_publish_skips_materialization(self): # object.__new__(ServerArgs) fixtures (no __init__, no field values) # must publish without touching the flags tier — dataclass defaults