Evolution of SGLang Scheduler English version | 简体中文 About the author: I am Liu Zhiyi, a second-year master's student in UESTC(graduating in 2027). I am currently looking for an internship in llm inference acceleration. Please feel free to contact me! tomlzy213@gmail.
About the author: I am Liu Zhiyi, a second-year master's student in UESTC(graduating in 2027). I am currently looking for an internship in llm inference acceleration. Please feel free to contact me! tomlzy213@gmail.com
The overall workflow of the Scheduler is shown in the figure below: 3
We will analyze the entire Scheduler process in conjunction with the code. However, before diving into the workflow, we first need to understand the key data structures in scheduling and how they transform into one another.
The Scheduler component is responsible for managing Active Requests. The following core global data structures are used to maintain these Active Requests within the Scheduler.
waiting_queue:
waiting_queue is a data structure designed to hold Active Requests. It dynamically reorders these requests based on priority (longest prefix of the Request) or available memory to optimize batch processing.retract_decode.new_batch:
remaining_tokens), it may be chunked into smaller parts.new_batch will undergo prefill/extend.new_batch will transition to the Global Batch for the next iteration.running_batch:
ScheduleBatch(reqs=[], batch_is_full=False)Scheduler may retract certain Requests from the running_batch via retract_decode, returning them to the waiting_queue for later processing.cur_batch:
Scheduler main loop (run_batch function). prefill takes precedence.cur_batch is assigned in event_loop_normal.cur_batch is:
new_batch) in this round, new_batch is used as cur_batch.cur_batch will process Requests ready for decode, so running_batch is used as cur_batch.
Overview:
ScheduleBatch is managed by schedule.py::Scheduler. It contains high-level scheduling data, most of which resides on the CPU.ModelWorkerBatch is managed by tp_worker.py::TpModelWorker. It is a subset of ScheduleBatch, containing only data relevant to model forward on the GPU, and it transitions from the CPU scheduler to the GPU model runner.ForwardBatch is managed by model_runner.py::ModelRunner and contains low-level tensor data.ScheduleBatch:
class ScheduleBatch: reqs: List[Req] # List of requests req_to_token_pool: ReqToTokenPool # Request to token mapping pool token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator # KV cache allocator tree_cache: BasePrefixCache # Prefix cache tree forward_mode: ForwardMode # Forward mode # Batch related input_ids: torch.Tensor # Input token IDs seq_lens: torch.Tensor # Sequence lengths extend_lens: List[int] # Extend lengths (seq_len - prefix_len) prefix_lens: List[int] # Prefix lengths
ModelWorkerBatch:
class ModelWorkerBatch: forward_mode: ForwardMode input_ids: torch.Tensor # Input token IDs req_pool_indices: torch.Tensor # Indices of out_cache_loc corresponding to req seq_lens: torch.Tensor # Sequence lengths out_cache_loc: torch.Tensor # Allocated KV cache # Extension related (seq - prefix) extend_num_tokens: Optional[int] extend_seq_lens: Optional[List[int]] extend_prefix_lens: Optional[List[int]]
ForwardBatch:
class ForwardBatch: forward_mode: ForwardMode batch_size: int input_ids: torch.Tensor seq_lens: torch.Tensor positions: torch.Tensor # Position encoding # Attention related attn_backend: AttentionBackend token_to_kv_pool: KVCache # Extension information (seq - prefix) extend_num_tokens: Optional[int] extend_start_loc: Optional[torch.Tensor] extend_prefix_lens: Optional[torch.Tensor]
Batch Transformation:
# 1. Scheduler creates ScheduleBatch def run_batch(self, batch: ScheduleBatch): # 2. Convert to ModelWorkerBatch model_worker_batch = batch.get_model_worker_batch() # 3. TpModelWorker processing def forward_batch_generation(self, model_worker_batch: ModelWorkerBatch): # 4. Convert to ForwardBatch forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) # 5. ModelRunner executes forward pass logits_output, can_run_cuda_graph = self.model_runner.forward(forward_batch) # 6. Sample to generate token next_token_ids = self.model_runner.sample(logits_output, forward_batch) return GenerationBatchResult( logits_output=logits_output, next_token_ids=next_token_ids, can_run_cuda_graph=can_run_cuda_graph, )
In SGLang, caching mainly involves three structures: req_to_token_pool, token_to_kv_pool, and tree_cache.
req_to_token_pool[req_idx]: ┌─────────────────────────────────────────────────────────────┐ │ Prefix Part (1984 tokens) │ New Chunk Part (2000 tokens) │ ├─────────────────────────────────────────────────────────────┤ │ [loc_1, loc_2, ..., loc_1984] │ [loc_1985, ..., loc_3984] │ └─────────────────────────────────────────────────────────────┘ Position: 0 1984 3984 KV Cache Pool: ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │loc_1 │loc_2 │ ... │loc_1984│loc_1985│ ... │loc_3984│ ... │ ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤ │ k1,v1│ k2,v2│ ... │k1984,v1984│k1985,v1985│ ... │k3984,v3984│ ... │ └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
ReqToTokenPool:
class ReqToTokenPool: def __init__(self, size: int, max_context_len: int, device: str, enable_memory_saver: bool): # Main storage structure: [number of requests, max context length] self.req_to_token = torch.zeros( (size, max_context_len), dtype=torch.int32, device=device ) self.free_slots = list(range(size)) # List of free slots self.size = size self.max_context_len = max_context_len
token_to_kv_pool:
Tree Cache:
It is essentially the organizational structure connecting the two pools. The scheduler accesses it frequently during scheduling to allocate slots in req_to_token_pool and token_to_kv_pool for requests.
tree_cache plays a key role in scheduling policy, determining when the current request is prefilled based on prefix matching.
page_size determines the granularity of prefix matching, key matching strategy, and paged matching algorithm.
page_size = 1 means exact token-by-token matching, capable of matching prefixes of any length.page_size > 1 means prefix matching by page (using tuple(tokens) as key).# page_size = 1 root └── 1 (child_key=1) └── 2 (child_key=2) └── 3 (child_key=3) └── 4 (child_key=4) └── 5 (child_key=5) # page_size = 4 root └── (1,2,3,4) (child_key=(1,2,3,4)) └── (5,6,7,8) (child_key=(5,6,7,8))
Relationship:
When a new request enters:
req_to_token_pool allocates free slots for extend_token to get the req_pool_idx.token_to_kv_pool_allocator allocates new KV Cache.req_pool_idx and KV Cache.# Directly allocate KV cache needed for extend tokens of all reqs in this batch out_cache_loc = alloc_token_slots(batch.tree_cache, batch.extend_num_tokens) # update mapping (prefix + extend) req_to_token_pool.write( (req_idx, slice(0, prefix_len)), prefix_tensors[i], ) req_to_token_pool.write( (req_idx, slice(prefix_len, seq_len)), out_cache_loc[pt : pt + extend_len], )

