ML Interview Notes
35 min read11 sections
Part 9 · The serving system around the engine · 09-04

Routers, KV-aware balancing, session affinity

Status
SOURCE PINNED
Primary sources
  • sgl-model-gateway/
  • python/sglang/srt/managers/disagg_service.py
Edition pins
vllm a556f3f · sglang 7d89325

A prefix cache is a per-process data structure. It lives inside one engine, in one GPU's HBM, and no other replica can see it. The moment you put a load balancer in front of eight replicas, the balancer — not the cache — decides your hit rate. Round-robin can reduce reuse under broad, capacity-limited prefix workloads; skewed hot sets that fit each replica can still have high hit rates.

§1

The problem

You run eight H100s, one Llama-3-8B replica each, behind an nginx round-robin. Your product is a B2B assistant: a thousand customers, each with their own 2,048-token system prompt — persona, tool schemas, retrieval preamble — followed by a short user turn. Traffic is 60 requests per second. Prefix caching is on in every replica. You measured a 90% hit rate in staging with one replica. In production you see 10%.

Nothing is broken. Here is the arithmetic, all of it derived from numbers this book has already established.

One replica's KV pool on an 80 GB H100 is 52.32 GiB, which at 128 KiB per token is 428,569 resident tokens (§2.1, derived). Live requests occupy roughly half of it, so call it ~214,000 tokens available to hold cached prefixes: 104 distinct 2,048-token system prompts per replica.

Under round-robin, every replica sees a uniform sample of all 1,000 prompts. Its LRU set converges to a random 104 of them, so the hit probability is $104/1000 = 10.4\%$. Under a routing policy that maps each prompt to one replica, a replica only ever sees $1000/8 = 125$ prompts and holds 104 of them: $104/125 = 83.2\%$.

A miss on a 2,048-token prefix costs a full prefill of those tokens: 34.0 TFLOP, 86 ms of H100 time at 40% of the bf16 peak (§1.1, derived). Multiply out.

58%
of an 8×H100 fleet burned on redundant prefix prefill under round-robin (derived)
3.8
equivalent GPU-seconds/s of prefill work avoided (model)
77 → 14 ms
mean prefix-recompute contribution to TTFT (derived)
Derived — 1,000 distinct 2,048-token prefixes, 60 req/s, 8 Llama-3-8B replicas on H100 SXM. Pool capacity from §2.1, prefill cost from §1.1. Nothing measured; no GPU was involved.
Policyprefixes seen per replicahit ratemisses/sprefill GPU-s per wall-sfleet tax
round_robin100010.4%53.84.6257.8%
perfect affinity12583.2%10.10.8710.8%

You did not lose the cache to a bug. You lost it to a scheduling decision made by a component that has never heard of a KV cache. vLLM's own integration documentation states the failure in one line:

docs/deployment/integrations/llm-d.md:L9-L11 vLLM
A single vLLM server is fast, but at scale the picture changes: across many replicas,
cache locality breaks under round-robin load balancing, long prompts inflate
time-to-first-token, and accelerators sit underused. llm-d adds the cluster-level layer
that vLLM does not aim to provide on its own:

