config: constructing a config no longer resolves it (#35907)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cheng Wan
2026-08-23 01:18:53 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 4bc79a1b49
commit 64aa859da2
34 changed files with 947 additions and 107 deletions
+5
View File
@@ -48,6 +48,11 @@ def _launch_server_target(launch_server_func: Callable, server_args: ServerArgs)
def launch_or_reuse_server(launch_server_func: Callable, server_args: ServerArgs):
# Resolve in the parent, before the fork. The pipeline probes the device
# (the default attention backend reads the CUDA capability), and a forked
# child cannot re-initialize CUDA once this process has.
server_args.resolve_once()
base_url = resolve_base_url("", server_args.host, server_args.port)
# Reuse an already-running server instead of forking a second one onto the
@@ -398,7 +398,7 @@ def _create_ray_engine_backend(server_args: ServerArgs):
placement_group=pg,
placement_group_bundle_index=0,
),
).remote(**dataclasses.asdict(server_args))
).remote(**dict(server_args._raw_input))
class _Proxy:
"""Forwards method calls to the remote RayEngine actor."""
@@ -432,15 +432,18 @@ def throughput_test(
server_args: ServerArgs,
bench_args: BenchArgs,
):
# A programmatic caller may hand over a freshly constructed record, and
# the backends below read the resolved paths and the raw snapshot.
server_args.resolve_once()
if bench_args.backend == "engine":
if server_args.use_ray:
backend = _create_ray_engine_backend(server_args)
else:
backend = Engine(**dataclasses.asdict(server_args))
backend = Engine(server_args=server_args)
if not backend:
raise ValueError("Please provide valid engine arguments")
elif bench_args.backend == "runtime":
backend = Runtime(**dataclasses.asdict(server_args))
backend = Runtime(**dict(server_args._raw_input))
else:
raise ValueError('Please set backend to either "engine" or "runtime"')
@@ -569,6 +572,7 @@ def cli_main():
raise e
server_args = ServerArgs.from_cli_args(args)
server_args.resolve_once()
bench_args = BenchArgs.from_cli_args(args)
logging.basicConfig(
+4 -2
View File
@@ -989,8 +989,10 @@ def latency_test(
def main(server_args, bench_args):
# Post-init write to the legacy cuda_graph_max_bs_decode field would
# not propagate to cuda_graph_config; update the decode phase directly.
server_args.resolve_once()
# The legacy cuda_graph_max_bs_decode field does not propagate; set the
# decode phase.
if server_args.cuda_graph_config is not None:
server_args.cuda_graph_config[Phase.DECODE].max_bs = max(bench_args.batch_size)
@@ -1255,6 +1255,7 @@ def cli_main():
args = parser.parse_args()
server_args = ServerArgs.from_cli_args(args)
server_args.resolve_once()
bench_args = BenchArgs.from_cli_args(args)
run_benchmark(server_args, bench_args)
+6
View File
@@ -102,6 +102,10 @@ def launch_server_internal(server_args):
def launch_server_process_and_send_one_request(
server_args: ServerArgs, compile_args: CompileArgs
):
# Keeps the device probe out of the fork below, for a caller that reaches
# this without resolving first.
server_args.resolve_once()
proc = multiprocessing.Process(target=launch_server_internal, args=(server_args,))
proc.start()
base_url = f"http://{server_args.host}:{server_args.port}"
@@ -177,6 +181,8 @@ def compile_server_args(args, compile_args: CompileArgs) -> ServerArgs:
args.watchdog_timeout = compile_args.timeout
args.warmups = "compile-deep-gemm"
server_args = ServerArgs.from_cli_args(args)
# `cuda_graph_config` is None until resolution parses it.
server_args.resolve_once()
server_args.cuda_graph_config[Phase.DECODE].backend = Backend.DISABLED
server_args.cuda_graph_config[Phase.PREFILL].backend = Backend.DISABLED
print(f"Disable CUDA Graph and Torch Compile to save time...")
@@ -393,6 +393,10 @@ class Runtime:
if is_port_available(port):
break
self.server_args = ServerArgs(*args, log_level=log_level, port=port, **kwargs)
# The spawned server gets a copy of this record, and this object keeps
# reading it afterwards -- `get_tokenizer` wants the downloaded GGUF
# file and the rewritten ModelScope path, not what the caller typed.
self.server_args.resolve_once()
self.url = self.server_args.url()
self.generate_url = self.url + "/generate"
@@ -455,7 +459,7 @@ class Runtime:
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
return get_tokenizer(
self.server_args.tokenizer_path,
self.server_args.tokenizer_path or self.server_args.model_path,
tokenizer_mode=self.server_args.tokenizer_mode,
trust_remote_code=self.server_args.trust_remote_code,
revision=self.server_args.revision,
+4
View File
@@ -15,6 +15,10 @@ suppress_noisy_warnings()
def run_server(server_args):
"""Run the server based on the gRPC flags and server_args.encoder_only."""
# The flags dispatched on below are decided by resolution (`--grpc-mode`
# folds into `smg_grpc_mode`), and `prepare_server_args` returns raw input.
server_args.resolve_once()
if server_args.encoder_only:
# For encoder disaggregation
if server_args.smg_grpc_mode or server_args.grpc_mode:
@@ -287,11 +287,11 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
dist_timeout=self.server_args.dist_timeout,
)
from sglang.srt.runtime_context import get_context
from sglang.srt.runtime_context import get_context, publish
from sglang.srt.server_args import ServerArgs as SrtServerArgs
if get_context()._server_args is None:
get_context().set_server_args(SrtServerArgs(model_path="dummy"))
publish(SrtServerArgs(model_path="dummy"), role="diffusion_gpu_worker")
# set proc title
if model_parallel_is_initialized():
@@ -20,7 +20,6 @@ from __future__ import annotations
import json
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
import torch
@@ -67,7 +66,12 @@ def _srt_trace_server_args():
prev_server_args = srt_server_args_module.get_global_server_args()
except ValueError: # nothing published yet
prev_server_args = None
set_global_server_args_for_scheduler(SimpleNamespace(trace_modules="request"))
# publish resolves what it is handed, so a stand-in cannot go through it.
from sglang.srt.server_args import ServerArgs as SrtServerArgs
set_global_server_args_for_scheduler(
SrtServerArgs(model_path="dummy", trace_modules="request")
)
try:
yield
finally:
+2
View File
@@ -1081,6 +1081,8 @@ class Engine(EngineScoreMixin, EngineBase):
# Configure global environment
configure_logger(server_args)
server_args.resolve_once()
_set_envs_and_config(server_args)
# Defensive: ensure plugins loaded (may already be loaded by
@@ -12,6 +12,10 @@ from sglang.srt.utils import MultiprocessingSerializer, kill_process_tree
def launch_server_process(server_args: ServerArgs) -> multiprocessing.Process:
# Resolve here, not in the child: the pipeline probes the device, and a
# forked child cannot re-initialize CUDA if this process already has. The
# child's gate then finds nothing left to do.
server_args.resolve_once()
p = multiprocessing.Process(target=launch_server, args=(server_args,))
p.start()
+2 -2
View File
@@ -464,8 +464,8 @@ class RayEngine(Engine):
# Set dist_init_addr on server_args so PortArgs.init_new() can compute
# TCP addresses correctly (required for DP attention path).
dp_server_args = dataclasses.replace(
server_args,
dp_server_args = server_args.replace_resolved(
"ray.dp_controller",
dist_init_addr=f"{rank0_node_ip}:{port_args.nccl_port}",
)
# Create the DP controller in-process. This blocks until all actors
+4 -5
View File
@@ -47,8 +47,6 @@ class SchedulerActor:
dp_rank: Optional[int],
dist_init_addr: Optional[str] = None,
):
import dataclasses
from sglang.srt.environ import envs
from sglang.srt.managers.scheduler import Scheduler, configure_scheduler_process
from sglang.srt.utils.numa_utils import (
@@ -56,10 +54,11 @@ class SchedulerActor:
numa_bind_to_node,
)
# Override dist_init_addr if provided (for multi-node)
# Override dist_init_addr if provided (for multi-node), through
# `replace_resolved` so the copy keeps the parent's resolution.
if dist_init_addr:
server_args = dataclasses.replace(
server_args, dist_init_addr=dist_init_addr
server_args = server_args.replace_resolved(
"ray.scheduler_actor", dist_init_addr=dist_init_addr
)
# Get actual GPU IDs from Ray runtime context
+9 -8
View File
@@ -1034,10 +1034,11 @@ class _ServerArgsOverride:
self._prev_capture = ctx.flags.capture.enable_torch_compile
from sglang.srt.arg_groups.overrides import (
_apply_fields,
declare_resolution,
declare_late_resolution,
)
server_args = ServerArgs(model_path="dummy")
server_args.resolve_once()
# Underscore names seed private property caches (the strict guard
# exempts them); everything else must be a real config field.
unknown = {name for name in self._fields if not name.startswith("_")} - set(
@@ -1047,23 +1048,19 @@ class _ServerArgsOverride:
raise ValueError(
f"override_server_args: unknown ServerArgs field(s): {sorted(unknown)}"
)
# Declared so the projection sees it.
# Declared so the projection sees it; late, because the record is
# resolved already and not yet published.
# Underscore names are not fields at all (they seed private property
# caches), so they stay a direct write.
declared = {
name: value for name, value in self._fields.items() if name[0] != "_"
}
if declared:
declare_resolution(server_args, "override_server_args", **declared)
declare_late_resolution(server_args, "override_server_args", **declared)
_apply_fields(
server_args,
{name: value for name, value in self._fields.items() if name[0] == "_"},
)
# The dummy boundary skips materialization, which would leave the
# strict mutation guard unarmed on the published object — mark it
# materialized so bare post-publish writes raise like they do on a
# fully resolved config.
object.__setattr__(server_args, "_declarations_materialized", True)
ctx.set_server_args(server_args)
self._installed = True
return server_args
@@ -1205,6 +1202,9 @@ ROLE_NAMESPACE_SETS: dict[str, frozenset[str] | None] = {
"encoder": None,
"expert_backup": None,
"weight_cache_daemon": None,
# The diffusion GPU worker runs a model and publishes a placeholder so
# shared SRT reads do not fail closed; declared full for that reason.
"diffusion_gpu_worker": None,
}
@@ -1325,6 +1325,7 @@ def publish(server_args, *, role: str, hf_config: Any = None) -> RuntimeContext:
f"publish role {role!r} has no ROLE_NAMESPACE_SETS entry; declare "
"its namespace set (None for the full tree)."
)
server_args.resolve_once()
discarded = _CONTEXT.overrides_log()
_CONTEXT.set_server_args(server_args)
if discarded:
+156 -21
View File
@@ -16,7 +16,9 @@
from __future__ import annotations
import argparse
import copy
import dataclasses
import functools
import glob
import importlib
import importlib.util
@@ -28,7 +30,6 @@ import random
import socket
import tempfile
import uuid
from functools import cached_property
from typing import Any, Callable, Dict, List, Literal, Optional, Union
from sglang.kernels.ops.kv_canary.consts import RealKvHashMode
@@ -3642,7 +3643,96 @@ class ServerArgs:
] = None
def __post_init__(self):
self._run_resolution_pipeline()
"""Construction leaves the record at what the caller asked for.
Resolution is a separate act, entered through ``resolve_once``: the
launcher runs it once per engine, and every publishing process asks the
gate on the way in. A record that is only constructed -- a fixture, a
config being inspected, one being handed to a subprocess that will
resolve it itself -- stays raw.
"""
def resolve_once(self) -> None:
"""Run the resolution pipeline, unless this record has been through it.
Resolution is a deterministic function of the raw inputs -- two records
built from the same arguments declare the same things -- but the
handlers do not survive a second pass over their own output: DP
attention halves ``chunked_prefill_size`` again on every re-entry.
The publishing entry of every process calls this. In a child the record
arrived by pickle and brought its declarations along, so the child has
nothing left to derive and projects what the parent decided.
"""
if getattr(self, "_declarations_materialized", False):
return
if getattr(self, "_resolution_failed", False):
raise RuntimeError(
"resolution already failed on this ServerArgs; the handlers that "
"ran left their writes on the record, and a second pass would "
"read that partial output as fresh input. Build a new record "
"from the corrected arguments."
)
try:
self._run_resolution_pipeline()
except BaseException:
# The handlers that ran already wrote to the record, and they are
# not idempotent over their own output.
object.__setattr__(self, "_resolution_failed", True)
raise
# Set here too, because the dummy/absent-model path returns before the
# materialization that normally sets it: the gate is about whether the
# handlers ran, not how far they got.
self._declarations_materialized = True
def replace_resolved(self, source: str, **changes: Any) -> ServerArgs:
"""A copy of this record that stays resolved, and says what it changed.
`dataclasses.replace` builds a new instance, so the copy carries none of
what makes a record resolved: no raw snapshot, no declarations, no
materialization. The next publish therefore finds an unmaterialized
record and runs the pipeline over values it already decided -- DP
attention halves `chunked_prefill_size` a second time (8192 -> 4096 ->
2048) and the schedule conservativeness is scaled again (0.3 -> 0.09).
The Ray paths replace `dist_init_addr` on a resolved record, which is
how they hit it.
The change is appended to the stash rather than left on the field: the
projection reads the raw snapshot plus the declarations, so a field the
copy set on its own would publish the parent's raw value instead.
The carry is shallow. The containers are copied so the copy's own
declaration does not travel back into the parent, but everything inside
them -- the stash entries, the raw-input values, the memoized
`ModelConfig` -- is shared. That is fine for what this is for: a copy
that immediately crosses a process boundary (Ray actors, the gateway's
workers), where pickling severs the sharing. A caller that mutates the
copy's deep structure in-process mutates the parent's too.
"""
replacement = dataclasses.replace(self, **changes)
if not getattr(self, "_declarations_materialized", False):
# Not resolved yet: the copy goes through the gate itself.
return replacement
# Everything outside the fields, enumerated from the instance: the raw
# snapshot, the stash, and what resolution memoized -- including the
# `get_model_config()` cache, which a resolved copy can no longer fill
# (the read-only guard refuses the write).
field_names = {field.name for field in dataclasses.fields(self)}
for name, value in vars(self).items():
if name in field_names or name == "_declarations_materialized":
continue
if isinstance(value, (dict, list, set)):
value = copy.copy(value)
object.__setattr__(replacement, name, value)
stash = getattr(replacement, "_resolved_overrides", None)
if stash is None:
stash = []
object.__setattr__(replacement, "_resolved_overrides", stash)
if changes:
stash.append((source, dict(changes)))
object.__setattr__(replacement, "_declarations_materialized", True)
return replacement
def _declare(self, source: str, **fields: Any) -> None:
"""This record's handlers declaring their resolution writes.
@@ -9522,8 +9612,17 @@ class ServerArgs:
# Lazy init to avoid circular import
from sglang.srt.configs.model_config import ModelConfig
if hasattr(self, "model_config"):
return self.model_config
memo = getattr(self, "model_config", None)
if memo is not None:
# A configuration built before resolution describes the path the
# caller typed; the GGUF and ModelScope handlers declare a
# different `model_path`, and every later decision keyed on the
# architecture would read the wrong contents. Only a real
# `ModelConfig` is checked -- a fixture's stand-in stays untouched.
if not (
isinstance(memo, ModelConfig) and memo.model_path != self.model_path
):
return memo
self.model_config = ModelConfig.from_server_args(self)
if self.model_config.is_hybrid_swa:
logger.info(
@@ -9556,9 +9655,9 @@ class ServerArgs:
# get_context().override(source, ...); a value one runner or worker
# owns travels as a constructor argument to it.
if (
not name.startswith("_")
and getattr(self, "_declarations_materialized", False)
getattr(self, "_declarations_materialized", False)
and not getattr(self, "_internal_write", False)
and (not name.startswith("_") or name in _underscore_field_names())
):
raise AttributeError(
f"server_args.{name} assigned after resolution; server_args is "
@@ -9599,30 +9698,47 @@ class ServerArgs:
def enable_mamba_extra_buffer_lazy(self) -> bool:
return mamba_extra_buffer_lazy_of(self)
@cached_property
@property
def max_speculative_num_draft_tokens(self) -> Optional[int]:
"""Return the maximum draft-token count speculative decoding may use."""
"""Return the maximum draft-token count speculative decoding may use.
Memoized only once the record is resolved: an answer computed off a raw
record describes inputs resolution is about to rewrite (auto speculative
sizing fills `speculative_num_draft_tokens` in), and a cache filled that
early would keep answering with it.
"""
memo = self.__dict__.get("_max_speculative_num_draft_tokens")
if memo is not None:
return memo
if self.speculative_num_draft_tokens is None:
return None
if not self.speculative_adaptive:
return self.speculative_num_draft_tokens
result = None
elif not self.speculative_adaptive:
result = self.speculative_num_draft_tokens
else:
from sglang.srt.speculative.adaptive_spec_params import (
resolve_candidate_steps_from_config,
)
from sglang.srt.speculative.adaptive_spec_params import (
resolve_candidate_steps_from_config,
)
candidate_steps = resolve_candidate_steps_from_config(
cfg_path=self.speculative_adaptive_config,
)
# TODO: adaptive spec currently requires topk=1, so each runtime state
# needs steps + 1 draft-token slots. Revisit this if topk>1 is supported.
return max(candidate_steps) + 1
candidate_steps = resolve_candidate_steps_from_config(
cfg_path=self.speculative_adaptive_config,
)
# TODO: adaptive spec currently requires topk=1, so each runtime
# state needs steps + 1 draft-token slots. Revisit this if topk>1
# is supported.
result = max(candidate_steps) + 1
if getattr(self, "_declarations_materialized", False):
object.__setattr__(self, "_max_speculative_num_draft_tokens", result)
return result
@property
def mamba_cache_chunk_size(self) -> int:
# For mamba cache with extra buffer, the chunk size is the max of FLA_CHUNK_SIZE
# (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.
# A pre-seeded `_mamba_cache_chunk_size` (fixtures supply one so a dummy
# model never loads an HF config) is honored as-is; otherwise the memo
# is only kept once the record is resolved, because `page_size` below
# is resolution-written.
if not hasattr(self, "_mamba_cache_chunk_size"):
try:
@@ -9639,6 +9755,8 @@ class ServerArgs:
assert (
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=}"
if not getattr(self, "_declarations_materialized", False):
return max(chunk_size, page_size)
self._mamba_cache_chunk_size = max(chunk_size, page_size)
return self._mamba_cache_chunk_size
@@ -10331,6 +10449,23 @@ def m3_fp8_attn_gemm_enabled(args) -> bool:
# reference. Do not add new call-sites — the counts are ratcheted
# (decrease-only) by test/registered/unit/test_legacy_global_ratchet.py.
# Imports are in-function so the two modules stay cycle-free at import time.
@functools.lru_cache(maxsize=1)
def _underscore_field_names() -> frozenset:
"""Real dataclass fields whose names start with an underscore.
The read-only guard exempts underscore names because they are the record's
own bookkeeping (the stash, the flags, the memoized model config). A *field*
that happens to start with an underscore is still resolved configuration --
`_speculative_draft_quantization_explicitly_set` is one -- and exempting it
by spelling would leave exactly one leaf writable on a read-only record.
"""
return frozenset(
field.name
for field in dataclasses.fields(ServerArgs)
if field.name.startswith("_")
)
def set_global_server_args_for_scheduler(server_args: ServerArgs):
"""Legacy publish shim (role=scheduler) — prefer
``runtime_context.publish(server_args, role=...)`` in new code."""