It is the core component in SGLang Scheduler responsible for batching prefill and decode requests. Its main role is to select suitable requests from the waiting queue and assemble them into a prefill batch that can be executed efficiently.
If Mixed Chunk is enabled, a batch can contain both prefill and decode requests simultaneously.
class PrefillAdder: def __init__(self, ...): self.page_size = page_size # memory page size self.tree_cache = tree_cache # radix kv cache self.token_to_kv_pool_allocator = token_to_kv_pool_allocator # kv cache pool self.running_batch = running_batch # currently running decode batch self.new_token_ratio = new_token_ratio # new token generation ratio self.can_run_list = [] # list of runnable requests self.preempt_list = [] # list of preempted requests self.new_chunked_req = None # new chunked request self.log_hit_tokens = 0 # number of cache hit tokens self.log_input_tokens = 0 # input token statistics @property def rem_total_tokens(self): """Calculate total remaining available tokens""" def add_one_req(self, req: Req, has_chunked_req: bool, truncation_align_size: Optional[int]): """Add a request to the batch""" def add_chunked_req(self, req: Req): """Handle chunked prefill request""" def preempt_to_schedule(self, req: Req, server_args: ServerArgs) -> bool: """Preempt low-priority requests to make way for high-priority ones"""
logits_output: Optional[LogitsProcessorOutput]
[batch_size, vocab_size]next_token_ids: Optional[torch.Tensor]
[batch_size]output_ids to advance the generation process.num_accepted_tokens: Optional[int]
next_draft_input: Optional[EagleDraftInput]
extend_input_len_per_req: Optional[List[int]]
extend_logprob_start_len_per_req: Optional[List[int]]
copy_done: Optional[torch.cuda.Event]
delay_sample_func: Optional[callable]
future_indices: Optional[FutureIndices]
LLMs have an autoregressive structure, so whether in Prefill or Decode, what drives them to the next inference step is the predicted next token of the sequence. Data containing next_token_ids is therefore crucial.
In prefill mode, next_token_ids contains:
[batch_size] - one token ID per request.In decode mode, next_token_ids contains:
[batch_size] - one token ID per request.When a Request enters SGLang Scheduler, it goes through the following stages:
Req -> Pre Schedule(CPU) -> Compute Batch -> Sample(GPU) -> Post Schedule(CPU) -> next Schedule ...