- **[Prefix-aware routing](https://llm-d.ai/docs/guides/precise-prefix-cache-aware).**
  Instead of round-robin, llm-d reads vLLM's KV-cache events and routes each request to
  the replica that already holds its prefix, reusing cache instead of recomputing it.
§2

Mental model

Think of the fleet's aggregate cache as a set-associative cache where the router is the placement function. Round-robin can duplicate hot keys across replicas, but the union of caches of size $M$ can still contain up to $NM$ distinct keys. Under the uniform independent-reference model above, a request sent to a random replica hits with probability $M/W$; this is an effective hit probability, not a bound of $M$ on fleet storage. Affinity can reduce duplication and improve that probability. The routing decision, not the memory, is what multiplies.

The hard part is that the router cannot read the placement function's own output. It does not know what any replica currently holds: the engine evicts on its own schedule, under its own memory pressure, with its own block granularity. So the router keeps a model of each replica's cache, built from what it has sent where, and decays that model on a timer. Everything interesting in this chapter follows from that model being an approximation.

Figure 1 — the same workload under two placement functions. All figures derived from §2.1 pool sizing and §1.1 prefill cost; nothing measured. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§3

First principles: cache dilution

Let $N$ be the number of replicas, $M$ the number of distinct cached prefixes one replica's KV pool can hold, and $W$ the size of the workload's distinct-prefix working set. Assume uniform prefix popularity — the worst case for caching, and close to reality for per-tenant system prompts.

Under round-robin each replica's request stream is a uniform sample of all $W$ prefixes, so its resident set is a uniform random $M$-subset and the steady-state hit rate is

$$H_{\mathrm{rr}} = \min\!\left(1, \frac{M}{W}\right)$$

Under an affinity map $h : \text{prefix} \to \text{replica}$ that is stable and roughly balanced, a replica only ever sees $W/N$ distinct prefixes:

$$H_{\mathrm{aff}} = \min\!\left(1, \frac{MN}{W}\right)$$

The fleet's effective distinct-prefix capacity is $M$ under round-robin and $MN$ under affinity. Call the gap cache dilution: round-robin replicates every hot prefix onto all $N$ replicas, so you pay $N\times$ the HBM for the same working set. It is the exact dual of the false-sharing problem in a multi-core cache.

The cost of a miss is a full prefill of the shared prefix. With arrival rate $\lambda$ and prefix length $L_p$, the wasted GPU time, in GPU-seconds per wall-second, is

$$G_{\mathrm{waste}} = \lambda \,(1 - H)\, t_{\mathrm{prefill}}(L_p)$$

Work it for the opening scenario. $N = 8$, $W = 1000$, $L_p = 2048$. Llama-3-8B ($L=32$, $h_{kv}=8$, $d_h=128$) stores $2 L h_{kv} d_h b = 131{,}072$ bytes = 128 KiB of KV per token. The 52.32 GiB pool holds 428,569 tokens; half of it is live-request KV, so $M = \lfloor 214{,}284 / 2048 \rfloor = 104$.

$H_{\mathrm{rr}} = 104/1000 = 0.104$. $H_{\mathrm{aff}} = \min(1, 832/1000) = 0.832$. A 2,048-token prefill is $2P\cdot T + 2 L h d_h T(T{-}1) = 32.9 + 1.1 = 34.0$ TFLOP, and at 40% of the H100's 989 TFLOP/s bf16 peak that is $t_{\mathrm{prefill}} = 86$ ms. So $G_{\mathrm{waste}}$ is $60 \times 0.896 \times 0.086 = 4.62$ against $60 \times 0.168 \times 0.086 = 0.87$. On a fleet with 8 GPU-seconds of capacity per wall-second, that is 58% versus 11%.

Where the model breaks

Uniform popularity is pessimistic. If prefix popularity is Zipfian — one system prompt serving 40% of traffic — round-robin looks far better, because every replica caches the head of the distribution anyway. Cache-aware routing pays off in proportion to how flat and wide your prefix distribution is. Measure that distribution before you tune the router; §10.1 owns how.

And here is the tension that makes this a genuinely hard problem rather than a hash function. A perfect affinity map sends every request for the hot prefix to one replica. If that prefix carries 40% of traffic, one replica gets 40% of the load and the other seven idle. Maximising hit rate and balancing queue depth are directly opposed objectives, and every real policy is a blend.

§4

How production systems do it

SGLang: a router as a separate product

SGLang ships its router as its own Rust workspace, sgl-model-gateway/, with Python and Go bindings. Reading Rust is not a prerequisite here — the design and the policy semantics are what matter, and the Python launcher under sgl-model-gateway/bindings/python/src/sglang_router/ is ordinary readable Python. Eight policies are registered at this SHA:

sgl-model-gateway/src/policies/factory.rs:L76-L91 SGLang
    /// Create a policy by name (for dynamic loading)
    pub fn create_by_name(name: &str) -> Option<Arc<dyn LoadBalancingPolicy>> {
        match name.to_lowercase().as_str() {
            "random" => Some(Arc::new(RandomPolicy::new())),
            "round_robin" | "roundrobin" => Some(Arc::new(RoundRobinPolicy::new())),
            "power_of_two" | "poweroftwo" => Some(Arc::new(PowerOfTwoPolicy::new())),
            "cache_aware" | "cacheaware" => Some(Arc::new(CacheAwarePolicy::new())),
            "bucket" => Some(Arc::new(BucketPolicy::new())),
            "manual" => Some(Arc::new(ManualPolicy::new())),
            "consistent_hashing" | "consistenthashing" => {
                Some(Arc::new(ConsistentHashingPolicy::new()))
            }
            "prefix_hash" | "prefixhash" => Some(Arc::new(PrefixHashPolicy::with_defaults())),
            _ => None,
        }
    }
random / round_robin

Body-blind

Uniform selection over healthy workers. needs_request_text() is false, so the router never parses the prompt.

power_of_two

Load-aware

Picks two workers at random, takes the lighter. Fed by a polling LoadMonitor, not by the router's own counters.

cache_aware

Radix affinity

Longest-prefix match against a per-pool tree of what the router has sent where, with a shortest-queue escape hatch. The default.

prefix_hash

Hash affinity

Hashes the first 256 tokens onto a consistent-hash ring, then walks clockwise if the landing worker exceeds 1.25× average load.

consistent_hashing / manual

Session affinity

Key-to-worker stickiness from an HTTP header, with different rebalancing behaviour on scale events.

bucket

Length-balanced prefill

Partitions the request-length distribution into per-worker ranges and re-fits the boundaries every 5 s so each prefill worker gets equal character load, not equal request count.

cache_aware: the approximate tree, and the escape hatch

The policy's own header comment is unusually candid about what it is doing:

sgl-model-gateway/src/policies/cache_aware.rs:L21-L32 SGLang
    This strategy maintains an approximate radix tree for each worker based on request history,
    eliminating the need for direct cache state queries. The tree stores raw text characters
    instead of token IDs to avoid tokenization overhead.

    Process:
    a. For each request, find the worker with the highest prefix match
    b. If match rate > cache_threshold:
    Route to the worker with highest match (likely has relevant data cached)
    c. If match rate ≤ cache_threshold:
    Route to the worker with smallest tree size (most available cache capacity)
    d. Background maintenance:
    Periodically evict least recently used leaf nodes to prevent memory overflow

Three design commitments are packed into that. Approximate: the router never asks a replica what it holds. Characters, not tokens: the tree is keyed on raw request text, so the router does not have to run a tokenizer on the hot path — but its notion of a shared prefix is therefore character-level, and can disagree with the engine's 16-token block hashing (§2.3) and RadixAttention's token-level tree (§2.4). History-driven: the tree records what was sent, never what was kept.

The load check runs before the tree lookup, and short-circuits it:

sgl-model-gateway/src/policies/cache_aware.rs:L405-L424 SGLang
        let (min_load, max_load) = workers.iter().fold((usize::MAX, 0usize), |(min, max), w| {
            let load = w.load();
            (min.min(load), max.max(load))
        });
        let min_load = if min_load == usize::MAX { 0 } else { min_load };

        // Check if load is imbalanced
        let is_imbalanced = max_load.saturating_sub(min_load) > self.config.balance_abs_threshold
            && (max_load as f32) > (min_load as f32 * self.config.balance_rel_threshold);

        if is_imbalanced {
            return self.select_worker_min_load(
                workers,
                &request_text,
                &healthy_indices,
                &tree_key,
                max_load,
                min_load,
            );
        }

Both conditions must hold — an absolute gap and a ratio. This is the blend the tension demands: affinity while the fleet is level, shortest-queue the moment one replica falls behind. Note the &&. A fleet of two where one worker has 100 in flight and the other 90 is not "imbalanced" under the Rust defaults ($100-90 = 10 \not> 32$), so the router keeps concentrating on the hot replica.

When balanced, the tree decides:

sgl-model-gateway/src/policies/cache_aware.rs:L436-L450 SGLang
            let result = tree.prefix_match_with_counts(text);
            let match_rate = if result.input_char_count == 0 {
                0.0
            } else {
                result.matched_char_count as f32 / result.input_char_count as f32
            };

            // Select worker without String allocation
            let selected_idx = if match_rate > self.config.cache_threshold {
                // Cache hit path: find worker by URL (compare &str directly, no allocation)
                let tenant_url: &str = &result.tenant;
                workers
                    .iter()
                    .position(|w| w.url() == tenant_url)
                    .filter(|&idx| workers[idx].is_healthy())

match_rate is a fraction of the whole input, not an absolute prefix length. Hold that thought; it is the sharpest edge in the chapter and §7 returns to it.

Whichever branch fires, the router then writes the request back into the tree, tagging this worker as the "tenant" of that prefix — including in the imbalanced branch, so the model stays current even while affinity is suspended:

sgl-model-gateway/src/policies/cache_aware.rs:L469-L471 SGLang
            if let Some(idx) = selected_idx {
                // Update the tree with this request (use worker URL directly, no allocation)
                tree.insert(text, workers[idx].url());

Decay is a background LRU sweep, not a subscription to engine evictions:

sgl-model-gateway/src/policies/cache_aware.rs:L127-L147 SGLang
        // Start background eviction thread if configured
        let eviction_task = if config.eviction_interval_secs > 0 {
            let trees_clone = Arc::clone(&trees);
            let max_tree_size = config.max_tree_size;

            Some(PeriodicTask::spawn(
                config.eviction_interval_secs,
                "Eviction",
                move || {
                    for tree_ref in trees_clone.iter() {
                        let tree_key = tree_ref.key();
                        let tree = tree_ref.value();
                        tree.evict_tenant_by_size(max_tree_size);

evict_tenant_by_size walks the tree, pushes every leaf into a min-heap ordered by tenant_last_access_time, and pops LRU leaves until each tenant's character count is at or below max_size (sgl-model-gateway/src/policies/tree.rs:L718-L753). Pure LRU on the router's own record of what it sent.

Figure 2 — the router's approximate cache model and how it decays. Defaults read from source; the residency comparison is derived arithmetic. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Session affinity

Cache-aware routing already gives multi-turn chat most of what it needs, because turn n shares a long prefix with turn n−1. That only works if the router keys on the whole conversation, which is a lesson the project learned the hard way:

sgl-model-gateway/src/routers/http/pd_router.rs:L950-L960 SGLang
    /// Builds the text used for cache-aware routing of a chat request.
    ///
    /// This must reflect the *full* conversation (system prompt, prior turns,
    /// the current message and tool context) so that KV-cache prefix matching
    /// routes to the worker that actually shares the most prefix. Using only the
    /// first message ignores the conversation history that drives KV reuse in
    /// multi-turn chats. See https://github.com/sgl-project/sglang/issues/26263.
    ///
    /// Returns `None` when the conversation has no text to route on, preserving
    /// the prior behavior of not feeding an empty key into prefix matching.
    fn build_chat_request_text(body: &ChatCompletionRequest) -> Option<String> {

For explicit stickiness there are two policies with deliberately different failure behaviour. consistent_hashing hashes a routing key onto a ring, so a scale event moves only ~1/N of keys — and it will infer a key from stable headers when the client sends none:

sgl-model-gateway/src/policies/consistent_hashing.rs:L136-L150 SGLang
        // Priority 3: Implicit routing key from stable headers (session affinity)
        let implicit_key = info.headers.and_then(|h| {
            h.get("authorization")
                .or_else(|| h.get("x-forwarded-for"))
                .or_else(|| h.get("cookie"))
                .and_then(|v| v.to_str().ok())
                .filter(|s| !s.is_empty())
        });

        if let Some(key) = implicit_key {
            return match Self::find_by_consistent_hash(workers, info, key) {
                Some(idx) => (Some(idx), Branch::RoutingKeyHit),
                None => (None, Branch::NoHealthyWorkers),
            };
        }

manual is stronger and states its contract up front:

sgl-model-gateway/src/policies/manual.rs:L1-L14 SGLang
//! Manual routing policy based on routing key header
//!
//! This policy provides sticky session routing where each unique routing key
//! is consistently mapped to the same worker. Unlike consistent hashing,
//! this policy:
//! - Does NOT redistribute any sessions when workers are added
//! - Only remaps sessions when their assigned worker becomes unhealthy
//! - Maintains up to 2 candidate workers per routing key for fast failover
//!
//! Use this when you need stronger stickiness guarantees than consistent hashing,
//! for example with stateful chat sessions where context is stored on the worker.
//!
//! ## Header
//! - `X-SMG-Routing-Key`: The routing key for sticky session routing

The mechanism is a DashMap from routing key to a bounded candidate list, refreshed on every hit and TTL-evicted after max_idle_secs (default 4 hours). The occupied-but-unhealthy branch is the interesting one: it silently re-assigns and appends the new worker to the candidate list (manual.rs:L170-L192). What breaks affinity, then, is exactly three things: the replica dies (health check flips it, the candidate list re-rolls), scale-down removes it (registry removal makes it un-selectable), and TTL expiry. Only the first two are visible to the user, as a sudden cold turn in the middle of a long conversation.

vLLM: no router in-tree, but the ground truth exported

At a556f3f, vLLM has no first-class router in the repository. What exists is a demo proxy under examples/, and its entire policy surface is this:

examples/disaggregated/disaggregated_serving/disagg_proxy_demo.py:L336-L341 vLLM
class RoundRobinSchedulingPolicy(SchedulingPolicy):
    def __init__(self):
        super().__init__()

    def schedule(self, cycler: itertools.cycle) -> str:
        return next(cycler)

One policy, over two itertools.cycle objects — one for prefill instances, one for decode. The dispatch is sequential rather than concurrent: prefill first with max_tokens = 1, awaited to completion, then decode.

examples/disaggregated/disaggregated_serving/disagg_proxy_demo.py:L250-L268 vLLM
    async def create_completion(self, raw_request: Request):
        try:
            request = await raw_request.json()

            kv_prepare_request = request.copy()
            kv_prepare_request["max_tokens"] = 1

            prefill_instance = self.schedule(self.prefill_cycler)
            try:
                async for _ in self.forward_request(
                    f"http://{prefill_instance}/v1/completions", kv_prepare_request
                ):
                    continue
            except HTTPException as http_exc:
                self.remove_instance_endpoint("prefill", prefill_instance)
                raise http_exc

            # Perform kv recv and decoding stage
            decode_instance = self.schedule(self.decode_cycler)

That asymmetry is itself the finding, and it is a deliberate architectural split rather than an omission. vLLM's contribution to routing is not a router; it is a feed of ground truth for somebody else's router. Turn on --kv-events-config and the engine publishes every block insertion and eviction over ZMQ:

vllm/distributed/kv_events.py:L50-L101, L97-L101 vLLM
class BlockStored(KVCacheEvent):
    block_hashes: list[ExternalBlockHash]
    parent_block_hash: ExternalBlockHash | None
    token_ids: list[int]
    block_size: int
# ...
class BlockRemoved(KVCacheEvent):
    block_hashes: list[ExternalBlockHash]
    medium: str | None
    group_idx: int | None = None
    locality: str | None = None

The publisher keeps a replay buffer so a router that restarts or drops frames can resynchronise — "subscribers can request missed batches by sending the starting sequence number as an 8-byte big-endian integer" (vllm/distributed/kv_events.py:L290-L313), with replay_endpoint and buffer_steps (default 10,000) in vllm/config/kv_events.py:L27-L36.

The contrast is clean and worth stating precisely:

Read from source at the pinned SHAs. Not a ranking — two different bets about where cluster knowledge should live.
QuestionSGLang sgl-model-gatewayvLLM at a556f3f
Router in tree?Yes — a Rust workspace with Python/Go bindingsNo — demo proxies under examples/ only
Cache model the router usesApproximate: a radix tree of what the router sent. SGLang's engine can publish exact BlockStored/BlockRemoved events too (python/sglang/srt/disaggregation/kv_events.py:L112-L135, gated on --kv-events-config), but nothing in sgl-model-gateway/ subscribes to themExact: the engine publishes BlockStored/BlockRemoved, and the consumer is out of tree
Who consumes itThe router itself, in-processAn out-of-tree router (llm-d, production-stack)
Tokenizer on the routing pathNo — characters, to avoid the costYes, implicitly — events carry token-level block hashes
StalenessBounded only by the LRU sweep intervalBounded by ZMQ delivery, with replay on gaps
Failure if the model is wrongSilent hit-rate lossSilent hit-rate loss, plus a dropped-event alarm you can build

The published effect of closing this gap, cited from vLLM's own integration docs (Llama 3.1 70B on AMD MI300X): "3x higher output throughput and 2x faster TTFT from prefix-aware routing vs round-robin" (docs/deployment/integrations/llm-d.md:L23). That is somebody else's benchmark on hardware this book has not touched; treat the ratio, not the absolute.

§5

Worked trace: P/D dual dispatch and the shared bootstrap_room

The most readable artefact in either repository is mini_lb.py, SGLang's 462-line Python load balancer for prefill/decode disaggregation. It is explicitly a debugging tool:

sgl-model-gateway/bindings/python/src/sglang_router/mini_lb.py:L54-L70 SGLang
    def _validate_router_args(self, router_args: RouterArgs):
        logger.warning(
            "\x1b[33mMiniLB is only for debugging purposes, it only supports random policy!\033[0m"
        )

        # NOTE: too many arguments unsupported, just validate some important ones
        if router_args.policy != "random":
            logger.warning("[MiniLB] Overriding policy to random")
            router_args.policy = "random"

        if not router_args.pd_disaggregation:
            raise ValueError("MiniLB only supports PD disaggregation mode")

Follow one POST /generate through it.

Step 1 — pick a pair. handle_generate_request calls lb.select_pair(), which draws one prefill URL (with its bootstrap port) and one decode URL independently at random (mini_lb.py:L103-L112). The router must therefore know two disjoint pools and their roles: prefill workers additionally carry a bootstrap port, decode workers do not. The Rust gateway keeps that distinction all the way into the cache tree, keying trees by pool::model so a prefill lookup can never evict a decode tenant (sgl-model-gateway/src/policies/cache_aware.rs:L76-L91).

Step 2 — mint a rendezvous token. The router rewrites the body, adding three fields:

sgl-model-gateway/bindings/python/src/sglang_router/mini_lb.py:L356-L383 SGLang
@app.post("/generate")
async def handle_generate_request(request_data: dict):
    prefill_server, bootstrap_port, decode_server = lb.select_pair()

    # Parse and transform prefill_server for bootstrap data
    parsed_url = urllib.parse.urlparse(prefill_server)
    hostname = maybe_wrap_ipv6_address(parsed_url.hostname)
    modified_request = request_data.copy()

    batch_size = _get_request_batch_size(modified_request)
    if batch_size is not None:
        modified_request.update(
            {
                "bootstrap_host": [hostname] * batch_size,
                "bootstrap_port": [bootstrap_port] * batch_size,
                "bootstrap_room": [
                    _generate_bootstrap_room() for _ in range(batch_size)
                ],
            }
        )
    else:
        modified_request.update(
            {
                "bootstrap_host": hostname,
                "bootstrap_port": bootstrap_port,
                "bootstrap_room": _generate_bootstrap_room(),
            }
        )

_generate_bootstrap_room() is random.randint(0, 2**63 - 1) (mini_lb.py:L436-L437). It is a 63-bit rendezvous nonce, nothing more. bootstrap_host/bootstrap_port point at the KV bootstrap server that only prefill instances start:

python/sglang/srt/managers/disagg_service.py:L21-L30 SGLang
    if disagg_mode == DisaggregationMode.PREFILL:
        # only start bootstrap server on prefill tm
        kv_bootstrap_server_class = get_kv_class(
            transfer_backend, KVClassType.BOOTSTRAP_SERVER
        )
        bootstrap_server = kv_bootstrap_server_class(
            host=server_args.host,
            port=server_args.disaggregation_bootstrap_port,
        )

Step 3 — dispatch the same body to both. This is the pattern worth internalising:

sgl-model-gateway/bindings/python/src/sglang_router/mini_lb.py:L129-L141 SGLang
        async with aiohttp.ClientSession(
            timeout=aiohttp.ClientTimeout(
                total=self.timeout
            )  # Add timeout for request reliability
        ) as session:

            tasks = [
                session.post(f"{prefill_server}/{endpoint}", json=prefill_req),
                session.post(f"{decode_server}/{endpoint}", json=decode_req),
            ]

            # Wait for both responses to complete. Prefill should end first.
            prefill_response, decode_response = await asyncio.gather(*tasks)

Why the same body to both? Because the decode instance needs the full request to allocate its own KV pages, set up sampling state and know the output length — it is running a real request, not replaying a transcript. What it does not do is recompute the prompt: it registers bootstrap_room with the prefill side's bootstrap server, blocks in WaitingForInput, and receives the KV pages over the transport that §1.6 owns. The room number is the join key between two independent HTTP requests to two independent processes.

The comment "Prefill should end first" is the whole invariant in four words. If it does not, something is wrong: the decode instance is blocked waiting for KV that will never arrive. The production Rust router encodes that suspicion explicitly, polling both futures with biased so prefill is checked first, and treating an early decode completion as a rejection:

sgl-model-gateway/src/routers/http/pd_router.rs:L711-L732 SGLang
        // Poll both until prefill resolves; decode normally resolves later, but
        // may resolve first if it rejects the request outright.
        let prefill_result;
        let mut decode_early: Option<Result<reqwest::Response, reqwest::Error>> = None;
        loop {
            tokio::select! {
                biased;
                pr = &mut prefill_fut => {
                    prefill_result = pr;
                    break;
                }
                dr = &mut decode_fut, if decode_early.is_none() => {
                    decode_early = Some(dr);
                }
            }
        }

        // Decode can't generate without prefill's KV, so any prefill failure
        // (non-2xx / transport error) dooms the paired decode request, which would
        // otherwise block in WaitingForInput until the 300s disaggregation
        // timeout. Drop the decode future to close its connection; the decode
        // engine then detects the disconnect and aborts the request in ~4-8s.

Step 4 — return decode's response, not prefill's. The client's tokens come from the decode server. The prefill response is discarded, except when return_logprob is set, in which case the router splices prefill's input_token_logprobs onto decode's output (mini_lb.py:L143-L156) — the prompt logprobs physically only exist on the machine that computed the prompt.

Figure 3 — P/D dual dispatch: one client request becomes two HTTP requests joined by a 63-bit nonce. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§6

Autoscaling, cold start, and the lifecycle

Autoscaling a stateless web service is a solved problem because a new replica is useful within a second of starting. An LLM replica is not. Here is what a scale-up event actually costs, taking the weight-load times §8.4 derived for Llama-3-70B at TP=8 (141.1 GB of bf16 weights, read once per node).

And then add the part nobody budgets for: the new replica's prefix cache is empty. It is not merely late, it is slower than its peers for as long as it takes to refill. Derive that too. A 70B TP=8 node's KV pool holds 1,332,954 tokens (§2.1); filling it with 2,048-token prefixes takes 651 prefills. One 2,048-token prefill of Llama-3-70B is $2P T + 2 L h d_h T(T{-}1) = 289.3 + 5.5 = 294.8$ TFLOP, and eight H100s at 40% MFU deliver 3,165 TFLOP/s, so 93 ms each — 61 s of aggregate prefill work in this illustrative full-cache fill model. It is not mandatory dead time: these prefills serve requests, partial caches are useful, and arrivals, repetition, routing, and eviction determine wall-clock convergence.

Figure 4 — time to parity for one scale-up event, Llama-3-70B TP=8. Weight-load segments are §8.4's derived link-rate arithmetic; the cold-cache segment is derived here from §2.1 pool sizing and §1.1 prefill cost. Nothing measured.

0 200 400 600 800 seconds after the scale-up decision -- DERIVED, not measured local NVMe 7.0 GB/s 20.2 s weights + 61 s cold cache = 81 s NFS, 25 GbE 3.125 GB/s 45.2 s + 61 s = 106 s NFS, 10 GbE 1.25 GB/s 112.9 s + 61 s = 174 s object store 0.2 GB/s 705.5 s weight pull -- 11.8 minutes before the first token weight load cold prefix cache, 651 prefills

The router's own defaults tell you the maintainers know this:

sgl-model-gateway/src/config/types.rs:L512-L515 SGLang
            max_payload_size: 536_870_912,     // 512MB
            request_timeout_secs: 1800,        // 30 minutes
            worker_startup_timeout_secs: 1800, // 30 minutes for large model loading
            worker_startup_check_interval_secs: 30,

A thirty-minute default startup budget is not defensive programming, it is a statement about the domain. Three consequences follow, and none of them is an implementation detail:

over-provision

Headroom is the only fast lever

If scale-up takes 81–174 s, the fleet must already hold that much slack. Reactive autoscaling on queue depth arrives after the incident.

warm pools

Pay to keep weights loaded

A paused replica with weights resident starts in seconds. That is a rental cost traded against a latency SLO, and it is a business decision, not an SRE one.

predictive

Scale on the calendar

Diurnal traffic is forecastable minutes ahead. Scaling on a forecast is the only way a 174-second replica is ever ready on time.

The second-order trap

Scale up under load with cache-aware routing and the new replica is chosen mostly by the low-match branch — it has no tenancies, so it attracts the requests nobody has a prefix for. Those are exactly the expensive ones. The replica you added to shed load spends its first minute doing the most expensive work in the queue, at a lower hit rate than its peers, while load() reports it as the least-loaded worker. Expect p99 to get worse before it gets better.

Ready, not merely up

"Up" means the process accepted a TCP connection. "Ready" means it has loaded weights, allocated the KV pool, captured CUDA graphs and can produce a token. The gateway distinguishes them at two levels. In Kubernetes discovery, a pod counts only when the kubelet's own readiness gate has passed:

sgl-model-gateway/src/service_discovery.rs:L144-L150, L219-L221 SGLang
        let is_ready = if let Some(conditions) = &status.conditions {
            conditions
                .iter()
                .any(|condition| condition.type_ == "Ready" && condition.status == "True")
        } else {
            false
        };
# ...
    pub fn is_healthy(&self) -> bool {
        self.is_ready && self.status == "Running"
    }

At runtime, a hysteretic probe keeps flapping replicas out of the pool:

sgl-model-gateway/src/core/worker.rs:L568-L579 SGLang
impl Default for HealthConfig {
    fn default() -> Self {
        Self {
            timeout_secs: 5,
            check_interval_secs: 30,
            endpoint: "/health".to_string(),
            failure_threshold: 3,
            success_threshold: 2,
            disable_health_check: false,
        }
    }
}

Do the arithmetic on those defaults: three consecutive failures at 30-second intervals is up to 90 seconds of routing requests into a dead replica before set_healthy(false) fires (worker.rs:L733-L782). The per-worker circuit breaker is what actually catches a fast failure; the health probe is the slow, hysteretic backstop. Note also that /health is a liveness ping, whereas /health_generate runs a real generation — mini_lb.py:L248-L257 fans it out to every prefill and decode server, which is why it is a startup gate and not a 30-second probe.

Draining

Draining a replica without killing in-flight streams is two separate acts. First, remove it from the registry — DELETE /workers/{worker_id} ends in WorkerRegistry::remove_by_url (sgl-model-gateway/src/core/worker_registry.rs:L364-L370), after which get_healthy_worker_indices can no longer select it, so no new request is routed there. Second, let the existing streams finish: the router's own shutdown path hands axum_server a grace period (sgl-model-gateway/src/server.rs:L1061-L1065) whose CLI default is 180 seconds (sgl-model-gateway/src/main.rs:L290-L292). Kill the pod inside that window and every open SSE stream terminates mid-token; §9.3 covers what the client sees.

One structural constraint on co-location while you are here: an embedding model and a chat model cannot share an engine process, because pooling and generation cannot share a batch (§7.5). A RAG deployment is therefore always at least two pools with two independent caches, and the router must model them separately — which is exactly why the cache tree is keyed by pool::model rather than by model alone.

§7

Pitfalls and war stories

1. cache_threshold is a ratio, and long unique suffixes suppress it. match_rate = matched_char_count / input_char_count. Send a 2,048-token system prompt followed by an 8,000-token retrieved document and the match rate is roughly $2048/10048 = 0.20$ — below both defaults, so the router takes the min-load branch and throws away a hit worth 86 ms. The threshold suppresses cache-aware routing exactly when the shared prefix is most valuable in absolute terms. If your workload is long-context RAG with a shared preamble, lower --cache-threshold deliberately and watch queue depth.

2. The Rust defaults and the Python launcher's defaults are different. Both are in-tree, both are current at this SHA:

Read from source at 7d89325. Which set you get depends on which entry point you launched.
KnobRust CacheAwareConfig::defaultPython RouterArgs
cache_threshold0.50.3
balance_abs_threshold3264
balance_rel_threshold1.11.5
eviction_interval_secs3060
max_tree_size10,00067,108,864

Sources: sgl-model-gateway/src/policies/mod.rs:L96-L116 and sgl-model-gateway/bindings/python/src/sglang_router/router_args.py:L53-L64. The max_tree_size gap is four orders of magnitude and it matters. 10,000 characters per tenant is roughly 2,500 tokens — one system prompt — so the Rust default forgets almost everything every 30 seconds. 226 characters is roughly 16.8M tokens, about 39× the 428,569 tokens a Llama-3-8B replica can actually hold (derived, at ~4 characters per token), so the Python default believes in prefixes the engine evicted long ago. Neither default is calibrated to your pool; compute $M \times L_p \times 4$ and set it.

3. The balance guard almost never fires at moderate load. With the Python defaults, "imbalanced" needs $(\max-\min) > 64$ and $\max > 1.5\min$. At 60 req/s across 8 replicas with ~1-second requests, mean in-flight is 7.5 per replica; a 64-request gap is a fleet already in trouble. Concentration on a hot prefix therefore goes uncorrected until it is severe. If you want the guard to engage earlier, --balance-abs-threshold is the knob, and the cost is hit rate.

4. load() is the router's in-flight counter, not the engine's queue depth. The polling LoadMonitor exists, but it feeds only power-of-two:

sgl-model-gateway/src/core/worker_manager.rs:L350-L357 SGLang
            let power_of_two_policies = policy_registry.get_all_power_of_two_policies();

            if power_of_two_policies.is_empty() {
                debug!("No PowerOfTwo policies found, skipping load fetch");
                continue;
            }

            let result = WorkerManager::get_all_worker_loads(&worker_registry, &client).await;

So cache_aware's balance decision is made on requests the router has dispatched and not yet seen complete. A replica that is thrashing its KV pool but holding few concurrent requests reads as lightly loaded.

5. A stale tenancy silently disables affinity. If the tree's tenant for a prefix is gone or unhealthy, the router removes the tenancy and falls back to healthy_indices.first() — the same worker for every such request until the tree re-learns (cache_aware.rs:L492-L512). And if the pool tree was never seeded at all, the log is explicit about the consequence:

sgl-model-gateway/src/policies/cache_aware.rs:L514-L521 SGLang
            warn!(
                "cache_aware: no tree found for key '{}', falling back to random \
                 worker selection — pool tree was not seeded \
                 (init_pd_cache_aware_policies missed or a race during worker \
                 registration); cache affinity is effectively disabled until this \
                 clears",
                tree_key
            );

Grep for that string first when hit rate is inexplicably at random-routing levels.

6. In P/D mode, a prefill failure strands the decode request for 300 seconds. The decode instance is blocked in WaitingForInput on a bootstrap_room that will never be filled. The router mitigates by dropping the decode future so the engine sees a disconnect and aborts in ~4–8 s (pd_router.rs:L728-L732) — but a router that merely fires and forgets, as a naive proxy would, leaves decode slots pinned for five minutes each.

The feed exists; the router ignores it

The gateway's tree has no input from the engine at all: its only decay is its own LRU sweep. That is not because SGLang lacks the signal. The engine ships the same event schema vLLM does — BlockStored, BlockStoredWithMetadata, BlockRemoved, AllBlocksCleared at python/sglang/srt/disaggregation/kv_events.py:L112-L135, published per step by SchedulerKvEventsPublisher (python/sglang/srt/managers/scheduler_components/kv_events_publisher.py:L45-L75) whenever --kv-events-config is set (python/sglang/srt/server_args.py:L1650-L1654, default None, so off). §13.1 reads the two schemas side by side and finds them field-for-field alike. What is missing is the other half of the wire: a grep -ri "blockremoved\|kv_event" sgl-model-gateway/src/ at 7d893255 returns nothing. Both projects publish exact cache state and neither ships the consumer that would close the loop — SGLang's own router included.

§8

Hands-on

Two replicas, two policies, one workload with a shared prefix. No cluster required — two GPUs, or two CPU-mode servers if you only want to watch the routing decisions.

measure the routing effect yourself shell
# two separate GPUs, each large enough for one replica; enable engine metrics
CUDA_VISIBLE_DEVICES=0 python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --port 30001 --enable-metrics &
CUDA_VISIBLE_DEVICES=1 python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --port 30002 --enable-metrics &

# router A: the wrong answer
python3 -m sglang_router.launch_router \
  --worker-urls http://127.0.0.1:30001 http://127.0.0.1:30002 \
  --policy round_robin --port 30000

# router B: the right one, with the tree budget sized to the pool
python3 -m sglang_router.launch_router \
  --worker-urls http://127.0.0.1:30001 http://127.0.0.1:30002 \
  --policy cache_aware \
  --cache-threshold 0.3 --max-tree-size 8388608 \
  --eviction-interval 60 --log-level debug --port 30000

# hit rate is reported by the engines, not the router:
curl -s http://127.0.0.1:30001/metrics | grep -i cache
curl -s http://127.0.0.1:30002/metrics | grep -i cache

# and the router's own view:
curl -s http://127.0.0.1:30000/workers

Drive it with a generator that emits, say, 40 distinct 2,000-token system prompts and short user turns, and compare cached-token counters between the two runs. Then flip one knob at a time: raise --cache-threshold to 0.9 and watch affinity collapse; drop --balance-abs-threshold to 2 and watch it collapse for the opposite reason.

For the vLLM side, watch the ground-truth feed a router would consume:

examples/disaggregated/disaggregated_serving/kv_events.sh:L37-L45 shell
vllm serve "$MODEL_NAME" \
    --port 8100 \
    --max-model-len 100 \
    --enforce-eager \
    --gpu-memory-utilization 0.8 \
    --trust-remote-code \
    --kv-events-config \
    '{"enable_kv_cache_events": true, "publisher": "zmq", "topic": "kv-events"}' &

Run examples/features/kv_events/kv_events_subscriber.py against it and send two prompts that share a prefix. You will see one BlockStored batch for the first and a shorter one for the second — that difference is precisely the signal SGLang's router is guessing at.

§9

Exercises

  1. Read the source. Open sgl-model-gateway/src/policies/cache_aware.rs and answer: in the imbalanced branch, does the router still update the tree? Which function, which line, and why does the answer matter for what happens after the imbalance clears?
    Answer

    Yes. select_worker_min_load inserts the request text against the min-load worker at cache_aware.rs:L354 (tree.insert(text, worker_url)), guarded by if let Some(text) = request_text. It matters because affinity is re-established against the new placement once the fleet levels out: the shortest-queue detour permanently re-tenants those prefixes rather than snapping back to the old owner. That is what stops the router oscillating between two tenants for the same prefix.

  2. Predict, then verify. With the Python launcher defaults, you send a request whose input is 40,000 characters, of which the first 6,000 exactly match a prefix tenanted to replica 2. Which branch fires and which replica is chosen? Verify by reading cache_aware.rs:L436-L466.
    Answer

    match_rate = 6000/40000 = 0.15, below the Python default cache_threshold of 0.3, so the else branch fires: minimum load() among healthy workers, ties broken at random. Replica 2 is chosen only by coincidence. The 6,000 characters of hit are discarded — roughly 1,500 tokens, about 63 ms of Llama-3-8B prefill at 40% MFU (derived). Now predict the Rust binary's behaviour with cache_threshold = 0.5: worse, not better.

  3. Size the tree. Your replicas are Llama-3-70B at TP=8 with a 1,332,954-token KV pool (§2.1). Assuming 4 characters per token and that half the pool is live-request KV, what --max-tree-size makes the router's model match reality? Compare with both shipped defaults.
    Answer

    Cacheable tokens ≈ 666,477; at 4 chars/token that is ≈ 2,665,908 characters per tenant, so --max-tree-size 2665908 (round to 221 = 2,097,152 to be conservative). The Rust default of 10,000 is 266× too small — the router forgets a prefix it could still hit. The Python default of 67,108,864 is 25× too large — the router routes to a tenant whose blocks were evicted, converting a predicted hit into a full prefill plus a lost balancing opportunity. Both defaults are wrong in opposite directions, which is why neither is safe to leave alone.

  4. Design under a real constraint. Traffic doubles every 4 minutes during a product launch. Weights live on a 10 GbE network filesystem. If effective additional capacity were delayed by 174 s, what capacity-to-current-demand ratio would cover that deterministic growth? Compare an assumed 81 s delay, and explain why Figure 4's full-cache work estimate does not establish either delay.
    Answer

    Under the stipulated all-or-nothing delay, demand grows by $2^{174/240}=1.65\times$: capacity must be about 65% above current demand, corresponding to at most 60.5% initial utilisation. An assumed 81 s delay gives $2^{81/240}=1.26\times$, about 26% extra capacity and 79.1% utilisation. These are deterministic growth calculations, not guarantees of zero queueing under stochastic arrivals. In Figure 4, the 61 s cache term is aggregate prefill work, not a period of unusable capacity. Partial caches serve traffic; routing, repetitions, eviction, concurrent filling and initial capacity determine the actual ramp. Local NVMe reduces weight-transfer time, while warm pools and shared KV can change other components. Measure the readiness and capacity ramp before using either assumed delay for autoscaling.

  5. Compare the two architectures. vLLM publishes BlockStored/BlockRemoved; SGLang's router infers. Name one workload where the approximation is better, and one failure mode the exact feed has that the approximation does not.
    Answer

    Better: short-prompt, high-QPS chat. The approximate tree costs one character-wise walk and no tokenizer call on the routing path, while the event feed's volume scales with block churn, which at high QPS and short prompts is enormous relative to its value. Failure mode unique to the exact feed: it is a lossy transport. The ZMQ publisher has a high-water mark (default 100,000) and drops when a consumer falls behind (vllm/config/kv_events.py:L37-L40). A router silently behind on events has a confidently wrong index, which is worse than an admittedly approximate one — hence the replay endpoint and sequence numbers.

§10

Key takeaways

  • The load balancer sets your cache hit rate, and round-robin sets it to $M/W$. This $M/W$ result assumes the uniform independent-reference workload, not every routing distribution. Fleet distinct capacity and per-request hit probability are different quantities. For 1,000 tenant prompts across 8 H100s that is 10.4% versus 83.2%, which models about 3.8 GPU-seconds/s of avoided prefill work, not a measured reduction in required cards.
  • The router cannot know what a replica holds, so it models it. SGLang keeps a per-pool::model radix tree of raw characters recording what it sent, decayed by an LRU sweep on a 30–60 s timer. Every pathology in this chapter is the gap between that model and the engine's actual pool.
  • Affinity and balance are opposed, and the blend is a two-condition guard. cache_aware checks $(\max-\min) > \text{abs}$ and $\max > \text{rel}\cdot\min$ before it looks at the tree. With abs = 64 that guard rarely fires at moderate load, so concentration goes uncorrected until it is severe — a deliberate bias toward hit rate.
  • P/D routing is dual dispatch on a shared nonce. The identical body goes to a prefill and a decode server with the same 63-bit bootstrap_room; decode allocates and blocks, prefill computes and pushes KV. The router must own the pairing, the invariant that prefill finishes first, and the abort when it does not — otherwise a decode slot pins for 300 seconds.
  • Startup and cache warmup are separate. The illustrative 81–766-second full-parity estimate includes 61 seconds of prefill work, not a mandatory wait before useful service. Weight-load time (§8.4) is only the visible half; a cold prefix cache can initially make the new replica slower than its peers while cache-aware routing preferentially hands it the requests nobody has a prefix for. Reactive autoscaling cannot win that race — headroom, warm pools and forecasting can.
  • The two projects bet differently on where cluster knowledge lives — but neither closes the loop. SGLang ships a router that approximates from its own send history; vLLM ships no router and exports exact BlockStored/BlockRemoved events with a replay endpoint for an out-of-tree one. The twist is that SGLang's engine publishes the same events (§13.1) and its own gateway does not subscribe. Neither approach is free: approximation drifts, and an exact feed can silently drop under its high-water mark.
§11

Further reading

  • SGLang sgl-model-gateway — the router workspace. Its README is the only prose documentation of the policy set; §"Load Balancing Policies" names the flags each policy reads.
  • SGLang issue #26263 — cache-aware routing keyed on only the first chat message, which broke multi-turn affinity. The fix comment is quoted in §4 and is the clearest short statement of why the routing key must be the whole conversation.
  • SGLang PR #19524 — cited in pd_router.rs:L697-L699 as the origin of the upstream-cancel behaviour that makes a client disconnect cancel the paired decode request.
  • SGLang: RadixAttention — the per-engine cache the router is trying to keep hot. §2.4 covers it properly; note that the engine's tree is over tokens and the router's is over characters.
  • llm-d: precise prefix-cache-aware routing and vLLM production-stack — the two out-of-tree routers vLLM's docs point at. Both consume the KV event stream described in vllm/distributed/kv_events.py.
  • vLLM disagg_proxy_multiturn.py — a proxy that keys cached KV-transfer parameters by a non-standard conversation_id so turn n+1's prefill can read turn n's decode blocks. A different answer to the same multi-turn problem: move the cache instead of the request.
  • Preble / prefix-aware scheduling literature and the Mooncake paper — the academic framing of the affinity-versus-balance tradeoff this chapter derives from first principles.
  • Neighbours: §1.6 for the KV transport behind bootstrap_room; §2.3 and §2.4 for the caches routing is trying to hit; §5.3 for DP-rank routing inside one engine; §9.3 for the single-request lifecycle the router wraps; §8.4 for the cold-start arithmetic reused in Figure 4; §13.1 for where this asymmetry sits in the wider comparison.

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