Inside SGLang
What happens between your prompt and the first token: a field guide to SGLang, the open-source engine that puts a model on GPUs and answers thousands of requests at once, written from its source code, with small simulations you can poke.
SGLang is an open-source inference engine: the server software that takes a model's weights, puts them on GPUs, and answers thousands of requests at once. I wrote this guide to walk through how it works, using the real code at github.com/sgl-project/sglang, with small simulations you can poke.
Most chapters take 5-10 minutes (chapter 5, the scheduler, takes longer). Chapters 1-3 cover ideas every engine shares, vLLM included. Chapters 4-8 open up SGLang itself. Chapters 9-13 are for when you want to run it or read the source.
- Prompts go in, tokens come out, and you pay per token
- Streaming, temperature, context windows
- Output tokens cost more than input tokens
- Some APIs discount "cached" input tokens
- Why those prices and limits are shaped the way they are
- How one GPU serves hundreds of users at once
- What SGLang's RadixAttention caches, and when it helps
- Enough vocabulary to read the code, the docs and the logs
Why inference engines exist
Running a model for one person is a short Python script. Running it for a thousand people at once, without wasting a GPU that rents for a few dollars an hour, is a scheduling and memory problem. That is the problem inference engines solve.
If you download an open model from Hugging Face, you can generate text in about five lines with the transformers library: load the weights, call model.generate(), print the result.
That works, and it gets you roughly 1-5% of what the same GPU can do when it serves many users.
Three things go wrong when that script becomes a service:
- One request at a time wastes the GPU. Generating a token for one user moves all the model's weights through the chip. Generating a token for 64 users in the same pass moves the same weights once. Batching is close to free throughput, and the engine's job is to keep the batch full.
- Users arrive and leave at random. One person wants 3 tokens ("Yes."), another wants 3,000. A batch that waits for its slowest member wastes most of its seats. Engines add and remove requests between every step.
- Memory runs out before compute does. Every token in every conversation leaves behind a block of saved numbers on the GPU (the KV cache). How many users you can serve is mostly a question of how well you manage that memory.
An answer is written one token per decode step. In each step the GPU reads every weight of the model once and makes the next token for every request in the batch. Below is that trip for Llama 3.1 8B (its weights fill 16 GB) on one H100 GPU.
Each request also keeps its own KV cache (the saved numbers from problem 3), and every step reads those too. So the length of each conversation matters as well as how many there are. All the caches have to fit in the memory the weights leave free, about 55 GB on this GPU.
64 requests share one trip through the weights. The step grows from 4.83 ms to 7.36 ms and now makes 64 tokens instead of 1, so the GPU’s total output is 42 times higher.
Throughput vs batch size · both axes log scale
Simplified model: a step lasts as long as the slower of reading memory and doing the math. It assumes Llama 3.1 8B in BF16 on one H100 SXM: 16.06 GB of weights, 131,072 bytes of KV cache per token, 3.35 TB/s of memory bandwidth, 60% of 989 TFLOP/s, about 55 GB for KV caches (as in chapter 3) and $3 per GPU-hour. In this model memory sets the pace at every batch size; real kernels can’t overlap reads and math perfectly, so math starts to matter at around 200 requests.
In this model, batching takes the GPU cost of a million output tokens from about $4 to under 5 cents, which is why an engine works so hard to keep the batch full. SGLang is built for the right half of that chart. In practice KV memory fills first, as the chart shows, which is why chapter 3 is all about that memory.
SGLang, vLLM, TensorRT-LLM and others are different answers to the same three problems. SGLang came out of LMSYS (the group behind Chatbot Arena) in 2024. Its best-known idea is RadixAttention: noticing that many requests start with the same text, and computing that text once. Today it serves production traffic at many AI labs and clouds, and it is a common engine for generating rollouts for reinforcement learning (RL).
Think of a restaurant kitchen. The model is the stove. A good expediter keeps every burner busy, starts the next order the moment a burner frees, and never preps the same sauce twice. SGLang is mostly an expediter.
What "SGLang" stands for
Structured Generation Language.
The original 2024 project had two halves: a Python mini-language for writing multi-step LLM programs (gen, select, fork), and a fast runtime underneath it.
The runtime won.
The language lives on in python/sglang/lang/, but the package README marks it as deprecated and no longer maintained.
When people say SGLang today, they mean the runtime, called SRT (SGLang Runtime) in the code: python/sglang/srt/.
Why is serving 64 users in one batch much cheaper per token than serving them one by one?
Prefill and decode: the two halves of every request
The prompt is read in one big parallel pass (prefill), then the answer is written one token per pass (decode). Prefill sets your time to first token. Decode sets your tokens per second.
A transformer predicts one next token from everything before it. To answer a 1,000-token prompt with a 200-token reply, the engine does this:
- Prefill: push all 1,000 prompt tokens through the model at once. The GPU is good at this, since it is one huge matrix multiplication. The output is the first reply token.
- Decode: feed that one new token back in, get the next one, and repeat 199 more times. Each pass handles a single token per request, so the GPU spends most of its time waiting for weights to arrive from memory rather than doing math.
That difference explains two things you have seen on API pricing pages. Input tokens are cheaper than output tokens, because prefill processes them in parallel and decode processes output one at a time. And a streamed answer starts after a pause and then flows at a steady rhythm, because the pause, time to first token (TTFT), is mostly prefill and the rhythm after it is decode.
Decode step 3. Only the newest token goes through the model. It looks back at the 16 saved keys and values instead of recomputing them.
In every layer, the newest token compares itself with every token so far, itself included, and pulls in information from the ones that match best. That step is called attention, and it is the only place where tokens see each other.
Attention needs two vectors from every earlier token, a key and a value. Step through the same dinner bill below, and switch the cache off to see what saving them buys.
Query. What the new token is looking for.
Key. What each earlier token offers to be matched on.
Value. What a token passes along when it matches.
Pass 5, decode. Only the new token “Add” computes a query, key and value. Its query is matched against its own new key and the 16 saved ones (strongest: “90” 16%, “3” 13%, “30” 10%, “each” 10%), and the blend of their values predicts “a”.
Simplified model: one attention head and made-up weights. Llama 3.1 8B does this in all 32 layers, with many heads at once, and keeps a key and value per token for every layer.
This is the whole reason a KV cache works. A token's key and value depend only on that token and the ones before it, so adding new tokens never changes them. The prompt's keys and values are computed once, during prefill, and every later decode step only adds one more pair per layer.
models/llama.py · split into q, k, v
radix_attention.py · RadixAttention.forward
flashattention_backend.py · decode writes the new K/V (default on H100)
memory_pool.py · set_kv_buffer
Here is the same split as you feel it in a chat window, on a GPU you share with other users.
Here is the receipt and our chat so far. How much does each of us owe with a 20% tip? 2,000-token prompt
Your first token arrives after 114 ms, which is 111 ms of prefill for 2,000 prompt tokens plus 2.7 ms waiting for the current step to finish. After that, decode adds one token every 5.5 ms (about 183 per second), the streaming speed you see. Sharing each step with 7 others costs you little (5.5 ms per token against 4.9 ms alone), because each step reads the 16 GB of weights once for everyone.
Simplified model: Llama 3.1 8B on one H100. How the numbers are made
- Other users. Each of them sends a 2,000-token prompt and gets a 200-token answer. Only your own request follows the prompt and answer sliders.
- Queue. The KV cache holds about 422,000 tokens (the chapter 3 calculator at its default setting). Simplified: every request reserves room for its whole prompt and answer, so about 191 of the others fit at once and the rest wait in rounds. The real scheduler bets that answers stop early (chapter 5), so it fits a few more. You arrive last and join the last round if it has room. When everyone fits, you only wait for the current step to end.
- Prefill. prompt ÷ 18,000 tokens per second. On an H100, SGLang reads a long prompt in chunks of 8,192 tokens, drawn as lines in the bar. Real prefill slows down on very long prompts, because attention grows with the square of the length.
- Decode. Each step reads the 16 GB of weights once, plus the saved keys and values of every running conversation (131 KB per token), at 3.35 TB/s. On average a conversation holds its prompt plus half its answer, so 2,100 tokens for each of the others. It can never beat the math, 16 GFLOP per token at 60% of 989 TFLOP/s.
time per token = max( (16.06 GB + (others in your round × 2,100 + your prompt + your answer ÷ 2) × 131 KB) ÷ 3.35 TB/s , everyone in your round × 16 GFLOP ÷ (60% × 989 TFLOP/s) )
Left out: other users’ prompts also need prefill, and by default that pauses everyone’s stream (chapter 5). A real busy server is slower than this.
Time to first token also includes queueing, so it grows with how busy the server is as well as with your prompt.
SGLang's benchmark reports both numbers, computing time per output token as (total time - time to first token) ÷ (output tokens - 1), and a server started with --enable-metrics exports them as Prometheus histograms.
benchmark/serving.py · TPOT
metrics_collector.py · time_to_first_token_seconds
scheduler.py · prefill runs first
schedule_policy.py · room for prompt + answer
memory_hook.py · 8,192-token chunks on H100
The engine's name for these
In SGLang's code, a prefill batch is called EXTEND, because it extends a sequence by many tokens at once (and sometimes the start of the sequence is already cached, so it only extends the rest).
A decode batch is DECODE.
These are values of ForwardMode, and you will see them everywhere in the scheduler.
The KV cache is the budget
For every token in every live conversation, the GPU keeps a small block of numbers called keys and values. Those blocks fill GPU memory long before the math runs out, so how many users fit depends on that memory.
Chapter 2 showed that attention needs a key and a value vector for every earlier token in every layer, and that the engine computes them once and saves them. That saved set is the KV cache.
The cost per token is fixed by the model's shape:
bytes per token = 2 (K and V) × layers × KV heads × head size × bytes per number
For Llama 3.1 8B that is 2 × 32 × 8 × 128 × 2 bytes = 131 KB per token. A single 100,000-token conversation needs about 13 GB of GPU memory just for its cache, on top of 16 GB of weights. That is why long context windows are expensive, and why providers limit them.
SGLang decides the split at startup.
It loads the weights, measures what is left, and turns a fraction of the GPU into one big pool of KV slots.
The flag --mem-fraction-static sets how much of the GPU goes to weights plus that pool.
The rest is scratch space for intermediate results.
If you leave it unset, SGLang picks a value from your GPU's size (about 0.83 on an 80 GB H100).
The memory slider jumps to roughly what SGLang would choose whenever you change the GPU or GPU count.
Things to try: switch Llama 3.1 70B onto one H100 and watch it fail to fit, then give it 4 GPUs, which splits every layer across them (tensor parallelism, chapter 9). Change the KV cache to FP8 and the token count doubles. Pick DeepSeek-V3, which uses a compressed cache design called MLA (multi-head latent attention): its per-token cost is about half of Llama 8B's despite being 80 times bigger.
The calculator uses the formula from SGLang's pool sizing code and the published shapes of each model. The real server measures free memory after loading, so expect its number to land within a few percent of this one.
pool_configurator.py · bytes per token
kv_cache_configurator.py · measuring free memory
memory_hook.py · default mem fraction per GPU
Two tables, not one big array
Here is a detail that matters later. SGLang does not give each conversation its own contiguous chunk of memory. It keeps two lookup tables:
ReqToTokenPool: one row per live request. Row r, column i says which KV slot holds token i of request r.TokenToKVPool(theKVCacheclasses): the actual memory. For each layer, a big tensor of shape[slots, kv_heads, head_dim]for keys and another for values.
A free-slot allocator hands out slot numbers like a coat check. Because a request's tokens can live in any slots, two requests can point at the same slots for a shared beginning. That one fact is what makes RadixAttention possible. vLLM's PagedAttention is the same idea with fixed-size blocks of 16 tokens. On most GPUs SGLang's default "page size" is 1 token, which lets it share prefixes down to the exact token. Some attention kernels need pages of 16-128 tokens, and on Blackwell GPUs such as the B200 the default kernel is one of them, so the default page size there is 64.
Below, one stream of requests runs through two ways of handing out a pool of 48 KV slots.
In the first (the way serving systems worked before PagedAttention), a request books one unbroken block for its prompt plus its whole max_tokens limit the moment it arrives.
The second is SGLang's way.
A request takes one slot per token from the front of the free list, the queue of unused slot numbers.
Step 7 · next to arrive: R9, a 4-token prompt with max_tokens 4, so it would book 8 slots up front
Reserve up front
Books prompt + max_tokens slots in one block on arrival.
Free runs: 13-22 (10), 44-47 (4). Longest: 10 of 14 free. R9 will need 8 in a row.
Slot allocator (SGLang)
Takes one slot per token from the front of the free list.
Free list, front first (14): 345814313925262728374432 The next step takes the first 6, one per running request.
Mid-run, after 7 steps. Reserve up front runs 3 requests and has turned away 4. Its 14 free slots sit in runs of 10 and 4, so a request that books more than 10 cannot get in. The slot allocator runs 6 requests in 33 slots. The line traces R2. It stays solid through one block in the first grid and turns dashed wherever it jumps between scattered slots in the second. Row 6 of the table lists those slots in order.
ReqToTokenPool, SGLang side: row = req_pool_idx, column = token position, cell = KV slot number
Hover or tap a row, or a slot, to trace that request through both grids. Row 0 is padding for CUDA graphs (chapter 7). New requests take free rows from the end of the free-row list.
| row | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ||||||||||||
| 29 | 30 | 38 | 45 | 1 | ||||||||
| 2 free | ||||||||||||
| 22 | 23 | 24 | 36 | 43 | 15 | |||||||
| 20 | 21 | 35 | 42 | 9 | ||||||||
| 17 | 18 | 19 | 34 | 41 | 7 | |||||||
| 10 | 11 | 12 | 13 | 16 | 33 | 40 | 6 | |||||
| 7 free | ||||||||||||
| 46 | 47 | 2 |
Simplified model: 48 slots and one token per step; requests that do not fit are dropped instead of queued, and finished requests free their slots instead of keeping them in the radix cache (chapter 6).
Because SGLang takes each slot right before it writes a token there, a request never holds an empty slot.
With --page-size 16 it hands out 16-slot pages instead, so a request can leave up to 15 slots of its last page empty, which is still far less than booking max_tokens ahead.
Real SGLang also estimates, before admitting a request, whether its answer and the answers already running will fit, without booking slots for any of them (chapter 5).
allocator/token.py · free list starts at slot 1
token.py · alloc takes the front
token.py · free appends to the end
memory_pool.py · padding row 0
allocation.py · one slot per request per decode step
overrides.py · default page size
schedule_policy.py · admission counts max_tokens
--kv-cache-dtype fp8_e4m3. What happens?Life of a request
SGLang is three cooperating programs. One talks HTTP and tokenizes, one schedules and drives the GPU, and one turns token ids back into text. They pass messages over fast local sockets (ZeroMQ).
When you run sglang serve, you get more than one process.
The split exists because Python can run only one thread of Python code at a time in a process.
If tokenizing a big prompt or formatting JSON ran in the same process as the scheduler, the GPU would sit idle while that work happened.
So the work that must never stall (keeping the GPU fed) gets a process of its own.
Here is one chat request passing through those three processes, with the token ids a Qwen model really sees. Step through it hop by hop and then token by token, or play the whole request to watch the answer come back one token per forward pass. The message panel shows what crosses each hop, using SGLang's own class and field names.
1 · You send a request
Your code calls POST /v1/chat/completions, exactly as it would call OpenAI. The body is plain JSON, and "stream": true asks for the answer as server-sent events, one small chunk per token. SGLang also speaks the Anthropic Messages API, Ollama’s chat API and its own native /generate.
JSON bodyHTTP POST · your app → HTTP serverPOST /v1/chat/completions
{
"model": "Qwen/Qwen2.5-7B-Instruct",
"messages": [
{"role": "system",
"content": "You are terse."},
{"role": "user", "content": "Hi"}
],
"temperature": 0.7,
"max_tokens": 64,
"stream": true
}Nothing yet. Text first reaches your app at step 8.
This request’s tokens Not tokens yet. The request is still JSON text.
Simplified model: each message shows a handful of its real fields, and … marks the ones left out. The token ids are real Qwen ids. The request id and KV slot numbers are made up.
In the default single-machine setup, each ZeroMQ hop is a PUSH socket feeding a PULL socket over an ipc:// address, which is a Unix socket file rather than a network port.
The messages are the Python objects above, serialized on one side and rebuilt on the other.
serving_chat.py · _convert_to_internal_request
io_struct.py · GenerateReqInput
io_struct.py · TokenizedGenerateReqInput
schedule_batch.py · Req
forward_batch_info.py · ForwardBatch
io_struct.py · BatchTokenIDOutput
io_struct.py · BatchStrOutput
tokenizer_manager.py · init_ipc_channels
server_args.py · ipc:// sockets
With more than one GPU
With --tp-size 4, which splits every layer of the model across four GPUs (tensor parallelism, chapter 9), SGLang starts four scheduler processes, one per GPU.
Only the first of them, called rank 0, reads from the socket.
It broadcasts each batch of new requests to the other three, and all four then make identical scheduling decisions, so their batches line up exactly when the model's layers talk to each other across GPUs.
Only rank 0 sends results back.
engine.py · _launch_subprocesses
tokenizer_manager.py · generate_request
detokenizer_manager.py
request_receiver.py · broadcast to TP ranks
Why does SGLang put the scheduler in its own process instead of a thread inside the web server?
The scheduler: continuous batching
Once per forward pass, the scheduler decides who rides. New requests get admitted the moment there is room, finished ones leave immediately, and the GPU never waits for the slowest request in a group.
This loop is the core of the engine.
Trimmed down, here is what event_loop_normal in scheduler.py does, forever:
# python/sglang/srt/managers/scheduler.py (trimmed)
while True:
self.ingest_requests() # pull new requests off the socket
plan = self.get_next_batch_to_run(...) # prefill new arrivals, or decode the running batch
batch = plan.batch_to_run
if batch:
result = self.run_batch(batch) # one forward pass on the GPU
self.process_batch_result(batch, result) # append tokens, check stop, stream out
else:
self.on_idle()
self.last_batch = batchThe interesting part is get_next_batch_to_run.
Each turn it:
- Merges the requests that were just prefilled into the running batch, the set of requests currently decoding.
- Drops finished requests from the running batch.
- Tries to build a prefill batch from the waiting queue. If it can, that runs this turn. Prefill gets priority, so new users see their first token quickly.
- Otherwise, it runs one decode step for everyone in the running batch.
That is continuous batching (sometimes called in-flight batching). Compare it with the obvious approach below.
Press Play to watch both schedulers run pass by pass. Each column is one forward pass.
Static batching. Fill the seats, run until the longest finishes, then refill.
Continuous batching. SGLang’s loop: a free seat is refilled with a prefill pass right away.
Simplified model: every forward pass is one column of the same width. Real prefill passes take longer than decode passes, so the pauses in the continuous grid cost more time than they show.
Look at the faded cells in the continuous grid. When a prefill pass runs, every request that is already decoding pauses for that pass. With a short prompt that costs little. With a 30,000-token prompt it stops everyone's stream for a noticeable moment.
Chunked prefill
SGLang cuts a long prompt into chunks of --chunked-prefill-size tokens and prefills one chunk per pass.
That caps how much scratch memory any single pass needs.
The default chunk size grows with GPU memory (8,192 tokens on an H100), because bigger GPUs are usually faster and need bigger passes to stay busy.
The chunk-size menu below lists the default for each class of GPU.
A half-done prompt carries over between passes as self.chunked_req and is admitted first on the next pass.
Chunking alone does not restart the other streams, because prefill keeps priority and the chunks run back to back.
Two flags change that.
--enable-mixed-chunk puts the decoding requests' next tokens into each chunk's pass (a MIXED batch), and --prefill-decode-interval N runs N decode passes after each chunk.
The full fix is to prefill on other GPUs (chapter 9).
Below, four users are partway through their answers when a fifth request arrives with a 24,000-token prompt (say, a long contract to summarize). Pick how the engine handles that prompt, then watch the four streams and the new request's wait for its first token.
Server flags none needed, 8,192 is the default chunk size on 60–160 GB GPUs
Chunked prefill, SGLang’s default. The prompt goes through in 3 passes of at most 8,192 tokens. Prefill keeps priority, so the chunks run back to back and the streams still stop for 1,233 ms. Chunking caps the size, and the scratch memory, of every pass.
Simplified model, illustrative numbers: a pass takes 8 ms, plus 1 second per 20,000 prompt tokens, plus 0.1 ms per decode token, and copying the KV cache between machines takes 40 ms. Real pass times depend on the model, the GPU and how long the context already is. Playback runs 4× slower than the clock.
Admission: betting on how long answers will be
The scheduler cannot know how many tokens a request will generate.
If it admits too many, they run out of KV memory mid-answer.
If it admits too few, the GPU idles.
The PrefillAdder class handles this: for each running request it reserves room for its remaining max_tokens times a new-token ratio.
The ratio starts at 0.7 (scaled by --schedule-conservativeness) and slowly decays toward about 0.1 while things go well, since most answers stop long before their limit.
When the bet loses and the pool fills up, the scheduler retracts: it pauses the requests with the fewest generated tokens, frees their memory, and puts them back in the queue to resume later.
You will see KV cache pool is full. Retract requests. in the log.
A few retractions are normal.
Frequent ones mean you should raise --schedule-conservativeness or lower --max-running-requests.
Who goes first
--schedule-policy picks the order of the waiting queue.
The default is fcfs (first come, first served).
The cache-aware option lpm (longest prefix match) sorts waiting requests by how much of their prompt is already cached, which raises cache hits when many requests share prefixes.
Sorting costs CPU time, so LPM quietly falls back to FCFS when more than 128 requests are waiting.
Other options include dfs-weight, lof (longest output first), hrrn and random.
The control room below runs all of these rules together on a deliberately small KV pool, so the pressure shows.
Each tick is one trip around the loop: new arrivals join the queue, then the scheduler runs a prefill if it can admit someone, or one decode step for everyone running.
Each card shows a request's max_new, the max_tokens it was sent with (SGLang stores it as max_new_tokens).
Yellow in the pool is cache, the KV that finished requests and shared system prompts left behind. A later prompt that starts the same way reuses it, and the scheduler evicts it when it needs room (chapter 6).
More traffic than the server can serve, and answers that run close to their max_new. Server flags: --max-total-tokens 6000 --schedule-conservativeness 1.0 --schedule-policy fcfs --chunked-prefill-size 2048. Changing a flag restarts the server and replays the same arrivals.
Pass 120 retract, then decode35 prefill and 81 decode passes so far
Pass 120: the pool ran out. The decode step needed 44 free slots and only 3 were left, even after evicting cache. SGLang retracted R45 (fewest tokens written) and sent it to the back of the queue. Retracted requests keep their output and recompute their KV when readmitted. The ratio was re-estimated from the requests still running: 0.52.
Waiting queue 15
fcfs: arrival order. Retracted requests rejoin at the back.
- R46292 tokmax_new 160system prompt B · 250 cachednext in line · needs 202, no room
- R4791 tokmax_new 128no shared prefix
- R4836 tokmax_new 128no shared prefix
- R4976 tokmax_new 96no shared prefix
- R5083 tokmax_new 96no shared prefix
- R51434 tokmax_new 96system prompt A · 400 cached
- +8 more waiting, then the back of the queue:
- R45494 tokmax_new 128retracted, keeps 24 tokens · system prompt A · 400 cached
Running batch 43
KV pool and the admission check
in use 5,947cached 0free 53of 6,000 tokens
in usereserved for running requestsnext request’s need, green if it fits, red if it waitsroom (free plus cached)past the end of the pool
New-token ratio
Starts at 0.70 (0.7 × conservativeness), drops a little after every decode pass toward the floor 0.10, is re-estimated after a retraction (red line), and goes back to the start when the server is idle. Prefill passes (small tick at the bottom) leave it alone.
Pass 120 retract, then decode
Scheduler log, in SGLang’s own format
Simplified model: every pass is one tick here, though real passes take different amounts of time. The ratio decays over 60 decode passes here instead of SGLang’s 600, decode lines print every 10 decode passes instead of 40, and the throughput figures assume an illustrative time per pass.
Things to try: play Rush hour to about pass 300, then raise conservativeness to 1.6. The server replays the same arrivals and retracts fewer requests. Drop it to 0.3 and it retracts more. In Shared system prompt, play to about pass 400, then switch the policy from lpm to fcfs. lpm serves requests whose prefix is already cached first, so it can leave others waiting. Under lpm the cache hit rate is a point or two higher and the finished counts stay close, but the longest wait for a first token is much longer.
scheduler.py · prefill runs first
schedule_policy.py · reserve for running requests
prefill_budget.py · check_prefill
new_token_ratio_tracker.py
schedule_batch.py · retraction order
scheduler.py · retraction log
scheduler.py · ratio reset when idle
schedule_policy.py · lpm sort
metrics_reporter.py · log lines
The overlap scheduler
Scheduling takes CPU time: matching prefixes, allocating memory, building tensors.
In a naive loop the GPU waits while the CPU does that between passes.
SGLang's default loop, event_loop_overlap, launches pass N on the GPU and then, while it runs, processes pass N-1's results and prepares pass N+1.
The catch is that pass N+1 needs the tokens pass N has not produced yet.
The second half of the simulation below shows how a GPU buffer called FutureMap gets around that.
Turn overlap off with --disable-overlap-schedule when debugging.
Server flags --disable-overlap-schedule
28.6 ms · CPU: waiting for pass 4 · GPU: running pass 4
Overlap off. Each pass, the CPU plans the batch, the GPU runs it while the CPU waits, and then the CPU processes the results while the GPU waits. The GPU idles 2.5 ms of every 8.5 ms, so it is busy 71% of the time.
How pass 5 can start before pass 4’s tokens exist FutureMap
CPU planning pass 5 for rows 3, 7 and 9
Pass 4 is running on the GPU. Each running request owns a numbered row in a table on the GPU, and these three requests are rows 3, 7 and 9. The CPU is already planning pass 5 for them. Pass 5’s input is each request’s next token, and pass 4 is still computing those.
In SGLang, pass N writes its tokens into this buffer in _relay_forward_payload, and pass N+1 reads them back in resolve_forward_inputs.
Simplified model, illustrative numbers: planning a pass takes the CPU 1.5 ms and processing its results takes 1 ms, whatever the GPU time. Real costs depend on batch size, model and hardware. The timeline shows the first passes after the loop starts.
Planning one pass ahead has a side effect.
A request that emits its stop token in pass N is already in pass N+1, because the CPU planned that pass before it saw the stop.
The GPU computes one extra token for it, and SGLang skips that token while processing results, so it is never sent.
A request that reaches its max_tokens limit is predictable, so the overlap loop leaves it out of the next pass instead.
scheduler.py · event_loop_normal
event_loop_overlap
get_next_batch_to_run
schedule_policy.py · PrefillAdder, policies
overlap_utils.py · FutureMap
KV cache pool is full. Retract requests. What just happened?RadixAttention: never compute the same prompt twice
SGLang keeps the KV cache of finished requests in a tree keyed by their tokens. A new request walks the tree, reuses whatever beginning it shares with earlier requests, and only computes the rest.
Real traffic repeats itself constantly. Every request to a chatbot starts with the same system prompt. Every turn of a conversation resends all previous turns. An agent re-reads its tool list and history on every step. Few-shot prompts share their examples across thousands of calls. Without a cache, the engine prefills all of that again each time.
The "cached input tokens" discount on API pricing pages is this same idea, run by the provider. With SGLang, you run it yourself, and it is on by default.
The data structure is a radix tree (a compressed trie). Each edge holds a run of tokens. Each node remembers which KV slots hold those tokens. Requests that share a beginning share a path from the root. The tree stores slot numbers, not the keys and values themselves, so sharing costs nothing: two requests simply point at the same slots.
Every request starts with the same instructions. Only the question differs.
- SYSTEM: You are a friendly assistant for splitting bills . Answer in one sentence . USER: How do I split $ 90 three ways ?
- SYSTEM: You are a friendly assistant for splitting bills . Answer in one sentence . USER: Should tip be split evenly ?
- SYSTEM: You are a friendly assistant for splitting bills . Answer in one sentence . USER: How do I split $ 90 with tax ?
- SYSTEM: You are a friendly assistant for splitting bills . Answer in one sentence . USER: Who pays for shared appetizers ?
- 18 of 24 tokens reused
- 0 of 27 tokens reused
Try the multi-turn conversation preset: each turn reuses almost everything. Then try unrelated prompts: nothing helps. Shrink the memory slider and watch the least recently used branches get evicted.
When it helps, and when it does not
| Workload | Typical reuse | Why |
|---|---|---|
| Multi-turn chat | Very high | Each turn resends the whole conversation |
| Agents and tool loops | Very high | Tool list and history repeat every step |
| Shared system prompt | High | Long fixed instructions, short questions |
| Few-shot or batch classification | High | Examples repeat across thousands of calls |
| Sampling n answers to one prompt | High | Same prompt, different continuations |
| One-off summarization of unique documents | Low | Little shared text beyond the instructions |
Two practical tips follow.
Put the fixed parts of your prompts first and the variable parts last, because only the beginning can match.
And avoid putting a timestamp or request id at the top of the system prompt, which makes every prompt unique from the first token.
Launch with --enable-cache-report and every OpenAI-style response reports usage.prompt_tokens_details.cached_tokens, so you can measure your hit rate.
What the real code does, step by step
- Match.
When a request is considered for admission,
match_prefixwalks the tree from the root and returns the slots of the longest matching beginning. If the match ends halfway along an edge, the node is split in two so the shared half becomes its own node. Splitting copies no KV data; it only slices the lists of slot numbers. - Lock.
The matched path gets its
lock_refraised, so those slots cannot be evicted while this request depends on them. - Allocate the rest.
Only the unmatched tail gets new KV slots and gets prefilled.
The log's
#cached-tokencounts the reused part. - Insert early. Right after prefill, the prompt is inserted into the tree, so other requests can share it while this one is still writing its answer.
- Insert again on finish. When the request finishes, the full sequence, answer included, goes into the tree. That is why the next turn of a chat hits: its prompt is the previous prompt plus the previous answer.
- Unlock and evict lazily.
The lock is released.
Nothing is deleted yet.
Only when the allocator runs short does the cache evict, picking unlocked leaves by
--radix-eviction-policy(defaultlru; alsolfu,slru,priority,tlru). Removing a leaf may turn its parent into a leaf, so eviction eats branches from the tips inward and the shared trunk stays longest.
A note on the code you will find: mem_cache/radix_cache.py is the clearest version to read, and the playground above mirrors it.
Production now runs UnifiedRadixCache, with the tree's core written in Rust by default, which applies the same ideas to newer model types (sliding-window and Mamba-style layers).
radix_cache.py · match_prefix
_split_node
evict
common.py · release_kv_cache
unified_radix_cache.pyBeyond one GPU's memory: HiCache
GPU memory is small, so popular prefixes get evicted.
--enable-hierarchical-cache adds two more tiers: evicted KV is kept in CPU memory (by default twice the size of the GPU pool), and optionally in a storage backend shared across servers (Mooncake, 3FS, NIXL, local files).
A request that matches an entry in CPU memory copies it back layer by layer, overlapped with compute, which is much faster than recomputing a long prompt.
Across many servers: cache-aware routing
With several SGLang replicas, a prefix cached on server A is useless if the next turn lands on server B.
SGLang's main router, the SGLang Model Gateway (written in Rust, in sgl-model-gateway/), keeps an approximate radix tree of recent request text for each server and sends each request to the one with the longest match.
When load gets lopsided it switches to the shortest queue, so no server falls far behind.
cache_aware is its default policy.
The repo also holds a slimmer router in sgl-router/, whose default policy is power_of_two.
Below, one router spreads eight chats across three SGLang servers. Every turn resends the whole chat so far, so a turn is cheap only on a server that still holds the turns before it. All three policies route the same requests, so you can switch between them at any point and compare.
- 1·3
- 2·4
- 3·3
- 6·4
- 7·4
- 8·4
- 1·4
992 prompt tokens
Load balanced · best match 74% > 0.3 → C
Server A
0 runningtree match 0%
2,622 / 5,000 tokens
Cached, last used first- chat 61,099
- chat 3747
- chat 5776
6 requests51% hit
Server B
0 runningtree match 0%
3,546 / 5,000 tokens
Cached, last used first- chat 41,383
- chat 21,185
- chat 1978
8 requests69% hit
Server C
3 runningtree match 74%
3,569 / 5,000 tokens
Cached, last used first- chat 71,076
- chat 51,239
- chat 81,254
8 requests67% hit
- Round robin20%
- Random18%
- Cache-aware64%
Simplified model: each server holds 5,000 tokens of KV cache, keeps a chat as a chain of turns, and evicts from the least recently used chat, newest turn first. A request that computes more tokens runs longer. Thresholds used: cache threshold 0.3 and relative balance threshold 1.5, the gateway’s command-line defaults; absolute balance threshold 2 instead of the default 64, scaled down because only a few requests run at once here.
Things to try: switch to round robin. Each new turn usually lands on a server without the turns before it, so it recomputes the whole chat and pushes other chats out of that server's cache. The hit rate stays at about a third or less. Back on cache-aware, press Step once to see the exception. Server C has three requests running and the others have none, so the router skips its tree for this one request and picks the shortest queue. A and B tie at zero, and the gateway breaks a tie at random without looking at its tree, so chat 1 lands on A, which holds none of it, while B holds its earlier turns. Near the end of a cache-aware run, dashed rows show chats the router still expects on a server that has already evicted them.
cache_aware.rs · imbalance check
match rate and threshold
low match: least busy worker
shortest queue
tree.rs · prefix_match_with_counts
main.rs · policy and threshold defaults
Your app's system prompt begins with "Current time: 14:02:37", then 2,000 tokens of fixed instructions. What is your cache hit rate likely to be?
On the GPU: kernels, CUDA graphs and sampling
The model code is ordinary PyTorch. Speed comes from three swaps underneath it: specialized attention kernels that read the paged KV cache, CUDA graphs that replay a whole decode step in one launch, and a fused sampler.
From batch to tensors
The scheduler's ScheduleBatch is Python bookkeeping.
Right before the GPU runs, it becomes a ForwardBatch: a handful of tensors that describe the whole batch at once, such as the token ids to run, their positions, each request's row in the slot table, and the KV slots this pass writes to.
Step 5 of the request diagram in chapter 4 shows one.
A model file, briefly
Each supported architecture has a file in python/sglang/srt/models/ (there are hundreds).
They read like the Hugging Face versions, except that linear layers are tensor-parallel and attention goes through RadixAttention.
Here is the attention block from llama.py:
# python/sglang/srt/models/llama.py · LlamaAttention.forward (trimmed)
qkv, _ = self.qkv_proj(hidden_states) # one matmul makes Q, K and V
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
q, k = self.rotary_emb(positions, q, k) # encode positions (RoPE)
attn_output = self.attn(q, k, v, forward_batch) # RadixAttention → attention backend
output, _ = self.o_proj(attn_output)Despite its name, RadixAttention has no tree logic in it.
It is a thin layer that hands q, k and v to whichever attention backend is active.
The backend writes the new k and v into the KV pool at the slots chosen for this pass (out_cache_loc), then runs a kernel that gathers every earlier token's keys and values through the slot table.
Prefix sharing is invisible at this level: the kernel reads slots and does not care who else points at them.
Attention backends
Attention over a paged cache is the one operation PyTorch cannot do well by itself, so SGLang plugs in kernels from several projects and picks one for your hardware.
Override with --attention-backend, or separately with --prefill-attention-backend and --decode-attention-backend.
| Hardware | Default for Llama/Qwen-style models | Notes |
|---|---|---|
| H100, H200 (Hopper) | fa3 | FlashAttention 3 |
| B200, B300 (Blackwell) | trtllm_mha | NVIDIA's TensorRT-LLM kernels; fa4 also available |
| A100, L40S, RTX cards | flashinfer | FlashInfer, a kernel library built for serving |
| AMD Instinct | aiter | AMD's kernel library |
| Anywhere | triton | Portable fallback written in Triton |
DeepSeek-style MLA models have their own kernels (flashmla, trtllm_mla, cutedsl_mla), and so do sparse-attention models.
CUDA graphs
A decode step for a small batch is hundreds of tiny GPU kernels, each finishing in microseconds. Launching each one from Python costs more time than running it. A CUDA graph records the whole sequence of launches once and replays it with a single call.
The catch: a graph is recorded for one exact batch size.
So at startup SGLang captures decode graphs for a list of sizes (1, 2, 4, 8, 12, 16, 24, 32, and so on up to 256 on an H100) and pads each real batch up to the nearest captured size.
That is why startup takes a minute, and why the log line says cuda graph: True on most decode steps.
Recent versions also capture prefill in pieces, with attention run normally between them.
1 Where the time goes in a small decode step
Step done at 204 µs. The GPU sat idle for 90 µs of it.
Eager. The CPU spends 5 µs launching each kernel, and with 4 requests most kernels finish faster than that, so the GPU waits for the next launch. It sits idle 90 µs of this 204 µs step.
Simplified model, illustrative timings: 40 kernels stand in for the hundreds in a real step, each launch costs the CPU 5 µs, and each kernel takes 2–14 µs, growing with the batch. Preparing the batch’s inputs is left out.
2 Which captured graph a batch uses
none for this size. 256 is the default on one 80 GB GPU.
36 decode graphs captured at startup, one per batch size:
13 real rows + 3 dummy = the size-16 graph
13 requests replay the size-16 graph. SGLang finds it with a binary search (bisect) over the sorted sizes, fills the 3 spare rows with dummy inputs, and drops their outputs after the replay.
With 4 or more GPUs in tensor parallel, the default on an 80 GB card rises to 512.
Setting --cuda-graph-max-bs-decode higher captures more graphs, which costs startup time and some GPU memory for the graphs' buffers.
cuda_graph_hook.py · captured decode sizes
memory_hook.py · 256 on 80 GB
base_cuda_graph_runner.py · _pad_to_bucket
decode_cuda_graph_runner.py · can_run_graph
model_runner.py · graph or eager
From logits to a token
The model's last layer produces a score (logit) for every token in the vocabulary, about 150,000 of them for Qwen. The sampler then applies, in order:
- Repetition, frequency and presence penalties
- The grammar mask, if the request asked for structured output (next chapter)
logit_bias- Temperature, then top-k, top-p and min-p filtering, then a random draw
Temperature 0 becomes plain argmax (greedy). When every request in a batch is greedy, the whole sampling step is a single argmax. These are the same knobs you set in an API call; here you can see where each one acts.
PromptSplit the bill and leave a generous
Divides logits by 0.8, so gaps widen and the top token gains.
Off (top_k = -1), so there is no limit on how many stay.
Keeps top tokens until they add up to 90%. That is 4 tokens here.
Off at 0. Nothing is dropped.
Top-p 0.9 is doing the work here. Temperature 0.8 sharpens the softmax, then top-p keeps the 4 most likely tokens that together reach 90%, and SGLang rescales them to sum to 100% before drawing. Move any knob and watch the change flow left to right.
Simplified model: 12 made-up candidates stand in for a vocabulary of about 150,000 tokens. Penalties, the grammar mask and logit_bias act on the logits before stage 1; none are set here. The 20 draws reuse the same random numbers until you press Sample again, so moving a knob shows the same luck landing on the new distribution.
sampling_batch_info.py · penalties, grammar mask, logit_bias
sampling_params.py · temperature 0 becomes top_k = 1
sampler.py · greedy argmax
sampler.py · temperature, softmax
sampler.py · FlashInfer top-k, top-p, min-p
sampler.py · top-k, top-p, min-p in PyTorch
protocol.py · request value, then generation_config.json, then fallback
decode_cuda_graph_runner.py
layers/sampler.pyStructured output and speculative decoding
A grammar mask makes invalid tokens impossible, so JSON output always parses unless it is cut off by max_tokens. Speculative decoding lets a small fast model guess several tokens ahead and the big model check them all in one pass.
Structured output: grammar masks
If you have used response_format with a JSON schema on the OpenAI API, you have used this.
The engine compiles your schema into a grammar.
Before every sampling step, the grammar computes which tokens are legal next, and every other token's logit is set to -∞.
The model can only pick among what the schema allows, so the output parses as long as max_tokens does not cut it off (check for finish_reason: "length").
Sure41% → 0%Here22% → 0%```14% → 0%{12% → 100%The11% → 0%
The schema says the answer must be a JSON object, so the only legal first token is an opening brace. The model would rather chat, but those tokens are masked to probability zero.
SGLang's default grammar engine is xgrammar (--grammar-backend also accepts outlines and llguidance).
You can constrain with a JSON schema, a regular expression or an EBNF grammar, one per request.
Through the OpenAI API, use response_format for JSON and extra_body={"regex": ...} or {"ebnf": ...} for the others.
For reasoning models launched with --reasoning-parser, the constraint switches on only after the model finishes thinking, so the thinking text stays free-form.
History note: the 2024 SGLang paper also introduced "jump-forward" decoding, which skipped ahead
when only one continuation was legal. The functions still exist in constrained/, but nothing in
the current scheduler calls them.
constrained/xgrammar_backend.py
sampling_batch_info.py · apply the mask
docs · structured outputsSpeculative decoding: guess, then check
Decode is slow because each token needs a full pass through the big model, and that pass is mostly spent waiting on memory. Checking 5 tokens costs the big model about the same as generating 1, because it reads its weights once either way. So:
- A small, cheap draft model guesses the next few tokens.
- The big target model checks all the guesses in a single pass.
- Guesses are kept up to the first one the target disagrees with. At that point the target's own token is used instead, so every pass gains at least one token.
With greedy sampling, the output is exactly what the big model would have produced alone. With temperature above 0, a rejection-sampling rule keeps the distribution the same. You get the big model's answer, faster.
Each row is one big-model pass. Accepted guesses are kept up to the first rejected one, and the big model adds the outlined token itself. This run takes 7 passes instead of 16.
Simplified model: each guess is right with the chance you set, independently of the others, and the draft model’s own time is left out.
The speedup depends on how often the draft is right, which depends on how predictable the text is. Code and structured text accept long runs; creative writing accepts fewer. Each round also costs the draft model's time, which is why good drafts are tiny.
SGLang supports several drafting methods through --speculative-algorithm:
- EAGLE3 (the docs' recommendation) and EAGLE: a one-layer draft head, cheap and well informed, that drafts a small tree of guesses. The widget below walks through one pass. You need a trained draft head for your model, published for many popular ones. SpecForge, a sibling project, trains them.
- MTP: models like DeepSeek-V3 ship with built-in multi-token-prediction layers.
There is no plain
MTPvalue; you passEAGLE(or its aliasNEXTN) and SGLang uses those layers as the draft. - STANDALONE: a small ordinary model from the same family as the big model serves as the draft, for example Qwen2.5-1.5B for Qwen2.5-7B.
- NGRAM: no extra model at all; it guesses by finding the current text earlier in the context. It works well for editing and retrieval tasks that copy text.
Once it runs, the decode log line shows accept len, the average number of tokens gained per big-model pass.
The simulator above drafts a single line of guesses, so one wrong guess ends the run. EAGLE hedges its bets. Its draft head works from the big model's hidden states, the vectors the big model computes for each token on its way to choosing the next one. At each step the draft head keeps its top few guesses, and the guesses grow into a small tree. Here is one pass, picking up the sentence after "Each person owes".
Server flags --speculative-num-steps 3 --speculative-eagle-topk 2 --speculative-num-draft-tokens 7
The big model’s own pick “20%” comes free with the pass and becomes the root of the next tree. This pass gained 4 tokens from one big-model forward pass. Plain decoding gains 1, and topk 1 with the same depth would have gained 2 here.
Draft tree · score under each guess
Tree attention mask · one verify pass, 7 tokens
| token | next | prefix | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|---|---|
| big model’s next token: $36 | reads | reads owes | masked | masked | masked | masked | masked | masked | |
| big model’s next token: after | reads | reads owes | reads $36 | masked | masked | masked | masked | masked | |
| big model’s next token: before | reads | reads owes | masked | reads $30 | masked | masked | masked | masked | |
| big model’s next token: the | reads | reads owes | reads $36 | masked | reads with | masked | masked | masked | |
| big model’s next token: the | reads | reads owes | reads $36 | masked | masked | reads after | masked | masked | |
| big model’s next token: 20% | reads | reads owes | reads $36 | masked | reads with | masked | reads the | masked | |
| big model’s next token: 20% | reads | reads owes | reads $36 | masked | masked | reads after | masked | reads the |
Each row is one token in the verify pass. next is the big model’s own choice after the row’s token. A filled cell means the row’s token can read that column’s token.
Row 6 (“owes $36 after the”) reads the cached prefix and its own branch. The mask hides the other 3 tokens. Tap a row or a guess in the tree to compare.
Simplified model: one request, greedy decoding (temperature 0), and hand-written probabilities for the draft head and choices for the big model. A real draft head scores its whole vocabulary at every step.
Two details make the single pass work. Guesses at the same depth share a position number, because each would sit at the same place in the text, and the mask keeps them from reading each other. The verify pass also saves the big model's hidden states, so the draft head can start the next tree from the token the big model just added. Bigger trees are not free. Each draft token adds work and memory to the verify pass, and each draft step is one more draft-head pass, so with a full batch a bigger tree can be slower. The docs suggest leaving all three flags unset so SGLang picks values for your model. SGLang's automatic choice for Llama models is 5 steps, topk 4 and 8 draft tokens. That makes 68 candidate guesses, pruned to the best 7.
spec_utils.py · score and pick branches
eagle_utils.py · organize_draft_results (prune)
spec_tree.py · build the tree mask
spec_tree.py · greedy tree verify
forward_batch_info.py · TARGET_VERIFY
llama_eagle.py · draft reads target hidden states
Why can checking 6 draft tokens cost the big model about as much as generating 1?
Many GPUs, many machines
When one GPU is not enough, you can split each layer across GPUs (tensor parallel), run copies (data parallel), spread a mixture-of-experts model's experts out (expert parallel), stack layers in stages (pipeline parallel), or send prefill and decode to different machines (PD disaggregation).
You will see these acronyms in every launch command for a big model. Each one is a different way to cut the same work. Pick a tab.
All-reduce over NVLink. The GPUs swap their partial results and add them up, so each one ends with the full output of the attention block. This happens twice per layer, so an 80-layer model does it 160 times in every forward pass.
One model, sliced. Every weight matrix is cut into four slices, one per GPU, and the KV cache is split by attention head the same way. Each GPU multiplies the same input by its own slice, then all four add up their partial results (an all-reduce) before the next block can start. That exchange happens twice per layer, so TP needs very fast links between GPUs (NVLink) and normally stays inside one machine. It is the standard way to fit a model too big for one GPU, such as Llama 3.1 70B on four H100s, and it also speeds up each token, since every GPU reads only a quarter of the weights.
--tp-size 4, also spelled --tensor-parallel-size 4.
In the codelinear.py · RowParallelLinear, the all-reducellama.py · LlamaDecoderLayerparallel.py · tp_size
Simplified model: one all-reduce after the attention block and one after the MLP block of each layer, as in Llama. Each GPU sends its partial result to every other GPU here; real all-reduce kernels move the same data in smarter patterns. Slowed down enormously.
Request 26 goes to GPU 1. The router hands requests to the four copies in turn. Each copy batches its own requests and adds a token to each of them every step, without waiting for the others.
Copies, side by side. Each GPU (or each group of GPUs) holds a whole copy of the model and serves its own share of the requests. The copies never talk to each other, so throughput grows almost linearly with the number of copies, while each single request runs no faster than on one copy. Inside one server, --dp-size 4 starts four copies behind a small controller that hands out requests in turn, round robin by default. For production, the SGLang docs recommend separate servers behind the SGLang Model Gateway, whose cache-aware routing (chapter 6) keeps each conversation on the copy that already holds its prefix.
--dp-size 4 on sglang.launch_server for copies inside one server. The gateway’s sglang_router.launch_server takes the same flag.
In the codedata_parallel_controller.py · DataParallelControllerround_robin_schedulerserving_hook.py · auto means round robindocs · DP, DP attention and the gateway
Simplified model: requests arrive at a steady pace and each generates 8 to 18 tokens.
Dispatch, an all-to-all. Each token gets 2 experts picked for it, and a copy of the token travels to whichever GPU holds each one. 9 of the 16 copies cross to another GPU.
For mixture-of-experts models. Models such as DeepSeek-V3 have many small expert networks in each MoE layer, and each token uses only a few of them (8 of 256 in DeepSeek-V3). Expert parallelism gives each GPU its own share of the experts. After attention, every token is sent to the GPUs that hold its chosen experts (dispatch), and the results come back (combine). Both steps are all-to-all exchanges, where every GPU sends to every other, done by libraries such as DeepEP. EP is usually paired with DP attention, where each GPU runs attention for its own requests and keeps its own KV cache. That matters for DeepSeek’s compressed attention cache (MLA), which has a single KV head and cannot be split by heads, so plain TP would copy it to every GPU.
--tp-size 4 --attn-dp-size 4 --ep-size 4 --moe-a2a-backend deepep. Here --tp-size counts all four GPUs. --attn-dp-size 4 replaces the deprecated pair --dp-size 4 --enable-dp-attention. Without --moe-a2a-backend, the default none moves tokens with all-reduce or all-gather instead of all-to-all.
In the codedeepep.py · dispatchcombineparallel.py · ep_sizeattn_dp_sizeserver_args.py · the deprecated flag
Simplified model: 16 experts and 2 per token so the picture stays readable, with random picks.
Slot 4 of 7. Every GPU works on a different micro-batch at the same time, like stations on an assembly line.
An assembly line. Each GPU holds a quarter of the layers (layers 1–20 on the first, and so on) and passes its output to the next. Only one small hand-off per micro-batch crosses each boundary, so PP copes with slower links and can span machines where tensor parallel would choke. The cost is idle time, called the bubble. A GPU waits until the first micro-batch reaches it, and idles again after its last one leaves. Sending more micro-batches back to back shrinks the bubble’s share of the time. While serving, SGLang’s scheduler cycles through as many micro-batches as there are stages, so steady traffic keeps every stage busy.
--pp-size 4, also spelled --pipeline-parallel-size 4. Across machines, add --nnodes, --node-rank and --dist-init-addr.
In the codeutils.py · get_pp_indices, the layer splitscheduler_pp_mixin.py · event_loop_ppmicro-batch slotsparallel.py · pp_size
Simplified model: equal micro-batches, each taking one time slot on each GPU.
Prompt 5 arrives. The prefill server reads all 13 of its tokens in one pass and builds their KV cache. Meanwhile the decode server reserves room for it and keeps streaming the others.
Different jobs, different machines. Prefill reads a whole prompt in one pass and is limited by compute. Decode adds one token per step and is limited by memory bandwidth. On one server, every prefill pass pauses the streams that are already decoding (the faded cells in chapter 5). With PD disaggregation, a prefill server computes the prompt’s KV cache and first token, then writes them over RDMA straight into memory that the decode server reserved for that request. The decode server never runs a prefill pass, so its streams keep a steady pace.
--disaggregation-mode prefill on one server and --disaggregation-mode decode on the other. --disaggregation-transfer-backend picks how the KV cache moves and defaults to mooncake. A router in front pairs them:
python -m sglang_router.launch_router \
--pd-disaggregation \
--prefill http://127.0.0.1:30000 \
--decode http://127.0.0.1:30001In the codeprefill.py · life of a requestdecode.py · skips the prefill passutils.py · first token sent with the KVdisagg.py · the flagsdocs · PD disaggregation
Simplified model: one prefill and one decode server, prompts arriving at a steady pace. The lower timeline replays the same arrivals on one server that does both jobs.
These combine. A typical large deployment of a DeepSeek-class model uses PD disaggregation across machines, expert parallelism and DP attention within each server, and the gateway in front. For a model that fits on one machine, plain TP plus a few DP copies behind the gateway covers most needs.
arg_groups/fields/parallel.py · the flags
data_parallel_controller.py
disaggregation/docs · PD disaggregationYou have two machines with 8 GPUs each, joined by an ordinary network. Which split usually goes between the machines?
SGLang, vLLM and the rest
SGLang and vLLM are the two big open-source engines, they borrow from each other constantly, and by now they share most core ideas. Pick by model support and by benchmarking your own workload, not by reputation.
Everything in chapters 1-3 applies to both. vLLM made paged KV memory famous in 2023 with PagedAttention; SGLang made prefix sharing famous in 2024 with RadixAttention. Each has since adopted the other's idea in its own form. The README of SGLang credits vLLM among the projects it learned from, and SGLang's own benchmark tool can drive a vLLM server for side-by-side tests.
| SGLang | vLLM | |
|---|---|---|
| Started | 2024, LMSYS (RadixAttention paper) | 2023, UC Berkeley (PagedAttention paper) |
| Prefix cache | Radix tree of token runs, on by default; 1-token granularity on most GPUs (64-token pages on B200, whose default kernels need them) | Hashes of fixed-size blocks ("automatic prefix caching"), on by default |
| Batching | Continuous batching, chunked prefill, CPU work overlapped with the GPU (on by default) | Continuous batching, chunked prefill, async scheduling that overlaps CPU work with the GPU (on by default) |
| Structured output | xgrammar by default | xgrammar and others |
| APIs | OpenAI, Anthropic Messages, Ollama, native /generate, offline Engine | OpenAI-compatible server, Anthropic Messages, offline LLM |
| Often chosen for | Large MoE deployments (DeepSeek-style), RL rollouts, agent and chat workloads with heavy prefix reuse | Broadest model and hardware coverage, largest community and integrations |
| Launch | sglang serve MODEL | vllm serve MODEL |
The prefix cache row hides a small practical difference. SGLang's radix tree compares a new prompt with cached ones token by token. vLLM's automatic prefix caching cuts every prompt into fixed blocks (16 tokens by default for most models on NVIDIA GPUs; its MLA kernels for DeepSeek-style models need 32 to 128), hashes each full block together with the hash of the block before it, and reuses a block only when that whole chain of hashes matches. A block that is only partly shared is computed again.
SGLang radix tree
page size 1 · every token is its own page46 of 53 reusedBlock hashing, as in vLLM
16-token blocks32 of 53 reusedA and B share their first 46 tokens. SGLang’s radix tree, matching token by token, reuses 46 tokens. The block cache reuses 32 tokens. Block 2 holds 14 tokens shared with A, but B changes partway through it, so its hash differs. That is 14 tokens of extra prefill. On a 2,000-token shared prefix, rounding would cost at most 15 tokens, 0.75% of it.
Simplified model: one word or punctuation mark stands for one token, and A is the only cached prompt. Real tokenizers cut text into more pieces than words. Both engines always compute the last prompt token, since its output is the first new token.
The rounding never costs more than one block or page minus a token per request, however long the shared prefix is. With a system prompt of a few thousand tokens that is under 1% of the prompt for 16-token blocks and up to about 3% for 64-token pages, so for most chat and agent traffic the two designs reuse nearly the same amount. The gap shows up when shared prefixes are short. SGLang rounds down too when an attention backend needs pages of more than one token. On a B200 its default kernels use 64-token pages, so there a 16-token block cache rounds less than SGLang does for Llama- and Qwen-style models. For DeepSeek-V3, vLLM's own MLA kernels use blocks of 32 tokens or more too.
radix_cache.py · page_aligned
radix_cache.py · match rounds down to a page
schedule_batch.py · last token always computed
overrides.py · default page size 1
overrides.py · backends that force larger pages
model_override_base.py · B200 picks trtllm_mha
Benchmarks between the two change with nearly every release, and both teams publish numbers that favor their own setups.
If performance decides it for you, run python -m sglang.benchmark.serving (or vLLM's equivalent) against both, on your model, your GPU and a dataset shaped like your traffic.
Other options you will hear about
| Tool | What it is | When it fits |
|---|---|---|
| Hosted APIs (OpenAI, Anthropic …) | Someone else runs the engine and the GPUs | Low or spiky volume, closed models, no ops team |
Hugging Face transformers | The reference model code, with generate() | Research and prototyping; not for serving many users |
| llama.cpp, Ollama | C++ engine for quantized models on laptops, Macs and CPUs; Ollama wraps it | One user, local, offline, no datacenter GPU |
| TensorRT-LLM | NVIDIA's engine, built around compiled, heavily tuned kernels | Squeezing the last bit from NVIDIA hardware, with more setup |
When would you reach for SGLang?
- Self-hosting an open model for cost, privacy or control, at a volume where a GPU stays busy.
- Big batch jobs with a shared prompt: tagging 500,000 reviews with the same instructions and examples.
The radix cache computes the shared part in the first batch and reuses it for the rest, and the offline
Engineskips HTTP entirely. - Agents with long, repeated context: tool definitions, files, history.
- Training with reinforcement learning, where a trainer needs millions of sampled answers quickly. verl, slime, AReaL and others use SGLang for this.
When traffic is low, a hosted API is usually cheaper than a GPU idling at a few dollars an hour.
Running it yourself
One command starts an OpenAI-compatible server on port 30000. Point any OpenAI SDK at it, then watch the log lines to see the scheduler and cache at work.
1 · Get a GPU
You need a Linux machine with an NVIDIA GPU (AMD and others work too; see the hardware docs).
Renting one by the hour from a cloud GPU provider is the easy path.
A 24 GB card is plenty for Qwen/Qwen3-0.6B or an 8B model.
SGLang also runs on Apple Silicon Macs through Metal and MLX, which is handy for poking around, though real serving means datacenter GPUs.
2 · Start the server
# with Docker (includes everything)
docker run --rm --gpus all --ipc=host -p 30000:30000 \
lmsysorg/sglang:latest \
sglang serve Qwen/Qwen3-0.6B --host 0.0.0.0 --port 30000
# or with uv in a Python 3.12 environment
uv pip install --prerelease=allow sglang
sglang serve Qwen/Qwen3-0.6B --host 0.0.0.0 --port 30000Wait for The server is fired up and ready to roll!.
The older spelling, python -m sglang.launch_server --model-path …, still works and appears in many docs pages.
3 · Call it like OpenAI
from openai import OpenAI
client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="Qwen/Qwen3-0.6B",
messages=[{"role": "user", "content": "Split $90 three ways."}],
temperature=0.7,
stream=True,
)
for chunk in resp:
print(chunk.choices[0].delta.content or "", end="")Structured output uses the standard OpenAI parameter:
resp = client.chat.completions.create(
model="Qwen/Qwen3-0.6B",
messages=[{"role": "user", "content": "Extract the dish and price: 'Pizza, $12'"}],
response_format={
"type": "json_schema",
"json_schema": {"name": "line_item", "schema": {
"type": "object",
"properties": {"item": {"type": "string"}, "price": {"type": "number"}},
"required": ["item", "price"],
}},
},
)4 · Or skip HTTP for batch jobs
The offline engine runs the same three-process system inside your Python script, which is ideal for one-off jobs over a big file:
import sglang as sgl
llm = sgl.Engine(model_path="Qwen/Qwen3-0.6B")
prompts = [f"Label the review as positive or negative: {r}\nLabel:" for r in reviews]
outputs = llm.generate(prompts, {"temperature": 0, "max_new_tokens": 4})
labels = [o["text"].strip() for o in outputs]
llm.shutdown()Every prompt in that list shares its instruction prefix, so the radix cache computes it in the first batch and reuses it for every batch after that.
5 · The flags worth knowing
| Flag | What it controls | Chapter |
|---|---|---|
--tp-size N | Split the model across N GPUs | 9 |
--dp-size N | Run N copies inside one server. For production the docs recommend the gateway: the same flag on python -m sglang_router.launch_server | 9 |
--mem-fraction-static | Share of GPU memory for weights plus KV pool. Lower it if you hit out-of-memory errors | 3 |
--context-length | Maximum tokens per request; defaults to the model's config | 3 |
--kv-cache-dtype | Store the cache in FP8 to fit about twice the tokens | 3 |
--max-running-requests | Cap on concurrent decoding requests | 5 |
--chunked-prefill-size | Most prompt tokens one prefill pass computes, summed over its requests; longer prompts are split into slices | 5 |
--schedule-policy | Queue order: fcfs (default), lpm, … | 5 |
--disable-radix-cache | Turn prefix caching off (for comparison or debugging) | 6 |
--enable-hierarchical-cache | Spill evicted KV to CPU memory and storage | 6 |
--enable-cache-report | Report cached prompt tokens in API usage | 6 |
--attention-backend | Which attention kernels to use | 7 |
--quantization | Load weights in a compressed format (e.g. fp8) | 3 |
--speculative-algorithm | Turn on speculative decoding (EAGLE3, NGRAM, …) | 8 |
--reasoning-parser, --tool-call-parser | Split thinking from the answer; parse tool calls into OpenAI format | - |
--enable-metrics | Prometheus metrics at /metrics | - |
The full list is long; run sglang serve --help or see the server arguments page in the docs.
The SGLang Cookbook gives ready-made launch commands per model and GPU, which beats tuning by hand.
Most of those flags have good defaults, so a real command needs only a few. Pick a model, your GPUs and what you need below. The builder sizes tensor parallelism with the memory math from chapter 3, adds only the flags your needs call for, and writes the client code that goes with them.
Memory per GPU for each tensor-parallel size
Each copy needs its weights plus room for at least 16K tokens of KV cache, a few requests at once. All copies together should hold about 256K for many users.
1 GPU: weights do not fit; 2 GPUs: 70.6 GB of weights per GPU and room for 4.3K tokens; 4 GPUs: 35.3 GB of weights per GPU and room for 429K tokens; 8 GPUs: 17.6 GB of weights per GPU and room for 1.3M tokens. Picked 4.
The command
sglang serve \ \ \ \ \ \ --tp-size 4 splits each layer across 4 GPUs (tensor parallelism, chapter 9). Llama 3.1 70B has 70.6B parameters at 2 bytes each, 141 GB of weights. With 2 GPUs the cache would hold only 4.3K tokens. With 4, each GPU keeps 35.3 GB of weights and about 35.1 GB of KV cache, room for 429K tokens (target 16K per copy).
Calling it with the OpenAI SDK
from openai import OpenAI client = OpenAI(base_url="http://localhost:30000/v1", api_key="EMPTY") tools = [{"type": "function", "function": { "name": "split_bill", "description": "Split a bill evenly", "parameters": {"type": "object", "properties": { "total": {"type": "number"}, "people": {"type": "integer"}}, "required": ["total", "people"]}}}] resp = client.chat.completions.create( model="meta-llama/Llama-3.1-70B-Instruct", messages=[{"role": "user", "content": "Split $90 three ways."}], tools=tools,)msg = resp.choices[0].messageprint(msg.tool_calls) # parsed by --tool-call-parserprint(msg.content)print(resp.usage.prompt_tokens_details.cached_tokens) # needs --enable-cache-reportSimplified rule: each model copy gets the fewest GPUs that hold its weights plus the cache target above, and Many users adds copies on the GPUs left over.
JSON output is the one goal with no flag.
Structured output is on by default with the xgrammar backend, and each request asks for it with response_format.
Speculative decoding (chapter 8) is left out.
EAGLE-style drafting needs a draft model trained for your model, DeepSeek-V3 carries its own draft layer (multi-token prediction, MTP), and NGRAM needs no draft model at all, so take the exact flags from the model's Cookbook page.
fields/parallel.py · --tp-size, --dp-size
fields/model.py · --kv-cache-dtype choices
server_args.py · parser flags
reasoning_parser_names.py
function_call/parser_names.py
fields/serving.py · --enable-cache-report
fields/observability.py · --enable-metrics
Prefill batch This pass ran prefill (EXTEND) for newly admitted requests. You get one of these lines for every prefill pass.
6 · Measure it
python -m sglang.benchmark.serving --backend sglang \
--host 127.0.0.1 --port 30000 --num-prompts 500It reports the numbers serving people argue about: TTFT (time to first token, mostly prefill and queueing), ITL or TPOT (time between output tokens, mostly decode), and total throughput.
Try the same run with --disable-radix-cache on the server, or with a different --max-running-requests, and you will see the trade-offs from this guide in your own numbers.
A decode line shows #running-req: 15 and gen throughput (token/s): 1287.40. Roughly how fast
is each user's stream?
Reading the code
The repository is huge (over 11,000 files), but the core path is about a dozen files. Read them in the order a request travels.
Almost everything that matters is under python/sglang/srt/.
The rest is kernels (python/sglang/kernels/, 3rdparty/), the Rust gateway (sgl-model-gateway/), image and video generation (python/sglang/multimodal_gen/), docs, benchmarks and tests.
The paths below are relative to python/sglang/srt/ and link to the exact commit this guide was written against.
Here is srt/ drawn to scale.
Each box's area is its lines of Python.
The blue numbers show where the 14 files of the reading order below live; open a folder to find them, or walk the reading order and watch a request's path jump between folders.
921,656 lines · 2,115 Python files
layers/ and models/ alone are 43% of it. The 14 reading-order files add up to 40,447 lines, 4.4%. Pick a folder to look inside.
Open python/sglang/srt/ on GitHubLines counted like wc -l over every .py file under python/sglang/srt/ at commit 773cb93, so comments and blank lines count. The color groups are this guide’s own. Keyboard: Tab into the map, move between boxes with the arrow keys, press Enter to open a folder and Backspace to go back up. Escape closes the card first, then goes back up.
Nearly half of the code outside the reading order sits in models/, layers/ and hardware_backend/, which hold per-model code, attention and MoE variants, quantization formats and support for other chips.
A first read can skip all of that.
- 1entrypoints/http_server.py
FastAPI app and every HTTP route. Find
/v1/chat/completionsand follow the call. - 2entrypoints/openai/serving_chat.py
Turns an OpenAI chat request into a native request: chat template, tools, response format.
- 3managers/tokenizer_manager.py
generate_requesttokenizes and sends;handle_loopreceives results and wakes waiting requests. - 4managers/io_struct.py
The message types passed between processes. Skim it to learn the vocabulary.
- 5managers/scheduler.py
The event loops and
get_next_batch_to_run. 6,000+ lines; read the two loops and that one function first. - 6managers/schedule_policy.py
Queue ordering and
PrefillAdder, the admission budget. - 7managers/schedule_batch.py
ReqandScheduleBatch: per-request state,prepare_for_extend,prepare_for_decode, retraction. - 8mem_cache/radix_cache.py
The radix tree in its clearest form, about 830 lines, plus
RadixKey, the prefix key every cache uses. A default server builds the same idea frommem_cache/unified_radix_cache.pyon a Rust core (chapter 6). Thenmem_cache/README.mdfor the map of the rest. - 9mem_cache/memory_pool.py
ReqToTokenPooland the KV pool classes. - 10managers/tp_worker.py
The bridge:
ScheduleBatch→ForwardBatch→ forward → sample. - 11model_executor/model_runner.py
Loads the model, sizes memory, picks the graph runner or eager path.
- 12models/llama.py
A whole model in one file. Compare it with the Hugging Face version.
- 13layers/attention/flashinfer_backend.py
Where K/V gets written to the pool and attention runs.
- 14managers/detokenizer_manager.py
Incremental decoding of token ids back into text.
Easier ways in
- Mini-SGLang: a compact rewrite by the same project, meant for learning. If the real scheduler is too much to start with, read this first.
- DeepLearning.AI short course on efficient inference with SGLang.
- docs.sglang.io: the advanced features section has a page per topic in this guide.
- The LMSYS blog: release write-ups that explain why each big feature exists.
A warning when reading older tutorials: this code moves fast. Server flags moved into
arg_groups/fields/, the default radix cache is now the unified version
(unified_radix_cache.py) on a Rust tree core, the old ChunkCache is gone, and several flags
were renamed. Trust the code over a blog post from last year.
Glossary
- All-reduce
A step where GPUs exchange partial results and each ends with their sum. Tensor parallelism does two per layer.
- Attention backend
The kernel library that runs attention over the paged KV cache: FlashAttention 3, FlashInfer, TensorRT-LLM, Triton and others.
- Batch
- The set of requests processed together in one forward pass.
- Chunked prefill
Splitting a long prompt into slices processed over several passes, which caps the memory one pass needs. With
--enable-mixed-chunk, decoding requests ride along with each slice.- Continuous batching
Adding and removing requests from the batch between every forward pass instead of waiting for a whole group to finish.
- CUDA graph
A recorded sequence of GPU kernel launches replayed with one call. It removes launch overhead from small decode steps.
- Decode
Generating output one token per forward pass, after prefill. Limited by memory bandwidth.
- Detokenizer
The process that turns token ids back into text, holding back half-finished characters.
- DP attention
Running attention data-parallel (each GPU owns its own requests and KV cache) while the rest of the model stays tensor- or expert-parallel.
--attn-dp-size.- EAGLE
A speculative decoding method whose draft is a tiny head that reads the target model's hidden states. EAGLE3 is the newest version.
- Eviction
Freeing cached KV to make room. SGLang evicts unlocked leaves of the radix tree, least recently used first by default.
- EXTEND
SGLang's name for a prefill pass. It extends a sequence by many tokens, possibly on top of a cached prefix.
- Expert parallelism (EP)
Spreading a mixture-of-experts model's experts across GPUs and routing each token to the GPUs holding its experts.
- Forward pass
One run of the input through every layer of the model. A decode forward pass is also called a decode step.
- ForwardBatch
The GPU-tensor description of a batch that the model actually runs on.
- Grammar mask
A per-step list of tokens allowed by a JSON schema, regex or grammar; every other token gets probability zero.
- Hidden states
The vectors a model computes for each token on its way through the layers. EAGLE's draft head reads them.
- HiCache
Hierarchical cache: KV kept on the GPU, then in CPU memory, then in shared storage.
--enable-hierarchical-cache.- KV cache
The saved keys and values for every earlier token, so each new token does not recompute them. The main consumer of GPU memory.
- Logit
The model's raw score for one vocabulary token. Softmax turns the scores into probabilities.
- LPM
Longest prefix match: a schedule policy that serves waiting requests with the most cached prefix first.
- mem-fraction-static
The share of GPU memory used for weights plus the KV pool. The rest is scratch space.
- MLA
Multi-head latent attention (DeepSeek): stores one compressed vector per token per layer instead of full keys and values per head.
- MoE
Mixture of experts: each layer has many small feed-forward networks, and each token uses only a few.
- New-token ratio
The scheduler's guess of how much of each running request's remaining
max_tokensit will really use. Admission reserves that much room. It starts at 0.7 and decays toward about 0.1.- Overlap scheduler
SGLang's default loop, which prepares the next batch on the CPU while the GPU runs the current one.
- Page size
How many tokens share one allocation unit in the KV pool. SGLang defaults to 1 on most GPUs and usually to 64 on Blackwell GPUs such as the B200, whose default kernels need larger pages.
- PagedAttention
vLLM's name for attention over KV memory stored in non-contiguous fixed-size blocks.
- PD disaggregation
Running prefill and decode on separate servers and transferring the KV cache between them.
- Pipeline parallelism (PP)
Splitting the model's layers into stages on different GPUs or machines.
- Prefill
Processing the whole prompt in one parallel pass to build its KV cache and produce the first token.
- Prefix caching
- Reusing the KV cache of a shared beginning across requests.
- RadixAttention
SGLang's prefix caching: a radix tree of token sequences mapping to KV slots, with LRU eviction and cache-aware scheduling.
- Rank
The number of one process in a multi-GPU group. Rank 0 is the first and coordinates the others.
- Req
The scheduler's object for one request: prompt ids, output ids, cached length, sampling settings, finish reason.
- Retraction
Pausing running requests and requeueing them when the KV pool fills mid-generation.
- Router / Model Gateway
The Rust front door for many SGLang servers, with cache-aware load balancing and PD routing.
- Speculative decoding
A draft model guesses several tokens; the target model verifies them all in one pass.
- SRT
- SGLang Runtime, the engine itself (
python/sglang/srt/). - Tensor parallelism (TP)
Splitting every layer's weights across GPUs that exchange partial results after each layer.
- TTFT
- Time to first token: queueing plus prefill.
- TPOT / ITL
Time per output token, or inter-token latency: the gap between streamed tokens during decode.
- xgrammar
The default library SGLang uses to compile schemas and grammars into per-step token masks.
- ZeroMQ (ZMQ)
The messaging library SGLang's processes use to pass requests and results on one machine.