Pre Schedule:
Schedule::recv_request() & Schedule::process_input_requests())running_batch. (Schedule::get_batch_to_run())
Req::init_next_round_input())ScheduleBatch::prepare_for_extend() & ScheduleBatch::prepare_for_decode())Compute batch:
New requests enter the Prefill phase, and after Prefill ends, they enter the Decode phase.
Prefill Schedule:
get_next_batch_to_run: Prefill takes precedence here, so get_new_batch_prefill is called, and the returned new_batch is directly used as the batch for this execution round (i.e., cur_batch).
get_new_batch_prefill:
PrefillAdder to manage batch construction, selecting requests from waiting_queue.init_next_round_input updates the prefix of the radix tree cache.ScheduleBatch, call prepare_for_extend:
req_pool_indices: Assign a unique index position in the request pool for each request. This index is used to store the request's token-to-KV mapping in req_to_token_pool.req_to_token_pool.run_batch(): Execute Prefill inference, call TpModelWorker::forward_batch_generation() -> ModelRunner::forward() -> ModelRunner::_forward_raw() -> ModelRunner::forward_extend(), then execute backend operators and wait for results.
Decode Schedule:
get_next_batch_to_run(): Process completed requests from the previous batch, then merge with running_batch.update_running_batch():
prepare_for_decode():
output_ids from the previous schedule become input_ids for this one.out_cache_loc, because in decode mode we generate only one token per batch at a time.out_cache_loc = alloc_token_slots(batch.tree_cache, bs * 1)
run_batch(): Execute Decode inference, call TpModelWorker::forward_batch_generation() -> ModelRunner::forward() -> ModelRunner::_forward_raw() -> ModelRunner::forward_decode(), then execute backend operators and wait for results.Sample:
TpModelWorker::forward_batch_generation():
next_token_ids of the batch for use in the next batch forward.
sampling_info.update_regex_vocab_mask() sampling_info.apply_logits_bias(logits_output.next_token_logits) next_token_ids = self.sampler( logits_output, forward_batch.sampling_info, forward_batch.return_logprob, forward_batch.top_logprobs_nums, forward_batch.token_ids_logprobs, # For prefill, we only use the position of the last token. ( forward_batch.positions if forward_batch.forward_mode.is_decode() else forward_batch.seq_lens - 1 ), )
Post Schedule:
process_batch_result_prefill()
tree_cache's cache_unfinished_req() to retain the cache for that req.process_batch_result_decode():
tree_cache (batch release after loop interpretation).stream_out() to detokenizer for next steps.def event_loop_normal(self): """A normal scheduler loop.""" while True: recv_reqs = self.recv_requests() self.process_input_requests(recv_reqs) batch = self.get_next_batch_to_run() self.cur_batch = 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.self_check_during_idle() self.last_batch = batch
Req -> Waiting_queue:
First execute recv_requests:
work_reqs are broadcast in attn_tp; system control_reqs are broadcast across the entire tp_size.Then execute process_input_requests
worker_id = recv_req.worker_idrecv_req = recv_req.objoutput = self._request_dispatcher(recv_req) Call request dispatcher.
recv_req as a new Req object._add_request_to_queue() to insert Req into waiting_queue.Waiting_queue/running_batch -> cur_batch:

Get prefill batch get_new_batch_prefill():
Create PrefillAdder, chunk new requests, then process multiple chunks at a time.
init_next_round_input():
input_len - 1).
The model needs an input token to query these cached historical information and calculate the logits (probability distribution) for the current step. If we count all tokens as prefix tokens and read them from cache, then there is no "input token" fed to the model for the current step, and the model cannot calculate the logits for t+1. Therefore, we must keep the last token out of prefix matching and let it be the
input_idsto the model for this inference.
ABC arrives, assume there is a node AFG in the current radix cache.match_prefix will try to find the longest prefix of existing ABC in the current radix cache, meaning it will find A in the AFG node.AFG into A and FG, with node A becoming the last node of the current Request.Create a new ScheduleBatch.
Call ScheduleBatch::prepare_for_extend()
Allocate req_pool_indices to assign a unique index position in the request pool for each request. This index is used to store the request's token-to-KV mapping in req_to_token_pool.
out_cache_loc.req_to_token_pool.req_to_token_pool[req_idx]: ┌─────────────────────────────────────────────────────────────┐ │ Prefix Part (1984 tokens) │ New Chunk Part (2000 tokens) │ ├─────────────────────────────────────────────────────────────┤ │ [loc_1, loc_2, ..., loc_1984] │ [loc_1985, ..., loc_3984] │ └─────────────────────────────────────────────────────────────┘ Position: 0 1984 3984 KV Cache Pool: ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │loc_1 │loc_2 │ ... │loc_1984│loc_1985│ ... │loc_3984│ ... │ ├──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤ │ k1,v1│ k2,v2│ ... │k1984,v1984│k1985,v1985│ ... │k3984,v3984│ ... │ └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
Get decode batch:
First remove completed or errored batches from the batch, then merge the previous round's decode batch with running_batch.
seq_lens, orig_seq_lens, output_ids, etc. using torch.cat.Call update_running_batch() to get decode batch.
First check if requests need to be retracted.
If so, re-insert requests to be retracted into waiting_queue and re-queue for scheduling.
Call ScheduleBatch::prepare_for_decode()
# Assume batch has 3 requests, current lengths are [10, 15, 8] # Decode needs to allocate space for the next position of each request # KV cache mapping before allocation req_to_token_pool[req1_idx, 0:10] = [loc1, loc2, ..., loc10] req_to_token_pool[req2_idx, 0:15] = [loc11, loc12, ..., loc25] req_to_token_pool[req3_idx, 0:8] = [loc26, loc27, ..., loc33] # After executing alloc_for_decode req_to_token_pool[req1_idx, 10] = loc34 # Allocate for position 10 req_to_token_pool[req2_idx, 15] = loc35 # Allocate for position 15 req_to_token_pool[req3_idx, 8] = loc36 # Allocate for position 8 # out_cache_loc = [loc34, loc35, loc36] # A faster in-place version self.seq_lens.add_(1) self.seq_lens_cpu.add_(1) self.orig_seq_lens.add_(1) # update all length self.seq_lens_sum += bs # bs = batch_size
Here we temporarily only consider the generation case (there is also the Embedding case).
ScheduleBatch to ModelWorkerBatch.TpModelWorker::forward_batch_generation()
ModelWorkerBatch to ForwardBatch.ModelRunner::forward(), finally calling backend flashinfer operators.
ModelRunner::forward_extend().ModelRunner::forward_decode().GenerationBatchResult.Prefill:
GenerationBatchResult.synchronize() to wait for GPU→CPU copy completion, ensuring subsequent data access is available on CPU.is_chunked > 0 indicates prefill is not yet fully complete, need to decrement counter and skip streaming output.stream_output() to send results (token, logprob, etc.) to the client (e.g., WebSocket / API response).Decode:
[GPU forward kernel] ↓ (Result written to GenerationBatchResult) [Scheduler.process_batch_result_decode()] ├── copy_done.synchronize() ├── next_token_ids.tolist() ├── Update req status ├── req.output_ids.append(next_token_id) ├── if finished, self.tree_cache.cache_finished_req(req) # Release kv cache ├── stream_output() └── free KV pages
Running the actual scheduling algorithm accounts for only a small fraction of the total scheduling overhead. Most of the overhead comes from model input preparation and model output post-processing. Specifically, the largest overheads come from constructing input tensors (Pre Schedule), executing output detokenization (Post Schedule), and preparing metadata for each request (Pre Schedule)2.
Overhead in constructing model input tensors and sampling metadata mainly stems from Python.
Using multi-step scheduling can reduce total scheduling overhead, but it also has drawbacks. For example, between two scheduling calls, even if some requests finish early, new requests cannot be added to the batch.
Before introducing the principle, we need to recall the four major steps of the inference process above and consider which steps can be overlapped to reduce GPU bubbles (idle periods). Here is a diagram of the current overlap pipeline.

