Repo map and process architecture
python/sglang/srt/python/sglang/srt/entrypoints/engine.pypython/sglang/srt/server_args.py
a556f3f · sglang 7d89325SGLang at 7d89325 is 3,397 Python files with no version-named directory anywhere, and a model runner that is smaller than it was three months earlier. Where vLLM forked a parallel worker tree and kept both alive (§11.1), SGLang halved its biggest file in place. This chapter is the map, and the process diagram, you need before §12.2–§12.4 make sense.
The problem
You arrive from vLLM, where the first thing you learned was that v1/ is the live engine and v1/worker/gpu/ is the one being built next to it. So the first question you ask of SGLang is: which generation am I reading?
$ find python/sglang -type d -name 'v[0-9]*' | wc -l
0
$ ls python/sglang/srt | grep -Ex 'v[0-9]+'
# (no output)
There is no generational fork to choose between. Good — one less thing to get wrong. So you go looking for where the tensors get built, and there is exactly one obvious candidate:
$ wc -l python/sglang/srt/model_executor/model_runner.py
2103 python/sglang/srt/model_executor/model_runner.py
$ grep -c "def capture" python/sglang/srt/model_executor/model_runner.py
0
2,103 lines, and the CUDA graph capture you came for is not in any of them. It is in model_executor/runner/decode_cuda_graph_runner.py (1,534 lines) and runner/prefill_cuda_graph_runner.py (1,817 lines), which did not exist before 2026-06-09. The runner did not get simpler; it got decomposed, and the pieces landed in sibling packages with names that look like versions but are not.
Then you try to attach a profiler, and discover the second surprise. There is no single "SGLang process". At tensor-parallel degree 2 with the default flags, one sglang.launch_server invocation produces four OS processes, and the one holding the GPU is not the one holding the HTTP socket. Three of them are named in source:
$ grep -rn "setproctitle.setproctitle" python/sglang/srt/
python/sglang/srt/managers/data_parallel_controller.py:817: setproctitle.setproctitle("sglang::data_parallel_controller")
python/sglang/srt/managers/multi_tokenizer_mixin.py:631: setproctitle.setproctitle("sglang::detokenizer_router")
python/sglang/srt/managers/multi_tokenizer_mixin.py:655: setproctitle.setproctitle(f"sglang::tokenizer_worker:{os.getpid()}")
python/sglang/srt/managers/detokenizer_manager.py:522: setproctitle.setproctitle("sglang::detokenizer")
python/sglang/srt/managers/scheduler.py:5089: setproctitle.setproctitle(f"sglang::scheduler{prefix.replace(' ', '_')}")
Two questions, then, and this chapter answers both: where does a concern live in this tree, and which process is it running in. Get those wrong and you will spend an afternoon setting breakpoints in a process that never executes the line.
Mental model
The default server path is a pipeline of operating-system processes connected by ZMQ, while an embedding/library API and an embedded Rust path also exist. Text becomes token IDs before model execution and generated IDs are detokenized separately. This reduces Python work in the GPU process, but the scheduler can still decode tail strings for stopping or related request state; it does not literally never handle strings.
On top of that pipeline sits one Python package, python/sglang/srt/, organised strictly by concern rather than by generation. Four rings, and every directory belongs to exactly one:
Figure 1 — the four rings of python/sglang/, and the process each ring runs in. File counts are find … -name '*.py' | wc -l at 7d89325.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
The important asymmetry: rings 2 and 3 share a process (the scheduler calls the model runner directly, no IPC), while ring 1 and the detokenizer are each their own process. The next section shows why.
First principles: the process architecture
Everything starts at one classmethod. It is just under 200 lines long and its docstring is the whole design in one sentence:
"""Launch the TokenizerManager in the main process, the Scheduler in a subprocess, and the DetokenizerManager in another subprocess.
Returns:
Tuple of (tokenizer_manager, template_manager, port_args, scheduler_init_result, subprocess_watchdog, weight_cache_daemon_procs).
"""
One scheduler per TP rank
The scheduler is not one process that fans out to workers. It is N peer processes, one per (pp_rank, tp_rank) pair on this node, each holding one GPU and each running the full scheduling loop:
for pp_rank in pp_rank_range:
for tp_rank in tp_rank_range:
reader, writer = mp.Pipe(duplex=False)
gpu_id = (
server_args.base_gpu_id
+ ((pp_rank % pp_size_per_node) * tp_size_per_node)
+ (tp_rank % tp_size_per_node) * server_args.gpu_id_step
)
attn_cp_rank, moe_dp_rank, moe_ep_rank = _compute_parallelism_ranks(
server_args, tp_rank
)
with maybe_reindex_device_id(gpu_id) as gpu_id:
proc = mp.Process(
target=run_scheduler_process_func,
args=(
server_args,
port_args,
gpu_id,
tp_rank,
attn_cp_rank,
moe_dp_rank,
moe_ep_rank,
pp_rank,
None,
writer,
),
)
Note what travels in that args tuple: five separate rank coordinates. A scheduler process knows its position along the tensor, pipeline, attention-context, MoE-data and MoE-expert axes independently — the topology is not one number. §12.1.6 enumerates them. The mp.Pipe is one-shot: the child sends scheduler.get_init_info() back once, after the Scheduler constructor returns, and the parent blocks on it. Everything after startup goes over ZMQ.
The sockets, named
Every endpoint is allocated up front in PortArgs, whose field comments are unusually load-bearing — they name both ends of each socket:
class PortArgs:
# The ipc filename for tokenizer to receive inputs from detokenizer (zmq)
tokenizer_ipc_name: str
# The ipc filename for scheduler (rank 0) to receive inputs from tokenizer (zmq)
scheduler_input_ipc_name: str
# The ipc filename for detokenizer to receive inputs from scheduler (zmq)
detokenizer_ipc_name: str
# The port for nccl initialization (torch.dist)
nccl_port: int
# The ipc filename for rpc call between Engine and Scheduler
rpc_ipc_name: str
# The ipc filename for Scheduler to send metrics
metrics_ipc_name: str
# The ipc filename for MultiTokenizerRouter to receive inputs from TokenizerWorker processes (zmq)
In the default single-node case these are ipc:// endpoints backed by temp files (python/sglang/srt/server_args.py:L10026-L10036); with --enable-dp-attention they become TCP so the same code works across nodes. The scheduler side binds them in a dedicated component, added 2026-05-19, which is 88 lines and worth reading in full:
@dataclass(frozen=True, slots=True, kw_only=True)
class SchedulerIpcChannels:
recv_from_tokenizer: Union[zmq.Socket, "ScriptedTokenizerRecvProxy"]
recv_from_rpc: Optional[zmq.Socket]
send_to_tokenizer: SenderWrapper
send_to_detokenizer: SenderWrapper
send_metrics_from_scheduler: Optional[zmq.Socket]
And the crucial gate — only TP rank 0 has sockets at all:
if is_rank_zero:
recv_from_tokenizer = get_zmq_socket(
context, zmq.PULL, port_args.scheduler_input_ipc_name, False
)
Non-ingress ranks do not read the frontend ZMQ socket; they receive the request list through the rank broadcast. They then process replicated logical requests and schedule rank-local execution. Thus a breakpoint at socket receive is ingress-only, while process_input_requests can run on every participating scheduler rank. Verify the resolved TP/PP/CP topology rather than equating receive with process.
Rank 0's output side chooses its destination at construction time, which is where the three-way split becomes explicit:
send_to_tokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
if skip_tokenizer_init:
# No decode work: send outputs straight to the tokenizer side
# (MultiTokenizerRouter fans out when tokenizer_worker_num > 1).
send_to_detokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
else:
# Send to the DetokenizerManager
send_to_detokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.detokenizer_ipc_name, False
)
Two PUSH sockets, not one. send_to_tokenizer carries control-plane replies (health, weight-update acknowledgements, aborts) directly back to the front. send_to_detokenizer carries generated token IDs to the middle-of-nowhere third process. With --skip-tokenizer-init the second collapses onto the first, because there is nothing to detokenize — and that collapse is the cleanest evidence that the detokenizer exists purely as a place to put CPU work.
Why three, not two
The other two ends are trivially symmetric. The front:
def init_ipc_channels(self, port_args: PortArgs):
context = zmq.asyncio.Context(2)
self.recv_from_detokenizer = get_zmq_socket(
context, zmq.PULL, port_args.tokenizer_ipc_name, True
)
if self.server_args.tokenizer_worker_num == 1:
self.send_to_scheduler = get_zmq_socket(
context, zmq.PUSH, port_args.scheduler_input_ipc_name, True
)
self.tokenizer_ipc_name = None
And the back:
context = zmq.Context(2)
self.recv_from_scheduler = get_zmq_socket(
context, zmq.PULL, port_args.detokenizer_ipc_name, True
)
# In multi-tokenizer mode, results are pushed back to each TokenizerWorker
# directly via SocketMapping inside multi_http_worker_event_loop, so the
# single send_to_tokenizer socket is unused.
if server_args.tokenizer_worker_num == 1:
self.send_to_tokenizer = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
Note the zmq.asyncio.Context on the tokenizer side and the plain zmq.Context on the detokenizer side. The front is an asyncio server multiplexing thousands of in-flight HTTP requests; the back is a synchronous straight-line loop:
def event_loop(self):
"""The event loop that handles requests"""
while True:
with self.soft_watchdog.disable():
recv_obj = sock_recv(self.recv_from_scheduler)
output = self._request_dispatcher(recv_obj)
if output is not None:
sock_send(self.send_to_tokenizer, output)
self.soft_watchdog.feed()
Now the design question. vLLM runs two process tiers — API/AsyncLLM and EngineCore — and does incremental detokenization inside the API tier. SGLang splits it a third way, and pays a ZMQ hop for it. What does the third process buy?
It buys a hard CPU boundary around the one piece of per-token Python work that is neither GPU-bound nor request-multiplexed. Incremental detokenization is a per-token, per-request tokenizer.decode call plus stop-string trimming plus UTF-8 boundary handling. Put that in the scheduler process and it lands squarely between two forward passes on the GIL-holding thread that must launch the next kernel. Put it in the asyncio front-end and it blocks the event loop that is servicing every open HTTP connection. Giving it its own process means neither the GPU launch thread nor the HTTP loop ever stalls on it. The cost is one extra serialize/deserialize round-trip per output batch, and one more process to lose. §9.3 traces the latency of that hop; §9.2 owns the incremental-decode algorithm itself.
When dp_size > 1 (or MoE expert-parallel scale-out is on), _launch_scheduler_processes takes the other branch entirely: it spawns one DataParallelController, which binds scheduler_input_ipc_name itself and then spawns the per-replica schedulers behind it. See python/sglang/srt/managers/data_parallel_controller.py:L150-L154 — the tokenizer manager's socket address never changes, so the front end cannot tell whether it is talking to a scheduler or to a load balancer. §5.5 owns the orchestration and failure semantics.
Figure 2 — the process architecture at TP=2, with sockets and payloads named. Arrowed solid lines are ZMQ sockets, named by their PortArgs field. The dashed line is a torch.distributed broadcast; the plain line is the NCCL process group. Both are also interprocess communication, but neither is the depicted ZMQ transport.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
The three-process split is being collapsed — in Rust, not Python. Setting SGLANG_RUST_SERVER makes _launch_subprocesses skip the detokenizer subprocess and the tokenizer manager entirely (python/sglang/srt/entrypoints/engine.py:L1179-L1183), because a Rust HTTP server, tokenizer and detokenizer run as threads inside the rank-0 scheduler process instead. The wrapper module states the intent plainly:
"""Embedded Rust server lifecycle for the scheduler.
The Rust server replaces the Python api-server + `TokenizerManager` +
`DetokenizerManager` stack (hence this module sits beside them in `managers/`),
running them as Rust threads inside the scheduler process. This wrapper keeps
all `SGLANG_RUST_SERVER` plumbing — startup, CPU-core partitioning, the
`server_args` blob, and control-response routing — out of `scheduler.py`. The
scheduler holds an `Optional[RustServer]` and delegates to it.
The crate is sglang-server, in the repository's top-level rust/ tree (built as a PyO3 cdylib imported as sglang.srt.rust_extensions._server). Once the GIL is out of the picture, three processes stop being necessary — threads suffice. Read this as the strongest available statement about what the three-way split was for.
vLLM is doing the same thing from the other end, and the symmetry is worth noticing before §13.1. Its rust/ tree is 309 .rs files and 109,867 lines, described in rust/README.md:L3 as “a Rust drop-in alternative frontend for vLLM… rebuild the northbound serving layer in Rust while still talking to the core Python vLLM engine process(es) via ZMQ over the existing engine boundary.” vLLM keeps the process boundary and rewrites the front half in Rust; SGLang deletes the process boundary by moving the front half into the scheduler as Rust threads. Both projects independently concluded that per-token Python on the serving path is the thing to remove.
The repo map, directory by directory
Citations in this book are repo-root-relative, so SGLang runtime paths all start python/sglang/srt/. In the tables below that prefix is abbreviated to srt/ for width; line-numbered citations keep the full path. The in-tree README is short, accurate, and the right place to start:
- `benchmark/`: Benchmark implementations and dataset utilities.
- `cli/`: Command-line interface commands and entrypoints.
- `kernels/`: Kernel interfaces, implementations, selection, and debugging utilities shared by the runtimes.
- `lang/`: Deprecated language frontend that is no longer actively maintained.
- `multimodal_gen/`: Core runtime for image, video, and audio generation models, most of which are diffusion models.
- `srt/`: Core runtime for autoregressive language models. (SRT = SGLang Runtime.)
- `test/`: Shared test and evaluation utilities.
Two things to take from that. First, SRT means SGLang RunTime — it is not an acronym you need to decode further, and everything this book calls "the engine" lives under it. Second, lang/, the structured-generation DSL that gave the project its name, is marked deprecated in-tree at this SHA (14 files, 4,644 lines). §12.4 owns what replaced it.
Figure 3 — the top-level map with responsibilities. Counts are find … -name '*.py' | wc -l and cat … | wc -l at 7d89325.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
Inside srt/
python/sglang/srt/ subdirectories that matter, at 7d89325. File and line counts are measurements of the repo, not benchmarks.| Path | Files | Owns | Read it when |
|---|---|---|---|
managers/ | 49 | scheduler.py (5,197 lines), tokenizer_manager.py (3,608), detokenizer_manager.py, schedule_batch.py (3,431), schedule_policy.py, tp_worker.py, data_parallel_controller.py, scheduler_components/ (20 files) | Anything about admission, batching, or process boundaries (§12.2) |
mem_cache/ | 126 | unified_radix_cache.py (2,887), radix_cache.py, hiradix_cache.py, memory_pool.py (5,059), allocator/, storage/, cpp_radix_tree/ | Prefix reuse, KV pools, tiering (§12.3, §2.4) |
model_executor/ | 56 | model_runner.py (2,103), forward_batch_info.py (1,808), model_runner_components/, runner/, runner_backend/, pool_configurator.py | Turning a batch into tensors, CUDA graphs |
layers/ | 313 | attention/ (36 backend files), moe/, quantization/, linear.py, radix_attention.py, dp_attention.py, rotary_embedding/ | You need the nn.Module a model constructs, or a quantization scheme |
models/ | 245 | One file per architecture, each exporting EntryClass. 195 files carry one. | Adding or debugging a model |
speculative/ | 48 | EAGLE (multi-layer and single), n-gram, DFlash, DSpark, frozen-KV MTP, spec_registry.py | Speculative decoding (§6.4) |
disaggregation/ | 33 | prefill.py, decode.py, and transport backends: mooncake/, nixl/ (3,104 lines in conn.py alone), mori/, ascend/ | Prefill/decode disaggregation |
entrypoints/ | 60 | http_server.py (2,827), engine.py (1,876), grpc_server.py, plus openai/, anthropic/, ollama/ API shims | Route definitions, engine startup (§9.1) |
constrained/ | 9 | xgrammar_backend.py, outlines_backend.py, llguidance_backend.py, grammar_manager.py | JSON-schema / regex decoding (§12.4) |
distributed/ | 28 | parallel_state.py (3,104), device_communicators/, communication_op.py | Group construction, custom all-reduce (§5.4) |
lora/ | 45 | lora_manager.py, mem_pool.py, backend/, eviction_policy.py, lora_registry.py | Multi-adapter serving |
eplb/, elastic_ep/ | 13 + 3 | Expert-parallel load balancing, expert location metadata and updaters; live expert backup/restore | MoE imbalance (§5.3) |
observability/ | 14 | metrics_collector.py, forward_pass_metrics.py, trace.py, startup_time.py | Prometheus, OTLP traces (§9.5) |
multimodal/ | 81 | Per-model processors (processors/), feature transport, evs/ | Vision/audio inputs |
weight_cache/, checkpoint_engine/ | 4 + 3 | A shared-memory weight daemon with its own lifecycle (daemon.py, ipc_loader.py); RL-style checkpoint push | Fast restart, RL weight sync |
The non-Python trees
The Rust router
252 .rs files, 94,170 lines — larger than SGLang's entire managers/ package. Control plane (worker registry, health checks, K8s discovery) plus data plane (HTTP, PD, gRPC and OpenAI-proxy routers, cache-aware and power-of-two policies). §9.4 and §12.4 own it. The older, slimmer experimental/sgl-router/ still exists alongside.
Three PyO3 crates
71 .rs files, 26,235 lines. sglang-server/ is the embedded API server described above; sglang-grpc/ backs entrypoints/grpc_server.py; sglang-mm/ handles multimodal preprocessing. All three build into sglang.srt.rust_extensions.
One file
proto/sglang/runtime/v1/sglang.proto. The only v1 path segment in the repository, and it is protobuf package versioning, not a code generation.
Seventeen files, AMD only
Not a vendored-dependency tree. Just 3rdparty/amd/. Compiled CUDA kernels are not here — see the note below.
SGLang's compiled kernels ship in the external sgl_kernel wheel. In-tree you get python/sglang/kernels/: aot/ (306 files, 114 of them CUDA/C++, the sources for that wheel), jit/ (210 files, 163 CUDA/C++, compiled on demand at runtime), and ops/ (665 files, 472 Python — the dispatch wrappers everything else imports).
sglang/kernels/
spec.py # KernelSpec, KernelBackend, FormatSignature,
# CapabilityRequirement, PlatformInfo
registry.py # process-wide KernelRegistry + register_kernel()
selector.py # heuristic select_kernel() and cached get_kernel()
fused_op.py # BaseFusedOp: per-operator multi-backend contract
ops/
<group>/ # one subpackage per operator group (see list below)
jit/ # shared JIT CUDA build/runtime infra: utils/, csrc/,
# include/, __main__ (KERNEL_PATH resolves here)
The registry records metadata only — an operator id, a backend, and an import path — and nothing imports torch or triggers a JIT build until a kernel is actually called. There is no priority ranking: an op with several backends must be resolved by naming one, and the extra backends exist as inventory so alternatives can be compared. That is a deliberately different posture from vLLM's selector.py heuristics, and it is why SGLang kernel bugs usually reproduce with an explicit backend=.
Naming and layout conventions
managers/ versus layers/ versus model_executor/
Directory names describe responsibilities, not exact object lifetimes. Managers hold long-lived scheduling and cache state; model_executor contains persistent model runners, graph runners and reusable buffers as well as per-forward metadata such as ForwardBatch; layers hold model modules and weights. Persistent captured graphs and their buffers must outlive every replay that references them.
The navigation trap here mirrors vLLM's layer-versus-backend confusion exactly. srt/layers/radix_attention.py (614 lines) is the attention module a model instantiates; srt/mem_cache/unified_radix_cache.py (2,887 lines) is the radix cache. They share a name and share nothing else. Meanwhile the actual attention kernels live in a third place, srt/layers/attention/, 36 backend files deep, and the metadata they consume is built by the backend selected in model_runner_components/attention_backend_setup.py.
EntryClass: model discovery by module attribute
SGLang has no model registry table. It walks the package and looks for an attribute:
def import_model_classes(package_name: str, strict: bool = False):
model_arch_name_to_cls = {}
package = importlib.import_module(package_name)
for _, name, ispkg in pkgutil.iter_modules(package.__path__, package_name + "."):
if not ispkg:
if hasattr(module, "EntryClass"):
entry = module.EntryClass
if isinstance(
entry, list
): # To support multiple model classes in one module
for tmp in entry:
assert (
tmp.__name__ not in model_arch_name_to_cls
), f"Duplicated model implementation for {tmp.__name__}"
model_arch_name_to_cls[tmp.__name__] = tmp
The dictionary key is the class name, matched against hf_config.architectures[0]. So models/llama.py ends with EntryClass = [LlamaForCausalLM, Phi3ForCausalLM, InternLM3ForCausalLM, IQuestCoderForCausalLM] (python/sglang/srt/models/llama.py:L930-L935) and thereby serves four HuggingFace architectures from one 935-line file. Two consequences worth knowing: an import error in any model file is swallowed with a warning unless strict=True, so a typo in a rarely-used model silently removes it from the registry; and out-of-tree models are supported by pointing SGLANG_EXTERNAL_MODEL_PACKAGE at your own package, with no fork required.
The _v2 suffix is a scar, not a switch
find python/sglang -name '*_v2*' returns files like speculative/eagle_worker_v2.py, speculative/standalone_worker_v2.py, speculative/dflash_worker_v2.py. It is tempting to conclude there is a v1 next to each. There is not. speculative/eagle_worker.py and speculative/standalone_worker.py were deleted in commit 28c1a3cb45, "[Spec] Deprecate Spec V1 (#25464)", on 2026-06-08; dflash_worker.py in fee717f303 on 2026-06-11. The _v2 files are the only implementation. Renaming does not erase Git history: use git log --follow for a file and git blame -M -C for moved/copied lines. Retaining the suffix may simplify navigation, but the maintainers' motivation is not established by the filename.
The configuration surface
python/sglang/srt/server_args.py is 10,127 lines — the largest Python file in the repository by 2,900 lines, larger than scheduler.py. It is one dataclass with 475 annotated fields, and it tells you how to navigate itself:
Adding new arguments
--------------------
1. **Place the field in the right section.** Arguments are grouped by
comment blocks (``# Model and tokenizer``, ``# LoRA``, etc.).
Add new fields to the matching section, or create a new section
with a ``# ---`` banner when none fits.
2. **Use the ``A[T, ...]`` annotation.** ``A`` is an alias for
``typing.Annotated``. The primary CLI flag is auto-derived from the
field name (``tp_size`` → ``--tp-size``). Use ``aliases`` for
longer alternate names
(``aliases=["--tensor-parallel-size"]``)::
Concretely: lines 500–3,620 are field declarations under 42 banner-delimited sections (# Model and tokenizer, # Memory and scheduling, # Speculative decoding, # PD disaggregation, and so on). Lines 3,621 onward are a resolution pipeline of sixty-three _handle_* methods that cross-validate and back-fill defaults. The CLI is generated from the annotations, so there is no separate argparse block to keep in sync.
Two satellites finish the picture. srt/arg_groups/ (9 files, 4,614 lines) holds the parts that would otherwise bloat the dataclass: overrides.py alone is 2,733 lines of a declarative model-override registry keyed on hf_config.architectures[0], so model identity can adjust server configuration without model code mutating ServerArgs imperatively; speculative_hook.py (843 lines) does the same for spec-decode defaults. And srt/runtime_context.py (1,638 lines) is where everything else reads configuration from:
``get_exec()`` / ``get_memory()`` / ``get_schedule()`` / ``get_device()`` /
``get_model()`` / ``get_spec()`` / ``get_lora()`` / ``get_mm()`` /
``get_disagg()`` / ``get_serving()`` / ``get_observability()`` return the
resolved **config namespace bags** — the single source of truth for config,
snapshotted from ``server_args`` at publish and driven by the ``NS(...)``
metadata on each field (multi-level under ``exec.*``). Reads are attribute
chains (``get_exec().moe.moe_runner_backend``); bags are read-only by bare
assignment (written via ``override``).
Each field carries an NS("...") marker — 345 of the 475 do, across eleven namespaces (serving 55, observability 43, model 42, schedule 38, spec 37, parallel 37, disagg 26, memory 22, mm 19, lora 14, device 12). Each process calls publish(server_args, role=...) before reading anything: the scheduler as role="scheduler", the detokenizer as role="detokenizer", the main process as role="tokenizer". So when you see get_schedule().chunked_prefill_size deep inside a layer, the value came from the snapshot published at process start, not from a global mutated later.
Decomposition in place, versus a parallel tree
This is the genuine engineering-culture difference between the two projects, and it is visible in a single number: the length of model_runner.py over the last year.
wc -l of three SGLang files at historical commits (git rev-list -1 --before=…). Repository measurements, not benchmarks.| Date | model_executor/model_runner.py | managers/scheduler.py | server_args.py |
|---|---|---|---|
| 2025-08-24 | 1,912 | 2,605 | 2,491 |
| 2025-11-24 | 2,501 | 2,675 | 4,402 |
| 2026-02-24 | 2,690 | 3,194 | 5,786 |
| 2026-05-24 | 3,540 | 3,819 | 7,788 |
| 2026-07-24 | 1,894 | 4,744 | 8,994 |
2026-08-21 (7d89325) | 2,103 | 5,197 | 10,127 |
model_runner.py climbed to 3,540 lines and then fell by 46%, while its neighbours kept growing. It did not fall because features were removed. It fell because of a burst of extraction commits: 2495c02c2c "[Refactor] Cuda Graph Runner/Backend Refactor (#23906)" created runner/ and runner_backend/ on 2026-06-09; d705a91de1 (#28386) added EagerRunner and moved the eager path out; then on a single day, 2026-07-14, four PRs pulled out weight update and export (#31148, −480 lines), distributed init (#31152, −157), model-loading helpers (#31155, −183) and CUDA-graph setup (#31168, −253) into model_runner_components/.
Crucially, nothing was left behind. There is no model_runner_v1.py. The extracted modules are imported unconditionally by the one ModelRunner class; the callers did not change. Compare vLLM's approach at the same layer:
# Construct the model runner
if self.use_v2_model_runner:
from vllm.v1.worker.gpu.model_runner import (
GPUModelRunner as GPUModelRunnerV2,
)
# HACK(woosuk): This is a temporary fix to avoid type errors.
self.model_runner: GPUModelRunner = GPUModelRunnerV2( # type: ignore
self.vllm_config, self.device
)
else:
from vllm.v1.worker.gpu_model_runner import (
GPUModelRunner as GPUModelRunnerV1,
)
self.model_runner = GPUModelRunnerV1(self.vllm_config, self.device)
Two classes with the same name in two trees, chosen at runtime by the VllmConfig.use_v2_model_runner policy — which the tri-state VLLM_USE_V2_MODEL_RUNNER overrides when set, and which otherwise picks V2 for dense models and V1 for the rest (§11.4). 8,008 lines in one, 2,024 in the other, both maintained, both shipped.
Figure 4 — two ways to replace a 3,500-line file. Left: SGLang extracts into sibling packages and rewires imports. Right: vLLM forks a parallel tree and adds a selection policy. Line counts at the pinned SHAs. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
What in-place decomposition buys. There is exactly one code path, so there is no dual maintenance, no selection policy, and no "which am I running?" question when reading a stack trace. A bug fix lands once. Test matrices do not double. A newcomer reading ModelRunner.__init__ sees the real initialisation order, not a facade over two of them.
What it costs. There is no clean A/B: you cannot benchmark the old path against the new one at the same SHA, and you cannot roll back a refactor with an environment variable when it regresses in production — you roll back the deployment. Deleting an old approach becomes a merge conflict rather than a directory removal. And blame history on the surviving file becomes dense: model_runner.py now interleaves five years of feature work with three months of extraction commits, which makes git log -L on a specific function noisy.
The same idiom appears at the scheduler, one layer up, in two forms at once. Scheduler composes six mixins, and delegates to the nineteen modules of scheduler_components/ (twenty files, 6,729 lines):
class Scheduler(
SchedulerDisaggregationDecodeMixin,
SchedulerDisaggregationPrefillMixin,
SchedulerMultiplexMixin,
SchedulerPPMixin,
SchedulerDllmMixin,
SchedulerMlxOverlapMixin,
):
"""A scheduler that manages a tensor parallel GPU worker."""
Mixins for modes that change the loop's shape (disaggregated prefill, pipeline parallelism, MLX overlap); component objects for concerns that are the same in every mode (request_receiver.py, output_sender.py, metrics_reporter.py, invariant_checker.py, ipc_channels.py). If you are looking for behaviour that only happens under a flag, look in a mixin; if it happens every step, look in a component. §12.2 walks the loop itself.
The runner/ package docstring is a good example of what the extracted pieces document about themselves:
"""Phase-aware CUDA graph runners.
One concrete runner per phase. Each runner owns its phase-specific
shape semantics (decode → batch size; prefill → token count) and
delegates capture/replay mechanics to a pluggable
BaseCudaGraphBackend chosen via cuda_graph_config.
The parallelism axes
SGLang exposes more independent parallelism dimensions than the standard TP/PP/EP trio, and three of them are recent enough that most tutorials predate them. All are declared in the # Distributed topology and parallelism section of server_args.py, whose banner sits at line 1003:
attn_cp_size: A[
int,
Arg(
help="The attention context parallelism size.",
aliases=["--attention-context-parallel-size"],
resolvable=True,
),
NS("parallel"),
] = 1
moe_dp_size: A[
int,
Arg(
help="The moe data parallelism size.",
aliases=["--moe-data-parallel-size"],
),
NS("parallel"),
] = 1
dwdp_size: A[
int,
Arg(
help="DWDP (Distributed Weight Data Parallelism) group size. "
"When > 1, MoE prefill uses weight prefetch instead of token all-to-all. "
"Must equal tp_size. Only supported with --disaggregation-mode null or prefill.",
),
NS("parallel"),
] = 1
7d89325. Flags and defaults read from server_args.py; derived axes read from runtime_context.ParallelContext.| Axis | Flag | What it splits | Owned by |
|---|---|---|---|
tp_size | --tp-size | Weight matrices across GPUs; one scheduler process per rank | §5.1 |
pp_size | --pp-size | Layers into stages; adds SchedulerPPMixin to the loop | §5.2 |
dp_size | --dp-size | Whole replicas; spawns a DataParallelController in front | §5.3 |
ep_size | --ep-size, --ep | MoE experts across ranks; pairs with --moe-a2a-backend (deepep, mooncake, nixl, mori, pplx, megamoe, flashinfer, …) | §5.3 |
moe_dp_size | --moe-dp-size | A data-parallel split inside the MoE block, independent of the attention block's TP. Its rank is computed per scheduler by _compute_parallelism_ranks and passed as moe_dp_rank. | this chapter; §5.3 for the collective |
attn_cp_size | --attn-cp-size | Context (sequence) parallelism for attention only. resolvable=True, so it can be derived rather than set. Adds an ATTN_CP{n} tag to the scheduler process title. | this chapter |
dcp_size | --dcp-size | Decode-context parallelism — context parallelism applied only to the decode phase, with its own --dcp-comm-backend | this chapter |
dwdp_size | --dwdp-size | Distributed Weight Data Parallelism. Must equal tp_size. Inverts the MoE prefill communication pattern. | this chapter (below) |
attn_tp_size | — | Derived, not a flag. A @property on ParallelContext reading get_attn_tensor_model_parallel_world_size(). Under --enable-dp-attention the attention block's TP degree differs from the MoE block's, and this is how code asks which one it is in. | §5.3 |
dwdp deserves its own paragraph because it inverts the usual MoE tradeoff. The standard expert-parallel prefill sends tokens to the rank that owns the expert (an all-to-all) and sends the results back. DWDP does the opposite:
"""DWDP (Distributed Weight Data Parallelism): MoE prefill with tokens kept on-rank and peer expert weights prefetched via NVLink into a composite VMM address space."""
Tokens stay put; the weights move, prefetched over NVLink into a virtual-memory-mapped composite address space so the local GEMM can read a peer's experts as if they were resident. The implementation is seven files, 1,226 lines, under python/sglang/srt/layers/moe/dwdp/: layout.py (which rank owns which expert), transport.py (the NVLink pulls), page_pool.py and weight_buffer.py (the VMM staging area), weight_manager.py and dwdp_manager.py (lifecycle). It is prefill-only and disaggregation-restricted, which tells you the intended deployment: a prefill-dedicated pool where tokens are plentiful and expert weights are the thing you would rather not shuffle.
I found no published benchmark comparing DWDP's weight-prefetch prefill against token all-to-all at this SHA, and I have no GPU to measure one. The tradeoff is arithmetically clear — you move experts × d × d_ff weight bytes instead of tokens × d activation bytes, so the crossover depends on prefill batch size — but the crossover point is unmeasured here. The lab in §10.5 is the right place to establish it.
Scale, like for like
Like-for-like against vLLM at a556f3f, so §13.1 inherits a comparable table:
find … -name '*.py' | wc -l and wc -l. Not benchmarks.| Measure | vLLM a556f3f | SGLang 7d89325 |
|---|---|---|
| Python files in the main package | 2,270 (vllm/) | 3,397 (python/sglang/) |
| Python lines in the main package | 857,814 | 1,286,816 |
| Version-named directories | v1/ (357 files), v1/worker/gpu/ | 0 |
| Largest Python file | v1/worker/gpu_model_runner.py — 8,008 | srt/server_args.py — 10,127 |
| Scheduler | v1/core/sched/scheduler.py — 3,037 | srt/managers/scheduler.py — 5,197 |
| Model runner (live path) | 2,024 (V2, the default for dense models) or 8,008 (V1), selected at runtime | srt/model_executor/model_runner.py — 2,103 |
| Configuration surface | vllm/config/ 32 files, 14,033 lines; envs.py 2,379 | server_args.py 10,127; arg_groups/ 9 files, 4,614 |
| Model definitions | 311 in model_executor/models/ + 195 in models/ | 245 files in srt/models/, 195 exporting EntryClass |
| Rust in tree | 309 .rs files, 109,867 lines (rust/) | 323 .rs files, 120,405 lines (sgl-model-gateway/ + rust/) |
SGLang is the larger tree by line count, but a large share of that is two things a vLLM reader will not expect: python/sglang/multimodal_gen/ (948 files, 311,716 lines — an entire separate diffusion/image-generation runtime with its own server_args.py, itself 3,367 lines) and python/sglang/kernels/ (594 files, 207,569 lines). Strip those and the LLM runtime is 1,621 files and 694,547 lines.
Worked trace: one request, one hop at a time
To make the map usable, here is the single path everything else hangs off, with the file to open at each step. §12.2 does this properly in code; this is the navigational skeleton.
POST /generate to first streamed token, by process and file.| # | Process | What happens | Open this |
|---|---|---|---|
| 1 | main | FastAPI route accepts the body | srt/entrypoints/http_server.py |
| 2 | main | TokenizerManager tokenizes, applies the chat template, builds TokenizedGenerateReqInput | srt/managers/tokenizer_manager.py, srt/managers/io_struct.py |
| 3 | main → sched 0 | send_to_scheduler.send() over scheduler_input_ipc_name | python/sglang/srt/managers/tokenizer_manager.py:L553-L555 |
| 4 | sched 0 | request_receiver.recv_requests() inside event_loop_normal; broadcast to ranks 1..N−1 | python/sglang/srt/managers/scheduler.py:L1748, scheduler_components/request_receiver.py |
| 5 | all sched | Radix-cache prefix match, admission, ScheduleBatch construction | schedule_policy.py, schedule_batch.py, mem_cache/unified_radix_cache.py |
| 6 | all sched | TpModelWorker.forward_batch_generation → ModelRunner → a runner/ runner → model.forward | python/sglang/srt/managers/tp_worker.py:L574, model_executor/model_runner.py |
| 7 | sched 0 | send_to_detokenizer pushes BatchTokenIDOutput | scheduler_components/output_sender.py |
| 8 | detokenizer | event_loop decodes incrementally, trims stop strings, pushes BatchStrOutput | python/sglang/srt/managers/detokenizer_manager.py:L167-L175 |
| 9 | main | recv_from_detokenizer resolves the per-request asyncio future; SSE chunk goes out | python/sglang/srt/managers/tokenizer_manager.py:L2155 |
For a given task, open this first
managers/schedule_policy.py
Then scheduler.py's get_next_batch_to_run. Not schedule_batch.py — that is the data structure, not the policy.
mem_cache/unified_radix_cache.py
The default at this SHA. hiradix_cache.py exists but check §12.3 before assuming it is what runs.
model_executor/runner/
decode_cuda_graph_runner.py for batch-size buckets, prefill_cuda_graph_runner.py for token-count buckets, shape_key.py for what makes a bucket.
srt/models/<arch>.py
Copy the nearest architecture, export EntryClass, and check arg_groups/overrides.py for whether your architecture needs config adjustments.
layers/attention/
36 backend files. The layer is layers/radix_attention.py; selection is model_runner_components/attention_backend_setup.py.
server_args.py _handle_*
Sixty-three resolution methods run in __post_init__. Your flag was probably overridden there, or by arg_groups/overrides.py keyed on the model architecture.
Pitfalls and war stories
Attach at the correct boundary. Ingress socket receive is owned by the selected rank-zero process. After broadcast, participating scheduler ranks process the same logical input list. A missing process_input_requests breakpoint on a non-ingress rank is not explained solely by its absent ZMQ socket: inspect broadcast flow, topology and the actual method invoked.
The scheduler looks idle and throughput is fine, but streaming stutters. Check the detokenizer. It is one synchronous process with no parallelism until you set --detokenizer-worker-num > 1, at which point _launch_detokenizer_subprocesses spawns N workers plus a sglang::detokenizer_router in front of them. Nothing about GPU utilisation tells you this is the bottleneck, because the GPU is not involved.
A model "is not supported" that clearly has a file. import_model_classes swallows import errors with a warning unless strict=True (python/sglang/srt/models/registry.py:L104-L109), so a missing optional dependency inside models/your_model.py removes the architecture from the registry and the failure surfaces much later as an unsupported-architecture error. Grep the startup log for Ignore import error when loading.
Your flag was silently overridden. Between __post_init__'s sixty-three _handle_* methods and the declarative registry in arg_groups/overrides.py (2,733 lines, keyed on hf_config.architectures[0]), a lot happens between your command line and the value a layer reads. The reliable check is the log line _launch_subprocesses emits before spawning anything: logger.info(f"{server_args=}") (python/sglang/srt/entrypoints/engine.py:L1095) dumps the fully resolved dataclass.
Searching for v1 and finding _v2. Reflex-grepping for a version and landing on eagle_worker_v2.py invites the conclusion that there is a v1 to compare against. There is not; it was deleted in #25464. Confirm with git log --diff-filter=D -- <path> before you go looking for the other half.
Two runtimes, one repo. python/sglang/multimodal_gen/runtime/server_args/server_args.py is 3,367 lines and is not the file you want when debugging an LLM flag. Grep results for a common argument name will hit both trees. Anchor your searches at python/sglang/srt/.
Hands-on
Everything here is filesystem and git archaeology — no GPU required. Run against the pinned checkout.
S=~/Documents/other_git_repos/sglang
cd $S && git rev-parse HEAD # expect 7d893255c359bb8ab74d2870c8ac865fb57230d6
# 1. the map, by weight
for d in $S/python/sglang/srt/*/; do
n=$(find "$d" -name '*.py' | wc -l)
l=$(find "$d" -name '*.py' -exec cat {} + | wc -l)
echo "$n $l $d"
done | sort -rn | head -20
# 2. no version directories
find python/sglang -type d -name 'v[0-9]*'
# 3. the decomposition, as a time series
for t in "12 months ago" "6 months ago" "3 months ago" "1 month ago"; do
h=$(git rev-list -1 --before="$t" HEAD)
echo "$t $(git show $h:python/sglang/srt/model_executor/model_runner.py | wc -l)"
done
# 4. what pulled 1,600 lines out of it
git log --format='%ad %h %s' --date=short --numstat \
-- python/sglang/srt/model_executor/model_runner.py | head -60
# 5. every ZMQ endpoint, both ends
grep -rn "get_zmq_socket" python/sglang/srt/managers/ | grep -v "^.*test"
Then read one file end to end: python/sglang/srt/managers/scheduler_components/ipc_channels.py. It is 88 lines and it is the entire process topology.
Exercises
- Read and answer. Open
python/sglang/srt/entrypoints/engine.pyand find_launch_scheduler_processes. Under what two conditions does it spawn aDataParallelControllerinstead of per-rank schedulers? Which ZMQ endpoint does the controller bind, and why does that make the choice invisible to theTokenizerManager? - Count. Using only
findandwc, work out how many Python files and lines are inpython/sglang/srt/versuspython/sglang/multimodal_gen/. Then explain why the naive "SGLang is 1.29M lines to vLLM's 858k" comparison is misleading. - Predict, then verify. You launch with
--tp-size 4 --skip-tokenizer-init. How many OS processes does_launch_subprocessescreate, and which ZMQ endpoints get bound? Predict first, then confirm againstpython/sglang/srt/entrypoints/engine.py:L1204-L1210andpython/sglang/srt/managers/scheduler_components/ipc_channels.py:L52-L65. - Archaeology. Pick any file in
srt/model_executor/model_runner_components/. Usegit log --diff-filter=A --reverseto find the commit that created it, then read that commit's diff againstmodel_runner.py. Was any code left behind in the original file? - Design. Argue the opposite case: name one concrete situation where vLLM's parallel-tree strategy is strictly better than SGLang's in-place decomposition, and one where it is strictly worse. Ground both in something you can point at in either repository.
Answers
1. use_dp_controller = get_parallel().dp_size > 1 or get_exec().moe.ep_join_mode == "scale" (python/sglang/srt/entrypoints/engine.py:L872-L874). The controller binds port_args.scheduler_input_ipc_name — the exact endpoint a rank-0 scheduler would have bound (python/sglang/srt/managers/data_parallel_controller.py:L150-L154). Since the address is identical, the TokenizerManager's send_to_scheduler socket is unchanged and the front end cannot distinguish a single scheduler from a load balancer fronting N replicas.
2. srt/: 1,621 files, 694,547 lines. multimodal_gen/: 948 files, 311,716 lines. The latter is a separate diffusion/image-generation runtime with its own server args and model definitions; it shares the repository but not the LLM code path. Comparing whole-repo totals therefore compares vLLM's LLM engine against SGLang's LLM engine plus a second product plus a 207k-line in-tree kernel library.
3. Six: the main process, four schedulers, and a detokenizer. The detokenizer is the trap — _launch_detokenizer_subprocesses runs unconditionally, so the process exists, but rank 0's send_to_detokenizer was rebound to tokenizer_ipc_name, so nothing is ever pushed to it and it sits idle on its PULL forever. Endpoints bound: scheduler_input_ipc_name (rank 0 PULL), tokenizer_ipc_name (main PULL), rpc_ipc_name (rank 0 DEALER), detokenizer_ipc_name (detokenizer PULL, unused). Ranks 1–3 bind nothing.
4. In every case, no. That is the defining property of the July 2026 extractions: the removed lines were removed, not duplicated. #31148 is the clearest example — 480 lines out, 50 back in as delegation to WeightUpdater and WeightExporter.
5. Strictly better: a change with an unknown production blast radius. vLLM can ship gpu/model_runner.py to real traffic behind VLLM_USE_V2_MODEL_RUNNER=1 and revert with an environment variable; SGLang would have to revert the deployment. Strictly worse: any cross-cutting bug fix. A correctness fix in vLLM's attention-metadata handling must land in both gpu_model_runner.py and gpu/model_runner.py or one path silently keeps the bug — and the # HACK(woosuk): This is a temporary fix to avoid type errors comment at vllm/v1/worker/gpu_worker.py:L429 is the maintenance tax made visible.
Key takeaways
- Three processes, and the third one is the interesting one. Incremental detokenization is per-token Python work that is neither GPU-bound nor request-multiplexed. SGLang gives it its own process so it can stall neither the kernel-launch thread nor the asyncio event loop, and pays one ZMQ round-trip per output batch for the isolation. The
SGLANG_RUST_SERVERpath collapses all three back into one process because Rust threads make the isolation unnecessary. - Only TP rank 0 has sockets. Every IPC field on ranks 1..N−1 is
None; they learn about work throughtorch.distributed. This one fact explains most confusing debugging sessions in a multi-GPU SGLang deployment. - In-place decomposition trades rollback for single-path clarity.
model_runner.pywent 3,540 → 1,894 lines in two months of extraction commits with nothing left behind, so there is one code path, one stack trace shape, and one place a bug fix lands. The price is that you cannot A/B a refactor at a single SHA, and cannot revert one with a flag. _v2in a filename means the v1 was deleted, not that a v1 exists.eagle_worker.pyandstandalone_worker.pywent away in #25464; the surviving names identify the retained implementation; rename-aware Git tools can still investigate earlier names.- Configuration is one 10,127-line dataclass plus a published snapshot. 475 annotated fields generate the CLI; sixty-three
_handle_*methods and a 2,733-line model-override registry resolve them;runtime_context.publish()then freezes the result into per-namespace bags that every process reads throughget_parallel(),get_schedule(), and friends. When a flag appears not to work, that pipeline is where it went. - There are nine parallelism dimensions, not three. Beyond TP/PP/DP/EP, SGLang carries
moe_dp_size,attn_cp_size,dcp_sizeanddwdp_sizeas independent flags plus a derivedattn_tp_size, and a scheduler process receives five separate rank coordinates at spawn.
Further reading
- PR #23906 — "[Refactor] Cuda Graph Runner/Backend Refactor". The commit that created
model_executor/runner/andrunner_backend/, 2026-06-09. Start here for the extraction pattern. - PR #31148 — "Introduce WeightUpdater and WeightExporter components", and its same-day siblings #31152, #31155, #31168. Four PRs, one day, 1,073 lines out of
model_runner.py. - PR #25464 — "[Spec] Deprecate Spec V1". Where
eagle_worker.pyandstandalone_worker.pywere deleted and the_v2names became load-bearing. - RFC #29630 — the unified
sglang.kernelsnamespace, and its finale #32072 which removed the legacysglang.jit_kernelpackage. The design rationale for registry-over-heuristics kernel selection. - Zheng et al., "SGLang: Efficient Execution of Structured Language Model Programs" (2023). The original paper, which describes the
lang/frontend that is now marked deprecated in-tree — a useful calibration on how fast the layout moves. - §11.1 for the vLLM counterpart, and §13.1 where the two halves are compared directly.
- In-tree:
python/sglang/README.md(the folder list) andpython/sglang/kernels/README.md(the kernel contract). Both are short and both are maintained.