TokenizerManager → Scheduler → TpModelWorker → ModelRunner
python/sglang/srt/managers/tokenizer_manager.pypython/sglang/srt/managers/scheduler.pypython/sglang/srt/managers/tp_worker.pypython/sglang/srt/model_executor/model_runner.py
a556f3f · sglang 7d89325vLLM decides once and tells everyone. SGLang tells everyone the inputs and lets each rank decide for itself. That single inversion — a full Scheduler object per tensor-parallel rank rather than one scheduler broadcasting a plan — propagates into every layer below it, and it is the sharpest architectural difference between the two engines. §9.3 already walked a request across these hops; this chapter stops moving and looks at the structure that carries it.
The problem
Here is what an SGLang operator sees when the thing that actually broke was one tensor-parallel rank taking a different branch inside its radix-cache bookkeeping:
def _watchdog_once(self):
watchdog_last_counter = 0
watchdog_last_time = time.perf_counter()
while True:
current = time.perf_counter()
if self.is_active():
current_counter = self.get_counter()
if watchdog_last_counter == current_counter:
if current > watchdog_last_time + self.watchdog_timeout:
break
else:
watchdog_last_counter = current_counter
watchdog_last_time = current
time.sleep(self.watchdog_timeout / 2)
if self.dump_info is not None and (info_msg := self.dump_info()):
logger.error(f"{self.debug_name} debug info:\n{info_msg}")
pyspy_dump_schedulers()
logger.error(
f"{self.debug_name} watchdog timeout "
f"({self.watchdog_timeout=}, {self.soft=})"
)
print(file=sys.stderr, flush=True)
print(file=sys.stdout, flush=True)
if not self.soft:
# Wait for some time so that the parent process can print the error.
time.sleep(5)
No exception, no traceback, no bad token. A counter stopped advancing, and after a timeout a background thread dumps py-spy stacks and SIGQUITs the parent. The counter is the forward-pass counter itself:
def create_scheduler_watchdog(
scheduler: Scheduler, watchdog_timeout: float, soft: bool = False
) -> WatchdogRaw:
def dump_info() -> str:
if scheduler.is_initializing:
return ""
_, messages = scheduler.invariant_checker._check_all_pools(
scheduler.pool_stats_observer.get_pool_stats(),
)
return (
f"{scheduler.cur_batch_for_debug.batch_size()=}\n"
f"{scheduler.cur_batch_for_debug.reqs=}\n" + "\n".join(messages)
)
return WatchdogRaw(
debug_name="Scheduler",
get_counter=lambda: scheduler.forward_ct,
is_active=lambda: (
scheduler.is_initializing or scheduler.cur_batch_for_debug is not None
),
watchdog_timeout=watchdog_timeout,
soft=soft,
dump_info=dump_info,
)
Replicated scheduling requires logical agreement. If ranks reuse different prefix lengths, one may schedule different query rows or collective shapes. Incompatible collective order or participation can hang; compatible shapes with different logical rows can silently mix unrelated values; explicit checks may raise. No single symptom follows inevitably from cache divergence. Validate logical batch identity in addition to progress.
You cannot debug that without being able to say which object lives in which process, what state each owns exclusively, and — the crux — which state is replicated rather than communicated.
Mental model: four process kinds, one replicated across N
SGLang runs four kinds of process, and one of them is replicated once per rank.
The first cut — between TokenizerManager and Scheduler — defeats the GIL, for the reason §11.2 derives for vLLM: host work that scales with batch size must not share an interpreter lock with the step loop. SGLang cuts twice rather than once — tokenisation in the TokenizerManager, detokenisation in a third DetokenizerManager process — so the pipeline is a straight line of three hops, not a there-and-back.
The second cut — one Scheduler process per TP×PP rank — gets one Python interpreter per GPU, because a CUDA context belongs to a process. That much matches vLLM. What differs is what goes in: vLLM puts a Worker there and keeps the scheduler above the cut; SGLang puts the entire scheduler there, once per rank.
Figure 1 — the layer stack, with process boundaries drawn and exclusive state labelled. Dashed edges cross a process boundary. Note that the dashed ZMQ edges terminate only at rank 0; every other rank's sockets are None.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
A layer owns state exclusively if no other layer can name the object. TokenizerManager.rid_to_state is invisible to the Scheduler; Scheduler.waiting_queue is invisible to the TokenizerManager. But Scheduler.tree_cache is invisible to the other seven schedulers too, and all must agree on logical request order, reusable prefix lengths and collective-compatible shapes; physical slot IDs and rank-sharded tensor values can differ. That is a different and much stronger obligation than any boundary in vLLM's stack, and §3 is about what makes it safe.
First principles: replicate the plan, or broadcast it
Both engines must get $N$ GPUs to execute the same batch. Two useful design patterns are centralized plan broadcast and replicated planning; hybrids are also possible.
Broadcast the plan. One scheduler decides, serialises the decision, every rank reads it. Ranks must still interpret the plan consistently, map logical work to valid local storage, and execute compatible collectives. §5.5 and §11.2 cover vLLM's version: a SchedulerOutput over a shared-memory MessageQueue per step. The # TODO: make v1 scheduling deterministic beside external_launcher (vllm/v1/executor/abstract.py:L78-L81) is vLLM stating why it cannot do otherwise.
Replicate the plan. Broadcast the inputs once at arrival; every rank derives the same decision independently. Correctness now depends entirely on every replica computing the same function of the same inputs. In SGLang this is not a mode, it is the only path — there is no SchedulerOutput type in the tree.
Figure 2 — broadcast-the-plan against replicate-the-plan, with the barrier marked in each. The barrier does not disappear in SGLang; it moves from after the decision to before it, and its payload shrinks from a plan to a request list. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
What actually crosses, per step
The barrier is real in both designs. In SGLang it sits at the top of every loop iteration, inside SchedulerRequestReceiver.recv_requests:
@scheduler_nvtx_method("scheduler.recv_requests")
def recv_requests(
self,
) -> List[Union[TokenizedGenerateReqInput, TokenizedEmbeddingReqInput, Any]]:
"""Receive results at tp_rank = 0 and broadcast it to all other TP ranks."""
if self.scripted_scheduler_hook is not None:
self.scripted_scheduler_hook.step()
if self.recv_skipper is not None:
if not self.recv_skipper.handle(self.get_last_batch()):
return []
recv_reqs = self._pull_raw_reqs()
if self.input_blocker is not None:
recv_reqs = self.input_blocker.handle(recv_reqs)
recv_reqs = self._broadcast_reqs_across_ranks(recv_reqs)
if self.ps.pp_rank == 0:
self.unwrap_pickle_wrapper(recv_reqs)
recv_reqs = self._apply_mm_receiver(recv_reqs)
self._finalize_shm_features(recv_reqs)
return recv_reqs
The docstring — "Receive results at tp_rank = 0 and broadcast it to all other TP ranks" — is the whole design in one line. Rank 0 drains its socket; every rank calls a collective. In the plain TP case that collective is one line:
elif self.ps.tp_size != 1:
recv_reqs = broadcast_pyobj(
recv_reqs,
self.tp_group.rank,
self.tp_cpu_group,
src=self.tp_group.ranks[0],
)
return recv_reqs
And the empty case — the common one, since arrivals are far rarer than steps — costs a single int64:
)
if rank == src:
if len(data) == 0:
tensor_size = torch.tensor([0], dtype=torch.long, device=device)
dist.broadcast(tensor_size, src=src, group=dist_group)
else:
serialized_data = pickle.dumps(data)
size = len(serialized_data)
tensor_data = torch.ByteTensor(
np.frombuffer(serialized_data, dtype=np.uint8)
).to(device)
tensor_size = torch.tensor([size], dtype=torch.long, device=device)
dist.broadcast(tensor_size, src=src, group=dist_group)
Take Llama-3-8B in bf16 on an H100 SXM. The book's derived weight-bandwidth floor is a 4.48 ms decode step at batch 1 (§0.4), and because batch 32 sits far below the ridge point $I^\* = 295$ the period barely moves as the batch grows — 223 loop iterations per second. SGLang's steady-state rank fan-out is therefore $223 \times 8 = 1.8$ KB/s of gloo traffic per rank, independent of batch size: arithmetic, nothing measured. A 2,048-token prompt pickles as roughly 8 KB and crosses once per arrival, not per step.
Bytes are not the point. vLLM's per-step SchedulerOutput is also small, and rides a shared-memory ring rather than gloo. What replication buys is that the fan-out payload is decoupled from the batch: it does not grow with the running requests, the newly allocated blocks, or the per-request deltas, because none of those are transmitted. Each rank recomputes them. What it costs is that every rank must recompute them identically.
What makes it safe
Three preconditions, all visible in the source.
Identical inputs. The collective above guarantees every rank sees the same request list in the same order in the same iteration. Requests arriving mid-forward do not exist for any rank until the next recv_requests.
Identical randomness. Sampling is a scheduler-side decision that must land on the same token, so the seed is broadcast at construction:
# Init nccl groups
self.pp_group = get_pp_group()
self.world_group = get_world_group()
# Sync random seed across TP workers.
# Elastic joiners cannot enter the launch-time WORLD broadcast.
if server_args.is_ep_joiner:
self.random_seed = server_args.random_seed
else:
self.random_seed = broadcast_pyobj(
[server_args.random_seed],
self.ps.tp_size * self.ps.pp_rank + self.ps.tp_rank,
self.world_group.cpu_group,
src=self.world_group.ranks[0],
)[0]
Identical code paths, checked on demand. SGLang ships an opt-in divergence detector. The @rank_consensus decorator records a formatted event per call; a background thread drains the queue in lock-step across ranks and compares a SHA-1 of the concatenation:
# Compute sha1 of concatenation of all msgs.
hasher = hashlib.sha1()
for msg in events:
hasher.update(msg.encode("utf-8"))
# Determine if some rank has a different value.
value_bytes = hasher.digest()
min_value = torch.tensor(list(hasher.digest()), dtype=torch.uint8)
max_value = min_value.clone()
for group in _sync_groups:
dist.all_reduce(min_value, op=dist.ReduceOp.MIN, group=group)
dist.all_reduce(max_value, op=dist.ReduceOp.MAX, group=group)
if not torch.equal(min_value, max_value):
# When divergence, all rank should output the following log.
logger.critical(
f"Found rank divergence for {len(events)} events(s)! local hash: {value_bytes.hex()}, events = {events}"
)
for handler in logger.handlers:
handler.flush()
# os._exit instead of sys.exit: this runs in a background thread, where
# SystemExit would only kill the thread, not the process. os._exit tears
# down the whole scheduler process so a TP/PP mismatch can never
# silently keep serving.
os._exit(1)
logger.debug(f"Consensus check passed for {len(events)} event(s).")
The os._exit(1) and its comment are the design's own risk assessment: a TP mismatch must never keep serving quietly. It is a debugging tool — the decorator compiles away entirely when SGLANG_ENABLE_RANK_CONSENSUS_CHECKER is unset, returning the undecorated function at import time.
Where the decorators sit tells you where the project believes the risk lives. At this SHA there are exactly three, all in the unified radix cache:
@rank_consensus(
same_params=["params"],
same_results=["result.full_kv_hit_length", "result.swa_host_hit_length"],
)
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
The others sit on check_prefetch_progress (python/sglang/srt/mem_cache/unified_radix_cache.py:L1753) and release_aborted_request (:L1971). §5.5 reached the same conclusion from the orchestration side: SGLang's own view is that divergence risk lives in host-side cache bookkeeping, not in the model forward. That is the right place to worry. Weights and kernels are identical by construction; what differs is Python state that touched a wall clock, a hash seed, a dict iteration order, or an unsynchronised host pool.
The five layers, and what each owns exclusively
TokenizerManager — the frontend
Process: the API server's. Concurrency: asyncio, single-threaded. It owns the HF tokenizer, the multimodal processor, and one ReqState per in-flight request — an asyncio.Event, an output list, lazily accumulated text (python/sglang/srt/managers/tokenizer_manager.py:L215-L245). Its sockets are asymmetric:
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
else:
# Use tokenizer_worker_ipc_name in multi-tokenizer mode
self.send_to_scheduler = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_worker_ipc_name, False
)
self.tokenizer_ipc_name = port_args.tokenizer_ipc_name
self.load_snapshot_reader = create_load_snapshot_reader(
port_args,
caller="TokenizerManager",
)
It pushes to the scheduler and pulls from the detokenizer: no reply channel on the hot path, outputs come the long way round. generate_request tokenises, calls _send_one_request, then awaits its own ReqState.event — write and wait entirely decoupled (python/sglang/srt/managers/tokenizer_manager.py:L806-L817).
Scheduler — the event loop, one per rank
Owns exclusively (per rank): the waiting_queue, the running_batch, the tree_cache, the req_to_token_pool and KV allocator, the grammar backend, the chunked-prefill cursor, stop-string state, and forward_ct. A large object — 5,197 lines at this SHA, six mixin bases — but the state it owns is precisely the state that must be identical on every rank.
Its most consequential single design choice is the one in ipc_channels.py: only rank zero gets sockets at all.
if is_rank_zero:
recv_from_tokenizer = get_zmq_socket(
context, zmq.PULL, port_args.scheduler_input_ipc_name, False
)
if enable_scripted_runtime:
else:
recv_from_tokenizer = None
recv_from_rpc = None
send_to_tokenizer = SenderWrapper(None)
send_to_detokenizer = SenderWrapper(None)
Every rank then runs the same output code, and the wrapper makes seven of the eight into no-ops:
def send_output(
self,
output: Union[BaseReq, BaseBatchReq],
recv_obj: Optional[object] = None,
):
if self.socket is None:
return
Three lines, and they are why replication is tractable. Because sending is a no-op rather than a branch, no if tp_rank == 0 is scattered through the result path — exactly the asymmetry that lets ranks diverge. The ranks run identical code and differ only in whether a socket exists.
TpModelWorker — the rank's model wrapper
Owns exclusively: the ModelRunner, a private tokenizer copy (the scheduler's stop-string matching uses it), the broadcast random seed, and the PP/world process groups. Thin at 709 lines, and its hot method does one interesting thing: translate the scheduler's object into the runner's.
# Get forward batch from schedule batch
if batch is not None:
# update the consumer index of hicache to the running batch
self.set_hicache_consumer(batch.hicache_consumer_index)
forward_batch = ForwardBatch.init_new(
batch,
self.model_runner,
capture_hidden_mode=capture_hidden_mode,
return_hidden_states_before_norm=False,
)
else:
# FIXME(lsyin): unify the interface of forward_batch
Everything below this line is device work; everything above it is scheduling. The worker also owns the decision to defer sampling: with overlap on and grammars in the batch it returns a delay_sample_func closure instead of tokens (python/sglang/srt/managers/tp_worker.py:L627-L646), because the grammar mask depends on the previous batch's committed tokens.
ModelRunner — the device
Owns exclusively: the torch.device, the loaded weights, the attention backend, the KV pools, the CUDA-graph runners, the expert-distribution recorder, and the LoRA manager. Its public surface is small: forward, sample, compute_logprobs_only, plus weight-update RPCs. forward wraps _forward_raw, which makes the one decision the scheduler cannot: whether this batch can replay a captured CUDA graph (python/sglang/srt/model_executor/model_runner.py:L1657-L1666). 2,103 lines, with setup pushed into model_runner_components/ and execution strategies into runner/.
DetokenizerManager — its own process
Owns exclusively: a second HF tokenizer instance and a bounded map of incremental decode state. Its whole loop is seven lines of body:
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()
The bound matters: decode_status is a LimitedCapacityDict, so a request evicted under pressure loses its incremental-decode offsets rather than growing the process without bound. §9.2 owns this path, including the finding that SGLang's stop-string matching runs in the scheduler via Req.tail_str() — one tokenizer.decode per request per step, and the only tokenisation work anywhere on the forward pass's critical path.
The ZMQ topology, and where DP adds a controller
Five IPC endpoints are allocated unconditionally, all declared on one dataclass (python/sglang/srt/server_args.py:L9955-L9976): tokenizer_ipc_name, scheduler_input_ipc_name, detokenizer_ipc_name, rpc_ipc_name, metrics_ipc_name. Three more on the same dataclass are conditional and None or empty in the default configuration: tokenizer_worker_ipc_name, decoupled_spec_ipc_config, and load_collector_ipc_name. Every hot-path socket is PUSH/PULL — there is no request/reply on the generation path. The one DEALER is recv_from_rpc, for out-of-band control calls.
Figure 3 — the ZMQ topology, with the payload type on each edge and the DP controller interposed. The controller does not change any endpoint name; it binds scheduler_input_ipc_name itself and fans out on private sockets.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
Every payload is a msgspec struct descending from one of two bases, and both are positional:
class BaseReq(msgspec.Struct, tag=True, kw_only=True, array_like=True):
"""Base for single-request IPC payloads."""
rid: Optional[str] = None
http_worker_ipc: Optional[str] = None
@classmethod
def __get_pydantic_core_schema__(cls, source, handler):
return msgspec_struct_pydantic_core_schema(cls, handler)
class BaseBatchReq(msgspec.Struct, tag=True, kw_only=True, array_like=True):
"""Base for batched IPC payloads."""
rids: Optional[List[str]] = None
# Used by batch messages whose items are parallel arrays, such as scheduler
# outputs. Tokenized input batches store routing on batch[i].http_worker_ipc
# because the scheduler unpacks them into single-request handlers.
http_worker_ipcs: Optional[List[Optional[str]]] = None
@classmethod
def __get_pydantic_core_schema__(cls, source, handler):
return msgspec_struct_pydantic_core_schema(cls, handler)
When the msgspec transport is selected, array_like=True makes encoded field order positional and tag=True supplies a type tag. That is not the unconditional default wire format: the pickle path discussed in the request lifecycle serializes Python objects instead. Record the transport setting and test its decoder/version pair; object dispatch and serialization layout are separate layers.
The outbound struct is columnar: BatchTokenIDOutput holds parallel lists — rids, decoded_texts, decode_ids, read_offsets, finished_reasons, then twelve logprob arrays (python/sglang/srt/managers/io_struct.py:L1395-L1440). One message per step for the whole batch, not one per token, which is the same amortisation argument §11.2 makes for vLLM's EngineCoreOutputs.
Data parallelism adds exactly one process and changes no addresses. When dp_size > 1, _launch_scheduler_processes spawns a DataParallelController instead of schedulers, and it binds scheduler_input_ipc_name — the address the TokenizerManager was already pushing to. It owns one private socket per DP worker and dispatches by round_robin, total_requests, total_tokens, or follow_bootstrap_room (python/sglang/srt/managers/data_parallel_controller.py:L156-L168).
ScheduleBatch, and the object it becomes
One object carries a batch through the scheduler, and its own class comment explains the split that matters:
class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
"""Store all information of a batch on the scheduler."""
# === Core: request list (ForwardBatch derives lora_ids / rids / grammars / positions from it) ===
reqs: List[Req]
# === Global config and shared resources (engine-lifetime; identical across batches) ===
# Memory pool and cache
req_to_token_pool: ReqToTokenPool = None
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator = None
tree_cache: BasePrefixCache = None
# Batch configs
model_config: ModelConfig = None
enable_overlap: bool = False
# Device
device: str = "cuda"
# HiSparse (engine-level coordinator ref, same across batches)
hisparse_coordinator: Optional[HiSparseCoordinator] = None
# === Batch-variant scheduler state (per-batch; not read by ForwardBatch) ===
# Tell whether the current running batch is full so that we can skip
# the check of whether to prefill new requests.
# This is an optimization to reduce the overhead of the prefill check.
batch_is_full: bool = False
The field groups are the design: engine-lifetime shared resources, batch-variant scheduler state ForwardBatch never reads, and GPU tensors that cross into the forward. ForwardBatch is strictly smaller, built fresh per forward, and says its core tensors are borrowed:
class ForwardBatch(ForwardBatchDeepSeekMHAMixin):
"""Store all inputs of a forward pass."""
# === Required core inputs (no default; input_ids / req_pool_indices / seq_lens / out_cache_loc are borrowed from ScheduleBatch) ===
# The forward mode
forward_mode: ForwardMode
# The batch size
batch_size: int
# The input ids
input_ids: torch.Tensor
# The indices of requests in the req_to_token_pool
req_pool_indices: torch.Tensor
# The sequence length
seq_lens: torch.Tensor
# The indices of output tokens in the token_to_kv_pool
out_cache_loc: torch.Tensor
# The sum of all sequence lengths
seq_lens_sum: int
Two operations mutate a ScheduleBatch; §1.3 owns the concept. filter_batch drops finished requests by building a keep-index and re-indexing every parallel tensor (python/sglang/srt/managers/schedule_batch.py:L3147-L3175). merge_batch concatenates a freshly prefilled batch onto the running one, and its internal ordering is a real invariant:
def merge_batch(self, other: ScheduleBatch):
# Penalizer orchestrator must be merged before Batch.reqs is merged. This is because
# orchestrator.merge() depends on Batch.reqs during preparation of each penalizers, so it
# needs to be called with pre-merged Batch.reqs.
self.sampling_info.merge_batch(other.sampling_info)
# Encoder-decoder infos
if self.model_config.is_encoder_decoder:
self.encoder_lens = torch.cat([self.encoder_lens, other.encoder_lens])
self.encoder_lens_cpu = self.encoder_lens_cpu + other.encoder_lens_cpu
self.req_pool_indices = torch.cat(
[self.req_pool_indices, other.req_pool_indices]
)
self.req_pool_indices_cpu = torch.cat(
[self.req_pool_indices_cpu, other.req_pool_indices_cpu]
These two calls are what make continuous batching continuous: get_next_batch_to_run filters last_batch, merges the survivors into running_batch, then asks for a new prefill batch — prefill wins if there is one, decode runs otherwise (python/sglang/srt/managers/scheduler.py:L3188-L3197). The whole prefill-priority policy is that one if new_batch is not None.
A third operation exists only because of overlap: ScheduleBatch.copy() snapshots exactly the fields process_batch_result will need, because by then the live batch has already been mutated for the next forward (python/sglang/srt/managers/schedule_batch.py:L3299-L3305).
Worked trace: one iteration of event_loop_overlap
SGLang has two event loops. The normal one is the honest baseline:
@DynamicGradMode()
def event_loop_normal(self):
"""A normal scheduler loop."""
while True:
if self.gracefully_exit:
break
# Receive requests
recv_reqs = self.request_receiver.recv_requests()
self.process_input_requests(recv_reqs)
if self._engine_paused:
continue
# Get the next batch to run
plan = self.get_next_batch_to_run(
running_batch=self.running_batch, last_batch=self.last_batch
)
self.running_batch = plan.running_batch
batch = plan.batch_to_run
self.cur_batch_for_debug = batch
# Launch the current batch
if batch:
result = self.run_batch(batch)
self.process_batch_result(batch, result)
else:
# When the server is idle, do self-check and re-init some states.
self._sched_idled = True
self.on_idle()
# Update last_batch
self.last_batch = batch
Receive, plan, launch, process, repeat. Every stage is strictly after the previous one, so the period is $T_{\text{fwd}} + S$ where $S$ is all host-side work: get_next_batch_to_run's queue arithmetic and prefix matching, plus process_batch_result's per-request finish-state and stop-string checks.
The overlap loop turns that sum into a maximum — the same $T_{\text{one}} = T_{\text{step}} + Bc$ versus $T_{\text{two}} = \max(T_{\text{step}}, Bc)$ that §11.2 derives for vLLM's process split, by a completely different mechanism. vLLM buys the maximum with a process boundary and a GIL escape; SGLang buys it with CUDA streams and a one-iteration lag, inside one process:
@DynamicGradMode()
def event_loop_overlap(self):
"""A scheduler loop that overlaps the CPU processing and GPU computation."""
self.result_queue: Deque[
Tuple[ScheduleBatch, Union[GenerationBatchResult, EmbeddingBatchResult]]
] = deque()
def pop_and_process():
# Process the results of the last batch
tmp_batch, tmp_result = self.result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result)
while True:
if self.gracefully_exit:
break
# Receive requests
recv_reqs = self.request_receiver.recv_requests()
self.process_input_requests(recv_reqs)
# Launch the current batch
if batch:
batch_result = self.run_batch(batch)
# Fence result processing behind this forward's shared reads.
self._apply_war_barrier()
self.result_queue.append((batch.copy(), batch_result))
else:
batch_result = None
self._sched_idled = True
# Process the last batch
if self.last_batch:
if not disable_overlap_for_batch:
pop_and_process()
elif batch is None:
# When the server is idle, do self-check and re-init some states
self.on_idle()
# Run sample of the current batch
# It depends on the result of the last batch (e.g., grammar), so we run it after the last batch is processed.
if self.is_generation:
self.launch_batch_sample_if_needed(batch_result, batch)
# Update last_batch
self.last_batch = batch
Read the order carefully. The current batch's forward is launched and pushed onto result_queue; only then is the previous batch's result popped and processed. The launch being asynchronous on forward_stream, everything pop_and_process does runs on the host while the GPU is busy. The sync point is one event wait at the top of the result processor:
def process_batch_result_decode(
self,
batch: ScheduleBatch,
result: GenerationBatchResult,
):
if result.copy_done is not None:
result.copy_done.synchronize()
if result.routed_experts_output is not None:
result.routed_experts_output.finalize()
result.routed_experts_output = None
Figure 4 — one iteration of event_loop_overlap, showing what runs on the host while the GPU is busy. Timings are the derived 4.48 ms Llama-3-8B decode floor on H100 SXM; the host-side durations are unmeasured and shown only as ordering.
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
Why the scheduler can plan a batch whose input tokens do not exist yet
The thing that makes the lag legal is FutureMap. The scheduler builds iteration $k+1$'s decode batch before iteration $k$ has sampled anything, so it cannot fill input_ids. It leaves the field None, and the forward stream gathers it from a device-resident buffer indexed by request-pool slot:
def resolve_forward_inputs(batch: ScheduleBatch, future_map: FutureMap) -> None:
"""Materialize input_ids at forward entry. Two sources:
- Prefill: H2D copy from pinned CPU staging (prefill_input_ids_cpu).
- Decode/spec_v2: gather from FutureMap (last iter's sampled token).
"""
if batch.prefill_input_ids_cpu is not None:
prefill_gpu = batch.prefill_input_ids_cpu.to(batch.device, non_blocking=True)
if batch.mix_running_indices is not None:
decode_gpu = future_map.output_tokens_buf[batch.mix_running_indices]
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(
decode_gpu,
future_map.output_tokens_buf,
batch.mix_running_indices,
)
batch.input_ids = torch.cat([prefill_gpu, decode_gpu])
else:
batch.input_ids = prefill_gpu
batch.prefill_input_ids_cpu = None
batch.mix_running_indices = None
elif batch.input_ids is None and future_map.spec_algo.is_none():
batch.input_ids = future_map.output_tokens_buf[batch.req_pool_indices]
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(
batch.input_ids, future_map.output_tokens_buf, batch.req_pool_indices
)
Enqueued on the same stream as the forward, the gather observes the previous forward's writes with no host synchronisation. The scheduler never learns the token; it only knows the slot — "Always-on pool-indexed relay for cross-iter values" (python/sglang/srt/managers/overlap_utils.py:L246-L249).
What breaks overlap
Four things turn overlap off.
def is_disable_overlap_for_batch(
self, batch: ScheduleBatch, last_batch: Optional[ScheduleBatch]
) -> bool:
# For two consecutive prefill batches, we disable overlap to improve the TTFT of the first batch.
# This might slightly hurt the throughput, so we use an environment variable to control it.
# In DP attention mode, use the globally synchronized is_extend_in_batch
# so all DP ranks make the same overlap decision (avoiding deadlock).
# In non-DP mode, use the local forward_mode directly.
if self.require_mlp_sync:
is_extend = lambda b: b and b.is_extend_in_batch
else:
is_extend = lambda b: b and b.forward_mode.is_extend()
batch_is_extend = is_extend(batch)
last_batch_is_extend = is_extend(last_batch)
disable_overlap_for_batch = (
envs.SGLANG_DISABLE_CONSECUTIVE_PREFILL_OVERLAP.get()
and batch_is_extend
and last_batch_is_extend
)
# Sync so the FSM advance lands before the next batch's bitmask. Permanent
# path for host-draft algorithms, not a pending migration.
need_grammar_sync = (
batch
and not batch.spec_algorithm.is_none()
and batch.grammar_needs_sync()
and batch.forward_mode.is_decode()
and len(self.result_queue) > 0
)
# Algorithms that support grammar overlap advance the FSM inside verify()
# via the grammar barrier (overlapping the target forward), which resolves
# whatever result is still pending in the queue — including the
# extend->decode boundary — so no grammar-specific overlap disable is needed.
return disable_overlap_for_batch or need_grammar_sync
Two consecutive prefills
Under SGLANG_DISABLE_CONSECUTIVE_PREFILL_OVERLAP, back-to-back extend batches drop overlap so the first batch's TTFT is not delayed by the second's forward. The comment says outright that this trades throughput for TTFT.
Grammar without overlap support
need_grammar_sync fires when a spec batch needs a bitmask and the previous result is still queued. §6.5 found this: algorithms whose supports_grammar_overlap() is false must drain the queue first, since the FSM advance has to land before the next bitmask is built.
Speculative decoding on CPU
python/sglang/srt/arg_groups/speculative_hook.py:L14-L21 sets disable_overlap_schedule = True outright with the message "Overlap schedule is not implemented for speculative decoding on CPU."
MPS device
python/sglang/srt/server_args.py:L4423-L4426 disables it on Apple mps unless MLX is in use, which has its own event_loop_overlap_mlx.
Which loop runs is a dispatch over eleven distinct loops — the fastest way to see how many execution modes SGLang carries:
def dispatch_event_loop(scheduler: Scheduler):
# The live PP property asserts before torch.distributed init (MLX stub).
disaggregation_mode: DisaggregationMode = scheduler.disaggregation_mode
if disaggregation_mode == DisaggregationMode.NULL:
if scheduler.enable_pdmux:
scheduler.event_loop_pdmux()
elif configured_pp_size() > 1:
scheduler.event_loop_pp()
elif scheduler.enable_overlap_mlx:
scheduler.event_loop_overlap_mlx()
elif scheduler.enable_overlap:
scheduler.event_loop_overlap()
else:
scheduler.event_loop_normal()
elif disaggregation_mode == DisaggregationMode.PREFILL:
if configured_pp_size() > 1:
scheduler.event_loop_pp_disagg_prefill()
elif scheduler.enable_overlap:
scheduler.event_loop_overlap_disagg_prefill()
else:
scheduler.event_loop_normal_disagg_prefill()
elif disaggregation_mode == DisaggregationMode.DECODE:
if configured_pp_size() > 1:
scheduler.event_loop_pp_disagg_decode()
elif scheduler.enable_overlap:
scheduler.event_loop_overlap_disagg_decode()
else:
scheduler.event_loop_normal_disagg_decode()
Note what is not orthogonal here: pipeline parallelism and overlap are separate loops, and the comment in run_batch says why — "FIXME: pp is not compatible with overlap" (python/sglang/srt/managers/scheduler.py:L3775).
The scheduler_components/ decomposition
The pieces the overlap loop calls — request_receiver, batch_result_processor, output_streamer, ipc_channels — are not historical structure. All four were carved out of Scheduler in a single week: PRs #25609, #25634, #25636 and #25714, all dated 2026-05-18/19, three months before the pinned SHA. The directory now holds twenty files — nineteen modules beside an __init__.py.
This is SGLang's counterpart to vLLM's V2 rewrite, and the contrast is instructive. vLLM's V2 work rewrote the layer below the narrowest interface in its stack — a new GPUModelRunner behind one if in Worker.init_device (§11.2). SGLang's carve-out went the other way: it attacked the layer with the widest interface, the god-object scheduler, by extracting cohesive state into dataclasses that the Scheduler holds by reference. Neither moved a process boundary, which is why both were affordable — but they are not symmetric: vLLM replaced an implementation, SGLang redrew ownership lines inside one.
Lifecycles: startup, abort, and the two watchdogs
Startup, in dependency order
_launch_subprocesses is the classmethod that builds everything (python/sglang/srt/entrypoints/engine.py:L1060-L1075). Its order is forced by real dependencies:
- Ports.
PortArgs.init_newallocates the five unconditional IPC names and the NCCL port before anything binds. - Schedulers. One
mp.Processper (pp_rank, tp_rank) pair, each with anmp.Pipeback to the parent and agpu_idderived from the rank. Ifdp_size > 1, a singleDataParallelControlleris forked instead and forks the schedulers itself. - Detokenizers. One process, or several behind a
MultiDetokenizerRouter. TokenizerManager, constructed in the parent — not forked, which is why the API server and the tokenizer share an event loop.wait_for_ready()blocks on every scheduler's pipe; each sendsget_init_info()once itsModelRunnerhas loaded weights, sized the KV pools, and captured CUDA graphs.- Config flows back up.
tokenizer_manager.max_req_input_lencomes fromscheduler_infos[0]— the frontend cannot validate request lengths until the engine has fitted the KV cache, exactly as in vLLM'sEngineCoreReadyResponse(§11.2). ThenSubprocessWatchdogstarts.
run_scheduler_process's exception handler is worth reading: it SIGQUITs the parent and, under SGLANG_KILLPG_ON_SCHEDULER_EXCEPTION, SIGKILLs the whole process group — otherwise the sibling ranks blocked in NCCL "spew thousands of NCCL/TCPStore tracebacks before they finally die" (python/sglang/srt/managers/scheduler.py:L5182-L5189).
Abort
Like vLLM, a request exists twice: as a ReqState in the TokenizerManager and as a Req in every scheduler. Unlike vLLM, every rank must abort it, so the AbortReq rides the same broadcast as every other input. Three cases are handled separately: still queued (pop it, free prefetch state, push an AbortReq back — python/sglang/srt/managers/scheduler.py:L4547-L4586), currently the chunked-prefill request (deferred via _pending_chunked_abort_req), or running (marked, freed by the normal finish path).
Two watchdogs, watching different things
The Scheduler starts a hard watchdog on forward_ct (§1 above) and, if --soft-watchdog-timeout is set, a soft one that logs and dumps without killing. The TokenizerManager and DetokenizerManager run soft watchdogs of their own, both disabled across the blocking socket receive — a process idle on recv is not stuck. §9.5 owns the operational view; the architectural point is that the scheduler watchdog exists because a replicated scheduler's failure mode is a hang, not an exception.
Pitfalls, and a reading guide
Any nondeterminism in scheduler-side Python is a deadlock
A dict iterated in insertion order that differs per rank, a time.monotonic() deadline compared against a per-rank clock, a host memory pool with rank-dependent occupancy. None of these touch the model, and all of them can make one rank schedule a different batch. Run with SGLANG_ENABLE_RANK_CONSENSUS_CHECKER=1 before believing a scheduler change is safe.
Adding an early return on rank 0 only
The reason SenderWrapper.send_output is a no-op rather than a caller-side branch is that branches drift. If you add if self.ps.tp_rank == 0: around anything that mutates scheduler state, you have introduced divergence.
Requests output twice under overlap
_stream_output_generation carries an explicit guard: "With the overlap schedule, a request will try to output twice and hit this line twice because of the one additional delayed token" (python/sglang/srt/managers/scheduler_components/output_streamer.py:L165-L169). Any new streaming path needs the same finished_output check.
Reading a ScheduleBatch field in the result processor
By the time process_batch_result runs, the live batch has been mutated for the next forward. Only the fields listed in ScheduleBatch.copy() are snapshotted. Reading anything else gives you the next iteration's value.
7d89325.| You want to change… | Open | Because it owns |
|---|---|---|
| Request validation or tokenisation | managers/tokenizer_manager.py | The only producer of TokenizedGenerateReqInput |
| A new field on the wire | managers/io_struct.py | array_like=True — append at the end only |
| How requests reach every rank | managers/scheduler_components/request_receiver.py | The rank-fan-out barrier |
| Batching or admission policy | managers/scheduler.py get_next_batch_to_run, schedule_policy.py | The only decision-maker — and it must stay deterministic |
| What a streaming client sees per token | managers/scheduler_components/output_streamer.py | Stream intervals and payload assembly |
| Finish conditions, logprobs, grammar advance | managers/scheduler_components/batch_result_processor.py | Everything after the forward |
| What crosses into the forward | managers/schedule_batch.py, model_executor/forward_batch_info.py | The two batch objects and the borrow boundary |
| Anything about tensors or CUDA graphs | model_executor/model_runner.py, model_executor/runner/ | Below every boundary in this chapter |
| The radix cache | mem_cache/ (§12.3) | The state most at risk of rank divergence |
Hands-on
1. See the replication. Launch with TP = 2 and count the processes:
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
--tp 2 --context-length 4096 &
# in another shell, once it is up:
ps -eo pid,comm,args | grep -E 'sglang::(scheduler|detokenizer)'
# expect: TWO scheduler processes, ONE detokenizer, and the API server itself.
py-spy dump --pid $(pgrep -f 'sglang::scheduler_TP0' | head -1)
py-spy dump --pid $(pgrep -f 'sglang::scheduler_TP1' | head -1)
Both dumps should sit in the same function. TP1 inside broadcast_pyobj while TP0 is inside get_next_batch_to_run is the barrier doing its job.
2. Turn on the divergence checker. This is the single most useful flag in this chapter:
SGLANG_ENABLE_RANK_CONSENSUS_CHECKER=1 python -m sglang.launch_server \
--model-path meta-llama/Meta-Llama-3-8B-Instruct --tp 2
# startup logs: "Rank consensus checker is enabled. The server will suicide if
# rank divergence detected."
It builds dedicated gloo groups so its all-reduces never touch the scheduler thread, and costs nothing when the env var is unset.
3. Measure what overlap is worth. Compare identical benchmark runs with and without --disable-overlap-schedule. Prediction to test, from §7: near zero gap at batch 1 where $S \ll T_{\text{fwd}}$, widening with batch as the per-request host work in process_batch_result_decode grows linearly while the forward stays memory-bound. §9.3's derived budget puts the crossover at 140 µs of host work per request at $B=32$ on the 4.48 ms floor.
4. Watch a step boundary. Every hot method carries an NVTX range — @scheduler_nvtx_method("scheduler.run_batch"), "scheduler.process_batch_result". Under nsys the overlap is directly visible: process_batch_result's range should sit underneath the forward kernels, not beside them.
Exercises
- Read and answer. Open
python/sglang/srt/managers/scheduler_components/ipc_channels.py. Which ranks get asend_metrics_from_schedulersocket, and why is that condition different from the one guardingrecv_from_tokenizer? - Read and answer. In
event_loop_overlap,launch_batch_sample_if_neededis called after the previous batch is processed, and the comment says why. Name the specific dependency, and say what would break if it were called immediately afterrun_batch. - Predict, then verify. Derive SGLang's per-rank gloo broadcast traffic for an idle 8-way TP server at the derived 4.48 ms step period, then predict how it changes at batch 128. Verify against
broadcast_pyobj, then say which quantity differs for vLLM'srpc_broadcast_mq. - Predict, then verify. You add a scheduler feature that skips a request when
time.monotonic() - req.arrival_time > deadline. Predict what happens at TP = 8 under load, where the symptom appears and with what text, and what you would have to add to make the tree's existing safeguard fire. - Design. Suppose rank 0 broadcast the derived plan instead, vLLM-style, while keeping the per-rank
Schedulerobjects. What would you have to serialise, where would the barrier move, and which@rank_consensusdecorators could you delete?
Answers
1. recv_from_tokenizer is gated on is_rank_zero, computed in Scheduler.init_ipc_channels as pp_rank == 0 and attn_tp_rank == 0 and attn_cp_rank == 0 — exactly one process may own the ingress socket. Metrics are gated on attn_tp_rank == 0 or enable_metrics_for_all_schedulers, because metrics are observational: multiple publishers are noisy, whereas multiple ingress consumers would split the request stream and desynchronise the ranks instantly.
2. The grammar bitmask for the current batch depends on the tokens the previous batch committed, and those are only committed inside process_batch_result → _accept_grammar_tokens. The in-source comment says "It depends on the result of the last batch (e.g., grammar), so we run it after the last batch is processed." Sampling earlier would apply a stale mask and could emit a token the grammar forbids.
3. Idle: broadcast_pyobj([]) sends one torch.tensor([0], dtype=torch.long) and returns — 8 bytes per iteration, so 223 × 8 = 1.8 KB/s, identical at batch 128 because the payload is the arrival list, not the batch. vLLM's SchedulerOutput carries per-request scheduled-token counts and new block ids, so it grows with the batch. What differs is not size — both are small — but scaling: O(1) versus O(B) per step.
4. Independently sampled deadline times can make ranks choose different logical batches. Depending on the resulting shapes and collective sequence, this can cause detected mismatch, corrupted values or a hang. The proposed check must compare ordered logical IDs, lengths and operation metadata; a watchdog alone only detects stalled progress. Broadcast the admission decision or use a consistent replicated timestamp before applying the deadline.
5. Broadcast ordered logical request IDs, query/prefix lengths and the operations each rank must perform. Rank-local KV addresses need not be identical or shipped verbatim; workers can resolve a logical plan to their own valid slots. Plan $k+1$ must arrive before forward $k+1$, but its preparation and transfer can overlap forward $k$ when dependencies allow. Centralization changes the critical path; it does not architecturally prohibit overlap or eliminate all cache/state validation.
Key takeaways
- SGLang replicates the scheduler; vLLM broadcasts the plan. Every TP rank runs a complete
Schedulerand derives the same batch from the same inputs; there is noSchedulerOutputtype in the tree. the cited vLLM path uses centralized planning, and its TODO flags determinism work for an alternate path, in the# TODO: make v1 scheduling deterministicbesideexternal_launcher. - The barrier does not disappear — it moves and shrinks.
recv_requestscallsbroadcast_pyobjon the gloo CPU group at the top of every iteration, so ranks are still lock-stepped once per step. Only the payload changes: 8 bytes when nothing arrived, O(1) rather than O(batch) in steady state. - Divergence can corrupt output, raise, or hang. Matching collective shapes with mismatched logical rows can return wrong values; different collective order or participation can hang. A watchdog detects lack of progress, not every wrong result. SGLang's answer is the opt-in
@rank_consensus, whose three placements say the risk lives in host-side cache bookkeeping. - Overlap reaches vLLM's
maxinstead ofsumby a different route. Where §11.2's process split escapes the GIL,event_loop_overlapuses CUDA streams and a one-iteration lag: launch batch $k$, then process $k-1$'s result under it.FutureMapmakes that legal —input_idsstaysNoneand the forward stream gathers it by request-pool slot. - Rank asymmetry is expressed as absent sockets, not as branches. Non-zero ranks get
SenderWrapper(None), whosesend_outputreturns immediately. Every rank runs identical code; only one has somewhere to send. Addingif tp_rank == 0around scheduler state is how you break this. - The
scheduler_components/carve-out is SGLang's answer to vLLM's V2 rewrite, and it went the opposite way. vLLM replaced the implementation below its narrowest interface; SGLang redrew ownership lines inside its widest. Nineteen modules beside an__init__.py, landed within days of each other in May 2026.
Further reading
- sgl-project/sglang#25609, #25634, #25636, #25714 — the four PRs that carved
SchedulerRequestReceiver,SchedulerOutputStreamer,SchedulerBatchResultProcessorandSchedulerIpcChannelsout of the god-object scheduler. Read them in that order. - SGLang: Efficient Execution of Structured Language Model Programs — the paper. It describes RadixAttention and the frontend DSL; the process architecture in this chapter is entirely post-paper.
- SGLang v0.4 release notes — the project's own account of zero-overhead batch scheduling, which is the
event_loop_overlapdesign in this chapter, and of data-parallel attention. - vllm-project/vllm#11400 — running vLLM under torchrun-compatible launchers: the closest it comes to replicate-the-scheduler, and the best statement of the tradeoff from the other side.
- §11.2 for the vLLM half of this comparison, §12.1 for the repo map and the parallelism axes, §12.3 for the cache whose bookkeeping the consensus checker guards, §5.5 for the orchestration view, and §9.3 for one request across both engines.