Inference Overview:
SGLang's inference process is mainly divided into the following four stages:
Scheduler::recv_request() & Scheduler::process_input_requests())running_batch. (Scheduler::get_batch_to_run())
Req::init_next_round_input())ScheduleBatch::prepare_for_extend() & ScheduleBatch::prepare_for_decode())Scheduler::run_batch())ModelRunner::sample())We will apply vocab mask during sampling to ensure invalid tokens are not sampled, so we need to update the vocab mask before sampling next batch.
Scheduler::process_batch_result())Overlap Overview1:
The Compute batch and Sample stages, which are adjacent, are GPU-heavy, while the two schedule stages are CPU-heavy. When multiple batches are pipelined, we can use GPU Compute and Sample to overlap the post scheduler of the previous batch with the pre scheduler of the current batch.
The Grammar Mask in the Prefill stage is usually based on the prompt, so we can just sample it here.
We implement overlap by using CUDA Stream + FutureMap, specifically:
Run Batch, two operations are submitted to the forward_stream stream: one retrieves the next token of the previous batch from FutureMap; the other uses this token as input_id for the next calculation.Sample, two operations are also submitted to the forward_stream stream: one performs sampling; the other writes the obtained next token back to FutureMap.
Post Schedule stage. Therefore, we need to ensure that the Post Schedule of the previous batch is completed before executing sampling of the current batch.Post Schedule 1 is executed before Launch Sample 2.Post Schedule to ensure next_token_ids on CPU can be processed.
def process_batch_result_decode( self: Scheduler, batch: ScheduleBatch, result: GenerationBatchResult, ): if result.copy_done is not None: result.copy_done.synchronize() logits_output, next_token_ids, can_run_cuda_graph = ( result.logits_output, result.next_token_ids, result.can_run_cuda_graph, ) next_token_ids = next_token_ids.tolist() next_token_logprobs = logits_output.next_token_logprobs.tolist()

