Head-to-head design comparison
vllm/python/sglang/srt/
a556f3f · sglang 7d89325Run both engines' own load generators with --random-range-ratio 0 and you have not run the same workload. In vLLM every prompt is exactly --random-input-len tokens; in SGLang every prompt is uniform on [1, --random-input-len]. The flag has the same name, the same default, and opposite meaning. That single fact is the reason this chapter contains no performance table — and it is a small instance of the thing this chapter is actually about.
The problem
Here is the inversion, from the two harnesses at the pinned SHAs.
if not (0.0 <= input_range_ratio < 1.0):
raise ValueError("input_range_ratio must be in [0, 1).")
if not (0.0 <= output_range_ratio < 1.0):
raise ValueError("output_range_ratio must be in [0, 1).")
num_special_tokens = int(tokenizer.num_special_tokens_to_add())
real_input_len = max(0, int(input_len) - num_special_tokens)
input_low = math.floor(real_input_len * (1 - input_range_ratio))
input_high = math.ceil(real_input_len * (1 + input_range_ratio))
def compute_random_lens(full_len: int, range_ratio: float, num: int) -> List[int]:
# full_len=0 is valid for embedding benchmarks where no output tokens are generated
if full_len <= 0:
return [0] * num
return np.random.randint(
max(int(full_len * range_ratio), 1),
full_len + 1,
size=num,
).tolist()
In vLLM, range_ratio is a fractional half-width around the mean: $r=0$ pins every length to $L$, and $r=1$ is rejected outright. In SGLang, range_ratio is the lower bound as a fraction of $L$: $r=1$ pins every length to $L$, and $r=0$ gives uniform on $[1,L]$. Both default to 0.0. A reader who copies a command from one project's README into the other's harness gets a mean input length that differs by a factor of two, and every number downstream — TTFT, prefix hit rate, tokens/s — moves with it.
This is not a bug in either project. It is what happens when two teams solve the same problem from opposite ends and nobody is obliged to reconcile the vocabulary. The book has been accumulating instances of that for twelve parts. This chapter puts them in one table.
The thesis is short: vLLM and SGLang differ far less in what they do than in where they put the complexity and what they are willing to be wrong about. Both page the KV cache, both batch continuously, both call FlashAttention and FlashInfer, both cache prefixes, both capture CUDA graphs, both are moving their serving layer into Rust. Where they diverge is in the shape of the code that surrounds those mechanisms — and each shape has a characteristic failure mode that you can predict from the shape alone.
Mental model
The cleanest way to see the difference is to draw both engines at the same level of abstraction and look at where the arrows cross a process boundary.
vLLM decides centrally and ships the decision. The scheduler in the engine-core process produces a SchedulerOutput, which is broadcast over a shared-memory message queue to every worker; workers execute what they are told.
def execute_model( # type: ignore[override]
self, scheduler_output: SchedulerOutput, non_block: bool = False
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
return self.collective_rpc(
"execute_model",
args=(scheduler_output,),
unique_reply_rank=self.output_rank,
non_block=non_block,
timeout=envs.VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS,
kv_output_aggregator=self.kv_output_aggregator,
ec_output_aggregator=self.ec_output_aggregator,
)
SGLang replicates the decision. Rank 0 receives requests and broadcasts the inputs; every TP rank then runs an identical copy of the scheduler over them and arrives at the same batch independently.
@scheduler_nvtx_method("scheduler.recv_requests")
def recv_requests(
self,
) -> List[Union[TokenizedGenerateReqInput, TokenizedEmbeddingReqInput, Any]]:
"""Receive results at tp_rank = 0 and broadcast it to all other TP ranks."""
Only rank zero holds the sockets. init_ipc_channels computes is_rank_zero from pp_rank == 0 and attn_tp_rank == 0 and attn_cp_rank == 0, and every other rank returns before recv_from_tokenizer is assigned (python/sglang/srt/managers/scheduler.py:L740-L768). One process reads the wire; $N$ processes reach the same conclusion about what to do with what it read.
Figure 1 — the two engines at the same level of abstraction, TP=4. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
Centralized planning communicates a plan; replicated planning communicates inputs and derives the plan locally. SGLang still has broadcast and synchronization costs, and rank-local physical slot IDs can legitimately differ. Correctness requires agreement on ordered logical requests, positions and collective-compatible operations, plus a valid local mapping on each rank. Divergence can corrupt outputs, raise or hang depending on what differs; separate addresses alone are not an error.
Where the complexity lives
Take that observation one level down. Every engine has to solve the same list of problems; what differs is which module absorbs the irregularity. Call it the complexity budget. Each project spends it somewhere, and the place it spends it is where its bugs live.
vLLM spends it in the block layer. Its allocator is block-structured — DEFAULT_BLOCK_SIZE: ClassVar[int] = 16 (vllm/config/cache.py:L59) — so the KV cache is indexed by a block table of one int32 per block. At max_num_reqs=256 and a 32,768-token window that is $256 \times 2048 \times 4 = 2$ MiB of metadata addressing the 52.32 GiB KV pool of a Llama-3-8B server (derived; see §2.2). The price is that nothing is free at token granularity: a prefix that diverges mid-block may require recomputation of the unshared partial block (copy-on-write is a distinct implementation choice, not implied by paging), a hash follows the configured block granularity, and eviction order is a convention the callers must honour rather than something the data structure enforces.
and then freed, it will be appended back with the eviction order:
1. The least recent used block is at the front (LRU).
2. If two blocks have the same last accessed time (allocated by the
same sequence), the one with more hash tokens (the tail of a block
chain) is at the front.
Note that we maintain this order by reversing the block order when free
blocks of a request. This operation is outside of this class.
Read that last sentence again. Leaf-first eviction — free the tail of a chain before its prefix — is correct in vLLM only because SingleTypeKVCacheManager.free happens to call self.block_pool.free_blocks(reversed(...)) (vllm/v1/core/single_type_kv_cache_manager.py:L516-L524). A caller that forgets the reversed() compiles, runs, and quietly degrades the hit rate.
SGLang spends it in the index. On CUDA its default page is a single token:
return {"page_size": 64}
if not is_musa():
return {"page_size": 1}
return {"page_size": 64}
One slot per token means zero internal fragmentation and prefix sharing at exact token boundaries with no copy-on-write machinery at all. It also means the req_to_token index is one int32 per token: $257 \times 32768 \times 4 = 32.1$ MiB at the same 256×32k operating point, allocated on the GPU where it competes with the KV cache itself (derived, §2.2). The free list is a GPU tensor sliced with self.free_pages[need_size:] rather than a CPU linked list (python/sglang/srt/mem_cache/allocator/token.py:L44-L66), which is why SGLang's allocator has no per-block Python object to walk — and also why "which blocks are free" is not a thing the CPU can cheaply inspect.
And in SGLang the eviction invariant is structural. The radix tree keeps an explicit set of evictable leaves and pops from a heap over it:
def _evict_device_start(self, request_cnt: int) -> None:
self._ensure_eviction_strategy()
self._evict_device_request_cnt = request_cnt
self._evict_device_last_node = None
self._evict_device_heap = [
(self.session_ref_eviction_strategy(n), n)
for n in self.tree_core.evictable_device_leaves
]
heapq.heapify(self._evict_device_heap)
# ... the signature of _evict_device_next_node elided ...
) -> Optional[NodeId]:
ct = self.component_type
lv = self._evict_device_last_node
if (
lv is not None
and lv.parent is not None
and lv.parent in self.tree_core.evictable_device_leaves
):
heapq.heappush(
self._evict_device_heap,
(self.session_ref_eviction_strategy(lv.parent), lv.parent),
)
self._evict_device_last_node = None
while tracker[ct] < self._evict_device_request_cnt and self._evict_device_heap:
_, x = heapq.heappop(self._evict_device_heap)
if x not in self.tree_core.evictable_device_leaves:
continue
self._evict_device_last_node = x
return x.id
That is the live path — FullComponent inside UnifiedRadixCache, not the RadixCache the blog posts point at (§12.3). A parent becomes a candidate only once its last child is gone, because it enters evictable_device_leaves only then and _evict_device_next_node pushes it back onto the heap after its child is popped. You cannot evict a prefix that something still depends on, because the data structure will not offer it to you. Same policy as vLLM; enforced instead of documented.
Figure 2 — where each project spends its complexity budget. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
The preemption row is the sharpest single difference, so it is worth the arithmetic. vLLM's _preempt_request frees the victim's blocks and returns it to the head of the waiting queue (vllm/v1/core/sched/scheduler.py:L1347-L1388). Freed blocks that carry a hash go to the back of the free queue and keep that hash, so unless the pool actually turns over, the victim's prefix is still cached when it resumes. SGLang's release_req does the opposite:
# TODO (csy): for preempted requests, we may want to insert into the tree
release_kv_cache(req, tree_cache, is_insert=False)
# NOTE(lsyin): we should use the newly evictable memory instantly.
num_tokens = remaing_req_count * envs.SGLANG_RETRACT_DECODE_STEPS.get()
evict_from_tree_cache(tree_cache, num_tokens)
is_insert=False suppresses insertion of the retracted request's new suffix; it does not erase already cached prefixes, including its own prior chunks. In either engine actual reuse depends on pressure, active references and eviction before resume. The illustrative recompute costs 3.7 ms at $f=0.99$ and 372 ms at $f=0$ compare assumed retention fractions, not engine guarantees or observed developer motivation. Measure each engine's contiguous reusable prefix after preemption.
One row per design decision
Here is the deliverable. Two tables, same seven columns, split only so the cells stay readable.
The vLLM and SGLang columns are read from the pinned trees at a556f3f and 7d89325 and every claim is cited elsewhere in this chapter or in the chapter linked from the row. The TensorRT-LLM, TGI, and llama.cpp columns are not. I did not read those projects' source at any pinned SHA. Cells in those columns are either sourced to public documentation I fetched while writing, or marked unread, which means exactly what it says: I do not know, and I have not guessed.
| Design decision | vLLM | SGLang | TensorRT-LLM | TGI | llama.cpp | Why they chose this |
|---|---|---|---|---|---|---|
| KV allocation granularity | Block-level, default 16 tokens (vllm/config/cache.py:L59); free list is a CPU doubly-linked list of KVCacheBlock objects |
Token-level: page_size=1 on CUDA (python/sglang/srt/arg_groups/overrides.py:L2378-L2397); free list is a GPU int64 tensor |
"Paged KV cache with intelligent block reuse" per the overview docs; block size not read | Docs credit "Paged Attention" to vLLM (TGI index); granularity not read | unread — server exposes slots, not blocks | vLLM optimises metadata size and CPU-side bookkeeping; SGLang optimises fragmentation and exact-boundary sharing, and is willing to pay 16× the index footprint on GPU to get it |
| Prefix cache structure | Hash-chained blocks in cached_block_hash_to_block + LRU over the free queue (vllm/v1/core/block_pool.py:L143-L190) |
Radix tree with tree-aware eviction (§2.4); the default at this SHA is UnifiedRadixCache |
"block reuse" claimed; structure unread | unread | Prompt caching on by default (--cache-prompt) plus --cache-reuse N via KV shifting, per the server README; per-slot, not a shared tree, as far as the docs say |
A hash map is O(1) and needs no tree maintenance; a radix tree gives longest-prefix matching and makes eviction order structural. The choice follows the granularity choice: token-level slots make tree nodes natural |
| Eviction order guarantee | Convention. Callers must free in reverse (vllm/v1/core/kv_cache_utils.py:L236-L242) |
Invariant. Only members of evictable_device_leaves are offered (python/sglang/srt/mem_cache/unified_cache/components/full_component.py:L190-L198, L205-L223) |
unread | unread | unread | A flat structure cannot express "this block is a prefix of that one" without an extra edge, so vLLM encodes it in call order. A tree already has the edge |
| What preemption costs | Hash retention permits reuse until overwritten; near-total reuse is not guaranteed. Conditional model: ~3.7 ms if f=0.99 (§1.4) | is_insert=False suppresses new suffix insertion, not preexisting prefix retention. Conditional model: ~372 ms if f=0 (§1.4) |
unread | unread | unread | SGLang needs the freed memory this step ("we should use the newly evictable memory instantly") and inserting into a tree it is about to evict from is wasted work. vLLM's free queue makes retention nearly free, so it retains |
| Scheduler placement | One scheduler in the engine-core process; SchedulerOutput broadcast to workers (vllm/v1/executor/multiproc_executor.py:L340-L351) |
One scheduler replicated per TP rank; only rank 0 holds ZMQ sockets (python/sglang/srt/managers/scheduler.py:L740-L768) |
unread | unread | unread — single process; slots are in-process | Broadcasting a decision keeps one source of truth and one place to change policy; replicating the scheduler removes the decision from the critical path entirely at the cost of $N$ CPU cores and a determinism requirement |
| Scheduling determinism | Not guaranteed. vLLM's own source: # TODO: make v1 scheduling deterministic (vllm/v1/executor/abstract.py:L79), which is what blocks the external-launcher executor |
Required by construction — all ranks must reach the same batch from the same broadcast inputs | unread | unread | unread | vLLM never needed it, because only one process decides. SGLang cannot function without it. The same property is a nice-to-have in one design and a load-bearing invariant in the other |
| Design decision | vLLM | SGLang | TensorRT-LLM | TGI | llama.cpp | Why they chose this |
|---|---|---|---|---|---|---|
| Compilation by default | Yes. optimization_level defaults to O2 (vllm/config/vllm.py:L435), which sets CompilationMode.VLLM_COMPILE (vllm/config/vllm.py:L1448-L1452) |
No Dynamo on a stock CUDA server. Prefill defaults to the breakable-CUDA-graph backend, not tc_piecewise (python/sglang/srt/model_executor/cuda_graph_config.py:L110-L119), and tc_compiler defaults to "eager" anyway (:L91-L95) |
No longer ahead-of-time. The migration guide states the TensorRT engine backend "has been removed", that "PyTorch is now the sole execution backend", that trtllm-build is gone and that there is "no engine-build step" (migration guide). The overview leads with "Architected on Pytorch" and a high-level Python LLM API (overview), while a separate PyTorch-backend page still calls the feature "currently in beta" (torch docs) — NVIDIA's own docs disagree with each other here |
unread | No compiler; hand-written kernels per backend (README) | vLLM bets that Inductor plus custom passes beats hand tuning across 200+ architectures. SGLang bets on hand-written kernels plus graph capture and refuses the compile-time and cache-invalidation cost. TensorRT-LLM used to move the entire cost offline and specialise hardest of the three; having removed that backend, it now bets on the same eager-plus-graph shape as the other two, run on NVIDIA hardware only |
| Unsupported attention version | Degrades. FA3 → FA4 or FA2 through a chain of documented fallbacks (vllm/v1/attention/backends/fa_utils.py:L70-L199) |
Refuses: raise ValueError(f"Invalid version: {self.fa_impl_ver=}") for anything but 3 or 4 (python/sglang/srt/layers/attention/flashattention_backend.py:L299-L300) |
unread | Docs list Flash Attention on "the most popular architectures" (TGI index); fallback behaviour not read | Many backends (CUDA, HIP, MUSA, Vulkan, SYCL, Metal, CPU) per the README; selection policy not read | vLLM's promise is "runs on your hardware", so a slow path beats a crash. SGLang's promise is "fast on the hardware we target", so a wrong-but-running kernel is worse than an exception |
| Speculative decoding shape | Chain only. The tree case is an open # FIXME (vllm/v1/spec_decode/llm_base_proposer.py:L1645-L1646) and a config field is described as "non-tree speculation" (vllm/config/speculative.py:L150-L151). Medusa ships (vllm/v1/spec_decode/medusa.py) |
Tree verification is real: speculative_eagle_topk > 1 is EAGLE tree verify and other features must fall back for it (python/sglang/srt/server_args.py:L6443-L6456). No Medusa in python/sglang/srt/speculative/ |
unread | unread | unread | Tree attention needs a custom mask in every attention backend. vLLM supports too many backends to pay that per-backend; SGLang targets fewer and can |
| Routing across replicas | Ships no router. Exports exact BlockStored/BlockRemoved events for out-of-tree routers (vllm/distributed/kv_events.py:L50-L119) |
Ships a Rust gateway with an approximate per-worker radix tree of raw text, explicitly "eliminating the need for direct cache state queries" (sgl-model-gateway/src/policies/cache_aware.rs:L1-L32) |
unread | unread | N/A — single node | vLLM publishes truth and lets someone else own the policy; SGLang owns the policy and accepts an approximation of the truth. Exactness costs an event stream per replica; approximation costs hit rate |
| Registry collision policy | Inconsistent within the project: quantization and attention overwrite with a debug log, model registry overwrites, KVConnectorFactory.register_connector raises, linear kernels append so yours runs last (§11.5) |
Also inconsistent, and in the opposite direction on the same registry kind: model register raises unless overwrite=True (python/sglang/srt/models/registry.py:L24-L35), while register_attention_backend silently overwrites (python/sglang/srt/layers/attention/attention_registry.py:L31-L38). Radix-cache and spec-algorithm registries raise |
unread | unread | unread | Nobody chose this. Registries accrete one PR at a time and the collision policy is whatever the first author needed. It is the clearest evidence in either tree that neither project has a single architectural authority |
| How a rewrite happens | Parallel version trees. v1/ beside the old engine; now v1/worker/gpu/ is Model Runner V2 and is already the default for dense models (vllm/config/vllm.py:L648-L700 and vllm/config/vllm.py:L725-L744) |
Decompose in place. No version-named directory; mem_cache/ is being folded into unified_cache/ under a tracking issue, with the layering documented in-tree (python/sglang/srt/mem_cache/README.md:L1-L40) |
unread | Project is in maintenance mode and explicitly redirects users to vLLM and SGLang (TGI index) | unread | A version tree lets you ship a rewrite behind a flag and cut over per model family; in-place decomposition avoids maintaining two of everything but means there is no single commit where the new design starts |
| Worker-level fault recovery | None. vllm/v1/fault_tolerance/ is a DP-group rebuild: the worker sentinel requires an FT-capable all2all backend and holds DP rank masks (vllm/v1/worker/sentinel/gpu_worker_sentinel.py:L29-L45) |
None. The watchdog's escalation is self.parent_process.send_signal(signal.SIGQUIT) (python/sglang/srt/utils/watchdog.py:L160-L163) |
unread | unread | unread | Both assume a supervisor above them restarts the process. Recovering a dead TP rank means rebuilding NCCL communicators and re-materialising KV that no longer exists; neither has decided that is worth it |
| Serving layer language | Rust frontend in-tree: 309 .rs files, 109,867 lines, described as "a Rust drop-in alternative frontend for vLLM" (rust/README.md:L1-L3) and gated on VLLM_USE_RUST_FRONTEND, default False (vllm/envs.py:L165) |
Rust router (252 files, 94,170 lines under sgl-model-gateway/) plus SGLANG_RUST_SERVER, which collapses api-server + tokenizer + detokenizer into threads inside the scheduler process (python/sglang/srt/managers/rust_server.py:L1-L8) |
unread | unread | C/C++ throughout, "without any dependencies" (README) | Both hit the same wall: Python tokenization and HTTP handling became the bottleneck at high request rates. vLLM replaced the frontend process; SGLang went further and deleted two processes |
The benchmark table that is not here
This is the point in a comparison article where you expect a chart. There isn't one, and the reason is worth more than the chart would have been.
First, I have no GPU. Every number in this chapter is either cited to source or derived by arithmetic from FORMULAS and published model shapes, and both kinds are labelled. Presenting either as measured would be fabrication.
Second — and this holds even for someone with eight H100s — the two projects' own harnesses do not measure the same quantity. The --random-range-ratio inversion in §1 is the loud one. The quiet one is inter-token latency. vLLM records one ITL sample per SSE chunk:
# Decoding phase
else:
output.itl.append(timestamp - most_recent_timestamp)
vLLM's own comment elsewhere in the harness concedes that this is not per token: it counts output length with the tokenizer instead of len(outputs[i].itl) "since multiple output tokens may be bundled together" (vllm/benchmarks/serve.py:L595-L598). SGLang's native backend divides the chunk gap by the number of new tokens and emits that many identical samples:
# Decoding phase
else:
num_new_tokens = output_len - last_output_len
if num_new_tokens == 0:
continue
chunk_gap = timestamp - most_recent_timestamp
adjust_itl = chunk_gap / num_new_tokens
output.itl.extend([adjust_itl] * num_new_tokens)
These produce different distributions from identical server behaviour. Under speculative decoding, where one step emits several tokens, vLLM's p99 ITL includes the whole multi-token gap as a single sample while SGLang's is smoothed by construction. A p99 ITL comparison across the two harnesses is not a comparison of the engines; it is a comparison of two definitions of the metric. §10.2 works through the rest.
Use one harness, matched semantic settings, tokenized traces, hardware, warmup and open-loop arrivals; report quality, goodput and uncertainty. This course has not executed a definitive engine comparison. A properly scoped external benchmark can support its own conditional conclusion; it is not invalid merely because the author did not run it.
Where they have converged
The differences make a better story, but the convergence is more instructive, because it tells you which ideas are settled.
Both engines now page the KV cache, batch continuously, chunk prefills, cache prefixes, capture CUDA graphs, dispatch to FlashAttention and FlashInfer, support FP8 and 4-bit weights, disaggregate prefill from decode, and speak the OpenAI API. Both READMEs list nearly the same feature set in nearly the same order: vLLM's "vLLM is fast with" bullets (repo-root README.md, lines 28–38) and SGLang's "Fast Runtime" bullet (repo-root README.md, line 68) name paged attention, continuous batching, chunked prefill, prefix caching, speculative decoding, and prefill-decode disaggregation between them. Neither project would have written that list in 2023.
The sharpest evidence of convergence is that SGLang has adopted vLLM's KV-event schema field for field:
class BlockStored(KVCacheEvent):
block_hashes: list[ExternalBlockHash]
parent_block_hash: ExternalBlockHash | None
token_ids: list[int]
block_size: int
lora_id: int | None
class BlockStored(KVCacheEvent):
block_hashes: list[int]
parent_block_hash: Optional[int]
token_ids: list[int]
block_size: int
lora_id: Optional[int]
medium: Optional[str] = None
Same class name, same field names, same order, same ZMQ publisher pattern. An engine that allocates at token granularity has no natural notion of a "block hash" at all — it adopted one so that a router written against vLLM's event stream works against it too. This is what a de facto standard looks like while it is forming.
Figure 3 — convergence: which ideas each project shipped first, and where they met. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
One more convergence, less flattering to both: dead code. The CUDA PagedAttention kernel that gave vLLM its name is gone from the tree; docs/design/paged_attention.md:L3-L5 now opens with its own tombstone —
!!! warning
This is a historical document based on the [original paper for vLLM](https://arxiv.org/abs/2309.06180).
It no longer describes the code used in vLLM today.
— and the file it points at, attention_kernels.cu, is not in csrc/attention/ any more — that directory holds six headers and no .cu, and the only surviving hand-written paged-attention kernel is csrc/rocm/attention.cu. On the SGLang side, try_jump_forward — the compressed-FSM trick from the 2024-02 blog post — has exactly one caller in the whole tree, and that caller is ReasonerGrammarObject.try_jump_forward (python/sglang/srt/constrained/reasoner_grammar_backend.py:L226-L229) delegating to the wrapped grammar object. Nothing in managers/ or model_executor/ ever calls it. Jump-forward decoding is not wired to the scheduler at 7d89325.
The radix-cache family is the same story at larger scale. default_radix_cache_factory never constructs RadixCache, HiRadixCache, MambaRadixCache, or SWARadixCache: the default path returns a ChunkCache variant, PureSWARadixCache, LMCRadixCache, RadixCacheCpp, or — in the common case — UnifiedRadixCache (python/sglang/srt/mem_cache/registry.py:L79-L143). Plain RadixCache survives as a base class and as create_simulated, a constructor whose docstring says "for simulation purpose" (python/sglang/srt/mem_cache/radix_cache.py:L335-L350). Both projects carry the fossils of their own founding papers.
The other three engines, honestly
I did not read TensorRT-LLM, TGI, or llama.cpp source at any pinned SHA for this book. Everything in this section is sourced to the public documentation linked inline, fetched while writing. Treat it as a pointer to where to look, not as the kind of claim the vLLM and SGLang sections make.
It stopped compiling first
NVIDIA describes it as a library "for accelerating and optimizing inference performance of the latest large language models (LLMs) on NVIDIA GPUs" with a "paged KV cache with intelligent block reuse", and the overview now leads with "Architected on Pytorch" and a high-level Python LLM API (overview). The ahead-of-time engine build that used to be the distinguishing move is gone: a migration guide headed "TensorRT Backend Removed" states that "The TensorRT engine backend has been removed", that "PyTorch is now the sole execution backend for TensorRT LLM", that trtllm-build / trtllm-refit / trtllm-prune were removed, and that there is "no engine-build step — HuggingFace checkpoints load directly" (migration guide). A separate PyTorch-backend page still says the feature is "currently in beta" (torch docs); the two pages contradict each other, which is itself worth knowing before you plan around either. Convergence, from the other direction — and further along than this book assumed.
In maintenance mode
Hugging Face's docs now open with a caution: TGI "is now in maintenance mode", accepting "minor bug fixes, documentation improvements and lightweight maintenance tasks", and explicitly recommend vLLM, SGLang, and llama.cpp "going forward" (index). Its feature list still reads as the 2023 consensus — continuous batching, tensor parallelism, "Flash Attention and Paged Attention", bitsandbytes and GPT-Q. It credits Paged Attention to vLLM by link. If you are choosing an engine today, this column is a historical note, not an option.
A different problem
"The main goal of llama.cpp is to enable LLM (and VLM) inference with minimal setup and state-of-the-art performance on a wide range of hardware - locally and in the cloud" (README). Plain C/C++ with no dependencies, GGUF weights, 1.5- to 8-bit integer quantization, Metal/CUDA/HIP/MUSA/Vulkan/SYCL backends, and CPU+GPU hybrid inference for models larger than VRAM. llama-server does have continuous batching and prompt caching on by default and a fixed slot count via -np (server README) — so "single-user only" is wrong. But it optimises for one machine you own, not a fleet you rent.
The honest summary: three of the five columns above are mostly unread, and that is the correct state for a book that refuses to describe an API from memory. If you need those cells filled, the method is the one this book uses throughout — pin a SHA, open the file, quote the lines, and cite the range.
Worked trace: one preemption, two engines
One request, 8,192 prompt tokens, Llama-3-8B on a single H100, decoding happily. The pool runs dry. Here is the code path in each engine, in order.
vLLM. The cited scheduler path selects a victim, releases request ownership, marks PREEMPTED, resets computed-token bookkeeping and requeues it. The block-pool path prioritizes hashless versus hashed reusable entries differently, preserving the possibility of a later hit. Subsequent allocations can overwrite those blocks, and outstanding holders can delay reuse. Thus the scenario's 512 prompt blocks are not guaranteed to survive; only if measured retention is near $f=0.99$ does the illustrative 3.7 ms tail-recompute model apply.
SGLang. The cited retraction path calls release_kv_cache(..., is_insert=False), releases request-held suffix state subject to ownership rules, and may evict tree state to satisfy the current deficit. It then resets request bookkeeping. Existing cached prefixes can still match on resume; the flag does not imply a zero hit rate. The 372 ms model applies only if no relevant prefix survives. Retraction policy, prefix reuse and physical reclamation are three separate observations.
The data structures make different retention operations convenient, but they do not fix the retained fraction. Test a grid of memory pressure, reusable-prefix length, active shared references and delayed resume. For each engine record blocks available before and after preemption, actual contiguous hit length, recompute tokens and SLO goodput. Compare scenarios with equal retention before attributing a difference to scheduling or kernel performance.
Pitfalls and war stories
Comparing a flag to a flag
Same name, different meaning, in both directions. --random-range-ratio 0 is "fixed length" in vLLM and "maximally variable" in SGLang; --random-range-ratio 1 is "fixed length" in SGLang and a hard ValueError in vLLM. page_size and block_size are the same concept with defaults of 1 and 16. Before comparing anything, read the flag's implementation in both trees.
Reading a class name as a fact
RadixCache is not what SGLang runs by default; UnifiedRadixCache is. HiRadixCache, MambaRadixCache, and SWARadixCache exist in mem_cache/ and are not reachable from default_radix_cache_factory. In vLLM, vllm/v1/worker/gpu_model_runner.py is the V1 runner and vllm/v1/worker/gpu/model_runner.py is V2, and V2 is the default for eligible dense configurations after all guards. Grep for the constructor, not the class.
The eager-mode surprise
An engineer benchmarks SGLang, sees no Dynamo activity in the profile, and concludes the build is broken. It isn't: prefill defaults to the breakable-CUDA-graph backend on CUDA and tc_compiler defaults to "eager". vLLM at the same moment is compiling with Inductor because optimization_level is O2. First-request latency and profile shape differ enormously; steady-state throughput may not.
The crash that is a feature
SGLang raises Invalid version: self.fa_impl_ver=2 on hardware where vLLM would quietly run FA2. The instinct is to file a bug against SGLang. The right reading is that the two projects made different promises: vLLM promises to run, SGLang promises to be fast on hardware it has tuned for. Neither error message is wrong; they encode different contracts.
The general pattern behind all four: a difference you observe between the engines is usually a difference in what the project was willing to be wrong about. vLLM is willing to be wrong about speed — it will run a slower kernel rather than fail. SGLang is willing to be wrong about availability — it will fail rather than run something it has not tuned. vLLM is willing to be wrong about determinism; SGLang cannot be. SGLang is willing to be wrong about preemption cost; vLLM is not. Read any table cell in §4 through that lens and the "why" column mostly writes itself.
Hands-on
Every structural claim in this chapter is reproducible without a GPU. Point these at your own checkouts and confirm the shape before you trust the prose.
V=~/Documents/other_git_repos/vllm
S=~/Documents/other_git_repos/sglang
# 1. Allocation granularity: 16 vs 1
grep -n "DEFAULT_BLOCK_SIZE" $V/vllm/config/cache.py
sed -n '2378,2397p' $S/python/sglang/srt/arg_groups/overrides.py
# 2. Eviction: convention vs invariant
sed -n '236,243p' $V/vllm/v1/core/kv_cache_utils.py
sed -n '190,223p' $S/python/sglang/srt/mem_cache/unified_cache/components/full_component.py
# 3. Determinism: vLLM's own TODO
grep -rn "make v1 scheduling deterministic" $V/vllm/
# 4. Compilation defaults
grep -n "optimization_level: OptimizationLevel" $V/vllm/config/vllm.py
grep -n 'tc_compiler: str' $S/python/sglang/srt/model_executor/cuda_graph_config.py
# 5. Which radix cache actually runs
grep -n "return .*RadixCache\|return .*ChunkCache" $S/python/sglang/srt/mem_cache/registry.py
# 6. Dead code: jump-forward has no scheduler caller
grep -rn "try_jump_forward" $S/python/ | grep -v "def try_jump_forward"
# 7. Rust convergence, by the numbers
find $V/rust -name '*.rs' | wc -l; find $V/rust -name '*.rs' -exec cat {} + | wc -l
find $S/sgl-model-gateway -name '*.rs' | wc -l
find $S/sgl-model-gateway -name '*.rs' -exec cat {} + | wc -l
Then the one that matters most: before you ever quote a cross-engine benchmark, diff the two harnesses' definitions of the metric you are quoting. vllm/benchmarks/lib/endpoint_request_func.py against python/sglang/benchmark/serving.py for ITL, and vllm/benchmarks/datasets/utils.py against python/sglang/benchmark/datasets/common.py for the workload. If they disagree, the number is not a comparison. Lab 11 is the one harness, two servers version.
Exercises
- Read and answer. Open
vllm/v1/core/block_pool.py:L719-L744. A block whoseblock_hashisNoneis prepended to the free queue; a block with a hash is appended. Why is prepending the hashless block the performance-correct choice, and what would break if both piles were appended? - Read and answer. In
python/sglang/srt/mem_cache/registry.py, tracedefault_radix_cache_factoryfor a plain dense Llama-3-8B server with no special flags. Which class is constructed? Now find every reference toHiRadixCacheinsrt/and decide whether it is reachable from that path. - Predict, then verify. You launch SGLang with
--page-size 16to match vLLM's block size. Predict what happens to (a) thereq_to_tokenfootprint at 256×32k, (b) prefix-hit granularity, and (c) whether FA3 remains selectable. Then checkserver_args.pyfor the constraints that fire onpage_size > 1— start from the comment atpython/sglang/srt/server_args.py:L5906. - Predict, then verify. Take one benchmark command from each project's README, normalise the workload so both generate identical prompt-length distributions, and write down what you had to change. Predict how much the reported mean input length moves in each direction if you forget.
- Design. Suppose you wanted SGLang to retain a retracted request's KV the way vLLM does. Sketch the change against
release_reqandevict_from_tree_cache. What invariant of the eviction heap does your change threaten, and what would you measure to prove it was a net win?
Answers
1. A hashless block will never produce a prefix-cache hit — it holds KV nobody can address by content. Reusing it immediately keeps recently touched GPU memory hot and, more importantly, defers touching any block that could still serve a hit. If both piles were appended, the allocator would evict cacheable blocks in the same rotation as worthless ones and the hit rate would fall for no benefit. The docstring at vllm/v1/core/kv_cache_utils.py:L236-L242 describes the resulting queue order.
2. UnifiedRadixCache, via _create_unified_radix_cache, which is the fall-through return of default_radix_cache_factory. HiRadixCache is imported by mem_cache/hybrid_cache/hybrid_pool_assembler.py but the default selection chain never constructs it; hierarchical caching now runs through UnifiedRadixCache.init_hicache. It is reachable only from code paths the default factory does not take.
3. (a) The dense req_to_token table does not shrink: it remains per token position, about 32.125 MiB at $(256+1)\times32768\times4$ bytes. Page size changes allocator granularity, not this tensor's dimensions. (b) Prefix hits round down to a 16-token boundary, so a shared prefix of 4,104 tokens matches at 4,096 — the same quantisation vLLM lives with. (c) It depends: the comment near python/sglang/srt/server_args.py:L5906 says FA3 is turned on for Hopper "unless user use spec decode with topk > 1 or page_size > 1", so speculative configurations and larger pages can push you off the FA3 path.
4. At minimum you must invert --random-range-ratio: vLLM's $r$ and SGLang's $r$ are not the same parameter, and the value that means "fixed length" is 0 in one and 1 in the other. Forget it and vLLM generates every prompt at exactly $L$ while SGLang generates uniform on $[1,L]$, a mean of roughly $L/2$ — a 2× difference in offered prefill work before any engine code runs.
5. You would call release_kv_cache with is_insert=True so cache_finished_req inserts the victim's path, then hold a lock reference or bias the eviction priority so the path is not immediately reclaimed by the evict_from_tree_cache call two lines later. The threat is that the eviction heap in full_component.py:L190-L198 assumes everything in evictable_device_leaves is genuinely free; a retained-but-unlocked path can be evicted immediately, giving you the insertion cost with none of the benefit, while a locked path reduces the memory you were preempting to reclaim in the first place. Measure: preemption rate, mean prefix-hit fraction on resumed requests, and end-to-end p99 TTFT under a workload tuned to sit just past the memory knee.
Key takeaways
- The engines differ in where complexity lives, not in what they do. vLLM centralises and versions: one scheduler, block-structured memory, parallel version trees, degrade-rather-than-fail. SGLang distributes and decomposes: a scheduler per rank, token-granular memory, in-place refactors, refuse-rather-than-degrade.
- Each shape predicts its own failure mode. vLLM's is silent slowness — a convention nobody honoured, a fallback kernel nobody noticed. SGLang's is loud unavailability — an exception on unsupported hardware, a
SIGQUITon watchdog timeout, workload-dependent recomputation on preemption. - A design choice three layers down sets the cost of a policy choice at the top. Both structures can preserve reusable prefixes; how much survives depends on existing cache entries, locks, eviction and pressure. The 100× ratio compares assumed f=0.99 and f=0, not architectural guarantees.
- Cross-harness performance numbers are not comparisons.
--random-range-ratiois inverted between the two projects and ITL is sampled per chunk in one and per token in the other. Any table that puts the two engines' own reported numbers side by side is measuring the harnesses. - Convergence is the strongest signal in the tree. Paged KV, continuous batching, chunked prefill, prefix caching, CUDA graphs, PD disaggregation, FlashAttention/FlashInfer, and now a field-for-field identical
BlockStoredevent schema and a Rust serving layer in both. The settled ideas are the ones both projects arrived at independently. - Both trees carry fossils. The CUDA PagedAttention kernel is gone from vLLM and its design doc says so; SGLang's jump-forward decoding has no scheduler caller and its default radix cache is not the class named
RadixCache. Verify what runs, not what is named.
Further reading
- Efficient Memory Management for Large Language Model Serving with PagedAttention — the vLLM paper. Read it alongside
docs/design/paged_attention.md, whose own warning that it "no longer describes the code used in vLLM today" is the most useful sentence in either project's documentation. - SGLang: Efficient Execution of Structured Language Model Programs — RadixAttention and the compressed-FSM work, including jump-forward decoding, which is in the paper and not in the scheduler.
- Fast and Expressive LLM Inference with RadixAttention and compressed finite state machine — the two SGLang posts whose features this chapter tracks into and out of the tree.
- SGLang v0.4: zero-overhead batch scheduler, cache-aware load balancer — the origin of the replicated-scheduler design and of
sgl-model-gateway's approximate radix tree. docs/design/model_runner_v2.mdin vLLM — the clearest statement either project has published of how it handles its own technical debt: "we discovered several fundamental design mistakes and accumulated significant technical debt… we implemented Model Runner V2 (MRV2) from first principles."python/sglang/srt/mem_cache/README.mdin SGLang — the mirror image: a layer diagram and a "where does my class go?" table, with the unification tracked in issues #25371 and #20415.- TensorRT-LLM docs, TGI docs, llama.cpp README — the three columns this chapter did not read at a pinned SHA.
Next: §13.2 turns this table into a decision procedure — workload shape, model family, hardware, and team capacity, in that order, with no benchmark screenshots. Then §13.3 takes the rows where both columns say "none" — worker-level fault recovery, deterministic scheduling, the KV cache as a distributed system — and asks what it would take to fix them.