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_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 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. 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 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): bare
server_args.x = ... after resolution
raises unconditionally in ServerArgs.__setattr__ — 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 legacy accessor is retired (
test_runtime_context.py): every
get_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 — not get_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 direct get_server_args().field and the alias form (function-local
— including local copies of an alias, cfg = sa — module-level, or parked on an
instance attribute, plus the getattr(..., "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 matches get_server_args by its
literal name — bare or module-qualified (ctx.get_server_args()) — and
TestNoRenamedAccessorImports in the same file bans import ... as renames 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: TestGlobalConfigReadRatchet holds the two baselines and
TestNoRenamedAccessorImports holds 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): global statements 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): every ServerArgs field resolves to a
namespace — from the arg_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 co
…(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---5
6# SGLang runtime-context architecture
7
8One 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.
10
11| 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 |
19
20`reset_context()` (unit-test teardown) drops the published config and installs fresh
21flags/resources/forward tiers.
22
23## Config: publish + namespace bags
24
25**`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 answers
28with what the operator typed, not with what resolution decided.**
29
30- 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, the
33 spawned encoder TP/DP workers, the benchmark work functions, ...); constructors
34 do not publish — `ModelRunner`, `TokenizerManager` and `MMEncoder` call
35 `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 launch
37 path publishes as `tokenizer`. The remaining non-publisher is
38 `run_multi_detokenizer_router_process`: it *is* handed a `ServerArgs`, and uses
39 it only for `configure_logger(server_args)` today, so it has nothing to publish
40 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 raw
42 fields; the accessors (`get_exec()` etc.) fail closed before it
43 runs. `role` records which process type published, and keys per-role namespace
44 enforcement: `SGLANG_ROLE_NAMESPACES=record` audits which namespaces each role's
45 process actually reads (per-pair persisted via `SGLANG_ROLE_NAMESPACES_OUT`;
46 reads inside torch.compile-traced code are NOT observed — audit with
47 compilation disabled before restricting a role), and
48 `=enforce` fails closed on bag reads outside the role's `ROLE_NAMESPACE_SETS` entry
49 (`None` = full tree; only audited roles are restricted).
50- Bag membership is **where the field is declared**: one class per namespace under
51 `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")` marker
53 survives only for a class that cannot express this — an ad-hoc dataclass spanning
54 namespaces, which is what the config-bag tests build. Coverage is linted two-way
55 (`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 leaves
58 are plain instance attributes, safe inside `torch.compile`-traced code.
59- **Mutating config after publish**: the ONLY entry point is
60 `get_context().override(source, **fields)`. It writes the bag leaves in place
61 (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 after
64 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 of
67 `override`. It resolves the flat name through the same `NS` map the write side
68 uses and raises on a name that is not a config leaf. Code that knows its field
69 when it is written reads the bag leaf directly; `config_leaf` is for
70 name-driven code, not a way around the seed ratchet.
71- **Post-startup control-plane changes** — a weight update, a HiCache mirror
72 attach, a parser resolved from the chat template — go through
73 `TokenizerManager.record_config_updates(source, **fields)`, a named wrapper
74 over `get_context().override`. One process keeps one log: the request dumps
75 ship `get_context().overrides_log()`, and `config_value(name)` /
76 `resolved_config_dict(base)` answer from the bags. The exposure ratchet
77 resolves the wrapper, so a field recorded through it joins the post-publish
78 override surface exactly like a direct `override` and needs the same ordering
79 judgment against any supplied-instance read of it
80 (`test_supplied_instance_exposure_ratchet.py`).
81- **`model_path` and `served_model_name` are answered off the manager.** Both are
82 `NS` leaves and `override` accepts them, but the tokenizer-side weight reload
83 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 other
87 process's bag on the old path while the log claimed a process-wide change. The
88 scheduler rewrites its own copy where the reload happens —
89 `ModelRunner.update_model_fields` overrides `model_path` / `load_format` for
90 the target runner.
91- **Late launcher-stage resolution (pre-publish)**: a few rules cannot run inside
92 `__post_init__` — LoRA normalization, and the auto-parser detection that needs a
93 tokenizer/chat-template load. They are resolution, not mutation, and they
94 **declare** via `arg_groups.overrides.declare_late_resolution(server_args,
95 source, **fields)`, which refuses the published instance. The declaration lands
96 in the stash on that very object, so every holder of it carries the decision —
97 the HTTP server, the multi-tokenizer workers it is serialized for, the
98 schedulers it forks — and each of them publishes bags projected from it. The
99 fields stay the operator's input; `resolution_result(sa, field)` and the bags
100 are what answer for the decision. Returning a variant here is a bug: the
101 launcher rebinds its local and everyone else keeps the unresolved object.
102- **A value another runner / worker owns is a constructor argument, not a config
103 copy.** The draft worker's `context_length`, load format and attention backend
104 travel as arguments to `TpModelWorker` / `ModelRunner` and live on the runner
105 (`ModelRunner.draft_attention_backend`, `kv_cache_dtype_str`, …); the encoder
106 DP worker's device is `MMEncoder(gpu_id=...)`. There is no `ServerArgs.derive`
107 any more — a config object is never copied-and-edited; test doubles that need a
108 modified copy use `sglang.test.test_utils.server_args_variant`.
109
110**Why a bag override cannot stand in for late resolution or per-runner
111construction.** The bags are projected at
112publish *from the declarations over the instance's raw fields*, so anything the
113runtime must read has to be declared before publish — an override afterwards puts instance and bags back out
114of agreement, and whole-object readers (`ModelConfig.from_server_args`,
115`build_load_config`, `MMEncoder`'s own `self.server_args.X`) never see it. And bags do
116not cross a process boundary: a child publishes from the object it receives and
117re-projects its own bags, so a parent-side override is lost. Values that feed
118construction before any bag exists (group init reads `server_args.tp_size`) have no
119bag to override at all.
120
121
122### Reads that legitimately stay on a `ServerArgs` instance
123
124- **Per-runner values** — there is no per-runner `ServerArgs` any more. The
125 draft-worker config copy is gone: every worker (`TpModelWorker`, the draft
126 workers in `speculative/`) is handed the *same* instance the process published,
127 so a bag leaf is the decision and `self.server_args.X` is the operator's
128 input — a
129 post-publish `override` moves only the bag, which is exactly why a field that
130 is process-wide config (`attention_backend`, `skip_tokenizer_init`,
131 `kv_cache_dtype`) reads from the bags like any other, and why a residual
132 instance read on this path is stale the moment someone overrides that leaf.
133 What is genuinely per-runner travels two ways, neither of them a config
134 instance: **constructor arguments** (`ModelRunner(draft_attention_backend=...)`,
135 `MMEncoder(gpu_id=...)`) and **runner attributes holding the resolved value**
136 (`model_runner.kv_cache_dtype_str`, `prefill_attention_backend_str`,
137 `num_fused_shared_experts`, `linear_attn_backends`) — threaded to consumers as
138 arguments, never backfilled onto a shared object. A per-runner choice also stays
139 *out* of the bags: recording it there is how a second runner inherits the first
140 one's answer, which is exactly the bug `linear_attn_backends` replaced. The one sanctioned bend in that rule is
141 *scoped*: `ModelRunner._load_format_scope` exposes the draft's
142 `--speculative-draft-load-format` through `get_model().override(load_format=...)`
143 for exactly the duration of the draft build, because model construction
144 reads that bag leaf — the override restores on exit, so nothing outlives
145 the scope. When there is a runner in hand, read its
146 stamp; that is a different rule from "read the instance".
147- **Per-instance boundaries** — the tokenizer-manager family, everything under
148 `entrypoints/`, and the tokenizer-process multimodal processors read the bags.
149 The old justification for keeping them on `self.server_args` ("several
150 `Engine`s can share one process, bags are last-publish-wins across them") is
151 **retracted** — owner ruling (2026-08-15): a process holds at most one live
152 config at a time (concurrent multi-Engine is unsupported; sequential rebuild
153 stays legal, unit tests rely on it). Nothing in those files reads the instance
154 any more -- the exposure ratchet's pin set is empty, so the next such read is a
155 new entry that has to argue for itself. What
156 genuinely stays per-instance is what differs per *worker* within one engine:
157 `base_gpu_id` travels as a constructor argument (`MMEncoder(gpu_id=...)`;
158 `BaseMultimodalProcessor._fast_image_processor_device` is the shape to copy).
159- **Whole-object passes** (`f(server_args)` handing the instance along) keep the
160 supplied-instance contract; don't rewrite the parameter reads unless the
161 field is runtime-mutated (see the elastic-EP `ep_size` case in
162 `eplb/expert_location.py`) — **or the field is one that resolution fills in
163 and the callee runs in a process that has published.** That second case is a
164 decision, not a style question: the record carries the user's raw input, so a
165 resolution-filled field read off it inside a runner-owned constructor answers
166 with the pre-resolution value instead of the effective one. The answer is not
167 automatically a bag read: pick where the value should come from — usually the
168 `get_*()` bag, sometimes a runner stamp or a constructor argument (the per-mode
169 attention pair and the encode-server `gpu_id` above are both this). The per-instance
170 boundaries above are **not** exempt from this unless-clause (the multi-Engine
171 exemption is retracted); each one gets its own disposition.
172 `test_supplied_instance_exposure_ratchet.py`
173 pins that set (empty today) — three spellings of the read: `server_args.field`,
174 literal-name `getattr(server_args, "field", default)`, and the parked form
175 (`self.x = server_args` in a method that takes the parameter, read as
176 `self.x.field` anywhere in the class) — and fails on a new one, so the
177 disposition gets picked when the read is written. Two shapes stay parameter-form on purpose: a helper the
178 *resolution pipeline* calls with a `resolved_view` (its parameter happens to be
179 named `server_args`), and a factory whose contract is "build X from the record
180 you are handed" (`create_kt_config_from_server_args`, `DllmConfig.from_server_args`).
181
182### Four ways a config sweep breaks something no test runs
183
184Each of these shipped in a review round and cost a real defect; each now has a
185guard, named here so the next sweep checks the same four things by hand first.
186
1871. **The other implementations of an interface.** Dropping a parameter means
188 auditing implementers, not just callers: `CustomSpecAlgo` is the plugin
189 base for speculative algorithms, and the dispatch calls it with the
190 built-in's argument list. Nothing in the tree implements it, so only a
191 plugin user hits the `TypeError`.
192 Guard: `test_plugin_hook_signatures.py`.
1932. **Publish order inside a process entry, not per file.** A file containing a
194 `publish` says nothing about whether a given read runs before it. Spawned
195 workers (`MMEncoder` for encoder DP/TP, the Ray scheduler actor) start with
196 an empty context, so a bag read above the publish raises only there.
197 Guard: `test_publish_precedes_bag_reads.py`.
1983. **The role namespace a process publishes under.** `ROLE_NAMESPACE_SETS`
199 narrows what each role may read; the DP controller is audited for `exec`
200 alone. A helper that reaches for another namespace passes every default-mode
201 test and aborts startup under `SGLANG_ROLE_NAMESPACES=enforce`. Prefer
202 answering from the caller's own namespaces over widening the set.
2034. **Sibling surfaces of a readback.** Changing what one entry point reports
204 means enumerating the others: HTTP, gRPC and in-process `Engine` each have
205 their own server-info and model-info, and each passes its own tests while
206 its users lose the field.
207 Guard: `test_effective_state_surfaces.py`.
208
209A fifth, from the same rounds: the accessor **name itself**. Called as an
210object member (`manager.get_disagg()`), or shadowed by a same-named import
211(`from model_loader import get_model` next to the model bag, where the later
212import silently wins and the loader call gets a zero-argument bag), it imports
213fine and fails only when that path runs. The invariant is one line: the name
214means the process-wide bag, takes no arguments, and is bound once per module.
215`ruff --select F811` catches the import collision; the member-call shapes are an
216`AttributeError` at call time only because `RuntimeContext` has no bag-named
217member and no `__getattr__` -- a delegating `__getattr__` would make them
218silent, and that is when this needs a guard again rather than a rule.
219
220Write these guards over a **derived** set, never a hand-kept list: an entry
221naming a function that no longer exists, or a field list missing the one field
222nobody migrated, passes green forever. Both happened here -- a `_ENTRY_POINTS`
223row for a method the Ray actor does not have, and an effective-field set
224without `load_format` -- and both were invisible because the assertion had
225slack (`>= len(...) - 1`) or compared key names instead of value sources.
226
227### `get_parallel()`: one spelling per name
228
229**There is no `.config` hop.** Ranks and group handles are `@property`
230read-through over the canonical getters, so they answer with the live process
231groups. Everything else — `tp_size`, `pp_size`, `attn_cp_size`, `dcp_size`,
232`moe_dp_size` included, alongside config-only leaves like `nccl_port`,
233`enable_dp_attention`, `dp_size`, `ep_size`, `dwdp_size` — is answered from the
234published `parallel` bag. Reading a leaf before publish raises a `ValueError`
235naming the namespace; an unknown name is an `AttributeError`.
236
237A size reads from the configuration because the groups are built at exactly the
238configured widths — checked at every assignment to `_TP` / `_PP` / `_ATTN_CP` /
239`_DCP` / `_MOE_DP` in `parallel_state.py`. Three things do not follow that rule:
240
241- `initialize_model_parallel` aliases `_MOE_DP` to `_ATTN_CP` when `attn_cp_size >
242 moe_dp_size`, so a reader that means **the MoE communicator's width** calls
243 `get_moe_cp_size()`, not `get_parallel().moe_dp_size`.
244- `patch_tensor_parallel_group` runs a scope under a different TP group (draft
245 workers), and declares it by overriding `tp_size`, `tp_rank` and `tp_group`
246 for the scope's duration. Readers inside need no special spelling.
247- Elastic EP scales `ep_size` / `dp_size` on the published bag while the group
248 coordinators keep their construction width. Those are different names, not two
249 answers to one name.
250
251DCP keeps its own pair: `get_parallel().attn_dcp_size` / `.dcp_enabled` answer the
252*effective* topology (`1` / `False` with no group installed), while `dcp_size` is
253what the launch requested.
254
255A process-global seed field-read of one of these sizes
256(`get_server_args().tp_size`, or an alias of it) is a read-ratchet failure. A
257`server_args` the object was *handed* is a different thing and not a ratchet
258matter — see "Reads that legitimately stay on a ServerArgs instance".
259Fail-loud is narrower: before dist init, a live *rank/group* read raises. The six
260parallel quotients are not live reads at all — `attn_tp_size`, `attn_dp_size`,
261`attn_dcp_size`, `moe_ep_size`, `moe_tp_size`, `dcp_enabled` are a function of the
262configured leaves, computed once at publish into bag leaves, and answered
263override → stamp → published leaf. So `dcp_enabled` means "the launch configured
264DCP" (`dcp_size > 1`), not "a DCP group is installed here"; in a scheduler the
265stamp makes the two identical, in a process that publishes without dist init they
266differ. `test_a_topology_is_stated_by_naming_the_width` and its neighbours in
267`test_runtime_context.py` pin this; they replaced
268`test_attn_dcp_defaults_when_group_is_uninitialized`. One consequence for tests:
269overriding a leaf no longer moves its quotient — state a topology by publishing a
270config, or by naming the width. After init,
271only the DCP group is optional (`_DCP` exists only when `dcp_size > 1`; attn-CP and
272moe-DP always install, as size-1 aliases if unused). The `config` hop is
273deliberately dynamo-traceable (a plain property over a slot, no
274`object.__getattribute__`); gate helpers like `enable_moe_dense_fully_dp()` run inside
275compiled model forwards (`test_parallel_config_leaves_trace_under_torch_compile` pins
276this).
277
278A third surface carries the same names: `ParallelState` (`self.ps` / `mr.ps`), the
279frozen per-process snapshot built once in `Scheduler.__init__` from these configured
280sizes plus this process's ranks, and handed down (draft runners included). Prefer it
281where an object was handed one; it is not a global accessor.
282
283### Reading config: the seed is off limits
284
285`get_server_args().field` in business code is a ratchet failure. Read:
286
287- **a resolved leaf** → its namespace bag (`get_exec().moe.moe_runner_backend`,
288 `get_schedule().chunked_prefill_size`, …). Bag-backed reads — a leaf directly, or
289 a bag-derived accessor below — are what see post-publish overrides. Only the
290 instance-derived accessors (the ones with no leaf to read) answer from the
291 startup record and therefore do not.
292- **a leaf the caller names at runtime** (a readback reporting a list of fields)
293 → `get_context().config_leaf(name)`; it resolves the name through `NS` and
294 raises on a non-leaf. A call site that knows its field reads the bag leaf.
295- **the live topology** → `get_parallel()` (bare names).
296- **a value derived from published leaves** → an accessor in `runtime_context` that
297 derives it *from the bags*. The strongest form of this is a `Derived(fn=...)`
298 declared beside the leaves it is computed from, in the namespace's own
299 `arg_groups/fields/` class: `publish` computes it once and stores it as an
300 ordinary bag leaf, so the read is a plain attribute load and it sees
301 post-publish overrides. `enable_mamba_extra_buffer`, `is_ep_joiner`,
302 `is_ep_scale_joiner` and `is_startup_weight_load_overlap` are declared that way
303 now — read them where they are declared:
304 `get_exec().mamba.enable_mamba_extra_buffer`, `get_exec().moe.is_ep_joiner`,
305 `get_model().is_startup_weight_load_overlap`. (The namespace is the class that
306 declares the field, not the namespaces its `fn` happens to read: the mamba one
307 spans `exec.mamba` and `memory`, which is exactly why it could not be a method
308 on either bag.) The
309 old `mamba_extra_buffer_enabled()` / `is_ep_joiner()` functions and the
310 same-named `ServerArgs` members are gone. The pre-publish helpers that remain
311 exist for resolution, which has no bag to read yet. `attention_backends()` derives the
312 `(prefill, decode)` pair from the three `exec.kernel` leaves, and
313 `max_speculative_num_draft_tokens()` / `cutedsl_moe_max_num_tokens()` derive
314 theirs from `spec` / `schedule` / `exec.graph`.
315- **a value only the instance can compute** → the named accessor in
316 `runtime_context`, which is the one module allowed to read the slot:
317 `mamba_cache_chunk_size()`, `mamba_state_chunk_size()`, `uses_mla_backend()`,
318 `process_model_config()`.
319 These have no leaf to read — they combine several fields, the HF config, or a
320 property with no bag of its own. A new derived member gets an accessor here
321 rather than call sites reaching for the record, and only when the bag-derived
322 shape above cannot express it.
323- **a parallel size** → `get_parallel().{tp,pp,moe_dp,attn_cp,dcp}_size`, which is
324 the parallel bag's own leaf: it answers with the resolved configuration and
325 follows a post-publish override. Two questions are *not* that, and have their
326 own spelling: the width of the MoE communicator you are about to collectively
327 operate on is `get_moe_cp_size()` (the `_MOE_DP = _ATTN_CP` alias makes it
328 differ), and the effective DCP topology is `get_parallel().attn_dcp_size` /
329 `.dcp_enabled` (`1` / `False` when no group is installed), which does not need
330 dist init to answer.
331- **this runner's resolved value** → the runner
332 (`prefill_attention_backend_str`, `kv_cache_dtype_str`,
333 `draft_attention_backend`, `num_fused_shared_experts` on the model).
334
335`self.server_args.field` is still right for handed per-instance config (see
336"Reads that legitimately stay on a ServerArgs instance" above for the full set —
337per-instance boundaries and whole-object passes; there are no per-runner config
338copies to read any more). The allow-list is `GrammarManager` and `MMEncoder`;
339what sits beside it is residue, not a family — and not for one single reason:
340
341- the tokenizer-manager family and `entrypoints/` **read the bags**; what is
342 left of them in the exposure ratchet is a handful of individually-dispositioned
343 pairs, not a family awaiting conversion. Read the ratchet for the current set
344 rather than assuming a directory is off-limits;
345- `GrammarManager` is a handed instance for its residual `self.server_args`
346 reads, but backend selection is **not** on the instance any more:
347 `create_grammar_backend` reads `get_exec().kernel.grammar_backend`, and
348 `__init__` calls that factory whenever `skip_tokenizer_init` is false. In
349 production the scheduler process has published; a test that constructs one
350 without publishing has to keep patching the factory (or publish itself);
351- `MMEncoder` publishes the very instance it is handed (`publish(server_args,
352 role="encoder")`) and takes its per-worker device as a separate `gpu_id`
353 argument. Its `self.server_args` reads are on this list as a construction-path
354 convention, and the residual is real: they answer with the raw input, so a leaf
355 resolution decided and a post-publish `override` both pass them by.
356
357Their tests are not one story: a `GrammarManager` built standalone turns the
358factory's bag read into "config namespace not published" unless the test patches
359it or publishes, while `MMEncoder` publishes in its own `__init__` and so needs
360no such arrangement.
361
362**Test doubles publish, they do not inject.** A stand-in that carries
363`server_args=SimpleNamespace(field=...)` stops working the moment production reads
364the bag; seed the value with `override_server_args`, which publishes only once it is
365entered or installed — the bare call just builds the override:
366
367```python
368override = get_context().override_server_args(field=...)
369override.install()
370self.addCleanup(override.restore) # or: with get_context().override_server_args(...):
371```
372
373Five separate test files learned this the hard way during the sweep.
374
375The rule is about a double standing in for **config**: a `SimpleNamespace` that
376pretends to be `server_args`. Prefer the context override even where a
377single-accessor stub would work — `override_server_args(...)` composed with the
378scoped bag / `get_parallel()` overrides expresses the *cause* (the configuration)
379rather than pinning one helper's answer, and it keeps working when a reader
380migrates between the accessor and the leaf. The sweep converted the last two
381accessor stubs to exactly that shape (`test_attention_patching.py` publishes the
382non-lazy strategy; `test_kimi_k3_vision.py` publishes `tp_size` and forces the
383live topology through `get_parallel().override`), so no test stubs an accessor
384today. Stubbing one *named accessor* remains a last resort for a case that
385isolates one branch of one helper where no published config can reach it —
386if you do it, say so in the test.
387
388### Mid-resolution reads (inside the pipeline only)
389
390Resolution runs in `__post_init__` and **writes nothing onto the record**: a
391handler declares (`self._declare` / `declare_resolution`), the declaration goes
392into the stash, and the fields keep what the caller passed. So a mid-resolution
393read of a field answers with the *raw input* — every reader in the pipeline goes
394through a view instead:
395
396- `resolving_view(server_args)` / `self._resolved()` — the live view (walks the
397 stash per read). This is what handlers and hooks bind, conventionally as
398 `cfg = resolving_view(self)` at the top of the handler.
399- `resolved_view(server_args)` — snapshots the overlay when built, which is what
400 a post-process pass wants: it reads the state at *its* slot.
401
402`test_resolution_reads_the_declarations` pins direct field reads at zero over the
403two scopes it can derive exactly (every `arg_groups` function taking a config,
404every `ServerArgs` handler the dispatcher reaches). Readers the pipeline calls
405from elsewhere (`ModelConfig`, the platform defaults, the spec-algo hook) have
406moved to the view as well — a field read there is the same bug, just one the
407derivation cannot enumerate.
408
409One consequence worth knowing: because the fields are the raw input, resolving a
410bare `dataclasses.replace` copy lands in the same place as the parent — the
411pipeline reads only its own input. `replace_resolved` is the way to copy a
412resolved record (it carries the declarations and the `model_config` memo, so the
413copy does not re-resolve at all).
414
415### Adding a model-specific config adjustment
416
417Never assign `server_args` fields from model code. Declare instead
418(`sglang/srt/arg_groups/overrides.py`):
419
420- Constant per-arch values → `MODEL_OVERRIDES["MyArchForCausalLM"] = {...}`.
421- Derived values → `@register_model_override("MyArchForCausalLM")` returning a dict; the
422 callable receives *pristine* `server_args` + `hf_config` and must not write.
423- Normalization that must see earlier declarations → a post-process pass invoked via
424 `run_post_process_pass` at its slot (reads a view, returns a declaration dict).
425- Values only knowable at load time are **per-runner state**, not declarations:
426 there is no `declare_load_time_override` any more. A model-family decision that
427 its checkpoint drives (shared-experts fusion) is a question the *loader* asks
428 the model class — `shared_experts_fusion_disable_reason(hf_config,
429 quant_config)`, a classmethod answering without an instance — at the single
430 model-instantiation point, and
431 `install_shared_experts_fusion_decision` writes the answer to the ACTIVE moe
432 flag before that model's layers build and read it
433 (`is_shared_experts_fusion_disabled`, config-intent fallback).
434 `draft_model_build_scope` brackets every draft build and routes the draft's
435 answer to the speculative leaf, so a draft's decision never overwrites the
436 target's. A process-level load-time fact (the sm80 dtype fallback —
437 device-driven, identical for every runner) records directly via
438 `get_context().override`.
439
440Declarable fields form a whitelist: `Arg(..., resolvable=True)` in the `ServerArgs`
441dataclass. A declaration against a non-whitelisted field fails at its slot.
442
443### Load-time vs resolution-time (critical)
444
445`__post_init__` runs in the launcher process before any model/platform import. Logic that
446consults an **extensible registry** (e.g. out-of-tree platforms registering attention
447backends in `init_backend()`, which runs at `model_runner` import) must stay at load time
448(ModelRunner init), writing through `get_context().override()`. Before moving any
449load-time logic into resolution, verify everything it reads is already complete at
450construction time.
451
452## Runtime flags (`get_flags()`)
453
454For state that init-time code *derives* and runtime code reads — parsed enums, platform
455probes, swappable ACTIVE values. Not for config mirrors (read the bag leaf instead).
456
457- Groups are typed dataclasses on `Flags` (`capture` / `moe` / `dp`): typo-safe writes,
458 transactional test-only `override(**kw)` context manager.
459- `flags.moe` is materialized by `initialize_moe_config()` at scheduler init (it
460 reads `exec.moe` / `spec` / `model`, and takes no record);
461 accessors (`get_moe_a2a_backend` etc.) are thin shims with lazy defaults. The speculative
462 contexts (`speculative_moe_backend_context`) swap the ACTIVE leaves around draft forwards.
463- `flags.dp` is materialized by `initialize_dp_attention`; `is_dp_attention_enabled()` is a
464 shim over `flags.dp.enabled`.
465- Adding a leaf: declare the dataclass field with a default equal to the pre-init behavior,
466 materialize it at the owning subsystem's init, keep any public accessor as a shim.
467
468## Resources (`get_resources()`)
469
470Named slots + two keyed-lazy registries:
471
472- `get_stream(name)` — get-or-create a named CUDA side stream; `set_stream(name, stream)`
473 installs explicitly. **Name leases by subsystem ROLE**: all model alternate streams share
474 `"alt"`; the offloader's copy stream is `"offload"`; DP-TBO comm is `"dp_tbo_comm"`; LoRA
475 side stream is `"lora_side"`. Two call sites may share a name only if their work belongs
476 on one stream — sharing across roles serializes intended overlap.
477- `get_buffer(name, factory)` — get-or-create a named persistent buffer. Grow-only or
478 per-device semantics manage their `resources.buffers` entries directly (see tokenspeed /
479 SM120 split / Marlin workspace). Buffer names are per-backend today; do not silently
480 share.
481- Singletons with manager semantics (EP dispatcher buffers, EPLB recorder/metadata, graph
482 memory pool) keep their owning accessors/classes as facades; only the *state* lives in a
483 resources entry. Preserve exact semantics in the shim: lazy defaults (the EPLB recorder
484 defaults to a Noop instance, not None), publish-once asserts, event-reuse contracts.
485- Stream/buffer creation is a driver call — it must happen outside cuda-graph capture;
486 keep lease points at init/warmup time.
487
488## Per-forward flags (`get_forward()`)
489
490Contextvar-backed; a new thread sees the defaults; `scoped(**kw)` is the regular write path
491(transactional, restores on exit and on exception); `set(name, value)` exists for legacy
492sticky setters (`is_extend_in_batch` is intentionally sticky within a thread). Use this
493tier for anything set-per-forward and read-within-forward. Before adding cross-thread
494state here, prove the readers' thread affinity: contextvars do NOT propagate to already-
495running or newly spawned threads. Note TBO ("two-batch overlap") interleaves ubatches on
496ONE thread — do not design for TBO threads that don't exist.
497
498## Testing idioms
499
500- **Force a code path by overriding causes, not effects**: compose
501 `get_context().override_server_args(**fields)` (publishes a fresh dummy-boundary
502 `ServerArgs` carrying the overrides AND projects the bags — `with`-scoped, or
503 `install()`/`restore()` + `addCleanup` for fixture-lifetime use) +
504 `get_<ns>().override(...)` (scoped override of one bag's own leaves) +
505 `get_parallel().override(...)` (live topology) + `get_flags().<group>.override(...)` +
506 `get_forward().scoped(...)`. All are scoped and transactional. Tests control execution
507 through the context — do not hand-build and publish config objects.
508- **Never monkeypatch import bindings** (`module.get_x = lambda: ...`) and never fake a
509 config source with a `SimpleNamespace` stand-in: production reads the published bags,
510 so a faked accessor silently stops intercepting after any reader migration. Publish
511 for real (`override_server_args(...)`), then adjust bag leaves with the scoped bag
512 `override` where the constructed `ServerArgs` cannot carry the value (e.g.
513 `get_device().override(device="meta")`). The one carve-out is the deliberate
514 single-accessor stub for isolating one predicate — the terms and the two
515 sanctioned examples live under "Test doubles publish, they do not inject"
516 above; anything wider than one named accessor is this rule.
517- Mocked runners/managers still need the **per-runner instance attributes** the code
518 under test reads (`kv_cache_dtype_str`, `server_args` for whole-object passes) — set
519 them explicitly on the mock; `MagicMock(spec=...)` raises on attributes that only
520 exist post-`__init__`, which is the fastest way to find a missed stub.
521- `reset_context()` in teardown when a test publishes outside a scoped override.
522- `ServerArgs(model_path="dummy")` early-returns the pipeline (few declarations, no
523 strict guard) — fine for lightweight fixtures.
524- **Asserting what resolution decided reads `resolution_result(sa, "field")`**, not
525 `sa.field`: the field is the raw input. Assert the field only when the point of
526 the case *is* that the record stayed pristine (the FA4 page-size and waterfill
527 cases do exactly that, and say so).
528- **Run changed test files per-file** (own process), the way CI does: a monolithic local
529 pytest run lets a context published by an earlier file mask a missing-publish bug in a
530 later one.
531
532## Guardrails (these fail CI; what to do when they fire)
533
5341. **Strict mutation guard** (always on): bare `server_args.x = ...` after resolution
535 raises unconditionally in `ServerArgs.__setattr__` — this *is* the guarantee that
536 no writer can desync the bags, so there is no writer ratchet any more. Change
537 resolved config with `get_context().override`; hand a per-runner value to its
538 runner as a constructor argument. Projected bags are sealed the same way (leaf
539 assignment raises).
5402. **Mutation ratchet** (`test_server_args_mutation_ratchet.py`, exact pin 0 over the whole
541 package minus the pipeline / multimodal_gen): textual scan for assignment forms. Never
542 raise the baseline.
5433. **No-copy contract** (`test_server_args_no_instance_mutation_entry.py`): neither
544 `ServerArgs.override` nor `ServerArgs.derive` exists, and nothing in the package
545 calls either form. Rerouting a writer to the bags means flipping **all its readers
546 in the same commit** (no transitional dual-write).
5474. **The legacy accessor is retired** (`test_runtime_context.py`): every
548 `get_global_server_args()` call now raises, because it answered with the record --
549 a caller reading a field resolution had decided got a stale value and no error.
550 The replacement for a *decision* is a bag leaf, a named accessor, or the owning
551 runner's stamp — not `get_server_args().field`, which the read ratchet below pins
552 at zero. `runtime_context.get_server_args()` is only for the
553 whole-object shapes (dumps, provenance, a hand-off to a callee that takes a config).
5545. **Global config read ratchet** (`test_global_config_read_ratchet.py`): baselines are
555 **0** for both the direct `get_server_args().field` and the alias form (function-local
556 — including local copies of an alias, `cfg = sa` — module-level, or parked on an
557 instance attribute, plus the `getattr(..., "field")` spelling of each; a name
558 computed at runtime or indirection deeper than a local name copy is census-tool
559 territory, per the test's docstring). The scanner matches `get_server_args` by its
560 literal name — bare or module-qualified (`ctx.get_server_args()`) — and
561 `TestNoRenamedAccessorImports` in the same file *bans* `import ... as` renames of it,
562 which is what makes literal-name matching sound. Exempt by owner
563 module only (`runtime_context.py`, `server_args.py`, `arg_groups/`). Two classes,
564 no more: `TestGlobalConfigReadRatchet` holds the two baselines and
565 `TestNoRenamedAccessorImports` holds the ban. There is no configured-size registry
566 here any longer — `get_parallel()` has one spelling per name, so a size read is not a
567 choice between two answers and nothing needs registering.
5686. **Module-state ratchet** (`test_module_state_ratchet.py`): `global` statements in the
569 flag-owning layers are pinned by name. A new module-level runtime global belongs on a
570 flags group / resources slot instead; migrating a pinned survivor must shrink the pin.
5717. **Namespace coverage** (`test_server_args_namespaces.py`,
572 `test_runtime_context_config_bags.py`): every `ServerArgs` field resolves to a
573 namespace — from the `arg_groups/fields/` class that declares it — and the
574 projected bags must cover the fields exactly (two-way).
575
576Never module-skip a test "until the migration settles" — seed the context instead
577(the deferral ratchet that once pinned this is retired; the rule stands).
578
579## Hard-won pitfalls (check these before/while refactoring)
580
581- **Moving code drops first-line guards**: early returns (`if self.is_draft_worker: return`)
582 are the easiest thing to lose when relocating a method body. A draft is built from
583 the target's published config — there is no draft co
584
585…(truncated)