output_id calculated by previous batch serves as input_id for next batch, implemented by accessing future_map.def init_overlap(self): if not self.enable_overlap: return self.forward_stream = torch.cuda.Stream() # GPU forward computation stream # Future map manages asynchronous results self.future_map = FutureMap(max_running_requests, device, spec_algorithm) # batch buffer (prevents GPU tensors from being GC'd) self.batch_record_buf = [None] * 2 self.batch_record_ct = 0
Workflow
future_indices = self.future_map.alloc_future_indices(bs) to obtain a set of negative indices (e.g., [-1, -2, -3]) that indicate “where the future results will be stored.”input_ids for the next batch.self.future_map.store_to_map(future_indices, batch_result) to copy the newly generated next_token_ids in GPU memory directly into the corresponding buffer locations in the FutureMap.self.future_map.resolve_future(model_worker_batch) to substitute the negative indices in input_ids with the real token IDs stored in the FutureMap.Stored on GPU.
future_ct: Current ring counter (pointer), used to generate new future indices (not "number of unfinished").
future_limit: Modulo for ring pointer (used for % self.future_limit). The code uses a factor of *3 to reduce index collision probability (preventing future_ct from wrapping around quickly and overwriting slots not yet written back).
future_buffer_len: Actual physical buffer length (*5), longer than future_limit to ensure enough space in write interval (preventing slice out-of-bounds or wrap-around write/read conflicts).
These two factors (3 and 5) are engineering empirical values used to increase safety margin; you can adjust based on concurrency and outstanding futures.
class FutureMap: def __init__( self, max_running_requests: int, ): self.future_ct = 0 # A factor of 3 is used to avoid collision in the circular buffer. self.future_limit = max_running_requests * 3 # A factor of 5 is used to ensure the buffer is large enough. self.future_buffer_len = max_running_requests * 5
def event_loop_overlap(self): self.result_queue = deque() # Store (batch, result) pairs while True: # === Pre Schedule 2 === recv_reqs = self.recv_requests() self.process_input_requests(recv_reqs) batch = self.get_next_batch_to_run() self.cur_batch = batch batch_result = None if batch: # === Launch Compute Batch 2 === batch_result = self.run_batch(batch) # Non-blocking launch self.result_queue.append((batch.copy(), batch_result)) # === Post Schedule 1 === # compute batch 2 executes in parallel with post schedule 1 if self.last_batch: # Process results in queue (GPU is computing current batch in parallel at this time) tmp_batch, tmp_result = self.result_queue.popleft() self.process_batch_result(tmp_batch, tmp_result) elif batch is None: self.self_check_during_idle() # === Launch Sample 2 === self.launch_batch_sample_if_needed(batch_result) # update vocab mask self.last_batch = batch
Schedule::run_batch()
Schedule::record_batch_in_overlap(): Alternately stores model_worker_batch references in two overlapping batches, avoiding CUDA kernel accessing dangling pointers or freed memory when overlap forward is not yet finished.FutureMap::resolve_future(): Replaces negative index placeholders with real tokens obtained from previous batch sample.TpModelWorker::forward_batch_generation(), this function merely delays execution of model_runner.sample function, returning batch_result first.Scheduler::launch_batch_sample_if_needed():
vocab_mask tensor size [batch, vocab_size].next_token_id to corresponding position in FutureMap's future_indices.
resolve_future during run_batch.The Sample stage depends on the previous batch's logits output
The Decode Compute batch stage depends on the previous batch's next token
# previous batch output is negative indices batch.output_ids = -self.future_map.alloc_future_indices(bs).indices with self.forward_stream_ctx: self.forward_stream.wait_stream(self.default_stream) _batch_result = batch_result.delay_sample_func() assert _batch_result is batch_result # store token to negative indices self.future_map.store_to_map(batch_result.future_indices, batch_result) batch_result.copy_to_cpu() # next batch input is output of previous batch with self.forward_stream_ctx: self.forward_stream.wait_stream(self.default_stream) # get token from negative indices self.future_map._resolve_future_token_ids(model_worker_batch.input_ids, self.token_ids_buf) batch_result = self.model_worker.forward_batch_generation( model_worker_batch )
update_vocab_mask to GPU for calculation(Now in Sampling), vocab_mask is also allocated directly on GPU, no longer transferred.next_token_ids to/from FutureMap.
FutureMap is also completely stored on GPU.

CUDA stream version:
vocab_mask and token_ids_buf; fully utilizes GPU memory bandwidth.token_ids_buf modified in-place directly on GPU, no need for repeated transfer.
token_ids_buf occupies GPU VRAM, fixed overhead.CPU launch version:
vocab_mask adds one CPU-GPU synchronization.