ML Interview Notes
38 min read14 sections
Part 13 · Comparison, frontier, and practice · 13-02

Choosing an engine for a workload

Status
SOURCE PINNED
Edition pins
vllm a556f3f · sglang 7d89325

You cannot pick a serving engine from a bar chart. The number on the chart was produced by a workload, on hardware, with flags, none of which are yours — and the flag that will actually decide your deployment is one nobody in the comparison touched. This chapter is an ordered procedure: two gates that eliminate, one axis that discriminates, and one tiebreak that decides whether you can still operate the thing in six months.

§1

The problem

A team serving a document-QA product reads that SGLang's radix tree gives near-perfect prefix reuse on shared-prefix workloads, benchmarks it on Llama-3-8B against their chat traffic, sees the win, and ships. Six weeks later they add a reranking pass — one request scores many candidate passages against one query — and enable it with --enable-mis. Throughput on that path is a fraction of what the benchmark predicted, and there is no regression to find. Three subsystems turned themselves off at startup, each with a logger.warning nobody read.

python/sglang/srt/server_args.py:L4808-L4830 SGLang
    def _handle_multi_item_scoring(self):
        """Setup and validate multi-item scoring constraints.

        Auto-disables settings incompatible with MIS mechanics (CUDA graph,
        radix cache, chunked prefill). Asserts on attention backend since
        changing it silently could surprise users who intentionally picked
        a non-flashinfer backend.
        """
        if not self.enable_mis:
            return

        if self.cuda_graph_config.decode.backend != Backend.DISABLED:
            logger.warning("CUDA graph is disabled because --enable-mis is set.")
        self.cuda_graph_config.decode.backend = Backend.DISABLED
        self.cuda_graph_config.prefill.backend = Backend.DISABLED

        if not self.disable_radix_cache:
            logger.warning("Radix cache is disabled because --enable-mis is set.")
            self.disable_radix_cache = True

        if self.chunked_prefill_size != -1:
            logger.warning("Chunked prefill is disabled because --enable-mis is set.")
            self.chunked_prefill_size = -1

CUDA graphs, the radix cache, and chunked prefill, in that order. The docstring gives the reason as written — these settings are "incompatible with MIS mechanics" — and the assertion just below the quoted range requires FlashInfer "for custom attention mask support". A correct decision, correctly logged, that deletes the reason the engine was chosen.

The expensive part is the second one, and it is not simply "no cache". SGLang's cache selection is a chain, and one branch of it swaps the tree for a different class rather than for nothing:

python/sglang/srt/mem_cache/registry.py:L91-L102 SGLang
    if ctx.effective_chunked_prefill_size is not None and ctx.disable_radix_cache:
        if not ctx.is_hybrid_swa:
            from sglang.srt.mem_cache.chunk_cache import ChunkCache

            return ChunkCache(params)
        if ctx.full_tokens_per_layer == 0:
            from sglang.srt.mem_cache.chunk_cache import PureSWAChunkCache

            return PureSWAChunkCache(params)
        from sglang.srt.mem_cache.chunk_cache import SWAChunkCache

        return SWAChunkCache(params)

ChunkCache is exactly what its docstring says: "used when radix cache is disabled" (python/sglang/srt/mem_cache/chunk_cache.py:L35-L41). Its match_prefix returns an empty index tensor and its insert is a documented no-op — "ChunkCache does not support prefix caching" (python/sglang/srt/mem_cache/chunk_cache.py:L67-L77). The tree is not degraded; it is absent, replaced by a scratchpad holding one request's own blocks.

And there is a third thing, independent of any of this: on the original chat path, the tree was probably never being scheduled for. SGLang's cache-aware policies are opt-in.

python/sglang/srt/server_args.py:L843-L858 SGLang
    schedule_policy: A[
        str,
        Arg(
            help="The scheduling policy of the requests.",
            choices=[
                "lpm",
                "random",
                "fcfs",
                "dfs-weight",
                "lof",
                "priority",
                "routing-key",
            ],
        ),
        NS("schedule"),
    ] = "fcfs"

The default is fcfs. The radix cache still services hits opportunistically, but the scheduler does not order the waiting queue to create them — §2.4 works through why the ordering, not the data structure, is what produces the hit rate under load.

None of this is an SGLang defect; vLLM has the mirror image of every one, and §5 quotes one. The point is the shape of the failure. The decision was made on an axis — prefix reuse — that a later feature flag switched off, on a path the original benchmark never covered. No comparison chart can show you that, because what disabled the mechanism is a property of your flags, not of the engine.

§2

Mental model: two gates, one axis, one tiebreak

Four things are usually offered as "how to choose": workload, model, hardware, team. They are not peers. Two eliminate: if your architecture is not in the registry or your accelerator has no backend, no benchmark matters. One discriminates: among engines that both work, workload shape decides which mechanism is load-bearing, and therefore which engine's version of it you are buying. One decides whether the decision survives: team capacity determines whether you can still operate the thing after the person who chose it leaves.

Order matters because the common failure is running them backwards: pick on the axis, meet the gate in production. And note the uncomfortable consequence — for the modal case, a Llama- or Qwen-family dense model on H100s, both gates pass, so the comparison chart everyone reads is answering the one question that was never in doubt.

Figure 1 — the four inputs are not peers. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The rest of the chapter walks the four in that order, then folds them into an explicit question list in §8.

§3

First principles: compute the ceiling before you compare

Almost every engine comparison is, underneath, a comparison of prefill efficiency: prefix caching, chunked prefill, cache-aware scheduling, P/D disaggregation. So the first quantity to compute is the fraction of your GPU time that prefill occupies. Call it $\phi$; everything the prefill machinery can buy you is bounded by it.

Let $T_p$ be the GPU time one request's prefill costs and $T_d$ the GPU time its decode costs, both amortised to a per-request share of a batched step. Let $\eta \in [0,1]$ be the fraction of prefill tokens a perfect prefix cache would serve from an existing hit. Then

$$\phi = \frac{T_p}{T_p + T_d}, \qquad \text{speedup} \le \frac{1}{1 - \phi\,\eta}$$

Amdahl's law with prefill as the accelerable part. It bounds the mechanism, not either engine, and the gap between two implementations of that mechanism is some fraction of it. If the ceiling is 1.06×, radix-versus-hash is an argument about a couple of percent.

