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 Ray SchedulerActor, 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, TokenizerManager and MMEncoder call
assert_published and fail loudly if an entry forgot. The roles are enumerated once,
as the keys of ROLE_NAMESPACE_SETS — there is no launcher role, the launch
path publishes as tokenizer. The remaining non-publisher is
run_multi_detokenizer_router_process: it is handed a ServerArgs, and uses
it only for configure_logger(server_args) today, so it has nothing to publish
for — a bag read added under that entry needs a publish at the entry first.
publish projects the config bags from the declarations over the record's raw
fields; the accessors (get_exec() etc.) fail closed before it
runs. role records which process type published, and keys per-role namespace
enforcement: SGLANG_ROLE_NAMESPACES=record audits which namespaces each role's
process actually reads (per-pair persisted via SGLANG_ROLE_NAMESPACES_OUT;
reads inside torch.compile-traced code are NOT observed — audit with
compilation disabled before restricting a role), and
=enforce fails closed on bag reads outside the role's ROLE_NAMESPACE_SETS entry
(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_PATH it stands for, and ServerArgs
is assembled from them (collect_input_fields). The per-field NS("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 inside torch.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 the ServerArgs instance — 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 of
override. It resolves the flat name through the same NS map 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_leaf is 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
over get_context().override. One process keeps one log: the request dumps
ship get_context().overrides_log(), and config_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 direct override and needs the same ordering
judgment against any supplied-instance read of it
(test_supplied_instance_exposure_ratchet.py).
model_path and served_model_name are answered off the manager. Both are
NS leaves and override accepts them, but the tokenizer-side weight reload
records only load_format and writes the two path fields as TokenizerManager
attributes (_MANAGER_OWNED_FIELDS); config_value and resolved_config_dict
overlay 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_fields overrides model_path / load_format for
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 via arg_groups.overrides.declare_resolution(server_args, source, **fields), the same call the rest of the pipeline makes; there is no
declare_late_resolution any more. When a declaration is made is not
something the code marks — the guardrails that used to read that marker
cover these sites through the ordinary keyword scan instead. 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 to TpModelWorker / ModelRunner and live on the runner
(ModelRunner.draft_attention_backend, kv_cache_dtype_str, …); the encoder
DP worker's device is MMEncoder(gpu_id=...). There is no ServerArgs.derive
any more — a config object is never copied-and-edited; test doubles that need a
modified copy use sglang.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
ServerArgs any more. The
draft-worker config copy is gone: every worker (TpModelWorker, the draft
workers in speculative/) is handed the same instance the process published,
so a bag leaf is the decision and self.server_args.X is the operator's
input — a
post-publish override moves 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 bug linear_attn_backends replaced. The one sanctioned bend in that rule is
scoped: ModelRunner._load_format_scope exposes the draft's
--speculative-draft-load-format through get_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 on self.server_args ("several
Engines 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_id travels as a constructor argument (MMEncoder(gpu_id=...);
BaseMultimodalProcessor._fast_image_processor_device is 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-EP ep_size case in
eplb/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 the
get_*() bag, sometimes a runner stamp or a constructor argument (the per-mode
attention pair and the encode-server gpu_id above 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.py
pins that set (empty today) — three spellings of the read: server_args.field,
literal-name getattr(server_args, "field", default), and the parked form
(self.x = server_args in a method that takes the parameter, read as
self.x.field anywhere 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 a resolved_view (its parameter happens to be
named server_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:
CustomSpecAlgo is 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 the TypeError.
Guard: test_plugin_hook_signatures.py.
- Publish order inside a process entry, not per file. A file containing a
publish says nothing about whether a given read runs before it. Spawned
workers (MMEncoder for 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_SETS
narrows what each role may read; the DP controller is audited for exec
alone. A helper that reaches for another namespace passes every default-mode
test and aborts startup under SGLANG_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
Engine each 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_parallel aliases _MOE_DP to _ATTN_CP when attn_cp_size > moe_dp_size, so a reader that means the MoE communicator's width calls
get_moe_cp_size(), not get_parallel().moe_dp_size.
patch_tensor_parallel_group runs a scope under a different TP group (draft
workers), and declares it by overriding tp_size, tp_rank and tp_group
for the scope's duration. Readers inside need no special spelling.
- Elastic EP scales
ep_size / dp_size on 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 through NS and
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_context that
derives it from the bags. The strongest form of this is a Derived(fn=...)
declared beside the leaves it is computed from, in the namespace's own
arg_groups/fields/ class: publish computes 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_joiner and is_startup_weight_load_overlap are 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 its fn happens to read: the mamba one
spans exec.mamba and memory, which is exactly why it could not be a method
on either bag.) The
old mamba_extra_buffer_enabled() / is_ep_joiner() functions and the
same-named ServerArgs members 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 three exec.kernel leaves, and
max_speculative_num_draft_tokens() / cutedsl_moe_max_num_tokens() derive
theirs from spec / 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 is get_moe_cp_size() (the _MOE_DP = _ATTN_CP alias makes it
differ), and the effective DCP topology is get_parallel().attn_dcp_size /
.dcp_enabled (1 / False when 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_experts on 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;
GrammarManager is a handed instance for its residual self.server_args
reads, but backend selection is not on the instance any more:
create_grammar_backend reads get_exec().kernel.grammar_backend, and
__init__ calls that factory whenever skip_tokenizer_init is false. In
production the scheduler process has published; a test that constructs one
without publishing has to keep patching the factory (or publish itself);
MMEncoder publishes the very instance it is handed (publish(server_args, role="encoder")) and takes its per-worker device as a separate gpu_id
argument. Its self.server_args reads 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-publish override both 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 as
cfg = 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. So a resolved record is not copied at
all. A caller that needs one field different for the process it is about to
hand the record to — the Ray paths and their dist_init_addr — declares it on
the record it holds (declare_resolution) and hands that over: the declaration
travels inside the object, the receiving process projects its bags from it, and
nothing re-resolves. There is no ServerArgs.replace_resolved any more, and the
model_config-memo bug that copying used to cause (a copy marked resolved but
arriving without the memo cannot refill it, because the guard refuses the write)
is gone by construction rather than guarded.
A bag override cannot stand in for this. It is not because overriding needs
a publish — set_server_args is what projects the bags and override works as
soon as the context holds a record — but because override writes bag leaves
and by contract never touches the record, so its effect cannot travel inside an
object to another process.
The declaration stash has one writer
Everything that decides configuration goes through
declare_resolution(server_args, source, **fields). It validates the names,
refuses the published config (the stash is projected at publish and never
again, so a later declaration is a silent no-op), and appends. The other names
around it are spellings, not mechanisms:
| name |
what it adds |
run_post_process_pass |
runs a pass at its slot and validates its return; declares through declare_resolution. A pass returning an empty dict is a validation, not a declaration, and stays legal on the published instance — Engine(server_args=sa) after Engine.shutdown() re-runs check_server_args on the very instance the context holds |
record_foreign_defaults |
for a resolver this tree does not own (an out-of-tree platform plugin, a registered speculative algorithm), whose interface is to assign fields. It gets a stand-in whose reads fall through to resolving_view; what it assigned is declared. The record is never written, so the write seal has no exception. In-tree code does not go through it — handle_platform_defaults wraps the platform hook, and the in-tree speculative dispatcher is called directly, because handed the stand-in its own declare_resolution calls would stash on that instead |
resolution_projection is gone; the whole-object readback is
ServerArgs.resolved_dict(), which is what /server_info and its gRPC and
in-process twins report.
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 pristine server_args + hf_config and must not write.
- Normalization that must see earlier declarations → a post-process pass invoked via
run_post_process_pass at 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_override any 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, and
install_shared_experts_fusion_decision writes 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_scope brackets 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 via
get_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-only override(**kw) context manager.
flags.moe is materialized by initialize_moe_config() at scheduler init (it
reads exec.moe / spec / model, and takes no record);
accessors (get_moe_a2a_backend etc.) are thin shims with lazy defaults. The speculative
contexts (speculative_moe_backend_context) swap the ACTIVE leaves around draft forwards.
flags.dp is materialized by initialize_dp_attention; is_dp_attention_enabled() is a
shim over flags.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 their resources.buffers entries 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-boundary
ServerArgs carrying the overrides AND projects the bags — with-scoped, or
install()/restore() + addCleanup for 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 a SimpleNamespace stand-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 bag
override where the constructed ServerArgs cannot 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_args for 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"), not
sa.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, and with no exception): bare
server_args.x = ... after resolution raises unconditionally in
ServerArgs.__setattr__ — the named lift that out-of-tree plugins used to
ask for is gone, they assign onto a stand-in instead — this is the guarantee that
no writer can desync the bags, so there is no writer ratchet any more. Change
resolved config with get_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): neither
ServerArgs.override nor ServerArgs.derive exists, 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
…(truncated)
1---2name: sglang-runtime-context3description: 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.4---56# SGLang runtime-context architecture78One container owns process-static runtime state: `sglang.srt.runtime_context.RuntimeContext`9(a process singleton reached via `get_context()`). Everything below is a tier on it.1011| Tier | Accessor | Holds | Lifecycle |12|------|----------|-------|-----------|13| 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 |14| 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` |15| 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 |16| 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()` |17| 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 |18| 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 |1920`reset_context()` (unit-test teardown) drops the published config and installs fresh21flags/resources/forward tiers.2223## Config: publish + namespace bags2425**`ServerArgs` holds the raw input and nothing else. Resolution writes no field:26it declares, and the declarations are what the namespace bags are projected from.27Business code never reads the record for a decision: a field read there answers28with what the operator typed, not with what resolution decided.**2930- Every publishing process entry calls `publish(server_args, role=...)`31 (`run_scheduler_process`, the Ray `SchedulerActor`, the DP controller, tokenizer,32 detokenizer, encoder, weight-cache daemon, the multi-tokenizer worker, the33 spawned encoder TP/DP workers, the benchmark work functions, ...); constructors34 do not publish — `ModelRunner`, `TokenizerManager` and `MMEncoder` call35 `assert_published` and fail loudly if an entry forgot. The roles are enumerated once,36 as the keys of `ROLE_NAMESPACE_SETS` — there is no `launcher` role, the launch37 path publishes as `tokenizer`. The remaining non-publisher is38 `run_multi_detokenizer_router_process`: it *is* handed a `ServerArgs`, and uses39 it only for `configure_logger(server_args)` today, so it has nothing to publish40 for — a bag read added under that entry needs a `publish` at the entry first.41 `publish` projects the config bags from the declarations over the record's raw42 fields; the accessors (`get_exec()` etc.) fail closed before it43 runs. `role` records which process type published, and keys per-role namespace44 enforcement: `SGLANG_ROLE_NAMESPACES=record` audits which namespaces each role's45 process actually reads (per-pair persisted via `SGLANG_ROLE_NAMESPACES_OUT`;46 reads inside torch.compile-traced code are NOT observed — audit with47 compilation disabled before restricting a role), and48 `=enforce` fails closed on bag reads outside the role's `ROLE_NAMESPACE_SETS` entry49 (`None` = full tree; only audited roles are restricted).50- Bag membership is **where the field is declared**: one class per namespace under51 `arg_groups/fields/`, each carrying the `_NS_PATH` it stands for, and `ServerArgs`52 is assembled from them (`collect_input_fields`). The per-field `NS("path")` marker53 survives only for a class that cannot express this — an ad-hoc dataclass spanning54 namespaces, which is what the config-bag tests build. Coverage is linted two-way55 (`test_server_args_namespaces.py`, `test_runtime_context_config_bags.py`).56- **Reading config**: `get_<ns>()[.sub].field` — e.g.57 `get_exec().moe.moe_a2a_backend`, `get_schedule().max_running_requests`. Bag leaves58 are plain instance attributes, safe inside `torch.compile`-traced code.59- **Mutating config after publish**: the ONLY entry point is60 `get_context().override(source, **fields)`. It writes the bag leaves in place61 (namespace readers see the new value) and records provenance in the overrides log.62 There is **no write-through** to the `ServerArgs` instance — it stays pristine.63 There is no in-place mutation entry on the instance at all: it is read-only after64 resolution.65- **Reading a leaf when the caller holds the field *name*** (a readback endpoint,66 a control-plane handler): `get_context().config_leaf(name)` — the read side of67 `override`. It resolves the flat name through the same `NS` map the write side68 uses and raises on a name that is not a config leaf. Code that knows its field69 when it is written reads the bag leaf directly; `config_leaf` is for70 name-driven code, not a way around the seed ratchet.71- **Post-startup control-plane changes** — a weight update, a HiCache mirror72 attach, a parser resolved from the chat template — go through73 `TokenizerManager.record_config_updates(source, **fields)`, a named wrapper74 over `get_context().override`. One process keeps one log: the request dumps75 ship `get_context().overrides_log()`, and `config_value(name)` /76 `resolved_config_dict(base)` answer from the bags. The exposure ratchet77 resolves the wrapper, so a field recorded through it joins the post-publish78 override surface exactly like a direct `override` and needs the same ordering79 judgment against any supplied-instance read of it80 (`test_supplied_instance_exposure_ratchet.py`).81- **`model_path` and `served_model_name` are answered off the manager.** Both are82 `NS` leaves and `override` accepts them, but the tokenizer-side weight reload83 records only `load_format` and writes the two path fields as `TokenizerManager`84 attributes (`_MANAGER_OWNED_FIELDS`); `config_value` and `resolved_config_dict`85 overlay them on top of the bags. Bags do not cross a process boundary (above),86 so recording those two in the tokenizer process would leave every other87 process's bag on the old path while the log claimed a process-wide change. The88 scheduler rewrites its own copy where the reload happens —89 `ModelRunner.update_model_fields` overrides `model_path` / `load_format` for90 the target runner.91- **Late launcher-stage resolution (pre-publish)**: a few rules cannot run inside92 `__post_init__` — LoRA normalization, and the auto-parser detection that needs a93 tokenizer/chat-template load. They are resolution, not mutation, and they94 **declare** via `arg_groups.overrides.declare_resolution(server_args, source,95 **fields)`, the same call the rest of the pipeline makes; there is no96 `declare_late_resolution` any more. *When* a declaration is made is not97 something the code marks — the guardrails that used to read that marker98 cover these sites through the ordinary keyword scan instead. The declaration lands99 in the stash on that very object, so every holder of it carries the decision —100 the HTTP server, the multi-tokenizer workers it is serialized for, the101 schedulers it forks — and each of them publishes bags projected from it. The102 fields stay the operator's input; `resolution_result(sa, field)` and the bags103 are what answer for the decision. Returning a variant here is a bug: the104 launcher rebinds its local and everyone else keeps the unresolved object.105- **A value another runner / worker owns is a constructor argument, not a config106 copy.** The draft worker's `context_length`, load format and attention backend107 travel as arguments to `TpModelWorker` / `ModelRunner` and live on the runner108 (`ModelRunner.draft_attention_backend`, `kv_cache_dtype_str`, …); the encoder109 DP worker's device is `MMEncoder(gpu_id=...)`. There is no `ServerArgs.derive`110 any more — a config object is never copied-and-edited; test doubles that need a111 modified copy use `sglang.test.test_utils.server_args_variant`.112113**Why a bag override cannot stand in for late resolution or per-runner114construction.** The bags are projected at115publish *from the declarations over the instance's raw fields*, so anything the116runtime must read has to be declared before publish — an override afterwards puts instance and bags back out117of agreement, and whole-object readers (`ModelConfig.from_server_args`,118`build_load_config`, `MMEncoder`'s own `self.server_args.X`) never see it. And bags do119not cross a process boundary: a child publishes from the object it receives and120re-projects its own bags, so a parent-side override is lost. Values that feed121construction before any bag exists (group init reads `server_args.tp_size`) have no122bag to override at all.123124125### Reads that legitimately stay on a `ServerArgs` instance126127- **Per-runner values** — there is no per-runner `ServerArgs` any more. The128 draft-worker config copy is gone: every worker (`TpModelWorker`, the draft129 workers in `speculative/`) is handed the *same* instance the process published,130 so a bag leaf is the decision and `self.server_args.X` is the operator's131 input — a132 post-publish `override` moves only the bag, which is exactly why a field that133 is process-wide config (`attention_backend`, `skip_tokenizer_init`,134 `kv_cache_dtype`) reads from the bags like any other, and why a residual135 instance read on this path is stale the moment someone overrides that leaf.136 What is genuinely per-runner travels two ways, neither of them a config137 instance: **constructor arguments** (`ModelRunner(draft_attention_backend=...)`,138 `MMEncoder(gpu_id=...)`) and **runner attributes holding the resolved value**139 (`model_runner.kv_cache_dtype_str`, `prefill_attention_backend_str`,140 `num_fused_shared_experts`, `linear_attn_backends`) — threaded to consumers as141 arguments, never backfilled onto a shared object. A per-runner choice also stays142 *out* of the bags: recording it there is how a second runner inherits the first143 one's answer, which is exactly the bug `linear_attn_backends` replaced. The one sanctioned bend in that rule is144 *scoped*: `ModelRunner._load_format_scope` exposes the draft's145 `--speculative-draft-load-format` through `get_model().override(load_format=...)`146 for exactly the duration of the draft build, because model construction147 reads that bag leaf — the override restores on exit, so nothing outlives148 the scope. When there is a runner in hand, read its149 stamp; that is a different rule from "read the instance".150- **Per-instance boundaries** — the tokenizer-manager family, everything under151 `entrypoints/`, and the tokenizer-process multimodal processors read the bags.152 The old justification for keeping them on `self.server_args` ("several153 `Engine`s can share one process, bags are last-publish-wins across them") is154 **retracted** — owner ruling (2026-08-15): a process holds at most one live155 config at a time (concurrent multi-Engine is unsupported; sequential rebuild156 stays legal, unit tests rely on it). Nothing in those files reads the instance157 any more -- the exposure ratchet's pin set is empty, so the next such read is a158 new entry that has to argue for itself. What159 genuinely stays per-instance is what differs per *worker* within one engine:160 `base_gpu_id` travels as a constructor argument (`MMEncoder(gpu_id=...)`;161 `BaseMultimodalProcessor._fast_image_processor_device` is the shape to copy).162- **Whole-object passes** (`f(server_args)` handing the instance along) keep the163 supplied-instance contract; don't rewrite the parameter reads unless the164 field is runtime-mutated (see the elastic-EP `ep_size` case in165 `eplb/expert_location.py`) — **or the field is one that resolution fills in166 and the callee runs in a process that has published.** That second case is a167 decision, not a style question: the record carries the user's raw input, so a168 resolution-filled field read off it inside a runner-owned constructor answers169 with the pre-resolution value instead of the effective one. The answer is not170 automatically a bag read: pick where the value should come from — usually the171 `get_*()` bag, sometimes a runner stamp or a constructor argument (the per-mode172 attention pair and the encode-server `gpu_id` above are both this). The per-instance173 boundaries above are **not** exempt from this unless-clause (the multi-Engine174 exemption is retracted); each one gets its own disposition.175 `test_supplied_instance_exposure_ratchet.py`176 pins that set (empty today) — three spellings of the read: `server_args.field`,177 literal-name `getattr(server_args, "field", default)`, and the parked form178 (`self.x = server_args` in a method that takes the parameter, read as179 `self.x.field` anywhere in the class) — and fails on a new one, so the180 disposition gets picked when the read is written. Two shapes stay parameter-form on purpose: a helper the181 *resolution pipeline* calls with a `resolved_view` (its parameter happens to be182 named `server_args`), and a factory whose contract is "build X from the record183 you are handed" (`create_kt_config_from_server_args`, `DllmConfig.from_server_args`).184185### Four ways a config sweep breaks something no test runs186187Each of these shipped in a review round and cost a real defect; each now has a188guard, named here so the next sweep checks the same four things by hand first.1891901. **The other implementations of an interface.** Dropping a parameter means191 auditing implementers, not just callers: `CustomSpecAlgo` is the plugin192 base for speculative algorithms, and the dispatch calls it with the193 built-in's argument list. Nothing in the tree implements it, so only a194 plugin user hits the `TypeError`.195 Guard: `test_plugin_hook_signatures.py`.1962. **Publish order inside a process entry, not per file.** A file containing a197 `publish` says nothing about whether a given read runs before it. Spawned198 workers (`MMEncoder` for encoder DP/TP, the Ray scheduler actor) start with199 an empty context, so a bag read above the publish raises only there.200 Guard: `test_publish_precedes_bag_reads.py`.2013. **The role namespace a process publishes under.** `ROLE_NAMESPACE_SETS`202 narrows what each role may read; the DP controller is audited for `exec`203 alone. A helper that reaches for another namespace passes every default-mode204 test and aborts startup under `SGLANG_ROLE_NAMESPACES=enforce`. Prefer205 answering from the caller's own namespaces over widening the set.2064. **Sibling surfaces of a readback.** Changing what one entry point reports207 means enumerating the others: HTTP, gRPC and in-process `Engine` each have208 their own server-info and model-info, and each passes its own tests while209 its users lose the field.210 Guard: `test_effective_state_surfaces.py`.211212A fifth, from the same rounds: the accessor **name itself**. Called as an213object member (`manager.get_disagg()`), or shadowed by a same-named import214(`from model_loader import get_model` next to the model bag, where the later215import silently wins and the loader call gets a zero-argument bag), it imports216fine and fails only when that path runs. The invariant is one line: the name217means the process-wide bag, takes no arguments, and is bound once per module.218`ruff --select F811` catches the import collision; the member-call shapes are an219`AttributeError` at call time only because `RuntimeContext` has no bag-named220member and no `__getattr__` -- a delegating `__getattr__` would make them221silent, and that is when this needs a guard again rather than a rule.222223Write these guards over a **derived** set, never a hand-kept list: an entry224naming a function that no longer exists, or a field list missing the one field225nobody migrated, passes green forever. Both happened here -- a `_ENTRY_POINTS`226row for a method the Ray actor does not have, and an effective-field set227without `load_format` -- and both were invisible because the assertion had228slack (`>= len(...) - 1`) or compared key names instead of value sources.229230### `get_parallel()`: one spelling per name231232**There is no `.config` hop.** Ranks and group handles are `@property`233read-through over the canonical getters, so they answer with the live process234groups. Everything else — `tp_size`, `pp_size`, `attn_cp_size`, `dcp_size`,235`moe_dp_size` included, alongside config-only leaves like `nccl_port`,236`enable_dp_attention`, `dp_size`, `ep_size`, `dwdp_size` — is answered from the237published `parallel` bag. Reading a leaf before publish raises a `ValueError`238naming the namespace; an unknown name is an `AttributeError`.239240A size reads from the configuration because the groups are built at exactly the241configured widths — checked at every assignment to `_TP` / `_PP` / `_ATTN_CP` /242`_DCP` / `_MOE_DP` in `parallel_state.py`. Three things do not follow that rule:243244- `initialize_model_parallel` aliases `_MOE_DP` to `_ATTN_CP` when `attn_cp_size >245 moe_dp_size`, so a reader that means **the MoE communicator's width** calls246 `get_moe_cp_size()`, not `get_parallel().moe_dp_size`.247- `patch_tensor_parallel_group` runs a scope under a different TP group (draft248 workers), and declares it by overriding `tp_size`, `tp_rank` and `tp_group`249 for the scope's duration. Readers inside need no special spelling.250- Elastic EP scales `ep_size` / `dp_size` on the published bag while the group251 coordinators keep their construction width. Those are different names, not two252 answers to one name.253254DCP keeps its own pair: `get_parallel().attn_dcp_size` / `.dcp_enabled` answer the255*effective* topology (`1` / `False` with no group installed), while `dcp_size` is256what the launch requested.257258A process-global seed field-read of one of these sizes259(`get_server_args().tp_size`, or an alias of it) is a read-ratchet failure. A260`server_args` the object was *handed* is a different thing and not a ratchet261matter — see "Reads that legitimately stay on a ServerArgs instance".262Fail-loud is narrower: before dist init, a live *rank/group* read raises. The six263parallel quotients are not live reads at all — `attn_tp_size`, `attn_dp_size`,264`attn_dcp_size`, `moe_ep_size`, `moe_tp_size`, `dcp_enabled` are a function of the265configured leaves, computed once at publish into bag leaves, and answered266override → stamp → published leaf. So `dcp_enabled` means "the launch configured267DCP" (`dcp_size > 1`), not "a DCP group is installed here"; in a scheduler the268stamp makes the two identical, in a process that publishes without dist init they269differ. `test_a_topology_is_stated_by_naming_the_width` and its neighbours in270`test_runtime_context.py` pin this; they replaced271`test_attn_dcp_defaults_when_group_is_uninitialized`. One consequence for tests:272overriding a leaf no longer moves its quotient — state a topology by publishing a273config, or by naming the width. After init,274only the DCP group is optional (`_DCP` exists only when `dcp_size > 1`; attn-CP and275moe-DP always install, as size-1 aliases if unused). The `config` hop is276deliberately dynamo-traceable (a plain property over a slot, no277`object.__getattribute__`); gate helpers like `enable_moe_dense_fully_dp()` run inside278compiled model forwards (`test_parallel_config_leaves_trace_under_torch_compile` pins279this).280281A third surface carries the same names: `ParallelState` (`self.ps` / `mr.ps`), the282frozen per-process snapshot built once in `Scheduler.__init__` from these configured283sizes plus this process's ranks, and handed down (draft runners included). Prefer it284where an object was handed one; it is not a global accessor.285286### Reading config: the seed is off limits287288`get_server_args().field` in business code is a ratchet failure. Read:289290- **a resolved leaf** → its namespace bag (`get_exec().moe.moe_runner_backend`,291 `get_schedule().chunked_prefill_size`, …). Bag-backed reads — a leaf directly, or292 a bag-derived accessor below — are what see post-publish overrides. Only the293 instance-derived accessors (the ones with no leaf to read) answer from the294 startup record and therefore do not.295- **a leaf the caller names at runtime** (a readback reporting a list of fields)296 → `get_context().config_leaf(name)`; it resolves the name through `NS` and297 raises on a non-leaf. A call site that knows its field reads the bag leaf.298- **the live topology** → `get_parallel()` (bare names).299- **a value derived from published leaves** → an accessor in `runtime_context` that300 derives it *from the bags*. The strongest form of this is a `Derived(fn=...)`301 declared beside the leaves it is computed from, in the namespace's own302 `arg_groups/fields/` class: `publish` computes it once and stores it as an303 ordinary bag leaf, so the read is a plain attribute load and it sees304 post-publish overrides. `enable_mamba_extra_buffer`, `is_ep_joiner`,305 `is_ep_scale_joiner` and `is_startup_weight_load_overlap` are declared that way306 now — read them where they are declared:307 `get_exec().mamba.enable_mamba_extra_buffer`, `get_exec().moe.is_ep_joiner`,308 `get_model().is_startup_weight_load_overlap`. (The namespace is the class that309 declares the field, not the namespaces its `fn` happens to read: the mamba one310 spans `exec.mamba` and `memory`, which is exactly why it could not be a method311 on either bag.) The312 old `mamba_extra_buffer_enabled()` / `is_ep_joiner()` functions and the313 same-named `ServerArgs` members are gone. The pre-publish helpers that remain314 exist for resolution, which has no bag to read yet. `attention_backends()` derives the315 `(prefill, decode)` pair from the three `exec.kernel` leaves, and316 `max_speculative_num_draft_tokens()` / `cutedsl_moe_max_num_tokens()` derive317 theirs from `spec` / `schedule` / `exec.graph`.318- **a value only the instance can compute** → the named accessor in319 `runtime_context`, which is the one module allowed to read the slot:320 `mamba_cache_chunk_size()`, `mamba_state_chunk_size()`, `uses_mla_backend()`,321 `process_model_config()`.322 These have no leaf to read — they combine several fields, the HF config, or a323 property with no bag of its own. A new derived member gets an accessor here324 rather than call sites reaching for the record, and only when the bag-derived325 shape above cannot express it.326- **a parallel size** → `get_parallel().{tp,pp,moe_dp,attn_cp,dcp}_size`, which is327 the parallel bag's own leaf: it answers with the resolved configuration and328 follows a post-publish override. Two questions are *not* that, and have their329 own spelling: the width of the MoE communicator you are about to collectively330 operate on is `get_moe_cp_size()` (the `_MOE_DP = _ATTN_CP` alias makes it331 differ), and the effective DCP topology is `get_parallel().attn_dcp_size` /332 `.dcp_enabled` (`1` / `False` when no group is installed), which does not need333 dist init to answer.334- **this runner's resolved value** → the runner335 (`prefill_attention_backend_str`, `kv_cache_dtype_str`,336 `draft_attention_backend`, `num_fused_shared_experts` on the model).337338`self.server_args.field` is still right for handed per-instance config (see339"Reads that legitimately stay on a ServerArgs instance" above for the full set —340per-instance boundaries and whole-object passes; there are no per-runner config341copies to read any more). The allow-list is `GrammarManager` and `MMEncoder`;342what sits beside it is residue, not a family — and not for one single reason:343344- the tokenizer-manager family and `entrypoints/` **read the bags**; what is345 left of them in the exposure ratchet is a handful of individually-dispositioned346 pairs, not a family awaiting conversion. Read the ratchet for the current set347 rather than assuming a directory is off-limits;348- `GrammarManager` is a handed instance for its residual `self.server_args`349 reads, but backend selection is **not** on the instance any more:350 `create_grammar_backend` reads `get_exec().kernel.grammar_backend`, and351 `__init__` calls that factory whenever `skip_tokenizer_init` is false. In352 production the scheduler process has published; a test that constructs one353 without publishing has to keep patching the factory (or publish itself);354- `MMEncoder` publishes the very instance it is handed (`publish(server_args,355 role="encoder")`) and takes its per-worker device as a separate `gpu_id`356 argument. Its `self.server_args` reads are on this list as a construction-path357 convention, and the residual is real: they answer with the raw input, so a leaf358 resolution decided and a post-publish `override` both pass them by.359360Their tests are not one story: a `GrammarManager` built standalone turns the361factory's bag read into "config namespace not published" unless the test patches362it or publishes, while `MMEncoder` publishes in its own `__init__` and so needs363no such arrangement.364365**Test doubles publish, they do not inject.** A stand-in that carries366`server_args=SimpleNamespace(field=...)` stops working the moment production reads367the bag; seed the value with `override_server_args`, which publishes only once it is368entered or installed — the bare call just builds the override:369370```python371override = get_context().override_server_args(field=...)372override.install()373self.addCleanup(override.restore) # or: with get_context().override_server_args(...):374```375376Five separate test files learned this the hard way during the sweep.377378The rule is about a double standing in for **config**: a `SimpleNamespace` that379pretends to be `server_args`. Prefer the context override even where a380single-accessor stub would work — `override_server_args(...)` composed with the381scoped bag / `get_parallel()` overrides expresses the *cause* (the configuration)382rather than pinning one helper's answer, and it keeps working when a reader383migrates between the accessor and the leaf. The sweep converted the last two384accessor stubs to exactly that shape (`test_attention_patching.py` publishes the385non-lazy strategy; `test_kimi_k3_vision.py` publishes `tp_size` and forces the386live topology through `get_parallel().override`), so no test stubs an accessor387today. Stubbing one *named accessor* remains a last resort for a case that388isolates one branch of one helper where no published config can reach it —389if you do it, say so in the test.390391### Mid-resolution reads (inside the pipeline only)392393Resolution runs in `__post_init__` and **writes nothing onto the record**: a394handler declares (`self._declare` / `declare_resolution`), the declaration goes395into the stash, and the fields keep what the caller passed. So a mid-resolution396read of a field answers with the *raw input* — every reader in the pipeline goes397through a view instead:398399- `resolving_view(server_args)` / `self._resolved()` — the live view (walks the400 stash per read). This is what handlers and hooks bind, conventionally as401 `cfg = resolving_view(self)` at the top of the handler.402- `resolved_view(server_args)` — snapshots the overlay when built, which is what403 a post-process pass wants: it reads the state at *its* slot.404405`test_resolution_reads_the_declarations` pins direct field reads at zero over the406two scopes it can derive exactly (every `arg_groups` function taking a config,407every `ServerArgs` handler the dispatcher reaches). Readers the pipeline calls408from elsewhere (`ModelConfig`, the platform defaults, the spec-algo hook) have409moved to the view as well — a field read there is the same bug, just one the410derivation cannot enumerate.411412One consequence worth knowing: because the fields are the raw input, resolving a413bare `dataclasses.replace` copy lands in the same place as the parent — the414pipeline reads only its own input. **So a resolved record is not copied at415all.** A caller that needs one field different for the process it is about to416hand the record to — the Ray paths and their `dist_init_addr` — declares it on417the record it holds (`declare_resolution`) and hands that over: the declaration418travels inside the object, the receiving process projects its bags from it, and419nothing re-resolves. There is no `ServerArgs.replace_resolved` any more, and the420`model_config`-memo bug that copying used to cause (a copy marked resolved but421arriving without the memo cannot refill it, because the guard refuses the write)422is gone by construction rather than guarded.423424A bag `override` cannot stand in for this. It is *not* because overriding needs425a publish — `set_server_args` is what projects the bags and `override` works as426soon as the context holds a record — but because `override` writes bag leaves427and by contract never touches the record, so its effect cannot travel inside an428object to another process.429430### The declaration stash has one writer431432Everything that decides configuration goes through433`declare_resolution(server_args, source, **fields)`. It validates the names,434refuses the published config (the stash is projected at publish and never435again, so a later declaration is a silent no-op), and appends. The other names436around it are spellings, not mechanisms:437438| name | what it adds |439|---|---|440| `run_post_process_pass` | runs a pass at its slot and validates its return; declares through `declare_resolution`. A pass returning an **empty** dict is a validation, not a declaration, and stays legal on the published instance — `Engine(server_args=sa)` after `Engine.shutdown()` re-runs `check_server_args` on the very instance the context holds |441| `record_foreign_defaults` | for a resolver this tree does not own (an out-of-tree platform plugin, a registered speculative algorithm), whose interface is to *assign* fields. It gets a stand-in whose reads fall through to `resolving_view`; what it assigned is declared. The record is never written, so the write seal has no exception. In-tree code does not go through it — `handle_platform_defaults` wraps the platform hook, and the in-tree speculative dispatcher is called directly, because handed the stand-in its own `declare_resolution` calls would stash on that instead |442443`resolution_projection` is gone; the whole-object readback is444`ServerArgs.resolved_dict()`, which is what `/server_info` and its gRPC and445in-process twins report.446447### Adding a model-specific config adjustment448449Never assign `server_args` fields from model code. Declare instead450(`sglang/srt/arg_groups/overrides.py`):451452- Constant per-arch values → `MODEL_OVERRIDES["MyArchForCausalLM"] = {...}`.453- Derived values → `@register_model_override("MyArchForCausalLM")` returning a dict; the454 callable receives *pristine* `server_args` + `hf_config` and must not write.455- Normalization that must see earlier declarations → a post-process pass invoked via456 `run_post_process_pass` at its slot (reads a view, returns a declaration dict).457- Values only knowable at load time are **per-runner state**, not declarations:458 there is no `declare_load_time_override` any more. A model-family decision that459 its checkpoint drives (shared-experts fusion) is a question the *loader* asks460 the model class — `shared_experts_fusion_disable_reason(hf_config,461 quant_config)`, a classmethod answering without an instance — at the single462 model-instantiation point, and463 `install_shared_experts_fusion_decision` writes the answer to the ACTIVE moe464 flag before that model's layers build and read it465 (`is_shared_experts_fusion_disabled`, config-intent fallback).466 `draft_model_build_scope` brackets every draft build and routes the draft's467 answer to the speculative leaf, so a draft's decision never overwrites the468 target's. A process-level load-time fact (the sm80 dtype fallback —469 device-driven, identical for every runner) records directly via470 `get_context().override`.471472Declarable fields form a whitelist: `Arg(..., resolvable=True)` in the `ServerArgs`473dataclass. A declaration against a non-whitelisted field fails at its slot.474475### Load-time vs resolution-time (critical)476477`__post_init__` runs in the launcher process before any model/platform import. Logic that478consults an **extensible registry** (e.g. out-of-tree platforms registering attention479backends in `init_backend()`, which runs at `model_runner` import) must stay at load time480(ModelRunner init), writing through `get_context().override()`. Before moving any481load-time logic into resolution, verify everything it reads is already complete at482construction time.483484## Runtime flags (`get_flags()`)485486For state that init-time code *derives* and runtime code reads — parsed enums, platform487probes, swappable ACTIVE values. Not for config mirrors (read the bag leaf instead).488489- Groups are typed dataclasses on `Flags` (`capture` / `moe` / `dp`): typo-safe writes,490 transactional test-only `override(**kw)` context manager.491- `flags.moe` is materialized by `initialize_moe_config()` at scheduler init (it492 reads `exec.moe` / `spec` / `model`, and takes no record);493 accessors (`get_moe_a2a_backend` etc.) are thin shims with lazy defaults. The speculative494 contexts (`speculative_moe_backend_context`) swap the ACTIVE leaves around draft forwards.495- `flags.dp` is materialized by `initialize_dp_attention`; `is_dp_attention_enabled()` is a496 shim over `flags.dp.enabled`.497- Adding a leaf: declare the dataclass field with a default equal to the pre-init behavior,498 materialize it at the owning subsystem's init, keep any public accessor as a shim.499500## Resources (`get_resources()`)501502Named slots + two keyed-lazy registries:503504- `get_stream(name)` — get-or-create a named CUDA side stream; `set_stream(name, stream)`505 installs explicitly. **Name leases by subsystem ROLE**: all model alternate streams share506 `"alt"`; the offloader's copy stream is `"offload"`; DP-TBO comm is `"dp_tbo_comm"`; LoRA507 side stream is `"lora_side"`. Two call sites may share a name only if their work belongs508 on one stream — sharing across roles serializes intended overlap.509- `get_buffer(name, factory)` — get-or-create a named persistent buffer. Grow-only or510 per-device semantics manage their `resources.buffers` entries directly (see tokenspeed /511 SM120 split / Marlin workspace). Buffer names are per-backend today; do not silently512 share.513- Singletons with manager semantics (EP dispatcher buffers, EPLB recorder/metadata, graph514 memory pool) keep their owning accessors/classes as facades; only the *state* lives in a515 resources entry. Preserve exact semantics in the shim: lazy defaults (the EPLB recorder516 defaults to a Noop instance, not None), publish-once asserts, event-reuse contracts.517- Stream/buffer creation is a driver call — it must happen outside cuda-graph capture;518 keep lease points at init/warmup time.519520## Per-forward flags (`get_forward()`)521522Contextvar-backed; a new thread sees the defaults; `scoped(**kw)` is the regular write path523(transactional, restores on exit and on exception); `set(name, value)` exists for legacy524sticky setters (`is_extend_in_batch` is intentionally sticky within a thread). Use this525tier for anything set-per-forward and read-within-forward. Before adding cross-thread526state here, prove the readers' thread affinity: contextvars do NOT propagate to already-527running or newly spawned threads. Note TBO ("two-batch overlap") interleaves ubatches on528ONE thread — do not design for TBO threads that don't exist.529530## Testing idioms531532- **Force a code path by overriding causes, not effects**: compose533 `get_context().override_server_args(**fields)` (publishes a fresh dummy-boundary534 `ServerArgs` carrying the overrides AND projects the bags — `with`-scoped, or535 `install()`/`restore()` + `addCleanup` for fixture-lifetime use) +536 `get_<ns>().override(...)` (scoped override of one bag's own leaves) +537 `get_parallel().override(...)` (live topology) + `get_flags().<group>.override(...)` +538 `get_forward().scoped(...)`. All are scoped and transactional. Tests control execution539 through the context — do not hand-build and publish config objects.540- **Never monkeypatch import bindings** (`module.get_x = lambda: ...`) and never fake a541 config source with a `SimpleNamespace` stand-in: production reads the published bags,542 so a faked accessor silently stops intercepting after any reader migration. Publish543 for real (`override_server_args(...)`), then adjust bag leaves with the scoped bag544 `override` where the constructed `ServerArgs` cannot carry the value (e.g.545 `get_device().override(device="meta")`). The one carve-out is the deliberate546 single-accessor stub for isolating one predicate — the terms and the two547 sanctioned examples live under "Test doubles publish, they do not inject"548 above; anything wider than one named accessor is this rule.549- Mocked runners/managers still need the **per-runner instance attributes** the code550 under test reads (`kv_cache_dtype_str`, `server_args` for whole-object passes) — set551 them explicitly on the mock; `MagicMock(spec=...)` raises on attributes that only552 exist post-`__init__`, which is the fastest way to find a missed stub.553- `reset_context()` in teardown when a test publishes outside a scoped override.554- `ServerArgs(model_path="dummy")` early-returns the pipeline (few declarations, no555 strict guard) — fine for lightweight fixtures.556- **Asserting what resolution decided reads `resolution_result(sa, "field")`**, not557 `sa.field`: the field is the raw input. Assert the field only when the point of558 the case *is* that the record stayed pristine (the FA4 page-size and waterfill559 cases do exactly that, and say so).560- **Run changed test files per-file** (own process), the way CI does: a monolithic local561 pytest run lets a context published by an earlier file mask a missing-publish bug in a562 later one.563564## Guardrails (these fail CI; what to do when they fire)5655661. **Strict mutation guard** (always on, and with no exception): bare567 `server_args.x = ...` after resolution raises unconditionally in568 `ServerArgs.__setattr__` — the named lift that out-of-tree plugins used to569 ask for is gone, they assign onto a stand-in instead — this *is* the guarantee that570 no writer can desync the bags, so there is no writer ratchet any more. Change571 resolved config with `get_context().override`; hand a per-runner value to its572 runner as a constructor argument. Projected bags are sealed the same way (leaf573 assignment raises).5742. **Mutation ratchet** (`test_server_args_mutation_ratchet.py`, exact pin 0 over the whole575 package minus the pipeline / multimodal_gen): textual scan for assignment forms. Never576 raise the baseline.5773. **No-copy contract** (`test_server_args_no_instance_mutation_entry.py`): neither578 `ServerArgs.override` nor `ServerArgs.derive` exists, and nothing in the package579 calls either form. Rerouting a writer to the bags means flipping **all its readers580 in the same commit** (no transitional dual-write).5814. **The 582583…(truncated)