config: publishing is the process entry's job (#36251)

This commit is contained in:
Cheng Wan
2026-08-26 04:58:41 -07:00
committed by GitHub
parent 702de26310
commit d7b144f64e
21 changed files with 209 additions and 108 deletions
+6 -1
View File
@@ -82,7 +82,7 @@ from sglang.srt.mem_cache.base_prefix_cache import EvictParams
from sglang.srt.model_executor.cuda_graph_config import Phase, cuda_graph_fully_disabled
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.runtime_context import get_parallel, get_schedule
from sglang.srt.runtime_context import get_parallel, get_schedule, publish
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
@@ -681,6 +681,8 @@ def correctness_test(
gpu_id,
tp_rank,
):
publish(server_args, role="scheduler")
# Configure the logger
configure_logger(server_args, prefix=f" TP{tp_rank}")
rank_print = print if tp_rank == 0 else lambda *args, **kwargs: None
@@ -881,6 +883,9 @@ def latency_test(
gpu_id,
tp_rank,
):
# `main` runs this inline for tp_size == 1 and spawns it per rank otherwise;
# a spawned child arrives with nothing published.
publish(server_args, role="scheduler")
initialize_moe_config(server_args)
initialize_fp8_gemm_config(server_args)
initialize_fp4_gemm_config(server_args)
+36
View File
@@ -29,6 +29,7 @@ Two declaration forms, keyed on ``hf_config.architectures[0]``:
from __future__ import annotations
import copy
import dataclasses
import inspect
import json
@@ -380,6 +381,41 @@ def resolution_result(server_args: Any, field: str, default: Any = None) -> Any:
return getattr(server_args, field, default)
def resolution_projection(server_args: Any) -> Dict[str, Any]:
"""Every field's resolved value, nested dataclasses expanded.
The whole-object shape of ``resolution_result``, for the exits that hand out
the entire configuration (``/server_info``, the gRPC and engine readbacks).
They used ``dataclasses.asdict``, which reads the fields -- correct only for
as long as declarations materialize onto the record, and the point of
declaring is that they will not. Field values only: the private resolution
bookkeeping and the ``model_config`` memo that a ``vars()`` dump carried into
the readback are not configuration.
"""
return {
field.name: _plain(resolution_result(server_args, field.name))
for field in dataclasses.fields(server_args)
}
def _plain(value: Any) -> Any:
"""``dataclasses.asdict``'s conversion, applied to one value: dataclasses
become dicts, containers recurse, everything else is deep-copied (a caller
mutating the dump must not reach the live configuration)."""
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {
field.name: _plain(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, tuple) and hasattr(value, "_fields"): # namedtuple
return type(value)(*(_plain(item) for item in value))
if isinstance(value, (list, tuple)):
return type(value)(_plain(item) for item in value)
if isinstance(value, dict):
return type(value)((_plain(k), _plain(v)) for k, v in value.items())
return copy.deepcopy(value)
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
@@ -24,7 +24,7 @@ from smg_grpc_proto import sglang_encoder_pb2, sglang_encoder_pb2_grpc
from sglang.srt.disaggregation.encoder.server import MMEncoder, launch_encoder
from sglang.srt.managers.io_struct import async_sock_send, wrap_as_pickle
from sglang.srt.managers.schedule_batch import Modality
from sglang.srt.runtime_context import get_disagg
from sglang.srt.runtime_context import get_disagg, publish
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.utils import random_uuid
from sglang.srt.utils.network import NetworkAddress, get_zmq_socket
@@ -201,6 +201,7 @@ class SGLangEncoderServer(SGLangEncoderServicer):
async def serve_grpc_encoder(server_args: ServerArgs):
publish(server_args, role="encoder")
ctx = mp.get_context("spawn")
zmq_ctx = zmq.asyncio.Context(10)
ipc_path_prefix = random_uuid()
@@ -53,6 +53,7 @@ from sglang.srt.runtime_context import (
get_observability,
get_parallel,
get_serving,
publish,
)
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.utils import configure_logger, random_uuid, set_prometheus_multiproc_dir
@@ -1473,6 +1474,7 @@ def launch_dp_worker(
dispatch_path: str,
result_path: str,
):
publish(server_args, role="encoder")
try:
configure_logger(server_args, prefix=f" encode_dp_worker[{dp_rank}]")
asyncio.run(
@@ -57,12 +57,13 @@ from sglang.srt.multimodal.encoder_preprocessing import (
)
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
from sglang.srt.runtime_context import (
ensure_published,
assert_published,
get_device,
get_disagg,
get_exec,
get_mm,
get_model,
publish,
)
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils import configure_media_url_security
@@ -448,7 +449,7 @@ class MMEncoder:
``base_gpu_id + rank`` — the DP launcher's per-worker placement. It is
this instance's value, not a config change, so it travels as an
argument."""
ensure_published(server_args, role="encoder")
assert_published(server_args, role="encoder")
logger.info(f"init MMEncoder {rank}/{server_args.tp_size}")
self.server_args = server_args
configure_media_url_security(
@@ -2036,6 +2037,7 @@ async def _handle_encoder_worker_request(encoder: MMEncoder, request):
def launch_encoder(server_args, schedule_path, dist_init_method, rank):
publish(server_args, role="encoder")
try:
asyncio.run(run_encoder(server_args, schedule_path, dist_init_method, rank))
except KeyboardInterrupt:
+1 -1
View File
@@ -1342,7 +1342,7 @@ class Engine(EngineScoreMixin, EngineBase):
)
return msgspec_to_builtins(
{
**dataclasses.asdict(self.tokenizer_manager.server_args),
**self.tokenizer_manager.server_args.resolved_dict(),
**self._scheduler_init_result.scheduler_infos[0],
"startup_time": self.tokenizer_manager.startup_time,
"internal_states": internal_states,
+1 -2
View File
@@ -7,7 +7,6 @@ TokenizerManager's event loop.
"""
import asyncio
import dataclasses
import json
import logging
from types import SimpleNamespace
@@ -423,7 +422,7 @@ class RuntimeHandle:
return json.dumps(result, default=str)
def get_server_info(self) -> str:
result: Dict[str, Any] = dataclasses.asdict(self.tokenizer_manager.server_args)
result: Dict[str, Any] = self.tokenizer_manager.server_args.resolved_dict()
result.update(self.scheduler_info)
result["kv_events"] = (
self.tokenizer_manager.server_args.describe_kv_events_publisher()
+4 -2
View File
@@ -232,6 +232,8 @@ async def init_multi_tokenizer() -> ServerArgs:
server_args.api_key is None
), "API key is not supported in multi-tokenizer mode"
publish(server_args, role="tokenizer")
# Create a new ipc name for the current process
port_args.tokenizer_ipc_name = (
f"ipc://{tempfile.NamedTemporaryFile(delete=False).name}"
@@ -488,6 +490,7 @@ from sglang.srt.runtime_context import (
get_model,
get_parallel,
get_serving,
publish,
)
elastic_ep_router.route_class = ORJSONRoute
@@ -811,10 +814,9 @@ async def server_info():
server_args = _global_state.tokenizer_manager.server_args
# server_args.model_config is not serializable but should be excluded by asdict.
return msgspec_to_builtins(
{
**dataclasses.asdict(server_args),
**server_args.resolved_dict(),
**_global_state.scheduler_info,
"startup_time": _global_state.tokenizer_manager.startup_time,
"internal_states": internal_states,
@@ -123,7 +123,7 @@ from sglang.srt.observability.request_metrics_exporter import (
)
from sglang.srt.observability.trace import SpanAttributes, extract_trace_headers
from sglang.srt.runtime_context import (
ensure_published,
assert_published,
get_context,
get_device,
get_disagg,
@@ -409,7 +409,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
):
# Parse args
self.server_args = server_args
ensure_published(server_args, role="tokenizer")
assert_published(server_args, role="tokenizer")
self.startup_time: Optional[Dict[str, Any]] = None
self.elastic_worker_count = get_parallel().config.dp_size
self.elastic_pending_ep_size = None
@@ -2040,7 +2040,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
part that cannot be reconstructed afterwards.
"""
try:
return self.resolved_config_dict(dataclasses.asdict(self.server_args))
return self.resolved_config_dict(self.server_args.resolved_dict())
except Exception as e:
logger.error(f"Failed to snapshot the resolved config for the dump: {e!r}")
return None
@@ -168,7 +168,7 @@ from sglang.srt.model_executor.runner import (
)
from sglang.srt.platforms import current_platform
from sglang.srt.runtime_context import (
ensure_published,
assert_published,
get_context,
get_device,
get_exec,
@@ -332,13 +332,10 @@ class ModelRunner:
self.dist_port = nccl_port
self.server_args = server_args
self.is_draft_worker = is_draft_worker
# Set the global server_args in the scheduler process (target worker
# only, so a draft init cannot clobber target-derived global state).
# Before the constructor's bag reads (page_size below): a standalone
# construction (benchmark/one_batch, the manual runner tests) has no
# earlier publish.
# The process entry published; a draft runner is not one (it must not
# clobber the target's config), so only the target checks.
if not is_draft_worker:
ensure_published(server_args, role="scheduler")
assert_published(server_args, role="scheduler")
# Set by maybe_init_lora_manager; stays None when LoRA is off and on
# draft runners, which serve adapters' target model unadapted.
self.lora_manager: Optional[LoRAManager] = None
+34 -24
View File
@@ -969,11 +969,12 @@ class RuntimeContext:
in a readback: HiCache attach/detach, the generated forward-pass-metrics
endpoint, tunables set via ``/set_internal_state``.
``base`` defaults to ``dict(vars(server_args))`` (matching the legacy
``vars`` dump); pass ``dataclasses.asdict(server_args)`` when nested
dataclass fields must be expanded first. Override leaves are flat
``ServerArgs`` field names, so overlaying them onto the top level of
either base is exact.
``base`` defaults to ``server_args.resolved_dict()`` -- the record's
fields as resolution decided them, nested dataclasses expanded. (It used
to be ``dict(vars(server_args))``, which carried the private resolution
bookkeeping and the ``model_config`` memo into the readback.) Override
leaves are flat ``ServerArgs`` field names, so overlaying them onto the
top level of the base is exact.
The log is per process: it carries what *this* process overrode. A
weight reload records ``model_path`` and ``load_format`` from the
@@ -984,7 +985,7 @@ class RuntimeContext:
The top-level ``/server_info`` fields are the startup record, not this
dump.
"""
d = dict(vars(self.server_args)) if base is None else dict(base)
d = self.server_args.resolved_dict() if base is None else dict(base)
for _source, fields in self._overrides_log:
d.update(fields)
return d
@@ -1374,29 +1375,38 @@ def publish(server_args, *, role: str, hf_config: Any = None) -> RuntimeContext:
return _CONTEXT
def ensure_published(server_args, *, role: str) -> RuntimeContext:
"""Publish unless this exact record is already published under this role.
def assert_published(server_args, *, role: str) -> RuntimeContext:
"""This record, under this role, is already published -- or fail loud.
Three constructors publish defensively, because each can be built with
nothing published before it -- `ModelRunner` (a benchmark harness, the
manual runner tests), `TokenizerManager`, and `MMEncoder` (spawned encoder
workers). Inside a process that already published the same record,
publishing again re-projects the bags: every `override()` taken between the
two calls is discarded, and the provenance log with it.
Publishing is the process entry's job: `run_scheduler_process`,
`init_multi_tokenizer`, a spawned encoder worker, the benchmark work
functions. A constructor arriving here unpublished means one of those
entries is missing.
No override sits in one of those windows today, so this removes a hazard
rather than a live bug. It is worth removing anyway: the drop is silent, it
depends on where a constructor happens to sit relative to the overrides
around it, and `publish` now says what a re-projection discarded so the
next one is loud.
So these callers ask for the end state -- this record, this role, published
-- and get a no-op when that already holds. An engine rebuild still calls
`publish` directly, because there the reset is the point.
A `publish` at this point re-projects the bags over a live process,
discarding every `override()` taken since and the provenance log with it,
so this raises.
"""
if _CONTEXT._server_args is server_args and _CONTEXT._publish_role == role:
return _CONTEXT
return publish(server_args, role=role)
if _CONTEXT._server_args is None:
detail = "nothing is published in this process"
elif _CONTEXT._server_args is not server_args:
detail = (
"a different record is published "
f"(role={_CONTEXT._publish_role!r}); this constructor was handed "
"one the process never published"
)
else:
detail = (
f"this record is published under role "
f"{_CONTEXT._publish_role!r}, not {role!r}"
)
raise RuntimeError(
f"config not published for role {role!r}: {detail}. The process entry "
"publishes -- add publish(server_args, role=...) there rather than "
"publishing from a constructor."
)
def publish_role() -> str | None:
+15
View File
@@ -3729,6 +3729,21 @@ class ServerArgs:
# handlers ran, not how far they got.
self._declarations_materialized = True
def resolved_dict(self) -> Dict[str, Any]:
"""This configuration as a plain dict of resolved field values.
What the whole-object readbacks report (`/server_info` and its gRPC and
in-process twins). `dataclasses.asdict(self)` reads the fields, which
carry resolution's result only while declarations materialize onto the
record; this reads the declarations, so it keeps answering with what
resolution decided once they stop. Nested dataclass fields are expanded
the way `asdict` expands them; the private resolution bookkeeping and the
`model_config` memo are not fields and do not appear.
"""
from sglang.srt.arg_groups.overrides import resolution_projection
return resolution_projection(self)
def replace_resolved(self, source: str, **changes: Any) -> ServerArgs:
"""A copy of this record that stays resolved, and says what it changed.
+2 -2
View File
@@ -1,8 +1,8 @@
"""Who installs the startup record into the runtime context, derived from code.
Two guards need this answer and neither should keep its own list: matching the
spellings by hand is how `ensure_published` once read as *not* publishing,
which turned a correct module into a reported violation. A publisher is
spellings by hand is how the constructors' old defensive publish once read as
*not* publishing, which turned a correct module into a reported violation. A publisher is
defined by what it does -- it reaches ``RuntimeContext.set_server_args`` --
and a *constructor* publisher is an ``__init__`` that calls one.
"""