Working $\phi$ for three real workloads

Llama-3-8B on one H100 SXM, with the constants Part 10 uses throughout: 7.505×109 non-embedding parameters (15.01 GB in bf16), 3.35 TB/s of HBM, 989.4 TFLOP/s of bf16 peak, 128 KiB of KV per token (§2.1). Prefill is compute-bound: $T_p = 2NP/F$. Decode is bandwidth-bound: a step costs $(W + \text{KV})/B_{\text{HBM}}$, and one request's share of $O$ output tokens is $O \, t_{\text{step}} / B$.

Derived arithmetic — no measurement. Llama-3-8B bf16, one H100 SXM. KV is evaluated at mid-run context $P + O/2$, and $T_d$ charges the $O-1$ shared decode steps — the first output token comes out of prefill (§10.3 uses the same convention). The agent row uses $B{=}48$ because $B{=}64$ at 8k context needs 64.50 GiB of KV, which does not fit in the 52.32 GiB pool §2.1 derives for this card (79.65 GiB × 0.92, less 14.96 GiB of resident weights and a 6.0 GiB allowance); $B{=}48$ needs 48.38 GiB and does. Long prompts cap your batch, which raises $\phi$ further. Note the two weight figures are different quantities: the 15.01 GB in $t_{\text{step}}$ is what a decode step streams, the 14.96 GiB here is what the model occupies — the embedding table costs capacity but is not read every step (§0.4).
Workload$P$ / $O$ / $B$KV at mid-run$t_{\text{step}}$$T_p$$T_d$$\phi$
Chat, decode-heavy512 / 1024 / 648.59 GB7.05 ms7.8 ms112.6 ms0.065
RAG / summarisation2048 / 256 / 6418.25 GB9.93 ms31.1 ms39.6 ms0.440
Agent / tool loop8192 / 128 / 4851.9 GB19.99 ms124.3 ms52.9 ms0.702

Now put those through the ceiling at an optimistic $\eta = 0.9$ — nine tenths of prompt tokens repeating something already cached, roughly what a stable system prompt plus a stable tool schema plus a growing conversation gives you.

1.06×
chat ceiling at $\eta = 0.9$ (derived)
1.66×
RAG ceiling at $\eta = 0.9$ (derived)
2.71×
agent ceiling at $\eta = 0.9$ (derived)

This is the single most useful number in the chapter. On a decode-heavy chat product a perfect prefix cache is worth 6%, so the radix-versus-hash literature is a rounding error and you should decide on hardware fit and operability. On an agent loop with 8k prompts and short replies the same mechanism is worth 2.7×, and nothing else is worth deciding on.

Figure 2 — the ceiling curve. Maximum speedup available from perfect prefix reuse, as a function of the prefill fraction $\phi$, for two removable-prefill-cost fractions. Derived from $1/(1-\phi\eta)$ with the three workloads of the table marked on the $\eta = 0.9$ curve. Nothing here is measured.

0 0.2 0.4 0.6 0.8 phi = prefill share of GPU time 1.0x 1.5x 2.0x 2.5x 3.0x below 1.3x: the mechanism cannot decide the engine hit rate eta = 0.9 eta = 0.5 CHAT 512/1024: 1.06x RAG 2048/256: 1.66x AGENT 8192/128: 2.71x

Use $\eta$ as the fraction of baseline prefill cost actually removable in the fixed-resource model. A reusable-token fraction approximates it only when that cost is nearly linear and shared states eliminate the corresponding work. Suffix attention still reads cached keys; lookup, eviction, admission and changed batch sizes can alter the measured gain. At fixed other costs, $1/(1-\phi\eta)$ gives 1.25 at $\phi\eta=0.2$ and 1.667 at 0.4. It is not a universal engine-speedup bound under changing resources.

§4

The axis: workload shape, mechanism by mechanism

Four sub-axes, each tied to a mechanism that one engine exposes differently from the other. None of them is "which engine is faster".

Shared prefix: the discriminator is the scheduler, not the tree

Both engines cache prefixes; §2.3 and §2.4 own the structures. What differs at the decision level is how much of the queue-ordering problem each one lets you express. SGLang's policy enum has six members, split by exactly one question — does the policy consult the cache:

python/sglang/srt/managers/schedule_policy.py:L200-L213 SGLang
class CacheAwarePolicy(Enum):
    """Scheduling policies that are aware of the tree cache."""

    LPM = "lpm"  # longest prefix match
    DFS_WEIGHT = "dfs-weight"  # depth-first search weighting


class CacheAgnosticPolicy(Enum):
    """Scheduling policies that are not aware of the tree cache."""

    FCFS = "fcfs"  # first come first serve
    LOF = "lof"  # longest output first
    RANDOM = "random"
    ROUTING_KEY = "routing-key"  # prioritize by routing key frequency in running batch

vLLM has two, neither of which consults the cache:

vllm/v1/core/sched/request_queue.py:L13-L17 vLLM
class SchedulingPolicy(Enum):
    """Enum for scheduling policies."""

    FCFS = "fcfs"
    PRIORITY = "priority"

That is the real shared-prefix decision, and it cuts both ways. If your prefixes branch — one document, many questions; one agent scaffold, many trajectories — reordering the queue so siblings run together is worth more than the data structure, and --schedule-policy lpm is a lever vLLM does not have. If they are flat, LPM has nothing to reorder and vLLM's hash chain hits the same prefix with less machinery. Two warnings before reaching for LPM: it is not the default, and it silently downgrades to FCFS past 128 queued requests (§10.3).

Long prefill versus long decode: whose TTFT do you protect

Chunked prefill (§1.5) is on by default in both. The interesting difference is the knob each exposes for the conflict between a long prefill and the decodes it delays. vLLM lets you declare a prompt "long" and cap how much of the token budget it may take:

vllm/config/scheduler.py:L70-L72 vLLM
    long_prefill_token_threshold: int = Field(default=0, ge=0)
    """For chunked prefill, a request is considered long if the prompt is
    longer than this number of tokens. 0 disables the cap (default)."""

SGLang instead lets you guarantee decode progress between prefill batches:

python/sglang/srt/server_args.py:L816-L820 SGLang
    prefill_decode_interval: A[
        int,
        "The number of decode rounds to run after a prefill batch before scheduling the next prefill. In data-parallel attention mode, the interval is synchronized across all DP ranks. Set to 0 to disable.",
        NS("schedule"),
    ] = 0

These are duals, and both default to 0, meaning off. vLLM's threshold bounds the damage one outlier prompt does; SGLang's interval bounds the rate at which prefill is admitted at all — the stronger guarantee for inter-token latency, the more expensive one for TTFT. If your SLO is written on ITL jitter under a mixed short/long workload, SGLang's knob maps onto it directly; if your problem is one pathological 200k-token prompt among 2k-token neighbours, vLLM's does.

Strict TTFT versus throughput-maximising batch

This axis chooses a configuration, not an engine — but it changes what a comparison means. A TTFT-bound deployment runs at low utilisation by construction, so per-step overhead and CUDA-graph coverage dominate; a saturated offline batch is decided by scheduler packing and memory efficiency. Engines routinely swap places between those regimes (§10.3). Decide which regime you are in before reading anyone's numbers, and discard every number taken in the other.

Bursty versus steady arrivals

Burstiness makes admission and preemption first-order, and preemption cost is where the two engines genuinely diverge — §13.1 has the row and the derived numbers, §1.4 the mechanism. If your traffic is bursty enough that preemption is routine, that row outweighs any prefix-cache comparison, because the cost lands exactly when your SLO is already in trouble. If arrivals are steady and you run below the knee, preemption is a tail event and the row is trivia.

§5

The two gates: model family and hardware

Gate 1: is your architecture actually served

The two registries have opposite failure modes, which is more decision-relevant than the raw counts. vLLM's is an explicit table of architecture string to module:

vllm/model_executor/models/registry.py:L72-L77 vLLM
_TEXT_GENERATION_MODELS = {
    # [Decoder-only]
    "AfmoeForCausalLM": ("afmoe", "AfmoeForCausalLM"),
    "ApertusForCausalLM": ("apertus", "ApertusForCausalLM"),
    "ArceeForCausalLM": ("arcee", "ArceeForCausalLM"),
    "ArcticForCausalLM": ("arctic", "ArcticForCausalLM"),

SGLang discovers models by importing every module in the package and looking for an EntryClass:

python/sglang/srt/models/registry.py:L95-L110 SGLang
def import_model_classes(package_name: str, strict: bool = False):
    model_arch_name_to_cls = {}
    package = importlib.import_module(package_name)
    for _, name, ispkg in pkgutil.iter_modules(package.__path__, package_name + "."):
        if not ispkg:
            if name.split(".")[-1] in envs.SGLANG_DISABLED_MODEL_ARCHS.get():
                logger.debug(f"Skip loading {name} due to SGLANG_DISABLED_MODEL_ARCHS")
                continue

            try:
                module = importlib.import_module(name)
            except Exception as e:
                if strict:
                    raise
                logger.warning(f"Ignore import error when loading {name}: {e}")
                continue

Read the except Exception. A module that fails to import — a missing dependency, a kernel package that did not build — warns and vanishes from the registry, and the symptom is "architecture not supported" for a model that is in the tree. vLLM's static table cannot fail that way, at the cost that adding a model means editing a central file.

Counted from the pinned trees at a556f3f and 7d89325. Entry counts include aliases and so overstate distinct architectures; module counts understate them. Orders of magnitude, not a scoreboard.
FamilyvLLMSGLangWhat it means for the decision
Text generation133 registry entries216 model modules totalBoth cover the mainstream. Check your exact architecture string
Multimodal120 registry entries51 processorsCounts alone do not establish broader capability; vLLM has a dedicated vllm/multimodal/ stack
Pooling: embedding, rerank, classify65 entries across 5 dicts13 dedicated modulesThese are incomparable inventory units, not a coverage ranking. vLLM's 65 span _EMBEDDING_MODELS (35), _LATE_INTERACTION_MODELS (11), _SEQUENCE_CLASSIFICATION_MODELS (10), _TOKEN_CLASSIFICATION_MODELS (6) and _REWARD_MODELS (3); SGLang's 13 are 5 embedding, 5 reward and 3 classification files. Validate the exact checkpoint, pooling method and output contract in both engines before applying this gate
Pure SSM (Mamba, Mamba2); distinguish hybrids Jamba/Zamba2presentabsentSGLang ships hybrids (Falcon-H1, Nemotron-H, Qwen3-Next) but no pure-SSM modules
MLA / DeepSeek22 entries under v1/attention/backends/mla/7 MLA backend files + dedicated dsa/, dsv4/, nsa/ treesBoth serious. SGLang carries DeepSeek kernel trees into python/sglang/kernels/
Tuned MoE kernel configs330 JSON files363, sharded by Triton versionComparable; SGLang keys configs by Triton version, vLLM does not

The model family also silently reconfigures the engine. vLLM derives whether prefix caching is even legal from the model's attention type and pooling config:

vllm/config/model.py:L2086-L2096 vLLM
    def is_prefix_caching_supported(self) -> bool:
        attn_type = self.attn_type

        if pooler_config := self.pooler_config:
            # for pooling models
            if attn_type == "encoder_only":
                logger.debug(
                    "Pooling models with bidirectional attn "
                    "do not support prefix caching."
                )
                return False

Encoder-only bidirectional attention generally prevents reusing a prefix's cached states across different suffixes because those states depend on the full sequence. This is not a statement about all embeddings: decoder-based embedding models can be causal, and repeated identical complete inputs can still be cached at an appropriate level. Check the checkpoint's attention, pooling semantics and runtime support separately; task name alone is insufficient.

SGLang's version of the same thing is per-architecture rather than per-attention-type, and it fires on multimodal models whose processors cannot be chunked:

python/sglang/srt/server_args.py:L3985-L3993 SGLang
        if (
            model_config.is_multimodal
            and not model_config.is_multimodal_chunked_prefill_supported
        ):
            self.chunked_prefill_size = -1
            logger.info(
                f"Automatically turn off --chunked-prefill-size as it is not supported for "
                f"{hf_config.model_type}"
            )

Note what this one does not do: it disables chunked prefill but leaves the radix cache alone, so a VLM keeps its tree and loses only the prefill-admission machinery. The cascade that costs you the tree needs disable_radix_cache too, set automatically at this SHA for EmbeddingGemma (python/sglang/srt/server_args.py:L3927-L3929), HRM-Text with prefix_lm (L3876-L3878), and --enable-mis. Read the startup log; do not reason about it.

Gate 2: hardware

vLLM has five built-in platforms and an entry-point group for everything else, with out-of-tree plugins taking precedence over built-ins:

vllm/platforms/__init__.py:L210-L216 vLLM
builtin_platform_plugins = {
    "tpu": tpu_platform_plugin,
    "cuda": cuda_platform_plugin,
    "rocm": rocm_platform_plugin,
    "xpu": xpu_platform_plugin,
    "cpu": cpu_platform_plugin,
}

SGLang instead carries hardware backends in-tree: python/sglang/srt/hardware_backend/ holds cpu, gpu, mlx, musa, npu, and xpu subtrees, and the attention-backend list names the accelerators directly:

python/sglang/srt/server_args.py:L181-L189, L202-L209 SGLang
ATTENTION_BACKEND_CHOICES = [
    # Common
    "triton",
    "torch_native",
    "flex_attention",
    "dsa",
    "nsa",  # Deprecated alias for "dsa"
    "dsv4",
    "compressed",  # Deprecated alias for "dsv4"
    # ...
    # AMD specific
    "aiter",
    "wave",
    # Other platforms
    "intel_amx",
    "ascend",
    "intel_xpu",
]

So: TPU is a vLLM built-in with no SGLang counterpart at this SHA. Ascend NPU is in-tree in SGLang (an ascend backend plus an npu/ tree with its own allocator, memory pool, MoE and quantization) and an out-of-tree plugin on vLLM. Apple Silicon appears in SGLang via mlx, not in vLLM. AMD and Intel XPU are first-class in both. Off NVIDIA and AMD the gate is usually decisive — in different directions.

The single-24GB-card case

Both engines tier their defaults by device memory, and there you can read each project's stated target. vLLM has three tiers and a candid TODO:

vllm/engine/arg_utils.py:L2609-L2638 vLLM
        if device_memory >= 160 * GiB_bytes:
            # for GPUs like B200/B300 with >= 160GB memory, use the largest defaults
            default_max_num_batched_tokens = {
                UsageContext.LLM_CLASS: 16384,
                UsageContext.OPENAI_API_SERVER: 16384,
            }
            default_max_num_seqs = {
                UsageContext.LLM_CLASS: 1024,
                UsageContext.OPENAI_API_SERVER: 1024,
            }
        elif device_memory >= 70 * GiB_bytes and "a100" not in device_name:
            # For GPUs like H100 and H200, use larger offline defaults.
            default_max_num_batched_tokens = {
                UsageContext.LLM_CLASS: 16384,
                UsageContext.OPENAI_API_SERVER: 8192,
            }
            default_max_num_seqs = {
                UsageContext.LLM_CLASS: 1024,
                UsageContext.OPENAI_API_SERVER: 1024,
            }
        else:
            # TODO(woosuk): Tune the default values for other hardware.
            default_max_num_batched_tokens = {
                UsageContext.LLM_CLASS: 8192,
                UsageContext.OPENAI_API_SERVER: 2048,
            }
            default_max_num_seqs = {
                UsageContext.LLM_CLASS: 256,
                UsageContext.OPENAI_API_SERVER: 256,
            }

SGLang has six, and the bottom two are named after consumer cards:

python/sglang/srt/server_args.py:L4866-L4883 SGLang
        if gpu_mem is not None:
            if gpu_mem < 20 * 1024:
                # T4, 4080
                # (chunked_prefill_size 2k, max_bs 8)
                if self.chunked_prefill_size is None:
                    self.chunked_prefill_size = 2048
                if decode_cuda_graph_config.max_bs is None:
                    decode_cuda_graph_config.max_bs = 8
            elif gpu_mem < 35 * 1024:
                # A10, 4090, 5090
                # (chunked_prefill_size 2k, max_bs 24 if tp < 4 else 80)
                if self.chunked_prefill_size is None:
                    self.chunked_prefill_size = 2048
                if decode_cuda_graph_config.max_bs is None:
                    if self.tp_size < 4:
                        decode_cuda_graph_config.max_bs = 24
                    else:
                        decode_cuda_graph_config.max_bs = 80

Read them as declarations of intent. vLLM collapses everything below 70 GB into one bucket with an explicit "tune the default values for other hardware" note; SGLang names T4, 4080, A10, 4090 and 5090 and picks a CUDA-graph batch ceiling for each. On a single 24 GB card, SGLang has thought about you more recently.

The quantization surface says it louder. SGLang's list includes the formats that matter when weights do not fit:

python/sglang/srt/server_args.py:L143-L152, L173-L179 SGLang
QUANTIZATION_CHOICES = [
    "awq",
    "fp8",  # MOE + linear online quantization.
    "mxfp8",  # MOE + linear online quantization.
    "gptq",
    "marlin",
    "gptq_marlin",
    "awq_marlin",
    "bitsandbytes",
    "gguf",
    # ...
    # Apple Silicon MLX backend — on-the-fly quantization of fp16 weights at load
    # time via mlx.nn.quantize. Only takes effect when SGLANG_USE_MLX=1.
    "mlx_q4",  # 4 bits, group_size=64 (mlx-community default)
    "mlx_q8",  # 8 bits, group_size=64
    "unquant",
    "humming",
]

vLLM's list, at this SHA, does not:

vllm/model_executor/layers/quantization/__init__.py:L12-L36 vLLM
QuantizationMethods = Literal[
    "awq",
    "auto_awq",
    "fp8",
    "fbgemm_fp8",
    "fp_quant",
    "modelopt",
    "modelopt_fp4",
    "modelopt_mxfp8",
    "modelopt_mixed",
    "auto_gptq",
    "gptq",
    "gptq_marlin",
    "awq_marlin",
    "humming",
    "compressed-tensors",
    "experts_int8",
    "quark",
    "moe_wna16",
    "torchao",
    "inc",
    "mxfp4",
    "gpt_oss_mxfp4",
    "deepseek_v4_fp8",
    "online",
Version-stamped

As of a556f3f there is no gguf and no bitsandbytes entry in vLLM's QuantizationMethods, and no gguf.py or bitsandbytes.py under vllm/model_executor/layers/quantization/ — only residual references in model code (e.g. vllm/model_executor/models/siglip.py:L421). SGLang has both as first-class implementations. This is a fast-moving surface: re-check at your own SHA before it decides anything, and note that register_quantization_config in the same vLLM file lets a third party add either back out-of-tree.

For a single 24 GB card the honest answer is often neither, and §9 says why.

One node, and more than one

On one 8×80 GB node both engines are mature and the gate passes; decide on the axis. Beyond one node the question is what ships in the box: SGLang ships a Rust gateway (sgl-model-gateway/) with cache-aware routing built in, vLLM ships no router and exports KV-cache events for someone else's to consume. §9.4 owns that comparison. In one line: SGLang gives you a policy you did not write; vLLM gives you truth you must write a policy against.

§6

The tiebreak: what each engine costs you to operate

Two questions: how much surface must an operator hold in their head, and where can an engineer cut in when the answer is "we need X and it is not there".

On surface area, the two projects made opposite structural choices. SGLang derives its entire CLI from one dataclass:

python/sglang/srt/server_args.py:L8746-L8749 SGLang
    def add_cli_args(parser: argparse.ArgumentParser):

        # Auto-derived from Annotated[..., Arg(...)] field metadata.
        add_cli_args_from_dataclass(parser, ServerArgs)

ServerArgs carries 475 annotated fields in a single 9,400-line class body (between class ServerArgs at L458 and the last field at L9858). vLLM splits the equivalent surface across 31 typed config modules in vllm/config/, roughly 14,000 lines. Neither is small. SGLang's is greppable in one file with no type-level grouping; vLLM's is grouped and individually documented, and requires you to know which of 31 files owns the flag.

On extension, the seams are genuinely different in kind. vLLM's are entry-point groups resolved at import time, one per process boundary:

vllm/plugins/__init__.py:L16-L30 vLLM
# Default plugins group will be loaded in all processes(process0, engine core
# process and worker processes)
DEFAULT_PLUGINS_GROUP = "vllm.general_plugins"
# IO processor plugins group will be loaded in process0 only
IO_PROCESSOR_PLUGINS_GROUP = "vllm.io_processor_plugins"
# Platform plugins group will be loaded in all processes when
# `vllm.platforms.current_platform` is called and the value not initialized,
PLATFORM_PLUGINS_GROUP = "vllm.platform_plugins"
# Stat logger plugins group will be loaded in process0 only when serve vLLM with
# async mode.
STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins"
# Endpoint plugins group is loaded in the API server front end process only.
# Each entry point resolves to a factory returning an `EndpointPlugin`
# (see `vllm/plugins/endpoint_plugins/interface.py`).
ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins"

SGLang's are in-process registration functions called at import: register_radix_cache_backend for the prefix cache (§12.3), SpeculativeAlgorithm.register for a decoding algorithm, and the add_*_choices helpers at python/sglang/srt/server_args.py:L397-L454, which let an out-of-tree package extend the CLI's own choice lists before ServerArgs is built. §11.5 and §12.4 audit both.

DEBUGGING

Where the state lives

vLLM concentrates the scheduling decision in one engine-core process and broadcasts it; SGLang replicates the scheduler per TP rank. At TP>1 that is the difference between reading one process' state and reasoning about whether $N$ processes agreed. §13.1 has the row.

DRIFT

Silent reconfiguration

Both engines rewrite your flags at startup and log it at INFO. The first thing to build is a startup-config assertion in CI: parse the resolved config from the startup log or /server_info, and fail the deploy if a flag you care about was flipped.

The uncomfortable truth is that both engines are large enough that "we will extend it" is a staffing commitment, not a flag. If nobody on the team can read a scheduler, decide on the gates and the axis, configure conservatively, and treat both extension-point chapters as reference rather than plan. One factor this book cannot cite: vLLM has the larger operator population, so more of your failure modes are already someone else's answered issue. Treat that as a prior, not evidence.

§7

The decision procedure

Eight questions, in order. Stop at the first that gives an answer.

The procedure. Each answer is conditional on the stated premise; none of them is unconditional.
#QuestionHow to answer itWhat it decides
Q1Is your exact architectures[0] string served by both?Grep the registries. Then actually start both servers with the model — SGLang can drop a model on an import errorEliminates. Nothing overrides it
Q2Is your accelerator a first-class backend?TPU: vLLM only. Ascend NPU or Apple MLX: SGLang in-tree. NVIDIA/AMD/XPU: bothEliminates
Q3Does the model silently disable the mechanism you are choosing for?Start the server, read the startup log for the auto-off lines quoted in §1 and §5Eliminates the reason, not the engine
Q4What is $\phi$ for your traffic?Token histogram from your logs (§10.1), then the arithmetic of §3Whether the axis can decide anything
Q5If $\phi\eta > 0.2$: do your prefixes branch or are they flat?Count distinct continuations per shared prefix in your trafficBranching favours SGLang + --schedule-policy lpm; flat is a tie
Q6Is your SLO written on TTFT, on ITL, or on throughput?Read the SLO. If nobody wrote one, stop and write oneWhich knob you need: vLLM's long-prompt cap or SGLang's prefill/decode interval
Q7Who is on call, and can they read a scheduler?HonestlyOperability tiebreak when Q4 says the ceiling is low
Q8Did you run the benchmark from §10 on your own traffic?Two servers, one harness, matched flags, open loop, $n \ge 3$Confirms or overturns everything above

Figure 3 — the procedure as a flowchart. Every leaf states its condition; none is an unconditional recommendation. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
The conditions matter more than the leaves

Every leaf above is false under a different premise. "SGLang for branching prefixes" is false if your queue routinely exceeds 128 and LPM downgrades to FCFS. "vLLM if nobody owns the engine" is false if your model is only in SGLang's tree. Publishing the leaf without the condition is how comparison posts become wrong six weeks later.

§8

Worked trace: one workload through the procedure

Concrete case. A coding agent: 4×H100-80GB on one node, an MoE model in the 30B-total / 3B-active class, prompts averaging 12k tokens (a stable ~2k system prompt and tool schema, a repository context block that is stable within a session, and a growing tool-call transcript), replies averaging 400 tokens, arrivals bursty around a working day, SLO written as p95 time-to-first-token under 3 s.

  1. Q1 — architecture. Both trees carry the Qwen3-MoE family, but do not stop at the family: check the exact architectures[0] from the checkpoint's config.json against vLLM's _TEXT_GENERATION_MODELS and SGLang's discovered set, and start both servers to confirm SGLang swallowed no import error. both pass
  2. Q2 — hardware. NVIDIA datacentre, one node. Both first-class. Both have H100 tuned MoE configs in their fused-MoE config directories. both pass
  3. Q3 — silent disable. Text-only, so the multimodal chunked-prefill auto-off does not fire; not a pooling model, so vLLM's prefix-caching gate does not fire. Read the startup log anyway. clean
  4. Q4 — the ceiling. Measure prefill and decode work for this checkpoint and batch distribution. The prompt/output ratio does not establish the phase fraction, and active experts per token do not equal all experts touched by an aggregate batch. No value above 0.75, or resulting 2.7× ceiling, is established here.
  5. Q5 — branching. Heavily branching: one repository context, many tool-call trajectories diverging from it, many turns per session. This is the case SGLang's tree and LPM ordering were built for.
  6. Q6 — SLO. p95 TTFT is a prefill-side SLO, the same side the axis is on. Consistent. Had it been ITL, SGLang's --prefill-decode-interval would be the lever; here it is not.
  7. Q7 — team. Include operating cost and ownership throughout; Q4 remains unmeasured and cannot settle this choice.
  8. Q8 — verify. Below.

Screening hypothesis, not an established winner. Include SGLang with explicitly configured LPM as one candidate for this branching workload, then compare it to vLLM on identical traces and quality requirements. The MoE prefill fraction is unmeasured, so no engine recommendation follows from $\phi>0.75$ here. Measure phase work, actual saved cost, queue depth, p95 TTFT, goodput and operational behavior. Failure of a cache hypothesis does not make all remaining engine differences disappear.

Unverified

MoE work depends on active experts per token, routing distribution, aggregate batch size, shared experts and expert-parallel communication. A long prefill may touch every expert across the batch without every token executing every expert. Prompt/output means and total/active parameter counts alone do not establish $\phi$ or a 2.7× ceiling. This scenario remains an unmeasured hypothesis until checkpoint-specific traces supply those quantities.

§9

When the answer is neither

Unverified

I did not read TensorRT-LLM or llama.cpp source at any pinned SHA. Everything in this section is sourced to public documentation I fetched while writing, quoted inline. It is a pointer to where to look, not a claim of the kind the vLLM and SGLang sections make. §13.1 describes these projects; this section only states the conditions under which they beat both engines this book is about.

llama.cpp / Ollama — when the machine is yours and the weights do not fit. The condition: a single consumer card, a laptop, or CPU-only, with a model too large for VRAM. Its docs describe a "Fast, lightweight, pure C/C++ HTTP server" with continuous batching on by default, -np, --parallel N for "number of server slots", --cache-prompt enabled by default, and -ngl, --gpu-layers for the "max. number of layers to store in VRAM" (server README). Note what that has and vLLM at a556f3f does not: GGUF weights and a first-class partial-offload story. It is not single-user — it batches. It optimises for one machine you own rather than a fleet you rent.

TensorRT-LLM — when you are NVIDIA-only and want NVIDIA's own kernels. NVIDIA's overview describes a "comprehensive open-source library for accelerating and optimizing inference performance of the latest large language models (LLMs) on NVIDIA GPUs", "Architected on Pytorch", with "Seamless distributed inference with tensor, pipeline, and expert parallelism across multiple GPUs and nodes" (overview). The ahead-of-time engine build that used to define it is gone: a migration guide states that "The TensorRT engine backend has been removed", that "PyTorch is now the sole execution backend for TensorRT LLM", and that there is "no engine-build step — HuggingFace checkpoints load directly" (migration guide). So the old winning condition — a shape-stable model you were willing to compile for — no longer describes it. What remains is the narrower one: NVIDIA hardware you control, and a preference for kernels and parallelism strategies maintained by the vendor of the silicon. §13.1 has the same reconciliation in its table.

A hosted API — when your utilisation is low. Most teams should price this and almost none do. One 8×H100 node at a rented $2–3 per GPU-hour is $16–24 per hour and $12k–17k per month whether or not a request arrives. Divide by your actual monthly token volume for your true cost per million tokens, then compare a provider's list price. A deployment at 10% utilisation pays 10× its marginal cost, and no engine choice recovers that — only batching traffic into fewer, busier replicas does. Self-hosting wins on sustained high utilisation, data residency, models nobody hosts, and latency floors an API cannot promise; not on price at low volume. §10.5 turns a curve into exactly this number.

TGI is no longer a live option. Hugging Face's own documentation opens with a caution: "text-generation-inference is now in maintenance mode. Going forward, we will accept pull requests for minor bug fixes, documentation improvements and lightweight maintenance tasks", and recommends "vllm, SGLang, as well as local engines with inter-compatibility such as llama.cpp or MLX" (TGI index, fetched while writing). It is a historical note, not a column in a decision matrix.

§10

Pitfalls: how the decision goes wrong

SCREENSHOT

Deciding on someone else's curve

§10 enumerates six mechanical ways a published number misleads, each with its control. The meta-failure is subtler: a vendor benchmark is chosen to be the workload on which that vendor wins. Not dishonest, not transferable. The only benchmark that decides your deployment is the one whose workload is yours.

FAMILY, NOT ARCH

"Qwen is supported"

Support is per architectures[0] string, not per family: a new MoE variant, a new vision tower, or a fine-tune that renamed its architecture all miss. On SGLang the miss can also be an import error logged as a warning — Ignore import error when loading {name}: {e} — then reported as an unsupported architecture.

FLAG DRIFT

The config you set is not the config that ran

Both engines rewrite flags at startup from model and hardware. SGLang forces chunked_prefill_size = -1 and disable_radix_cache = True for several model classes and for --enable-mis; vLLM derives both from the model config. Assert the resolved config in CI.

DEFAULT LEFT ON

Choosing for a mechanism you then do not enable

The archetype: picking SGLang for RadixAttention and leaving --schedule-policy at fcfs, so every argument in the decision was about an ordering that is not happening. Whatever you chose on, write down the flag that enables it and assert it.

ONE POINT

A single load, reported as a verdict

Engines swap places along a load curve: one wins at low load on per-step overhead, the other near saturation on packing. A single-point comparison picks a winner by picking a load.

§11

Hands-on: run the benchmark that decides it

Six mechanical ways a published comparison misleads, each with its control. Every one is a knob in a harness both projects ship.

The failure, the mechanism, the control. Mechanisms and controls are read from the harnesses in §10.2 and §10.3.
The failureWhy the number movesThe control
Batch size unstatedDecode step time is a function of batch size, and the CUDA-graph bucket ladder makes sweeps plateauReport max_num_seqs / max-running-requests and the achieved batch size
Input/output length distribution unstated$\phi$ swings from 0.065 to 0.70 across the three workloads in §3 — a 10× change in what is being measuredReport the full histogram, not the mean. §10.1 has the shape
TTFT quoted where TPOT decidesThey move in opposite directions under chunked prefill and under any prefill-admission knobReport TTFT, ITL and E2EL percentiles together, or goodput under a stated SLO
Closed loop presented as loadThe client is the admission control, so the offered rate is $N/\bar{W}$ and overload is unreachableOpen loop with an explicit arrival schedule. Say so if --max-concurrency is set — latency then excludes client queueing
Warmup too shortQueue relaxation at $\rho = 0.9$ takes ~1,000 requests, not 10 (§10.3). Short runs understate tail latencyWarm at the load you will measure, flush, then discard one residence time
Quantization mismatchAn FP8 arm against a bf16 arm is a format comparison in an engine comparison's clothesSame checkpoint, same format, same KV dtype on both arms — or run all four cells

There is a seventh that is specific to this chapter's subject. The default synthetic dataset in vLLM's harness has no shared prefix at all:

vllm/benchmarks/datasets/datasets.py:L573-L577 vLLM
    # Default values copied from benchmark_serving.py for the random dataset.
    DEFAULT_PREFIX_LEN = 0
    DEFAULT_RANGE_RATIO = 0.0
    DEFAULT_INPUT_LEN = 1024
    DEFAULT_OUTPUT_LEN = 128

DEFAULT_PREFIX_LEN = 0: --dataset-name random with default flags measures $\eta = 0$, the exact worst case for the mechanism that per §3 decides an agent workload. A comparison run that way cannot see the axis you care about, and most published comparisons are run that way. (DEFAULT_RANGE_RATIO = 0.0 is a second problem; §10.1 owns it.)

So measure $\phi$ and $\eta$ first, then run the two arms that differ. Both harnesses ship a shared-prefix generator: vLLM's PrefixRepetitionRandomDataset (vllm/benchmarks/datasets/datasets.py:L4380) and SGLang's generated_shared_prefix with Zipf-distributed prefix groups (python/sglang/benchmark/datasets/generated_shared_prefix.py:L23-L41).

exploratory datasets, not a controlled cache comparison shell
# Exploratory workload sensitivity only: these datasets have different defaults.
# Their delta does NOT isolate caching or measure a theoretical ceiling.
# Start the server with VLLM_SERVER_DEV_MODE=1 for reset_prefix_cache.
# For a causal cache test, replay IDENTICAL saved/tokenized requests and
# arrival times against cache-enabled and cache-disabled server configurations.
vllm bench serve --model $MODEL --dataset-name random \
  --random-input-len 8192 --random-output-len 128 \
  --request-rate 6 --num-prompts 2000 --seed 1 \
  --save-result --result-filename eta0.json

curl -X POST localhost:8000/reset_prefix_cache      # needs VLLM_SERVER_DEV_MODE=1

vllm bench serve --model $MODEL --dataset-name prefix_repetition \
  --request-rate 6 --num-prompts 2000 --seed 1 \
  --save-result --result-filename etahigh.json

# Do not decide from this unmatched pair; run the matched cache-on/off test
# Keep exact tokenized inputs, output limits, arrivals and quality checks fixed.
engine comparison: one harness, matched trace, separate server runs shell
# Run these servers SEQUENTIALLY on the same GPUs, stopping one first.
# One client, both servers, matched semantics, open loop, n >= 3.
# Cross-harness numbers are NOT comparable - see section 10.2.
VLLM_SERVER_DEV_MODE=1 vllm serve $MODEL \
  --max-num-seqs 256 --max-num-batched-tokens 8192 --enable-prefix-caching

python -m sglang.launch_server --model-path $MODEL \
  --max-running-requests 256 --chunked-prefill-size 8192 \
  --schedule-policy lpm --port 30000

# Then point ONE harness at each in turn, with identical arrival schedule
# and seed, and sweep request-rate to get a curve rather than a point.

The full protocol is in honest benchmarking and Lab 11. The unmatched dataset runs above are exploratory only. For cache attribution, use a two-by-two design: engine A/B crossed with cache on/off, replaying the same tokenized requests, output constraints and intended arrivals. Start each arm from a documented empty state, then apply the same warmup; verify the resolved cache flag, actual hit counts and quality. Repeat paired runs in alternating order, report achieved savings and SLO goodput with uncertainty, and avoid calling a measured delta a theoretical ceiling.

§12

Exercises

  1. Derive. Your traffic averages 4,096-token prompts and 512-token outputs on Llama-3-8B at batch 64. Using the constants of §3, compute $T_p$, $t_{\text{step}}$, $T_d$, and $\phi$. At $\eta = 0.7$, what is the ceiling? Would you spend a week comparing prefix-cache implementations?
  2. Read the code. Open python/sglang/srt/mem_cache/registry.py and follow default_radix_cache_factory from L80. List every condition under which the function returns something that is not a UnifiedRadixCache, and say which of them a user could trigger without passing a cache-related flag.
  3. Predict, then verify. You start SGLang on a 24 GB RTX 4090 with no --chunked-prefill-size and no CUDA-graph flags. Predict the two values the server will choose. Then read python/sglang/srt/server_args.py:L4866-L4883 and check. Now predict vLLM's max_num_batched_tokens for the same card served via vllm serve, and check against vllm/engine/arg_utils.py:L2609-L2638.
  4. Predict. A teammate reports that a model works in vLLM but SGLang says the architecture is unsupported, and the model file is visibly present in python/sglang/srt/models/. Name the exact log line you would grep for and the mechanism that produced it.
  5. Design. You must serve an embedding model and a chat model behind one API. Which capability tests in §2 are decisive, and under what attention/pooling assumptions do the prefix-reuse arguments in §3 not apply to the embedding half? What would you deploy?
Answers

1. $T_p = 2 \times 7.505{\times}10^{9} \times 4096 / 9.894{\times}10^{14} = 62.1$ ms. Mid-run context $= 4352$, so KV $= 64 \times 4352 \times 131072 = 36.5$ GB and $t_{\text{step}} = (15.01 + 36.5)/3350 = 15.4$ ms. $T_d = 511 \times 15.4/64 = 123.0$ ms. $\phi = 62.1/185.1 = 0.336$. Ceiling at $\eta = 0.7$ is $1/(1-0.235) = 1.31\times$ — above the 1.25× line but not far, and the gap between two engines' implementations is a fraction of that 31%. Run step 1 of §10 for an afternoon instead of a week.

2. Non-UnifiedRadixCache returns: ChunkCache, PureSWAChunkCache or SWAChunkCache when chunked prefill is set and the radix cache is disabled; RadixCacheCpp under SGLANG_EXPERIMENTAL_CPP_RADIX_TREE; PureSWARadixCache for a pure sliding-window model; LMCRadixCache under LMCache; the FlexKV factory under --enable-flexkv. The branches a user reaches without a cache flag are the chunk-cache ones, because the model-family handlers set disable_radix_cache themselves — EmbeddingGemma, HRM-Text, or --enable-mis.

3. 24 GB satisfies gpu_mem < 35 * 1024, so SGLang picks chunked_prefill_size = 2048 and, at tp_size < 4, decode.max_bs = 24. vLLM falls through both the $\ge$160 GiB and $\ge$70 GiB branches into the else, so vllm serve (UsageContext.OPENAI_API_SERVER) gets max_num_batched_tokens = 2048 and max_num_seqs = 256. Note the asymmetry: a graph capture coverage limit of 24 from one engine versus a scheduler sequence limit of 256 from the other. These constrain different things: larger SGLang batches may run eagerly, subject to its separate admission and memory limits. A naive A/B on a 4090 compares two very different configurations.

4. Grep the SGLang startup log for Ignore import error when loading, from import_model_classes (python/sglang/srt/models/registry.py:L95-L110), which imports every module inside a try and, when strict is false, warns and continues. The module never registers its EntryClass, so the architecture is absent and the user sees "unsupported". Usual root cause: a missing optional dependency or a kernel package that did not build for the installed CUDA.

5. Test support for the exact embedding and chat checkpoints, pooling methods, tokenizer and response contract; registry-entry and source-file counts are not commensurable. The shared-prefix argument fails for varying-suffix encoder-only bidirectional pooling, not for every embedding architecture. Separate replicas often simplify distinct batching and memory objectives, but colocating compatible workloads is also a measurable deployment choice.

§13

Key takeaways

  • Compute $\phi$, the prefill share of GPU time, before reading anyone's comparison. Perfect prefix reuse is bounded by $1/(1-\phi\eta)$: for Llama-3-8B at 512-in/1024-out that ceiling is 1.06× under this fixed-cost model; at 8192-in/128-out it is 2.71× so cache savings may be worth investigating, alongside other costs. Both derived, neither measured.
  • Model family and hardware are gates that eliminate; workload shape is the axis that discriminates among survivors; team capacity is the tiebreak. Running them in the wrong order is the standard failure — you pick on the axis and meet the gate in production.
  • The shared-prefix decision is about scheduling, not data structures. SGLang's policy enum has six members, two of which consult the cache; vLLM's has two, neither of which does. (The --schedule-policy flag advertises seven choices — the six enum members plus priority, which is not a policy of its own but a layer over fcfs or lof, asserted as such at python/sglang/srt/server_args.py:L9333-L9336.) But SGLang's default is fcfs, so the lever you chose the engine for is off until you set it — and it downgrades past 128 queued requests.
  • Both engines rewrite your flags at startup from the model config, the device, and other flags. In SGLang a multimodal checkpoint forces chunked_prefill_size = -1; EmbeddingGemma, HRM-Text and --enable-mis additionally force disable_radix_cache = True, which swaps UnifiedRadixCache for ChunkCache — an object whose match_prefix returns empty. vLLM derives prefix caching and chunked prefill from the model's attention type. Assert the resolved config in CI, not the flags you passed.
  • The gates are decisive in opposite directions and on non-obvious surfaces: TPU is vLLM-only; Ascend NPU and Apple MLX are in-tree in SGLang; embedding and reranker counts use incomparable units and require checkpoint-level tests; distinguish pure SSMs from hybrids; and at a556f3f GGUF and bitsandbytes are SGLang-only.
  • The comparison that decides your deployment is one you run: two servers, one harness, matched flags, open loop, a curve rather than a point, on a dataset whose $\eta$ resembles yours. --dataset-name random defaults to DEFAULT_PREFIX_LEN = 0, which measures $\eta = 0$ — the one case where the axis cannot appear.
§14

Further reading

  • §13.1 is the comparison matrix this chapter deliberately does not duplicate: one row per design decision, with the "why they chose this" column that makes the tradeoffs legible. Read it before running the procedure here, not instead of it.
  • §10.1 for measuring $\eta$ and the token histogram that feeds $\phi$; §10.2 for why the two harnesses' numbers cannot share a table; §10.3 for the protocol; §10.5 for turning a curve into cost per million tokens, which is the input to the hosted-API comparison in §9.
  • §2.3 and §2.4 for the two cache mechanisms, and §1.5 for the prefill-admission machinery whose knobs §4 compares.
  • §11.5 and §12.4 for the extension seams behind the team-capacity section, including what each project explicitly refuses to make extensible.
  • llama.cpp server README, TensorRT-LLM overview, and the TGI index — the three documents behind §9. Fetch them yourself; all three moved in the last year and the TGI one in particular changed what it recommends.
  • The pinned trees themselves are the reference for Q1 and Q2: vllm/model_executor/models/registry.py, python/sglang/srt/models/registry.py, vllm/platforms/, and python/sglang/srt/hardware_backend/. Every count in the table in §5 is reproducible with ls and grep, and you should reproduce it at your own SHA rather than trusting mine.

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