sglang-runtime-context
GitHub解析SGLang运行时上下文架构,涵盖配置发布、命名空间配置袋及进程状态管理。指导开发者正确访问ServerArgs与动态配置,避免直接修改全局状态,适用于涉及sglang服务器参数、模型覆盖及前向传播状态的代码开发与测试。
触发场景
安装
npx skills add sgl-project/sglang --skill sglang-runtime-context -g -y
SKILL.md
Frontmatter
{
"name": "sglang-runtime-context",
"description": "How SGLang's runtime configuration and process-global state are organized (RuntimeContext tiers, publish + namespace config bags, the pristine ServerArgs seed, override entry points, resource\/stream\/buffer leases, per-forward flags), the CI guardrails that enforce the design, and the idioms for developing and testing against it. Load this before touching server_args, model overrides, module-level state, or per-forward state in sglang."
}
SGLang runtime-context architecture
One container owns process-static runtime state: sglang.srt.runtime_context.RuntimeContext
(a process singleton reached via get_context()). Everything below is a tier on it.
| Tier | Accessor | Holds | Lifecycle |
|---|---|---|---|
| raw config seed | get_server_args() |
the published ServerArgs — the startup record, for debugging, dumps and provenance. Business code does not read fields off it: the read ratchet pins that at zero, and "Reading config: the seed is off limits" below says what to read instead, which forms the ratchet sees, and what is outside it by construction (a runtime-computed name; a whole-object hand-off) |
published at process entry; re-publish is last-publish-wins (the tokenizer publish in the launcher process; sequential engine rebuild in one process, e.g. unit tests) and re-projects the bags; read-only |
| resolved config | get_exec() get_memory() get_schedule() get_model() get_spec() get_serving() get_observability() get_disagg() get_lora() get_mm() get_device() |
namespace config bags — the single source of truth for resolved config; leaves are real attributes (dynamo-traceable). Each is a module function of no arguments, and a module binds the name once: manager.get_disagg(), self.get_disagg = get_disagg, or a same-named import next to the bag one (from model_loader import get_model) all import fine and fail only when that path runs. ruff --select F811 catches the import collision; RuntimeContext has no bag-named member and no __getattr__, so the member-call shapes are an AttributeError at call time — give it a delegating __getattr__ and they go silent instead |
projected at publish from the declarations over server_args' raw fields; mutated only via get_context().override |
| runtime flags | get_flags() |
state that is not a pure function of config: capture (cuda-graph lifecycle), moe (ACTIVE backends, swappable), dp (DP-attention runtime flags) |
materialized at subsystem init; groups offer override() for tests |
| resources | get_resources(), get_stream(name), get_buffer(name, factory) |
process-level handles: graph pools, EPLB state, EP dispatcher state, named side streams, workspace buffers | lazy; cleared by reset_context() |
| per-forward | get_forward() |
forward-scoped flags (multi-stream switch, MoE output buffer, attn-TP inputs, extend-in-batch) | contextvar-backed; scoped(**kw) restores on exit; new threads see defaults |
| parallel | get_parallel() |
one spelling per name: ranks and group handles are the live topology (@property, read-through); every other name, sizes included, is a leaf of the parallel config bag |
ranks/groups: after dist init; leaves: after publish |
reset_context() (unit-test teardown) drops the published config and installs fresh
flags/resources/forward tiers.
Config: publish + namespace bags
ServerArgs holds the raw input and nothing else. Resolution writes no field:
it declares, and the declarations are what the namespace bags are projected from.
Business code never reads the record for a decision: a field read there answers
with what the operator typed, not with what resolution decided.
- Every publishing process entry calls
publish(server_args, role=...)(run_scheduler_process, the RaySchedulerActor, the DP controller, tokenizer, detokenizer, encoder, weight-cache daemon, the multi-tokenizer worker, the spawned encoder TP/DP workers, the benchmark work functions, ...); constructors do not publish —ModelRunner,TokenizerManagerandMMEncodercallassert_publishedand fail loudly if an entry forgot. The roles are enumerated once, as the keys ofROLE_NAMESPACE_SETS— there is nolauncherrole, the launch path publishes astokenizer. The remaining non-publisher isrun_multi_detokenizer_router_process: it is handed aServerArgs, and uses it only forconfigure_logger(server_args)today, so it has nothing to publish for — a bag read added under that entry needs apublishat the entry first.publishprojects the config bags from the declarations over the record's raw fields; the accessors (get_exec()etc.) fail closed before it runs.rolerecords which process type published, and keys per-role namespace enforcement:SGLANG_ROLE_NAMESPACES=recordaudits which namespaces each role's process actually reads (per-pair persisted viaSGLANG_ROLE_NAMESPACES_OUT; reads inside torch.compile-traced code are NOT observed — audit with compilation disabled before restricting a role), and=enforcefails closed on bag reads outside the role'sROLE_NAMESPACE_SETSentry (None= full tree; only audited roles are restricted). - Bag membership is where the field is declared: one class per namespace under
arg_groups/fields/, each carrying the_NS_PATHit stands for, andServerArgsis assembled from them (collect_input_fields). The per-fieldNS("path")marker survives only for a class that cannot express this — an ad-hoc dataclass spanning namespaces, which is what the config-bag tests build. Coverage is linted two-way (test_server_args_namespaces.py,test_runtime_context_config_bags.py). - Reading config:
get_<ns>()[.sub].field— e.g.get_exec().moe.moe_a2a_backend,get_schedule().max_running_requests. Bag leaves are plain instance attributes, safe insidetorch.compile-traced code. - Mutating config after publish: the ONLY entry point is
get_context().override(source, **fields). It writes the bag leaves in place (namespace readers see the new value) and records provenance in the overrides log. There is no write-through to theServerArgsinstance — it stays pristine. There is no in-place mutation entry on the instance at all: it is read-only after resolution. - Reading a leaf when the caller holds the field name (a readback endpoint,
a control-plane handler):
get_context().config_leaf(name)— the read side ofoverride. It resolves the flat name through the sameNSmap the write side uses and raises on a name that is not a config leaf. Code that knows its field when it is written reads the bag leaf directly;config_leafis for name-driven code, not a way around the seed ratchet. - Post-startup control-plane changes — a weight update, a HiCache mirror
attach, a parser resolved from the chat template — go through
TokenizerManager.record_config_updates(source, **fields), a named wrapper overget_context().override. One process keeps one log: the request dumps shipget_context().overrides_log(), andconfig_value(name)/resolved_config_dict(base)answer from the bags. The exposure ratchet resolves the wrapper, so a field recorded through it joins the post-publish override surface exactly like a directoverrideand needs the same ordering judgment against any supplied-instance read of it (test_supplied_instance_exposure_ratchet.py). model_pathandserved_model_nameare answered off the manager. Both areNSleaves andoverrideaccepts them, but the tokenizer-side weight reload records onlyload_formatand writes the two path fields asTokenizerManagerattributes (_MANAGER_OWNED_FIELDS);config_valueandresolved_config_dictoverlay them on top of the bags. Bags do not cross a process boundary (above), so recording those two in the tokenizer process would leave every other process's bag on the old path while the log claimed a process-wide change. The scheduler rewrites its own copy where the reload happens —ModelRunner.update_model_fieldsoverridesmodel_path/load_formatfor the target runner.- Late launcher-stage resolution (pre-publish): a few rules cannot run inside
__post_init__— LoRA normalization, and the auto-parser detection that needs a tokenizer/chat-template load. They are resolution, not mutation, and they declare viaarg_groups.overrides.declare_late_resolution(server_args, source, **fields), which refuses the published instance. The declaration lands in the stash on that very object, so every holder of it carries the decision — the HTTP server, the multi-tokenizer workers it is serialized for, the schedulers it forks — and each of them publishes bags projected from it. The fields stay the operator's input;resolution_result(sa, field)and the bags are what answer for the decision. Returning a variant here is a bug: the launcher rebinds its local and everyone else keeps the unresolved object. - A value another runner / worker owns is a constructor argument, not a config
copy. The draft worker's
context_length, load format and attention backend travel as arguments toTpModelWorker/ModelRunnerand live on the runner (ModelRunner.draft_attention_backend,kv_cache_dtype_str, …); the encoder DP worker's device isMMEncoder(gpu_id=...). There is noServerArgs.deriveany more — a config object is never copied-and-edited; test doubles that need a modified copy usesglang.test.test_utils.server_args_variant.
Why a bag override cannot stand in for late resolution or per-runner
construction. The bags are projected at
publish from the declarations over the instance's raw fields, so anything the
runtime must read has to be declared before publish — an override afterwards puts instance and bags back out
of agreement, and whole-object readers (ModelConfig.from_server_args,
build_load_config, MMEncoder's own self.server_args.X) never see it. And bags do
not cross a process boundary: a child publishes from the object it receives and
re-projects its own bags, so a parent-side override is lost. Values that feed
construction before any bag exists (group init reads server_args.tp_size) have no
bag to override at all.
Reads that legitimately stay on a ServerArgs instance
- Per-runner values — there is no per-runner
ServerArgsany more. The draft-worker config copy is gone: every worker (TpModelWorker, the draft workers inspeculative/) is handed the same instance the process published, so a bag leaf is the decision andself.server_args.Xis the operator's input — a post-publishoverridemoves only the bag, which is exactly why a field that is process-wide config (attention_backend,skip_tokenizer_init,kv_cache_dtype) reads from the bags like any other, and why a residual instance read on this path is stale the moment someone overrides that leaf. What is genuinely per-runner travels two ways, neither of them a config instance: constructor arguments (ModelRunner(draft_attention_backend=...),MMEncoder(gpu_id=...)) and runner attributes holding the resolved value (model_runner.kv_cache_dtype_str,prefill_attention_backend_str,num_fused_shared_experts,linear_attn_backends) — threaded to consumers as arguments, never backfilled onto a shared object. A per-runner choice also stays out of the bags: recording it there is how a second runner inherits the first one's answer, which is exactly the buglinear_attn_backendsreplaced. The one sanctioned bend in that rule is scoped:ModelRunner._load_format_scopeexposes the draft's--speculative-draft-load-formatthroughget_model().override(load_format=...)for exactly the duration of the draft build, because model construction reads that bag leaf — the override restores on exit, so nothing outlives the scope. When there is a runner in hand, read its stamp; that is a different rule from "read the instance". - Per-instance boundaries — the tokenizer-manager family, everything under
entrypoints/, and the tokenizer-process multimodal processors read the bags. The old justification for keeping them onself.server_args("severalEngines can share one process, bags are last-publish-wins across them") is retracted — owner ruling (2026-08-15): a process holds at most one live config at a time (concurrent multi-Engine is unsupported; sequential rebuild stays legal, unit tests rely on it). Nothing in those files reads the instance any more -- the exposure ratchet's pin set is empty, so the next such read is a new entry that has to argue for itself. What genuinely stays per-instance is what differs per worker within one engine:base_gpu_idtravels as a constructor argument (MMEncoder(gpu_id=...);BaseMultimodalProcessor._fast_image_processor_deviceis the shape to copy). - Whole-object passes (
f(server_args)handing the instance along) keep the supplied-instance contract; don't rewrite the parameter reads unless the field is runtime-mutated (see the elastic-EPep_sizecase ineplb/expert_location.py) — or the field is one that resolution fills in and the callee runs in a process that has published. That second case is a decision, not a style question: the record carries the user's raw input, so a resolution-filled field read off it inside a runner-owned constructor answers with the pre-resolution value instead of the effective one. The answer is not automatically a bag read: pick where the value should come from — usually theget_*()bag, sometimes a runner stamp or a constructor argument (the per-mode attention pair and the encode-servergpu_idabove are both this). The per-instance boundaries above are not exempt from this unless-clause (the multi-Engine exemption is retracted); each one gets its own disposition.test_supplied_instance_exposure_ratchet.pypins that set (empty today) — three spellings of the read:server_args.field, literal-namegetattr(server_args, "field", default), and the parked form (self.x = server_argsin a method that takes the parameter, read asself.x.fieldanywhere in the class) — and fails on a new one, so the disposition gets picked when the read is written. Two shapes stay parameter-form on purpose: a helper the resolution pipeline calls with aresolved_view(its parameter happens to be namedserver_args), and a factory whose contract is "build X from the record you are handed" (create_kt_config_from_server_args,DllmConfig.from_server_args).
Four ways a config sweep breaks something no test runs
Each of these shipped in a review round and cost a real defect; each now has a guard, named here so the next sweep checks the same four things by hand first.
- The other implementations of an interface. Dropping a parameter means
auditing implementers, not just callers:
CustomSpecAlgois the plugin base for speculative algorithms, and the dispatch calls it with the built-in's argument list. Nothing in the tree implements it, so only a plugin user hits theTypeError. Guard:test_plugin_hook_signatures.py. - Publish order inside a process entry, not per file. A file containing a
publishsays nothing about whether a given read runs before it. Spawned workers (MMEncoderfor encoder DP/TP, the Ray scheduler actor) start with an empty context, so a bag read above the publish raises only there. Guard:test_publish_precedes_bag_reads.py. - The role namespace a process publishes under.
ROLE_NAMESPACE_SETSnarrows what each role may read; the DP controller is audited forexecalone. A helper that reaches for another namespace passes every default-mode test and aborts startup underSGLANG_ROLE_NAMESPACES=enforce. Prefer answering from the caller's own namespaces over widening the set. - Sibling surfaces of a readback. Changing what one entry point reports
means enumerating the others: HTTP, gRPC and in-process
Engineeach have their own server-info and model-info, and each passes its own tests while its users lose the field. Guard:test_effective_state_surfaces.py.
A fifth, from the same rounds: the accessor name itself. Called as an
object member (manager.get_disagg()), or shadowed by a same-named import
(from model_loader import get_model next to the model bag, where the later
import silently wins and the loader call gets a zero-argument bag), it imports
fine and fails only when that path runs. The invariant is one line: the name
means the process-wide bag, takes no arguments, and is bound once per module.
ruff --select F811 catches the import collision; the member-call shapes are an
AttributeError at call time only because RuntimeContext has no bag-named
member and no __getattr__ -- a delegating __getattr__ would make them
silent, and that is when this needs a guard again rather than a rule.
Write these guards over a derived set, never a hand-kept list: an entry
naming a function that no longer exists, or a field list missing the one field
nobody migrated, passes green forever. Both happened here -- a _ENTRY_POINTS
row for a method the Ray actor does not have, and an effective-field set
without load_format -- and both were invisible because the assertion had
slack (>= len(...) - 1) or compared key names instead of value sources.
get_parallel(): one spelling per name
There is no .config hop. Ranks and group handles are @property
read-through over the canonical getters, so they answer with the live process
groups. Everything else — tp_size, pp_size, attn_cp_size, dcp_size,
moe_dp_size included, alongside config-only leaves like nccl_port,
enable_dp_attention, dp_size, ep_size, dwdp_size — is answered from the
published parallel bag. Reading a leaf before publish raises a ValueError
naming the namespace; an unknown name is an AttributeError.
A size reads from the configuration because the groups are built at exactly the
configured widths — checked at every assignment to _TP / _PP / _ATTN_CP /
_DCP / _MOE_DP in parallel_state.py. Three things do not follow that rule:
initialize_model_parallelaliases_MOE_DPto_ATTN_CPwhenattn_cp_size > moe_dp_size, so a reader that means the MoE communicator's width callsget_moe_cp_size(), notget_parallel().moe_dp_size.patch_tensor_parallel_groupruns a scope under a different TP group (draft workers), and declares it by overridingtp_size,tp_rankandtp_groupfor the scope's duration. Readers inside need no special spelling.- Elastic EP scales
ep_size/dp_sizeon the published bag while the group coordinators keep their construction width. Those are different names, not two answers to one name.
DCP keeps its own pair: get_parallel().attn_dcp_size / .dcp_enabled answer the
effective topology (1 / False with no group installed), while dcp_size is
what the launch requested.
A process-global seed field-read of one of these sizes
(get_server_args().tp_size, or an alias of it) is a read-ratchet failure. A
server_args the object was handed is a different thing and not a ratchet
matter — see "Reads that legitimately stay on a ServerArgs instance".
Fail-loud is narrower: before dist init, a live rank/group read raises. The six
parallel quotients are not live reads at all — attn_tp_size, attn_dp_size,
attn_dcp_size, moe_ep_size, moe_tp_size, dcp_enabled are a function of the
configured leaves, computed once at publish into bag leaves, and answered
override → stamp → published leaf. So dcp_enabled means "the launch configured
DCP" (dcp_size > 1), not "a DCP group is installed here"; in a scheduler the
stamp makes the two identical, in a process that publishes without dist init they
differ. test_a_topology_is_stated_by_naming_the_width and its neighbours in
test_runtime_context.py pin this; they replaced
test_attn_dcp_defaults_when_group_is_uninitialized. One consequence for tests:
overriding a leaf no longer moves its quotient — state a topology by publishing a
config, or by naming the width. After init,
only the DCP group is optional (_DCP exists only when dcp_size > 1; attn-CP and
moe-DP always install, as size-1 aliases if unused). The config hop is
deliberately dynamo-traceable (a plain property over a slot, no
object.__getattribute__); gate helpers like enable_moe_dense_fully_dp() run inside
compiled model forwards (test_parallel_config_leaves_trace_under_torch_compile pins
this).
A third surface carries the same names: ParallelState (self.ps / mr.ps), the
frozen per-process snapshot built once in Scheduler.__init__ from these configured
sizes plus this process's ranks, and handed down (draft runners included). Prefer it
where an object was handed one; it is not a global accessor.
Reading config: the seed is off limits
get_server_args().field in business code is a ratchet failure. Read:
- a resolved leaf → its namespace bag (
get_exec().moe.moe_runner_backend,get_schedule().chunked_prefill_size, …). Bag-backed reads — a leaf directly, or a bag-derived accessor below — are what see post-publish overrides. Only the instance-derived accessors (the ones with no leaf to read) answer from the startup record and therefore do not. - a leaf the caller names at runtime (a readback reporting a list of fields)
→
get_context().config_leaf(name); it resolves the name throughNSand raises on a non-leaf. A call site that knows its field reads the bag leaf. - the live topology →
get_parallel()(bare names). - a value derived from published leaves → an accessor in
runtime_contextthat derives it from the bags. The strongest form of this is aDerived(fn=...)declared beside the leaves it is computed from, in the namespace's ownarg_groups/fields/class:publishcomputes it once and stores it as an ordinary bag leaf, so the read is a plain attribute load and it sees post-publish overrides.enable_mamba_extra_buffer,is_ep_joiner,is_ep_scale_joinerandis_startup_weight_load_overlapare declared that way now — read them where they are declared:get_exec().mamba.enable_mamba_extra_buffer,get_exec().moe.is_ep_joiner,get_model().is_startup_weight_load_overlap. (The namespace is the class that declares the field, not the namespaces itsfnhappens to read: the mamba one spansexec.mambaandmemory, which is exactly why it could not be a method on either bag.) The oldmamba_extra_buffer_enabled()/is_ep_joiner()functions and the same-namedServerArgsmembers are gone. The pre-publish helpers that remain exist for resolution, which has no bag to read yet.attention_backends()derives the(prefill, decode)pair from the threeexec.kernelleaves, andmax_speculative_num_draft_tokens()/cutedsl_moe_max_num_tokens()derive theirs fromspec/schedule/exec.graph. - a value only the instance can compute → the named accessor in
runtime_context, which is the one module allowed to read the slot:mamba_cache_chunk_size(),mamba_state_chunk_size(),uses_mla_backend(),process_model_config(). These have no leaf to read — they combine several fields, the HF config, or a property with no bag of its own. A new derived member gets an accessor here rather than call sites reaching for the record, and only when the bag-derived shape above cannot express it. - a parallel size →
get_parallel().{tp,pp,moe_dp,attn_cp,dcp}_size, which is the parallel bag's own leaf: it answers with the resolved configuration and follows a post-publish override. Two questions are not that, and have their own spelling: the width of the MoE communicator you are about to collectively operate on isget_moe_cp_size()(the_MOE_DP = _ATTN_CPalias makes it differ), and the effective DCP topology isget_parallel().attn_dcp_size/.dcp_enabled(1/Falsewhen no group is installed), which does not need dist init to answer. - this runner's resolved value → the runner
(
prefill_attention_backend_str,kv_cache_dtype_str,draft_attention_backend,num_fused_shared_expertson the model).
self.server_args.field is still right for handed per-instance config (see
"Reads that legitimately stay on a ServerArgs instance" above for the full set —
per-instance boundaries and whole-object passes; there are no per-runner config
copies to read any more). The allow-list is GrammarManager and MMEncoder;
what sits beside it is residue, not a family — and not for one single reason:
- the tokenizer-manager family and
entrypoints/read the bags; what is left of them in the exposure ratchet is a handful of individually-dispositioned pairs, not a family awaiting conversion. Read the ratchet for the current set rather than assuming a directory is off-limits; GrammarManageris a handed instance for its residualself.server_argsreads, but backend selection is not on the instance any more:create_grammar_backendreadsget_exec().kernel.grammar_backend, and__init__calls that factory wheneverskip_tokenizer_initis false. In production the scheduler process has published; a test that constructs one without publishing has to keep patching the factory (or publish itself);MMEncoderpublishes the very instance it is handed (publish(server_args, role="encoder")) and takes its per-worker device as a separategpu_idargument. Itsself.server_argsreads are on this list as a construction-path convention, and the residual is real: they answer with the raw input, so a leaf resolution decided and a post-publishoverrideboth pass them by.
Their tests are not one story: a GrammarManager built standalone turns the
factory's bag read into "config namespace not published" unless the test patches
it or publishes, while MMEncoder publishes in its own __init__ and so needs
no such arrangement.
Test doubles publish, they do not inject. A stand-in that carries
server_args=SimpleNamespace(field=...) stops working the moment production reads
the bag; seed the value with override_server_args, which publishes only once it is
entered or installed — the bare call just builds the override:
override = get_context().override_server_args(field=...)
override.install()
self.addCleanup(override.restore) # or: with get_context().override_server_args(...):
Five separate test files learned this the hard way during the sweep.
The rule is about a double standing in for config: a SimpleNamespace that
pretends to be server_args. Prefer the context override even where a
single-accessor stub would work — override_server_args(...) composed with the
scoped bag / get_parallel() overrides expresses the cause (the configuration)
rather than pinning one helper's answer, and it keeps working when a reader
migrates between the accessor and the leaf. The sweep converted the last two
accessor stubs to exactly that shape (test_attention_patching.py publishes the
non-lazy strategy; test_kimi_k3_vision.py publishes tp_size and forces the
live topology through get_parallel().override), so no test stubs an accessor
today. Stubbing one named accessor remains a last resort for a case that
isolates one branch of one helper where no published config can reach it —
if you do it, say so in the test.
Mid-resolution reads (inside the pipeline only)
Resolution runs in __post_init__ and writes nothing onto the record: a
handler declares (self._declare / declare_resolution), the declaration goes
into the stash, and the fields keep what the caller passed. So a mid-resolution
read of a field answers with the raw input — every reader in the pipeline goes
through a view instead:
resolving_view(server_args)/self._resolved()— the live view (walks the stash per read). This is what handlers and hooks bind, conventionally ascfg = resolving_view(self)at the top of the handler.resolved_view(server_args)— snapshots the overlay when built, which is what a post-process pass wants: it reads the state at its slot.
test_resolution_reads_the_declarations pins direct field reads at zero over the
two scopes it can derive exactly (every arg_groups function taking a config,
every ServerArgs handler the dispatcher reaches). Readers the pipeline calls
from elsewhere (ModelConfig, the platform defaults, the spec-algo hook) have
moved to the view as well — a field read there is the same bug, just one the
derivation cannot enumerate.
One consequence worth knowing: because the fields are the raw input, resolving a
bare dataclasses.replace copy lands in the same place as the parent — the
pipeline reads only its own input. replace_resolved is the way to copy a
resolved record (it carries the declarations and the model_config memo, so the
copy does not re-resolve at all).
Adding a model-specific config adjustment
Never assign server_args fields from model code. Declare instead
(sglang/srt/arg_groups/overrides.py):
- Constant per-arch values →
MODEL_OVERRIDES["MyArchForCausalLM"] = {...}. - Derived values →
@register_model_override("MyArchForCausalLM")returning a dict; the callable receives pristineserver_args+hf_configand must not write. - Normalization that must see earlier declarations → a post-process pass invoked via
run_post_process_passat its slot (reads a view, returns a declaration dict). - Values only knowable at load time are per-runner state, not declarations:
there is no
declare_load_time_overrideany more. A model-family decision that its checkpoint drives (shared-experts fusion) is a question the loader asks the model class —shared_experts_fusion_disable_reason(hf_config, quant_config), a classmethod answering without an instance — at the single model-instantiation point, andinstall_shared_experts_fusion_decisionwrites the answer to the ACTIVE moe flag before that model's layers build and read it (is_shared_experts_fusion_disabled, config-intent fallback).draft_model_build_scopebrackets every draft build and routes the draft's answer to the speculative leaf, so a draft's decision never overwrites the target's. A process-level load-time fact (the sm80 dtype fallback — device-driven, identical for every runner) records directly viaget_context().override.
Declarable fields form a whitelist: Arg(..., resolvable=True) in the ServerArgs
dataclass. A declaration against a non-whitelisted field fails at its slot.
Load-time vs resolution-time (critical)
__post_init__ runs in the launcher process before any model/platform import. Logic that
consults an extensible registry (e.g. out-of-tree platforms registering attention
backends in init_backend(), which runs at model_runner import) must stay at load time
(ModelRunner init), writing through get_context().override(). Before moving any
load-time logic into resolution, verify everything it reads is already complete at
construction time.
Runtime flags (get_flags())
For state that init-time code derives and runtime code reads — parsed enums, platform probes, swappable ACTIVE values. Not for config mirrors (read the bag leaf instead).
- Groups are typed dataclasses on
Flags(capture/moe/dp): typo-safe writes, transactional test-onlyoverride(**kw)context manager. flags.moeis materialized byinitialize_moe_config()at scheduler init (it readsexec.moe/spec/model, and takes no record); accessors (get_moe_a2a_backendetc.) are thin shims with lazy defaults. The speculative contexts (speculative_moe_backend_context) swap the ACTIVE leaves around draft forwards.flags.dpis materialized byinitialize_dp_attention;is_dp_attention_enabled()is a shim overflags.dp.enabled.- Adding a leaf: declare the dataclass field with a default equal to the pre-init behavior, materialize it at the owning subsystem's init, keep any public accessor as a shim.
Resources (get_resources())
Named slots + two keyed-lazy registries:
get_stream(name)— get-or-create a named CUDA side stream;set_stream(name, stream)installs explicitly. Name leases by subsystem ROLE: all model alternate streams share"alt"; the offloader's copy stream is"offload"; DP-TBO comm is"dp_tbo_comm"; LoRA side stream is"lora_side". Two call sites may share a name only if their work belongs on one stream — sharing across roles serializes intended overlap.get_buffer(name, factory)— get-or-create a named persistent buffer. Grow-only or per-device semantics manage theirresources.buffersentries directly (see tokenspeed / SM120 split / Marlin workspace). Buffer names are per-backend today; do not silently share.- Singletons with manager semantics (EP dispatcher buffers, EPLB recorder/metadata, graph memory pool) keep their owning accessors/classes as facades; only the state lives in a resources entry. Preserve exact semantics in the shim: lazy defaults (the EPLB recorder defaults to a Noop instance, not None), publish-once asserts, event-reuse contracts.
- Stream/buffer creation is a driver call — it must happen outside cuda-graph capture; keep lease points at init/warmup time.
Per-forward flags (get_forward())
Contextvar-backed; a new thread sees the defaults; scoped(**kw) is the regular write path
(transactional, restores on exit and on exception); set(name, value) exists for legacy
sticky setters (is_extend_in_batch is intentionally sticky within a thread). Use this
tier for anything set-per-forward and read-within-forward. Before adding cross-thread
state here, prove the readers' thread affinity: contextvars do NOT propagate to already-
running or newly spawned threads. Note TBO ("two-batch overlap") interleaves ubatches on
ONE thread — do not design for TBO threads that don't exist.
Testing idioms
- Force a code path by overriding causes, not effects: compose
get_context().override_server_args(**fields)(publishes a fresh dummy-boundaryServerArgscarrying the overrides AND projects the bags —with-scoped, orinstall()/restore()+addCleanupfor fixture-lifetime use) +get_<ns>().override(...)(scoped override of one bag's own leaves) +get_parallel().override(...)(live topology) +get_flags().<group>.override(...)+get_forward().scoped(...). All are scoped and transactional. Tests control execution through the context — do not hand-build and publish config objects. - Never monkeypatch import bindings (
module.get_x = lambda: ...) and never fake a config source with aSimpleNamespacestand-in: production reads the published bags, so a faked accessor silently stops intercepting after any reader migration. Publish for real (override_server_args(...)), then adjust bag leaves with the scoped bagoverridewhere the constructedServerArgscannot carry the value (e.g.get_device().override(device="meta")). The one carve-out is the deliberate single-accessor stub for isolating one predicate — the terms and the two sanctioned examples live under "Test doubles publish, they do not inject" above; anything wider than one named accessor is this rule. - Mocked runners/managers still need the per-runner instance attributes the code
under test reads (
kv_cache_dtype_str,server_argsfor whole-object passes) — set them explicitly on the mock;MagicMock(spec=...)raises on attributes that only exist post-__init__, which is the fastest way to find a missed stub. reset_context()in teardown when a test publishes outside a scoped override.ServerArgs(model_path="dummy")early-returns the pipeline (few declarations, no strict guard) — fine for lightweight fixtures.- Asserting what resolution decided reads
resolution_result(sa, "field"), notsa.field: the field is the raw input. Assert the field only when the point of the case is that the record stayed pristine (the FA4 page-size and waterfill cases do exactly that, and say so). - Run changed test files per-file (own process), the way CI does: a monolithic local pytest run lets a context published by an earlier file mask a missing-publish bug in a later one.
Guardrails (these fail CI; what to do when they fire)
- Strict mutation guard (always on): bare
server_args.x = ...after resolution raises unconditionally inServerArgs.__setattr__— this is the guarantee that no writer can desync the bags, so there is no writer ratchet any more. Change resolved config withget_context().override; hand a per-runner value to its runner as a constructor argument. Projected bags are sealed the same way (leaf assignment raises). - Mutation ratchet (
test_server_args_mutation_ratchet.py, exact pin 0 over the whole package minus the pipeline / multimodal_gen): textual scan for assignment forms. Never raise the baseline. - No-copy contract (
test_server_args_no_instance_mutation_entry.py): neitherServerArgs.overridenorServerArgs.deriveexists, and nothing in the package calls either form. Rerouting a writer to the bags means flipping all its readers in the same commit (no transitional dual-write). - The legacy accessor is retired (
test_runtime_context.py): everyget_global_server_args()call now raises, because it answered with the record -- a caller reading a field resolution had decided got a stale value and no error. The replacement for a decision is a bag leaf, a named accessor, or the owning runner's stamp — notget_server_args().field, which the read ratchet below pins at zero.runtime_context.get_server_args()is only for the whole-object shapes (dumps, provenance, a hand-off to a callee that takes a config). - Global config read ratchet (
test_global_config_read_ratchet.py): baselines are 0 for both the directget_server_args().fieldand the alias form (function-local — including local copies of an alias,cfg = sa— module-level, or parked on an instance attribute, plus thegetattr(..., "field")spelling of each; a name computed at runtime or indirection deeper than a local name copy is census-tool territory, per the test's docstring). The scanner matchesget_server_argsby its literal name — bare or module-qualified (ctx.get_server_args()) — andTestNoRenamedAccessorImportsin the same file bansimport ... asrenames of it, which is what makes literal-name matching sound. Exempt by owner module only (runtime_context.py,server_args.py,arg_groups/). Two classes, no more:TestGlobalConfigReadRatchetholds the two baselines andTestNoRenamedAccessorImportsholds the ban. There is no configured-size registry here any longer —get_parallel()has one spelling per name, so a size read is not a choice between two answers and nothing needs registering. - Module-state ratchet (
test_module_state_ratchet.py):globalstatements in the flag-owning layers are pinned by name. A new module-level runtime global belongs on a flags group / resources slot instead; migrating a pinned survivor must shrink the pin. - Namespace coverage (
test_server_args_namespaces.py,test_runtime_context_config_bags.py): everyServerArgsfield resolves to a namespace — from thearg_groups/fields/class that declares it — and the projected bags must cover the fields exactly (two-way).
Never module-skip a test "until the migration settles" — seed the context instead (the deferral ratchet that once pinned this is retired; the rule stands).
Hard-won pitfalls (check these before/while refactoring)
- Moving code drops first-line guards: early returns (
if self.is_draft_worker: return) are the easiest thing to lose when relocating a method body. A draft is built from the target's published config — there is no draft config copy and no nested publish any more — so a body moved out of a draft-aware call site keeps reading the target's bags, and only that guard tells the two apart. What the draft build does scope is narrower and named:draft_model_build_scope()for the MoE fusion gates,speculative_moe_backend_context()for the runner backends. - Registry-completeness timing: a gate that consults an extensible list is only correct
after the registrars ran (platform
init_backend()at module import). See "load-time vs resolution-time". - Late function-scope imports shadow module names for the WHOLE function (UnboundLocalError at earlier lines). Audit moves with AST, not grep.
- Lease names are per-role, not per-API-shape (the offloader-vs-"alt" lesson).
- Storage matrix for state read inside torch.compile-traced model code
(piecewise cuda graph compiles the whole model forward): contextvars are
untraceable (hard error); dict-slot values are guarded per value — for a
per-forward int that is one recompile per distinct size, straight into the
recompile limit; class/instance attributes are the only compile-friendly
form (attribute-source ints get automatic-dynamic after the first size
change). Bools (≤2 values) are tolerable in any form — see
ForwardFlags._GRAPH_VISIBLE. Config-bag leaves are real instance attributes for exactly this reason. Parallel leaves are the exception that was measured rather than assumed: they come throughParallelContext.__getattr__, which traces undertorch.compile(fullgraph=True)(object.__getattribute__is the form that graph-breaks, and it is not on this path). Before moving such state, prove its readers sit outside compile coverage; a piecewise-prefill boot of a small model is the fast check (recompile storms show astorch._dynamo hit config.recompile_limitduring the compile pass). - Engine-booting e2e tests are the only coverage for launcher-path code; a child crash
kills the process tree and pytest dies silently — run with
PYTHONUNBUFFERED=1and read child logs. - CI arms
SGLANG_ENABLE_ASYNC_ASSERT=1(device-sidetorch._assert_asyncprobes, e.g. KV-cache OOB): a fired device assert kills the tree with no Python traceback, and the same bug is silent corruption locally with the flag off. Arm it when reproducing CI crashes. - CI startup logs print the full
server_args=ServerArgs(...); diffing that dump between runs is the fastest config-divergence check.
Where to read the code
Key source files: python/sglang/srt/runtime_context.py (the container, every tier,
publish, _ConfigBag, override_server_args),
python/sglang/srt/arg_groups/overrides.py (override registry, passes,
declare_late_resolution), python/sglang/srt/server_args.py (NS metadata,
Arg(..., resolvable=True), __setattr__ strict guard), and the guardrail tests under
test/registered/unit/ (test_server_args_mutation_ratchet.py,
test_global_config_read_ratchet.py,
test_module_state_ratchet.py, test_server_args_namespaces.py,
test_runtime_context.py — the last one doubles
as executable documentation of every tier's semantics).
版本历史
-
13469c1
当前 2026-09-09 15:47
移除废弃的get_global_server_args;重构并行配置声明,将商值纳入配置袋并简化派生逻辑。
-
803b4fb
2026-08-28 19:12
更新并行配置层级的拼写规范,明确作用域声明;细化配置解析与写入规则,强调 ServerArgs 仅作为原始输入。
- 1df78c2 2026-08-20 08:18


