ML Interview Notes
32 min read13 sections
Part 13 · Comparison, frontier, and practice · 13-01

Head-to-head design comparison

Status
SOURCE PINNED
Primary sources
  • vllm/
  • python/sglang/srt/
Edition pins
vllm a556f3f · sglang 7d89325

Run 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.

§1

The problem

Here is the inversion, from the two harnesses at the pinned SHAs.

vllm/benchmarks/datasets/utils.py:L66-L73 vLLM
    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))
python/sglang/benchmark/datasets/common.py:L56-L64 SGLang
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.

§2

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.

vllm/v1/executor/multiproc_executor.py:L340-L351 vLLM
    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.

python/sglang/srt/managers/scheduler_components/request_receiver.py:L75-L79 SGLang
    @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

Loading…

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.

§3

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.

vllm/v1/core/kv_cache_utils.py:L236-L242 vLLM
    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:

python/sglang/srt/arg_groups/overrides.py:L2394-L2397 SGLang
        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:

python/sglang/srt/mem_cache/unified_cache/components/full_component.py:L190-L198, L205-L223 SGLang
    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

Loading…

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:

python/sglang/srt/managers/schedule_batch.py:L1941-L1945 SGLang
    # 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.

§4

One row per design decision

Here is the deliverable. Two tables, same seven columns, split only so the cells stay readable.

Read this before the table

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.

Memory, cache, and scheduling. vLLM/SGLang cells read from source at the pinned SHAs; other columns from public docs or marked unread.
Design decisionvLLMSGLangTensorRT-LLMTGIllama.cppWhy 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
Execution, extension, and engineering process. Same sourcing rules as above.
Design decisionvLLMSGLangTensorRT-LLMTGIllama.cppWhy 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
§5

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:

vllm/benchmarks/lib/endpoint_request_func.py:L238-L240 vLLM
                                # 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:

python/sglang/benchmark/serving.py:L753-L761 SGLang
                                # 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.

What would be valid

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.

§6

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:

vllm/distributed/kv_events.py:L50-L56 vLLM
class BlockStored(KVCacheEvent):
    block_hashes: list[ExternalBlockHash]
    parent_block_hash: ExternalBlockHash | None
    token_ids: list[int]
    block_size: int

    lora_id: int | None
python/sglang/srt/disaggregation/kv_events.py:L112-L118 SGLang
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

Loading…

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 —

docs/design/paged_attention.md:L3-L5 vLLM
!!! 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.

§7

The other three engines, honestly

Unverified

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.

TensorRT-LLM

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.

TGI

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.

llama.cpp

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.

§8

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.

§9

Pitfalls and war stories

pitfall

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.

pitfall

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.

war story

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.

war story

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.

§10

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.

reproduce the table's vLLM/SGLang cells shell
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.

§11

Exercises

  1. Read and answer. Open vllm/v1/core/block_pool.py:L719-L744. A block whose block_hash is None is 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?
  2. Read and answer. In python/sglang/srt/mem_cache/registry.py, trace default_radix_cache_factory for a plain dense Llama-3-8B server with no special flags. Which class is constructed? Now find every reference to HiRadixCache in srt/ and decide whether it is reachable from that path.
  3. Predict, then verify. You launch SGLang with --page-size 16 to match vLLM's block size. Predict what happens to (a) the req_to_token footprint at 256×32k, (b) prefix-hit granularity, and (c) whether FA3 remains selectable. Then check server_args.py for the constraints that fire on page_size > 1 — start from the comment at python/sglang/srt/server_args.py:L5906.
  4. 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.
  5. Design. Suppose you wanted SGLang to retain a retracted request's KV the way vLLM does. Sketch the change against release_req and evict_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.

§12

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 SIGQUIT on 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-ratio is 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 BlockStored event 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.
§13

Further reading

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.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px