1. Why Routing Matters for LLM Inference #

In normal web-services land, routing is basically a solved problem. You've got a pool of stateless app servers, all interchangeable. Computing a shopping cart total gives the same answer at the same speed no matter which server does it. Round robin works. Least connections works. Honestly, even random works fine. The routing decision barely matters for performance.

LLM inference throws all that out the window. Every pod running an engine like vLLM keeps a KV cache -- a data structure in GPU memory holding the intermediate attention computations for token sequences it's already processed. This cache is the single biggest factor in inference latency. If a request comes in whose input tokens match a prefix that's already cached on some pod, that pod skips the expensive prefill phase for the matched part and starts generating output almost immediately. Send the exact same request to a pod with a cold cache and it has to do the whole prefill from scratch -- potentially seconds of GPU work for long contexts.

The thing that made this click for me: in LLM inference, pods are not interchangeable. The KV cache makes every pod stateful, so WHERE a request lands determines HOW FAST it runs. For a 4096-token input on Llama-3.1-70B at BF16, prefill takes about 8.2 seconds on a single NVIDIA A100 (80GB SXM). A full cache hit drops that to roughly 45ms (just the cache lookup and attention recomputation overhead) -- a 182x speedup. That one fact breaks every traditional load balancing algorithm.

This is the problem llm-d exists to solve. At the center of it is the Endpoint Picker Policy (EPP) -- a routing component that actually understands vLLM's cache state and optimizes for compute reduction, not just spreading load around. It scores every available pod on five dimensions, combines them into one composite score, and sends each request to whichever pod will serve it fastest. This post is my full walkthrough of how that algorithm works.

5 Scoring factors per pod
0.60 Default cache weight
182x Cache hit vs miss speedup (4K tokens, 70B)
<1ms Routing decision latency
Key Takeaway

LLM inference pods are stateful because of the KV cache. Where a request lands determines how fast it runs -- a cache hit on a 70B model saves about 8.2 seconds of prefill compute, which is why traditional load balancers just don't work here.

2. The EPP Scoring Algorithm in Full Detail #

The EPP is the routing brain of llm-d. For every incoming request, it computes a score for every available pod and picks the highest scorer. The formula is just a weighted linear combination of five factors, each normalized to [0, 1]:

EPP Composite Scoring Formula
Score = w_cache * cacheMatchRatio + w_load * (1 - loadRatio) + w_queue * (1 - min(queueDepth / maxQueue, 1)) + w_health * healthScore + w_adapter * adapterMatch
w_cache 0.60
w_load 0.20
w_queue 0.10
w_health 0.05
w_adapter 0.05

Since each factor is normalized to [0, 1] and the weights sum to 1.0 (0.60 + 0.20 + 0.10 + 0.05 + 0.05 = 1.00), the composite score is guaranteed to land in [0, 1] too. A score of 1.0 is the theoretical best case: a fully healthy pod with 100% cache match, zero GPU load, zero queue depth, and the requested adapter already loaded. Let's go through each factor.

cacheMatchRatio (weight: 0.60) #

This is just: how many token blocks of the request's input are already cached on this pod, divided by the total blocks in the input. 1.0 means the pod has the entire input prefix cached -- the request skips prefill completely and starts generating after only the cache lookup overhead (about 45ms for a 4096-token context on A100). 0.0 means a total cache miss -- the pod has to chew through the full input from scratch.

At 0.60, cache match is the dominant factor, and for good reason. Prefill is the most expensive part of inference. For a 4096-token input on Llama-3.1-70B at BF16, prefill takes about 8.2 seconds on a single NVIDIA A100 (80GB SXM). For Llama-3.1-8B at the same context length, it's about 0.9 seconds. A full cache hit wipes that out entirely. Nothing else -- not load balancing, not queue management -- comes close to that kind of latency win. The 0.60 weight means a pod with a strong cache match almost always wins the routing decision, unless it's badly overloaded or unhealthy.

loadRatio (weight: 0.20) #

loadRatio is the pod's current GPU utilization, normalized as currentUtilization / maxUtilization. The score uses the inverse, (1 - loadRatio), so low utilization scores high. A pod at 90% GPU utilization gets a load score of 0.10; an idle pod gets 1.0.

The 0.20 weight is a deliberate choice: load matters, but it shouldn't override cache affinity. Take a pod at 80% GPU utilization with a 100% cache match versus an idle pod with no cache. The cache-hot pod scores 0.60 * 1.0 + 0.20 * 0.2 = 0.64 on these two factors alone; the idle pod scores 0.60 * 0.0 + 0.20 * 1.0 = 0.20. The cache-hot pod wins by a mile even though it's way more loaded. And that's the right call -- sending the request to the idle pod would burn 8.2 seconds of GPU prefill on a 70B model for something that could've been served in 45ms from cache.

queueDepth (weight: 0.10) #

The queue score counts how many requests are already waiting in the pod's inference queue, normalized by a configurable max: 1 - min(queueDepth / maxQueue, 1). Zero queued requests scores 1.0. At or above the max, it scores 0.0. It drops linearly at 1/maxQueue per queued request -- with the default maxQueue=20, every extra queued request costs exactly 0.05.

Queue depth gets a lower weight than load because it's a jumpier signal -- queues can spike and drain within seconds. But it's an important tiebreaker: when two pods have similar cache matches, the one with the shorter queue serves the request sooner. The min(..., 1) clamp floors the score at 0.0 once a pod's queue hits maxQueue, so it can never go negative and drag down the composite.

healthScore (weight: 0.05) #

Instead of the usual binary healthy/unhealthy check, llm-d uses a continuous health score in [0, 1]. A fully healthy pod is 1.0. A pod that's partially degraded -- intermittent GPU ECC errors, elevated memory pressure, that kind of thing -- might report 0.7. Anything with healthScore < 0.3 gets excluded from routing entirely (the EPP treats its composite score as 0, so it drops out of the candidate pool). Pods in the [0.3, 1.0] range contribute their health score proportionally.

The 0.05 weight is small on purpose -- in steady state most pods are healthy, so this factor doesn't differentiate anything. It only kicks in when a pod is degraded, and then the score reduction shifts traffic away gradually instead of dropping it all at once. The maximum health penalty is 0.05 * (1.0 - 0.3) = 0.035 points (for a pod right at the exclusion threshold) -- small enough to not override a strong cache match, big enough to break ties between otherwise-equal pods.

adapterMatch (weight: 0.05) #

If you're running LoRA (Low-Rank Adaptation) fine-tuned adapters, this factor is 1.0 when the requested adapter is already loaded on the pod, 0.0 otherwise. Loading an adapter isn't free: rank-16 adapters (the common case) load in about 200ms on A100, rank-64 in about 800ms. Those numbers depend on your config -- adapter size and GPU memory bandwidth both matter.

0.05 makes sense here because adapter load time (200-800ms) is real but small next to a full cache miss (0.9-8.2 seconds depending on model size and context length). In practice it's a tiebreaker when several pods have similar cache and load scores. If you're multi-tenant with 10+ LoRA adapters, bumping w_adapter to 0.15-0.20 can be worth it -- more on that in Section 13.

Score bounds: all five factors live in [0, 1] and the weights sum to exactly 1.0, so the composite score is always in [0, 1]. You only get 0.0 if every factor bottoms out at once (total cache miss, 100% GPU load, full queue, health at the exclusion threshold, no adapter match), and 1.0 needs everything maxed. Real scores cluster between 0.15 and 0.85, and cache match ratio is what actually separates pods most of the time.

Key Takeaway

The EPP scores each pod as a weighted sum of five normalized factors. Cache match dominates at 0.60 because it decides whether the request skips seconds of prefill. The weights sum to 1.0, so the composite score stays predictably in [0, 1].

3. Mathematics of Scoring #

Section 2 was the intuitive version. Now let's get formal -- notation, a couple of proofs, and a sensitivity analysis showing exactly how much pull each input variable has on the routing decision. Feel free to skim this one if math isn't your thing; the takeaway box at the bottom has the punchline.

Formal Definition #

First, the five input variables and the composite scoring function, defined precisely. Lowercase for variables, bold for vectors.

Definition 1: Input Variables
Let c = cacheMatchRatio , c in [0, 1] Let l = loadRatio , l in [0, 1] Let q = queueDepth , q in [0, +inf) Let h = healthScore , h in [0, 1] Let a = adapterMatch , a in {0, 1} Let Q = maxQueue , Q > 0 (constant, default = 20)
Definition 2: Composite Score Function
S(c, l, q, h, a) = w_c * c + w_l * (1 - l) + w_q * (1 - min(q / Q, 1)) + w_h * h + w_a * a where w_c + w_l + w_q + w_h + w_a = 1.0 and each w_i >= 0

Proof: The Score is Bounded in [0, 1] #

Quick proof that for all valid inputs, the composite score S stays in the closed interval [0, 1].

Theorem 1: Score Boundedness
Claim: For all valid inputs, 0 <= S(c,l,q,h,a) <= 1 Proof (upper bound): Each normalized factor is at most 1: c <= 1, (1 - l) <= 1, (1 - min(q/Q,1)) <= 1, h <= 1, a <= 1 Therefore: S <= w_c*1 + w_l*1 + w_q*1 + w_h*1 + w_a*1 S <= w_c + w_l + w_q + w_h + w_a = 1.0 QED Proof (lower bound): Each normalized factor is at least 0: c >= 0, (1 - l) >= 0 (since l <= 1), (1 - min(q/Q,1)) >= 0, h >= 0, a >= 0 All weights w_i >= 0, so: S >= 0 + 0 + 0 + 0 + 0 = 0 QED

Partial Derivatives (The Gradient) #

The gradient of S tells you exactly how much the composite score moves per unit change in each input. Since S is linear in each normalized factor (with a piecewise-linear clamp on the queue term), the partial derivatives are constant within each region.

Theorem 2: Gradient of S
dS/dc = w_c = 0.60 (cache match ratio) dS/dl = -w_l = -0.20 (load ratio; negative because higher load = lower score) dS/dq = -w_q / Q (for q < Q) = -0.10 / 20 = -0.005 (per additional queued request) dS/dq = 0 (for q >= Q; clamped, no further penalty) dS/dh = w_h = 0.05 (health score) dS/da = w_a = 0.05 (adapter match; discrete, not continuous)
The gradient is constant because S is linear in each variable. This means the marginal impact of each variable does not depend on the values of the others -- a key property that makes the scoring function predictable and easy to reason about.

Sensitivity Analysis #

So which input actually moves the score the most? Compare the partial derivatives -- but normalize by each variable's range first, or the comparison isn't fair. The "sensitivity" of S to each variable is the max score change it can cause swinging from its worst value to its best.

Variable Range dS/dx Max Score Impact Rank
cacheMatchRatio (c) [0, 1] +0.60 0.60 points 1st
loadRatio (l) [0, 1] -0.20 0.20 points 2nd
queueDepth (q) [0, Q] -0.005 per req 0.10 points 3rd
healthScore (h) [0, 1] +0.05 0.05 points 4th (tied)
adapterMatch (a) {0, 1} +0.05 0.05 points 4th (tied)

The numbers back up the intuition: cache match ratio is 3x more influential than load ratio, 6x more than queue depth, and 12x more than health or adapter match. A pod going from 0% to 100% cache match gains 0.60 points. To get that same 0.60 from load alone, load ratio would have to drop by 3.0 -- but it's capped at [0, 1], so the most load can ever give you is 0.20 points. Cache dominance isn't a coincidence, it falls straight out of the weight configuration.

Proof: Cache Match Dominance #

There's a stronger result here. Let's try to prove that with default weights, a pod with a cache match advantage of at least 0.40 (40 percentage points) always outscores a pod with zero cache match, no matter what the other variables do. (Spoiler: the first attempt fails in an interesting way.)

Theorem 3: Cache Dominance Threshold
Claim: If pod X has c_x >= 0.40 and pod Y has c_y = 0, then S(X) > S(Y) for all valid values of l, q, h, a. Proof: The cache contribution difference is: w_c * (c_x - c_y) >= 0.60 * 0.40 = 0.24 The maximum possible advantage Y can have over X from all other factors: load: w_l * 1.0 = 0.20 (Y fully idle, X fully loaded) queue: w_q * 1.0 = 0.10 (Y empty queue, X full queue) health: w_h * 0.7 = 0.035 (Y perfect, X at exclusion threshold 0.3) adapter: w_a * 1.0 = 0.05 (Y has adapter, X does not) Maximum non-cache advantage for Y: 0.20 + 0.10 + 0.035 + 0.05 = 0.385 But wait: if X has h < 0.3, it is excluded entirely. So the worst case for X (while still eligible) is h_x = 0.3. Net advantage for X from cache alone: 0.24 Maximum non-cache disadvantage for X: 0.385 0.24 < 0.385, so the claim does NOT hold for c_x = 0.40 in the absolute worst case. Let us find the exact threshold. w_c * c_threshold = 0.385 c_threshold = 0.385 / 0.60 = 0.6417 Revised claim: A cache advantage of >= 0.65 (65 pp) guarantees cache-hot pod X wins over cold pod Y in ALL scenarios. QED
In practice, the "absolute worst case" (X is fully loaded, full queue, nearly excluded on health, missing adapter, while Y is idle across every dimension) is extremely rare. For typical scenarios where the non-cache factors are within 50% of each other, a cache advantage of just 0.35 (35 pp) is sufficient for dominance.

This one's genuinely useful to know operationally. Once a pod has a 65%+ cache match advantage over the next-best option, no combination of load, queue, health, or adapter factors can flip the routing decision. And in practice, multi-turn conversations routinely build up cache advantages of 80-90% (see Section 9, Example 4) -- at that point the conversation is basically welded to its pod.

Worked Derivative Example: d(Score)/d(cacheMatch) #

Here's a concrete run-through of the partial derivative in action. The question: if cache match improves from 50% to 60%, how much does the score change?

Derivative Example: Score Change per Cache Increment

Consider a pod with the following state: c=0.50, l=0.40, q=5, h=1.0, a=0.0. Default weights, Q=20.

# Current score at c=0.50: S(0.50) = 0.60*0.50 + 0.20*0.60 + 0.10*0.75 + 0.05*1.0 + 0.05*0.0 = 0.300 + 0.120 + 0.075 + 0.050 + 0.000 = 0.545 # Score after cache improves to c=0.60 (all else constant): S(0.60) = 0.60*0.60 + 0.20*0.60 + 0.10*0.75 + 0.05*1.0 + 0.05*0.0 = 0.360 + 0.120 + 0.075 + 0.050 + 0.000 = 0.605 # Change: delta_S = 0.605 - 0.545 = 0.060 delta_c = 0.60 - 0.50 = 0.10 # Derivative: dS/dc = delta_S / delta_c = 0.060 / 0.10 = 0.60 # This confirms: dS/dc = w_cache = 0.60 (constant) # Every 10 percentage points of cache improvement adds 0.06 to the score. # Every 1 percentage point of cache improvement adds 0.006 to the score.
A 10 percentage-point improvement in cache match (50% to 60%) adds exactly 0.060 to the composite score. To achieve the same 0.060 score improvement through load reduction alone, GPU utilization would need to drop by 30 percentage points (0.060 / 0.20 = 0.30). To achieve it through queue reduction, 12 requests would need to drain from the queue (0.060 / 0.005 = 12). This confirms that cache match improvements are the most efficient path to a higher composite score.

Cross-Factor Equivalence Table #

This table shows how much change in each variable equals a 10-percentage-point bump in cache match. Handy when you're reasoning about tradeoffs like "is it worth sending this to a pod with 10% less cache if its queue is X shorter?"

# Equivalence: what equals a 10pp improvement in cache match? # A 10pp cache improvement adds 0.060 to the score. Cache match: +10 pp = +0.060 score (baseline) Load ratio: -30 pp = +0.060 score (3x harder to match via load) Queue depth: -12 req = +0.060 score (12 fewer queued requests) Health score: impossible to match (max health range is 0.05) Adapter match: impossible to match (max adapter range is 0.05) # Translation: 10% more cache match is worth 30% less GPU load, # or 12 fewer queued requests, and can never be fully offset by # health or adapter improvements alone.

Why linearity matters: the scoring function is deliberately linear rather than quadratic or exponential. Linear means constant partial derivatives, which means the marginal value of each input doesn't depend on the current state. You can reason about weight changes without worrying about interaction effects: bump w_cache by 0.10 and the cache factor becomes exactly 0.10 more influential, no matter what load or queue look like. That predictability is a big deal when you're tuning -- nonlinear scoring functions are famously hard to reason about without a pile of simulation.

Key Takeaway

Cache match is 3x more influential than load and 12x more than health or adapter match. A 65-percentage-point cache advantage guarantees the cache-hot pod wins, full stop. And because the scoring function is linear, the whole thing stays predictable when you tune it.

4. KV Cache Prefix Matching -- How Prefix Trees Work #

So cache match ratio is the most important factor in the formula -- but actually computing it efficiently across a fleet of pods is a real distributed systems problem. Here's how llm-d pulls it off.

Tokenization and Block Hashing #

Every request starts as a text prompt. The engine tokenizes it into token IDs with the model's tokenizer, then groups the tokens into fixed-size blocks -- typically 16 tokens each (configurable via vLLM's block_size). Each 16-token block gets hashed with xxHash64 into a 64-bit fingerprint. Collision odds (two different 16-token blocks producing the same hash) are about 1 in 2^64, roughly 5.4 x 10^-20 -- so, not something you'll ever see.

Why blocks instead of individual tokens? Tradeoff. Token-level matching would be more precise but would blow the prefix tree up to millions of nodes on a busy cluster and slow every lookup. Block-level matching shrinks the tree 16x and costs you basically nothing -- vLLM evicts at block granularity anyway, so finer-grained matching in the EPP wouldn't buy you anything. For a 4096-token input, the lookup walks exactly 256 nodes (4096 / 16), which finishes in under 50 microseconds on modern hardware.

# Block hashing with xxHash64 tokens = [1042, 556, 8832, ..., 4401] # 128 tokens total block_size = 16 blocks = [ xxhash64(tokens[0:16]), # Block 0 -> 0x7a3f1c9e... xxhash64(tokens[16:32]), # Block 1 -> 0x2b8d4a01... xxhash64(tokens[32:48]), # Block 2 -> 0x9c5e7f23... ... xxhash64(tokens[112:128]) # Block 7 -> 0x4d1a8b67... ] # 128 tokens / 16 per block = 8 block hashes (64-bit each)

The Prefix Tree Structure #

The EPP keeps a prefix tree (radix tree, trie, whatever you want to call it) mapping block hash sequences to the pods that have them cached. It's a single in-memory structure inside the EPP process. Each node is one block hash; a path from the root to any node is a cached prefix -- a contiguous run of block hashes matching a token prefix stored on one or more pods.

Each node carries a set of pod IDs -- which pods have this exact prefix cached. Interior nodes are partial prefixes; leaf nodes (or the longest path matching your query) are the deepest cache match available.

How the EPP Maintains a Global View #

Each pod periodically reports its cache state to the EPP through lightweight status updates -- basically "here are the block hashes currently sitting in my GPU memory." The EPP folds these into the tree: new paths when a pod caches new prefixes, removals when a pod evicts. It's eventually consistent, which means there's a window (typically 1-5 seconds, set by the pod's reporting interval) between a pod evicting an entry and the EPP finding out. I get into what that race condition means in Section 15.

How Lookup Works #

When a request comes in, the EPP hashes its input into blocks and walks the tree from the root. At each level it looks up the next block's hash: matching child node, record which pods hold it and descend; no match, stop. What you end up with is, per pod, the count of consecutive blocks matched from the start of the input.

# Prefix tree walk (simplified) def lookup(request_blocks, tree): pod_matches = defaultdict(int) node = tree.root for i, block_hash in enumerate(request_blocks): if block_hash in node.children: node = node.children[block_hash] for pod in node.pods: pod_matches[pod] = i + 1 else: break # prefix match ends here return pod_matches # {pod_id: matched_block_count}

The match ratio for each pod is then:

cacheMatchRatio = matched_blocks / total_blocks

A ratio of 0.75 means the pod has the first 75% of the input cached. For a 4096-token input on Llama-3.1-70B, that means skipping prefill on 3072 tokens (about 6.15 seconds of compute) and only prefilling the remaining 1024 (about 2.05 seconds). A ratio of 1.0 means zero prefill -- just the 45ms lookup overhead -- which is the ideal case and the whole point of the routing system.

Partial vs. Full Matches #

Partial matches are common and they're worth a lot. In a chatbot, the first message establishes the system prompt prefix. The second message shares the system prompt plus the first exchange. A pod that cached turn one has a partial match for turn two -- the system prompt and first exchange are cached, only the new user message needs prefill. That partial match might cover 80-90% of the input, and you save that fraction of the prefill compute.

Full matches (ratio = 1.0) happen when the exact same input has been seen before -- common for system prompts, shared document prefixes, and repeated queries. Prefill drops to about 45ms on A100, limited only by cache lookup and attention recomputation overhead.

Key Takeaway

A global prefix tree of xxHash64 block fingerprints tells the EPP exactly which pods have which token prefixes cached. Lookups finish in under 50 microseconds, and partial matches matter -- even 75% cache coverage saves most of the prefill compute.

5. Why Traditional Load Balancing Algorithms Fail for LLM Inference #

To see why llm-d needs a custom routing algorithm, it helps to see why every standard one falls over. And these aren't subtle failures -- they're structural.

Round Robin #

Round robin cycles requests through pods in order: pod 1, pod 2, pod 3, repeat. It assumes pods are interchangeable -- that a request runs equally well anywhere. For LLM inference that's just false. Round robin ignores cache state completely, so a request with a perfect cache match on pod 1 might get sent to pod 3's cold cache because, well, it's pod 3's turn. You get pointless prefill, wasted GPU cycles, higher latency. At scale the cache miss rate approaches 100%, because scattering requests uniformly means no pod ever builds a useful cache for any prefix.

Least Connections #

Least connections sends each request to the pod with the fewest active connections. Better than round robin since it at least sees load -- but it actively fights cache locality. A pod that's been serving a popular system prompt has a warm cache and, naturally, more active connections than a freshly started cold pod. So least connections routes new requests -- including ones that would've hit that warm cache -- to the cold pod, where they eat the full prefill cost. It optimizes load distribution at the direct expense of compute efficiency.

Random #

Random is the worst case for cache locality. The odds a request lands on a pod with useful cached state are 1/N for N pods -- in a 32-pod cluster, 97% of requests hit a cold cache. Every pod becomes an island with its own fragmentary cache, and none has enough memory to hold a useful working set.

Weighted Round Robin #

Weighted round robin can handle heterogeneous hardware -- more traffic to beefier GPUs. Still cache-blind though. A pod with an A100 and an empty cache is worse for a given request than a pod with an A10 and a warm cache for that request's prefix, because the cache hit skips prefill entirely.

The core tension: traditional load balancers optimize for even load distribution. LLM routing needs to optimize for compute reduction. Those goals directly conflict. The EPP's answer is to make cache affinity the primary objective (weight 0.60) and use load (0.20) and queue depth (0.10) as secondary pressure so no single pod gets buried.

It all comes back to the same point: for LLM inference, WHERE a request runs determines HOW FAST it runs. On a cache-hot pod, prefill overhead is about 45ms. On a cold pod, the same request takes 0.9-8.2 seconds of prefill depending on model size. No traditional load balancer models this because no traditional workload has it -- web servers, databases, and microservices are either stateless (same computation everywhere) or their state lives in shared storage everyone can reach. The KV cache is neither. It's pod-local, volatile, and it directly sets your execution cost.

Key Takeaway

Traditional load balancers optimize for even distribution; LLM routing has to optimize for compute reduction. Round robin, least connections, random, weighted -- all cache-blind, all happy to scatter requests away from warm pods and burn GPU time on prefill that didn't need to happen.

6. Queue Depth Scoring -- Why It Matters More Than GPU Utilization #

GPU utilization is the metric everyone watches, and it's the wrong one for routing. Utilization is a lagging indicator -- it tells you what the GPU was doing over the last sampling interval (typically 100ms-1s, depending on your NVIDIA DCGM or nvml polling config). By the time it spikes to 95%, the damage is done: the GPU is saturated and requests are already queuing. You can't route around a problem you only detect after it happened.

Queue depth is a leading indicator. It's about future latency, not past utilization. Queue depth of 10 means your next request waits behind 10 others before it even starts. Even if GPU utilization happens to read 40% right now (maybe a batch just finished and the next one hasn't started), those 10 queued requests will keep that GPU busy for a while.

The Counterintuitive Case #

Here's the case that trips people up. Two pods:

  • Pod A: 80% GPU utilization, 0 queued requests
  • Pod B: 40% GPU utilization, 10 queued requests

A traditional load balancer picks Pod B -- lower utilization, easy call. But Pod A is actually the better choice. Its GPU is busy but the queue is empty, so your request starts as soon as the current batch finishes. Pod B looks less loaded right now, but your request sits behind 10 others. By the time it starts processing, Pod A would've finished long ago.

Preventing Pile-up on Cache-Hot Pods #

Queue depth scoring is what keeps the EPP from drowning popular pods. Without it, a pod with a warm cache for a popular system prompt would get every single request for that prompt, its queue would grow forever, and latency would blow up. The queue factor is backpressure: as the queue grows the pod's score drops, and eventually a less-cached but less-loaded pod becomes the better pick. That request pays some prefill cost, but skips the queue wait.

Queue Normalization and the Linear Gradient #

The queue score is:

queueScore = 1 - min(queueDepth / maxQueue, 1)

maxQueue is configurable (default: 20) -- it's the queue depth where the pod counts as fully saturated. The score is linear in queue depth with slope -1/maxQueue. With maxQueue=20:

# Queue score derivative: d(queueScore)/d(queueDepth) = -1/maxQueue # With maxQueue=20, each additional queued request costs exactly 0.05 points queueDepth = 0 -> queueScore = 1.00 queueDepth = 1 -> queueScore = 0.95 queueDepth = 5 -> queueScore = 0.75 queueDepth = 10 -> queueScore = 0.50 queueDepth = 15 -> queueScore = 0.25 queueDepth = 20 -> queueScore = 0.00 # clamped at 0 queueDepth = 30 -> queueScore = 0.00 # still clamped at 0

The min(..., 1) clamp floors the score at 0.0 past maxQueue. The weighted contribution is w_queue * queueScore = 0.10 * queueScore, so the biggest possible queue penalty is 0.10 points (0.10 at empty queue down to 0.00 at full). Deliberately much smaller than cache's max of 0.60, but enough to push traffic off badly congested pods.

Graceful Degradation Under Congestion #

When every pod is congested (all queues deep), queue scores converge toward zero across the board and cache match becomes even more dominant. Which is exactly right: if your request is going to sit in a queue no matter what, you might as well send it to the pod that'll process it fastest when it reaches the front -- the one with the best cache match. The system degrades gracefully instead of making arbitrary calls under pressure.

Key Takeaway

Queue depth predicts future latency; GPU utilization only reports the past. The queue score is the backpressure that keeps cache-hot pods from drowning, and when everything's congested, routing falls back to cache-optimal -- which is the right default.

7. Health Checking -- Beyond Simple Liveness #

Kubernetes liveness and readiness probes answer yes/no questions: is the process alive? Can it take traffic? For LLM inference, that's necessary but nowhere near enough. A pod can be "live" and "ready" by Kubernetes standards and still be a terrible place to send an inference request.

What llm-d Health Checks Include #

  • Is the model loaded? Loading a 70B parameter model from storage into GPU memory takes 120-300 seconds depending on storage bandwidth (NVMe vs. network-attached). During this time, the pod is alive and its HTTP server is responding to health checks, but it cannot serve inference requests. The llm-d health check distinguishes between "process running" and "model loaded and ready for inference."
  • Is the GPU healthy? GPUs can experience ECC (Error-Correcting Code) memory errors, thermal throttling, and memory corruption without crashing the process. A pod with accumulating ECC errors may produce correct results today but is at elevated risk of failure. Thermal throttling (triggered above 83C on A100) reduces compute throughput by 10-30% without any visible error. The EPP health check incorporates GPU health metrics from NVIDIA's management libraries (NVML).
  • Is the cache warm? A freshly restarted pod has an empty KV cache. It is technically healthy and ready for inference, but every request will require full prefill. The EPP treats cache warmth as a health signal -- a cold pod is not "unhealthy," but it is less preferred than a warm pod for most requests.
  • Is the inference engine responsive? The vLLM process can enter states where it is running but not processing requests -- for example, during garbage collection pauses, CUDA synchronization stalls, or internal queue deadlocks. The EPP performs active inference-level health checks (not just TCP connection checks) to detect these conditions.

Continuous Health Scores and Exclusion Thresholds #

The health score is a continuous value in [0, 1], not a flag, and that opens up more nuanced routing:

  • 1.0: Fully healthy -- model loaded, GPU healthy, cache warm, engine responsive.
  • 0.7-0.9: Minor degradation -- intermittent ECC errors, moderate thermal throttling, or elevated memory pressure. The pod still receives traffic but at reduced priority.
  • 0.3-0.6: Significant degradation -- the pod is functional but should only receive traffic when better alternatives are unavailable.
  • < 0.3: Below exclusion threshold -- the EPP zeroes out this pod's composite score, effectively removing it from the candidate pool. The pod receives no new traffic. Existing in-flight requests complete normally.
  • 0.0: Draining or failed -- the pod is explicitly marked as unavailable.

Why does continuous beat binary here? Because routing decisions are expensive to get wrong. Yank a pod from the pool abruptly and its cached state is gone, instantly. Winding traffic down gradually gives the system time to replicate cached prefixes onto other pods, so the disruption stays small.

Key Takeaway

Continuous health scoring (0 to 1) means graceful degradation instead of abrupt removal. Below 0.3 a pod is out entirely; degraded pods in the 0.3-1.0 range still get traffic proportional to their value, which keeps cached state alive through transitions.

8. Session Affinity -- Multi-Turn Conversation Routing #

Multi-turn conversations are the bread and butter of real LLM traffic. Chatbots, coding assistant sessions, interactive document analysis -- they're all a series of requests where each turn builds on the last. Every turn appends tokens: system prompt, first user message, first assistant response, second user message, and so on. By turn five the input can be thousands of tokens.

Cache-Aware Routing Provides Natural Session Affinity #

My favorite property of the whole algorithm: session affinity just falls out of it. Nobody configures it. Here's why:

After turn one, the pod that served it has the system prompt plus that first exchange cached. Turn two arrives with a prefix of system prompt + first exchange + new user message. The prefix tree lookup finds that the turn-one pod has the longest match (the entire prior context), so it scores highest on cache and almost certainly wins turn two. Same logic for turn three, four, and on. Each turn extends the cached prefix on the same pod, and the scoring keeps sending the conversation back there.

No sticky sessions anywhere. The usual session affinity tricks -- cookie routing, IP hashing, header injection -- are brittle. They break when pods scale, when cookies expire, when someone switches from wifi to cellular. The cache-aware approach shrugs all of that off because it's based on content, not metadata. The question is always just "which pod has the best cache for this exact token sequence?" As long as that pod is up and not slammed, the conversation stays put.

When the Preferred Pod is Overloaded #

If the pod that's been serving the conversation is now slammed (deep queue, high load), the EPP may route a turn elsewhere. The new pod usually has a partial match -- maybe the shared system prompt is cached but the conversation-specific context isn't -- so it does a partial prefill for the uncached part. That's the cost of migration: a one-time prefill penalty for whatever context only lived on the original pod.

Still strictly better than traditional affinity, which would either (a) park the request behind everything else on the overloaded pod, or (b) fail over to a totally cold pod. The EPP finds the middle: route to the best available pod -- often a partial match -- and pay a proportional prefill cost.

Comparison to Traditional Session Affinity #

Cookie-based affinity maps a session ID to a pod -- scale that pod down and the session breaks; crash it and the state's gone. IP hashing falls apart behind NAT and with mobile users whose IPs change constantly. Header routing needs the application to cooperate. None of these have any idea what the pod actually has cached for this session. They're all indirect proxies for the signal the EPP measures directly: cache content.

Key Takeaway

Session affinity emerges from cache-aware routing on its own -- no sticky sessions, cookies, or IP hashing. Each turn extends the cached prefix on the same pod, so the affinity is self-reinforcing, content-based, and doesn't care about pod scaling or network changes.

9. The Mathematics -- Worked Examples #

Formulas are one thing; let's actually run the numbers. Five full examples, all using the default weights: w_cache=0.60, w_load=0.20, w_queue=0.10, w_health=0.05, w_adapter=0.05 and maxQueue=20.

Example 1: Cache Affinity vs. Load Balance

A request arrives with 64 token blocks (1024 tokens at block_size=16). The model is Llama-3.1-70B at BF16 on A100. Three pods are available. No LoRA adapter is needed. All pods are healthy (healthScore = 1.0).

  • Pod A: 64/64 blocks cached (100% match), 80% GPU load, 4 queued requests
  • Pod B: 0/64 blocks cached (0% match), 10% GPU load, 0 queued requests
  • Pod C: 32/64 blocks cached (50% match), 50% GPU load, 2 queued requests
# Pod A: cache=64/64=1.00, load=1-0.80=0.20, queue=1-4/20=0.80 score_A = 0.60 * 1.00 # cache + 0.20 * 0.20 # load + 0.10 * 0.80 # queue + 0.05 * 1.00 # health + 0.05 * 0.00 # adapter (N/A) = 0.600 + 0.040 + 0.080 + 0.050 + 0.000 = 0.770 # Pod B: cache=0/64=0.00, load=1-0.10=0.90, queue=1-0/20=1.00 score_B = 0.60 * 0.00 # cache + 0.20 * 0.90 # load + 0.10 * 1.00 # queue + 0.05 * 1.00 # health + 0.05 * 0.00 # adapter (N/A) = 0.000 + 0.180 + 0.100 + 0.050 + 0.000 = 0.330 # Pod C: cache=32/64=0.50, load=1-0.50=0.50, queue=1-2/20=0.90 score_C = 0.60 * 0.50 # cache + 0.20 * 0.50 # load + 0.10 * 0.90 # queue + 0.05 * 1.00 # health + 0.05 * 0.00 # adapter (N/A) = 0.300 + 0.100 + 0.090 + 0.050 + 0.000 = 0.540
Winner: Pod A (0.770). The full cache match overwhelms Pod A's high load. Pod B, despite being nearly idle, scores lowest because it has zero cached context. Pod A skips all 1024 tokens of prefill (~2.05s on 70B), completing in ~45ms. Pod B would require the full ~2.05s of prefill. Pod C falls in the middle: it skips 512 tokens of prefill but must process the remaining 512 (~1.03s).

Example 2: LoRA Adapter Routing

A request requires a specific rank-16 LoRA adapter ("customer-support-v3"). The adapter loads in approximately 200ms on A100. Four pods are available. All are healthy (healthScore = 1.0). Input: 40 token blocks (640 tokens).

  • Pod A: 20/40 blocks cached (50% match), 30% load, 1 queued, adapter loaded
  • Pod B: 30/40 blocks cached (75% match), 40% load, 3 queued, adapter NOT loaded
  • Pod C: 10/40 blocks cached (25% match), 20% load, 0 queued, adapter loaded
  • Pod D: 0/40 blocks cached (0% match), 15% load, 0 queued, adapter NOT loaded
# Pod A: cache=0.50, load=0.70, queue=0.95, health=1.0, adapter=1.0 score_A = 0.60*0.50 + 0.20*0.70 + 0.10*0.95 + 0.05*1.0 + 0.05*1.0 = 0.300 + 0.140 + 0.095 + 0.050 + 0.050 = 0.635 # Pod B: cache=0.75, load=0.60, queue=0.85, health=1.0, adapter=0.0 score_B = 0.60*0.75 + 0.20*0.60 + 0.10*0.85 + 0.05*1.0 + 0.05*0.0 = 0.450 + 0.120 + 0.085 + 0.050 + 0.000 = 0.705 # Pod C: cache=0.25, load=0.80, queue=1.00, health=1.0, adapter=1.0 score_C = 0.60*0.25 + 0.20*0.80 + 0.10*1.00 + 0.05*1.0 + 0.05*1.0 = 0.150 + 0.160 + 0.100 + 0.050 + 0.050 = 0.510 # Pod D: cache=0.00, load=0.85, queue=1.00, health=1.0, adapter=0.0 score_D = 0.60*0.00 + 0.20*0.85 + 0.10*1.00 + 0.05*1.0 + 0.05*0.0 = 0.000 + 0.170 + 0.100 + 0.050 + 0.000 = 0.320
Winner: Pod B (0.705) -- cache match dominates even without the adapter. Pod B must load the adapter (~200ms for rank-16) but skips 30/40 blocks of prefill. Pod A (0.635) has the adapter but a weaker cache. The adapter loading cost (200ms) is dwarfed by the extra prefill cost Pod A would incur for its 10 additional uncached blocks. If Pod B's cache were only 60% (24/40 blocks) instead of 75%, its score would drop to 0.645, and Pod A (0.635) would be within 0.01 points -- at that point the adapter tiebreaker becomes decisive.

Example 3: Degraded Pod with Best Cache

Three pods, one degraded with GPU ECC errors. No adapter needed. Input: 50 token blocks (800 tokens).

  • Pod A: 50/50 blocks cached (100% match), 60% load, 6 queued, healthScore = 0.4 (accumulating ECC errors)
  • Pod B: 25/50 blocks cached (50% match), 30% load, 1 queued, healthScore = 1.0
  • Pod C: 0/50 blocks cached (0% match), 20% load, 0 queued, healthScore = 1.0
# Pod A (degraded): cache=1.00, load=0.40, queue=0.70, health=0.40 score_A = 0.60*1.00 + 0.20*0.40 + 0.10*0.70 + 0.05*0.40 + 0.05*0.0 = 0.600 + 0.080 + 0.070 + 0.020 + 0.000 = 0.770 # Pod B (healthy): cache=0.50, load=0.70, queue=0.95, health=1.00 score_B = 0.60*0.50 + 0.20*0.70 + 0.10*0.95 + 0.05*1.00 + 0.05*0.0 = 0.300 + 0.140 + 0.095 + 0.050 + 0.000 = 0.585 # Pod C (healthy, cold): cache=0.00, load=0.80, queue=1.00, health=1.00 score_C = 0.60*0.00 + 0.20*0.80 + 0.10*1.00 + 0.05*1.00 + 0.05*0.0 = 0.000 + 0.160 + 0.100 + 0.050 + 0.000 = 0.310
Winner: Pod A (0.770) -- even at healthScore=0.4, the 100% cache match is so valuable that the degraded pod still wins. The health penalty is only 0.05 * (1.0 - 0.4) = 0.030 points. However, if Pod A's health dropped below 0.3, it would be excluded from routing entirely (composite score zeroed), and Pod B would win at 0.585. This illustrates the design: degraded pods still receive traffic proportional to their value, but pods below the 0.3 exclusion threshold are removed completely.

Example 4: Multi-Turn Conversation Routing

A chatbot conversation over 5 turns. System prompt: 8 blocks (128 tokens). Each user message: 4 blocks (64 tokens). Each assistant response: 6 blocks (96 tokens). Three pods available, all healthy (healthScore=1.0), no adapter. We track which pod wins each turn and how caches evolve.

Turn 1: Input = 8 blocks (system prompt) + 4 blocks (user msg 1) = 12 blocks total. No pod has any cache. Routing falls back to load + queue.

# Turn 1: All pods have 0% cache match. Decision based on load/queue. # Pod A: load=30%, queue=1 | Pod B: load=25%, queue=0 | Pod C: load=35%, queue=2 score_A = 0.60*0.00 + 0.20*0.70 + 0.10*0.95 + 0.05*1.0 + 0.05*0.0 = 0.335 score_B = 0.60*0.00 + 0.20*0.75 + 0.10*1.00 + 0.05*1.0 + 0.05*0.0 = 0.350 # WINNER score_C = 0.60*0.00 + 0.20*0.65 + 0.10*0.90 + 0.05*1.0 + 0.05*0.0 = 0.270 # -> Pod B wins. Full prefill: 12 blocks. Pod B now caches blocks 0-11.

Turn 2: Input = 8 (sys) + 4 (user1) + 6 (asst1) + 4 (user2) = 22 blocks. Pod B has blocks 0-17 cached (sys + user1 + asst1 = 18 blocks cached from Turn 1 processing). Match: 18/22 = 0.818.

# Turn 2: Pod B has 18/22 blocks cached (81.8% match). # Pod A and Pod C: only system prompt (8/22 = 36.4%) if shared prefix. # Assuming Pods A,C have the common system prompt cached from other users: score_A = 0.60*0.364 + 0.20*0.68 + 0.10*0.90 + 0.05*1.0 + 0.05*0.0 = 0.218 + 0.136 + 0.090 + 0.050 + 0.000 = 0.494 score_B = 0.60*0.818 + 0.20*0.72 + 0.10*0.85 + 0.05*1.0 + 0.05*0.0 = 0.491 + 0.144 + 0.085 + 0.050 + 0.000 = 0.770 # WINNER score_C = 0.60*0.364 + 0.20*0.60 + 0.10*0.85 + 0.05*1.0 + 0.05*0.0 = 0.218 + 0.120 + 0.085 + 0.050 + 0.000 = 0.473 # -> Pod B wins again. Only 4 new blocks to prefill (user msg 2).

Turns 3-5: The pattern continues. Pod B's cache advantage grows with each turn.

# Cache match progression on Pod B across 5 turns: # (Other pods only have the 8-block system prompt cached) Turn 1: input=12 blocks, Pod B cache=0/12 (0.0%) -> full prefill Turn 2: input=22 blocks, Pod B cache=18/22 (81.8%) -> 4 blocks prefill Turn 3: input=32 blocks, Pod B cache=28/32 (87.5%) -> 4 blocks prefill Turn 4: input=42 blocks, Pod B cache=38/42 (90.5%) -> 4 blocks prefill Turn 5: input=52 blocks, Pod B cache=48/52 (92.3%) -> 4 blocks prefill # Pod B's composite score vs best alternative (Pod A, system prompt only): Turn 1: Pod B = 0.350 vs Pod A = 0.335 (delta: +0.015) # tie-like Turn 2: Pod B = 0.770 vs Pod A = 0.494 (delta: +0.276) # clear win Turn 3: Pod B = 0.803 vs Pod A = 0.467 (delta: +0.336) # dominant Turn 4: Pod B = 0.821 vs Pod A = 0.452 (delta: +0.369) # dominant Turn 5: Pod B = 0.832 vs Pod A = 0.441 (delta: +0.391) # dominant
Pod B serves all 5 turns. After Turn 1, its cache advantage is self-reinforcing: each turn adds context that only Pod B has cached, making it increasingly dominant. By Turn 5, Pod B's score (0.832) is nearly double the next-best alternative (0.441). Session affinity emerges naturally from cache awareness -- no sticky session configuration needed. The conversation only migrates if Pod B's queue grows deep enough to offset a 0.39+ point cache advantage, which requires queue depth of 20 (full saturation) and would still only close the gap by 0.10 points.

Example 5: Thundering Herd -- 50 Simultaneous Requests

A batch job submits 50 requests simultaneously, all with the same 80-block system prompt (1280 tokens). Pod A has the system prompt fully cached. Pods B, C, D have cold caches. All pods: healthy (1.0), 20% load initially, 0 queued, no adapter. maxQueue=20.

Initial state (requests 1-3): Pod A has 100% cache match, everyone else has 0%.

# Request 1: Pod A dominates on cache. score_A = 0.60*1.00 + 0.20*0.80 + 0.10*1.00 + 0.05*1.0 + 0.05*0.0 = 0.910 score_B = 0.60*0.00 + 0.20*0.80 + 0.10*1.00 + 0.05*1.0 + 0.05*0.0 = 0.310 # Pod A wins. Queue: A=1, B=0, C=0, D=0 # Request 2: Pod A's queue score drops slightly. score_A = 0.60*1.00 + 0.20*0.80 + 0.10*0.95 + 0.05*1.0 + 0.05*0.0 = 0.905 # Pod A still wins. Queue: A=2 # Request 3: Queue continues building. score_A = 0.60*1.00 + 0.20*0.80 + 0.10*0.90 + 0.05*1.0 + 0.05*0.0 = 0.900 # Pod A still wins. Queue: A=3

Crossover point: When does Pod A's queue penalty make cold pods competitive?

# Pod A score as queue grows (load increases to ~40% as requests process): Queue= 0: score_A = 0.60*1.0 + 0.20*0.80 + 0.10*1.00 + 0.05*1.0 = 0.910 Queue= 5: score_A = 0.60*1.0 + 0.20*0.70 + 0.10*0.75 + 0.05*1.0 = 0.865 Queue=10: score_A = 0.60*1.0 + 0.20*0.60 + 0.10*0.50 + 0.05*1.0 = 0.820 Queue=15: score_A = 0.60*1.0 + 0.20*0.50 + 0.10*0.25 + 0.05*1.0 = 0.775 Queue=20: score_A = 0.60*1.0 + 0.20*0.40 + 0.10*0.00 + 0.05*1.0 = 0.730 # Cold pods (B, C, D) remain at ~0.310 if idle. # Pod A NEVER drops below the cold pods on score alone! # Even at queue=20, Pod A (0.730) >> Pod B (0.310). # The 0.60 cache advantage is too large for queue alone to overcome. # BUT: at queue=20, Pod A is fully saturated. # The EPP can apply a hard cutoff: pods at maxQueue are ineligible. # Requests 21+ spill to Pods B, C, D via load-based distribution.

Post-spillover: cache warming across the cluster.

# After requests spill, Pods B/C/D prefill and cache the system prompt. # By the time the burst completes: Pod A: 80-block system prompt cached, queue draining Pod B: 80-block system prompt cached (from spillover requests) Pod C: 80-block system prompt cached (from spillover requests) Pod D: 80-block system prompt cached (from spillover requests) # Next batch of requests with the same system prompt: # All 4 pods now have 100% cache match for the shared prefix. # Routing falls back to load/queue balancing among equally-cached pods. # The thundering herd has warmed the entire cluster's cache.
The first ~20 requests route to Pod A (the cache-hot pod), filling its queue. When Pod A reaches maxQueue, requests spill to Pods B, C, D, which perform full prefill. This costs ~8.2s of prefill per cold pod (for 70B), but has a critical side effect: those pods now have the system prompt cached. Future requests for the same prefix can distribute evenly across all 4 pods. The thundering herd is the cluster's cache warming mechanism -- backpressure via queue depth prevents any single pod from drowning while ensuring the cache propagates across the fleet.
Key Takeaway

Same story in every example: cache match dominates. A 100% match beats idle cold pods, a degraded pod with good cache still beats healthy cold ones, and a thundering herd ends up warming the whole cluster's cache through queue-based spillover.

10. Comparison with Other Routing Approaches #

A fair question at this point: couldn't you do this with the proxies everyone already runs? I went down that road, so here's the comparison.

Router Cache Awareness Queue Depth Health Granularity Adapter Affinity LLM-Ready
Envoy Proxy None Circuit breaking Binary None No
Istio None Via Envoy Binary None No
NGINX Hash-based* None Binary None No
HAProxy None Queue depth Weighted None No
llm-d EPP Prefix tree Scored Continuous Scored Yes

Envoy Proxy #

Envoy is a very capable L7 proxy -- header-based routing, weighted clusters, circuit breaking, retries. But it lives entirely at the HTTP level. It has no idea what a KV cache is, no way to query vLLM's internal state, no prefix matching. You can set up consistent hashing on a header or URL, but the hash key is HTTP metadata, not token content. Two requests with identical prompts but different headers hash differently; two requests with different prompts but the same URL hash the same. The hash key is just wrong for LLM routing.

Istio #

Istio puts a control plane on top of Envoy -- VirtualService, DestinationRule, weight-based splitting, header routing, fault injection. But its routing rules are static or weight-based: they describe how traffic should be distributed, not where each individual request should go. There's no way for Istio to check each pod's cache state at request time and route on it. Istio manages traffic policy; the EPP solves a lower-level, per-request placement problem. Different jobs.

NGINX #

NGINX's hash directive is the closest thing to cache-aware routing among the traditional options. Hash on the request URI or a custom header, get consistent pod assignment for identical keys, and you do get a form of cache locality -- all requests for the same system prompt land on the same pod, which builds a warm cache. The problem, again, is the hash key. NGINX hashes the URL or a header, not the tokenized content. Tiny variations -- formatting, extra whitespace, an added parameter -- produce different hashes and different pods even when the token prefix is identical. And consistent hashing has no notion of partial matches: a request either hashes to the "right" pod or it doesn't. There's no "this pod has 60% of the prefix cached."

HAProxy #

HAProxy is genuinely good at health checking and queue management -- per-server connection limits, queuing of excess requests, active probes, weighted health. Of all the traditional load balancers, its health checking is the closest to the EPP's. But it has no inference-specific metrics. It can't query a pod's KV cache, doesn't understand token prefixes, can't factor in adapter loading. Great general-purpose load balancer; blind to the things that matter most here.

Why none of these work: every traditional proxy routes on HTTP-level metadata -- headers, URLs, cookies, source IPs. The EPP routes on model-level state -- cached token prefixes, GPU utilization, inference queue depth, adapter inventory. Those are fundamentally different information sources. The EPP can make good decisions because it speaks the inference protocol and understands vLLM's cache internals. A traditional proxy, however capable, is deciding with the wrong inputs.

Key Takeaway

Envoy, Istio, NGINX, and HAProxy all route on HTTP-level metadata. None of them can see a pod's KV cache or understand token prefixes. The EPP routes on model-level state, which is what per-request placement for LLM inference actually requires.

11. Gateway API Integration #

The EPP doesn't run in a vacuum -- in a real deployment it plugs into the Kubernetes Gateway API, the standard that's replacing the old Ingress resource. If you want to deploy llm-d properly you need to understand how the EPP fits into that model, so here's the architecture, the custom resources, and the full request flow from client to pod.

The InferencePool and InferenceModel CRDs #

llm-d extends the Gateway API with two CRDs that describe the inference topology:

InferencePool is a set of pods running the same base model behind a shared EPP -- the basic unit of capacity in llm-d. It specifies which model is loaded, the resource requirements (GPU type, memory), and the EPP config (scoring weights, maxQueue, reporting intervals). Basically a deployment group that also carries inference-specific metadata.

# InferencePool: defines a pool of model-serving pods with EPP routing apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: name: llama-70b-pool namespace: llm-serving spec: targetPortNumber: 8000 selector: app: vllm-llama-70b endpointPickerConfig: scoringProfile: cacheWeight: 0.60 loadWeight: 0.20 queueWeight: 0.10 healthWeight: 0.05 adapterWeight: 0.05 maxQueueDepth: 20 podReportIntervalMs: 2000

InferenceModel is the logical model name clients put in their API requests. It maps client-facing model identifiers to the InferencePool(s) that serve them, and declares which LoRA adapters are available. Multiple InferenceModels can point at the same pool -- that's how you serve several fine-tuned variants off one set of pods.

# InferenceModel: maps a client-facing model name to a pool apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: customer-support-model namespace: llm-serving spec: modelName: customer-support-v3 criticality: Critical poolRef: name: llama-70b-pool targetModels: - name: customer-support-lora-v3 weight: 100

How HTTPRoute Connects to the EPP #

The standard HTTPRoute resource says which requests go to which backend. In llm-d, the HTTPRoute points at an InferencePool instead of a Kubernetes Service. The Gateway controller takes the request, matches it against the HTTPRoute rules, and hands it to the EPP for that pool -- which then makes the pod-level call using the scoring algorithm from this post.

# HTTPRoute: connects the Gateway to the InferencePool apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: llm-inference-route namespace: llm-serving spec: parentRefs: - name: inference-gateway rules: - matches: - path: type: PathPrefix value: /v1/completions backendRefs: - group: inference.networking.x-k8s.io kind: InferencePool name: llama-70b-pool port: 8000

Full Request Flow #

The full path of a request through the llm-d stack touches five components. Here's the flow, annotated:

Request Flow: Client to Pod

1. Client sends POST /v1/completions with model="customer-support-v3" -> Request arrives at the Gateway (e.g., Envoy-based Gateway controller)
2. Gateway matches HTTPRoute rules (path=/v1/completions) -> Identifies backendRef as InferencePool "llama-70b-pool"
3. Gateway invokes the EPP as an ext_proc filter (gRPC sidecar) -> EPP receives the request body, tokenizes input, looks up InferenceModel
4. EPP walks prefix tree, computes scores for all pods in the pool -> Selects highest-scoring pod (e.g., pod-llama-70b-7xk9d, score=0.82)
5. EPP returns selected pod address to Gateway via ext_proc response -> Gateway forwards the request directly to the selected pod's IP:port

The EPP runs as an external processing filter (ext_proc) -- a gRPC service the Gateway calls mid-request to make the routing decision. That's a standard extension mechanism in Envoy-based gateways. Importantly, the EPP is not in the data path for the response: after the routing decision, the response streams straight from the pod back through the Gateway to the client. So the EPP adds sub-millisecond latency for the scoring computation and never becomes a throughput bottleneck.

Comparison with Traditional Ingress-Based Routing #

Why not plain old Ingress? It does basic HTTP routing -- host and path matching, TLS termination, backend references to Services -- but for LLM inference it has some hard limits:

  • No extension mechanism: Ingress has no concept of ext_proc or external routing filters. You cannot plug the EPP into an Ingress controller. The routing decision is hardcoded to the Ingress controller's built-in algorithms (typically round robin or IP hash).
  • Service-level granularity: Ingress routes to a Kubernetes Service, which then uses kube-proxy (iptables or IPVS) to select a pod. There is no way to influence the pod selection from outside. The EPP needs to select specific pods, which requires the Gateway API's backend-level routing.
  • No typed backend references: Ingress can only reference Services. Gateway API can reference arbitrary backends (including InferencePool), enabling the EPP integration pattern.
  • Limited extensibility: Ingress annotations are implementation-specific and fragile. Gateway API provides a standardized extension model through policy attachment and custom backend types.

Multi-Model Routing via InferenceModel Selectors #

A really common pattern: serving multiple fine-tuned models from one InferencePool. Say a customer support platform offers "customer-support-v3" (general), "customer-support-v3-enterprise" (tuned for enterprise clients), and "customer-support-v3-billing" (tuned for billing questions). All three share the same base model, so they can all run on the same pods with different LoRA adapters.

Each variant gets its own InferenceModel, all pointing at the same pool with different targetModels. A request for "customer-support-v3-billing" comes in, the EPP resolves the InferenceModel, sees it needs the "billing-lora-v3" adapter, and folds the adapter match into the score. Pods that already have the billing adapter loaded get the 0.05-point bonus (default weights), which breaks ties when cache and load are close.

A note on maturity: InferencePool and InferenceModel come from the Gateway API inference extension, which is still v1alpha2. The core Gateway API (Gateway, HTTPRoute, GatewayClass) hit GA (v1.0) and works with every major Kubernetes gateway implementation. The inference extensions sit on top of that stable base, adding the LLM-specific semantics. As they move toward beta and GA, expect more gateway controllers to support them.

Key Takeaway

The EPP hooks into the Kubernetes Gateway API as an ext_proc filter. InferencePool defines the pod group and scoring weights, InferenceModel maps client-facing model names to pools and adapters, HTTPRoute wires the Gateway to the pool. Routing decisions cost sub-millisecond and the EPP stays out of the response path.

12. Custom Scoring Plugins #

The default five-factor formula covers most routing needs, but sometimes you need scoring logic it just can't express. Maybe you want to factor in per-pod cost and route to cheaper spot instances when latency allows. Maybe your research cluster should prefer certain GPU architectures for certain model configs. Maybe you have data residency rules and need to penalize pods in the wrong availability zone.

Good news: the scoring pipeline is pluggable. You write a custom scorer in Go, register it with the EPP, and it participates in the calculation right alongside the built-in factors. Here's the architecture, and then we'll build one.

Architecture of the Scorer Interface #

Every scoring factor implements the same Scorer interface. A scorer gets the request context and a candidate pod, and returns a normalized score in [0, 1] plus metadata for logging and debugging.

// Scorer is the interface that all scoring plugins must implement. // Each scorer evaluates a single factor for a candidate pod. type Scorer interface { // Name returns a unique identifier for this scorer (e.g., "cache", "load"). Name() string // Score evaluates a candidate pod for the given request context. // Returns a value in [0.0, 1.0] where 1.0 is optimal. Score(ctx context.Context, req *InferenceRequest, pod *PodState) (float64, error) // Weight returns this scorer's contribution weight. // All weights across all registered scorers must sum to 1.0. Weight() float64 }

InferenceRequest carries the tokenized input, the requested model name, adapter requirements, and request metadata (headers, timestamps, client identity). PodState carries the pod's current metrics: cache contents, GPU utilization, queue depth, health score, loaded adapters, and any custom annotations the pod reports.

The Scoring Pipeline #

On each request, the EPP runs all registered scorers in parallel for every candidate pod, then collects, weights, and sums the results into the composite score. The pipeline looks like this:

// scorePod computes the composite score for a single candidate pod. func (e *EPP) scorePod( ctx context.Context, req *InferenceRequest, pod *PodState, ) (float64, map[string]float64, error) { scores := make(map[string]float64) composite := 0.0 for _, scorer := range e.scorers { score, err := scorer.Score(ctx, req, pod) if err != nil { return 0, nil, fmt.Errorf("scorer %s failed: %w", scorer.Name(), err) } // Clamp to [0, 1] for safety if score < 0 { score = 0 } if score > 1 { score = 1 } weighted := scorer.Weight() * score scores[scorer.Name()] = score composite += weighted } return composite, scores, nil }

Writing a Custom Scorer: Cost Awareness #

Let's build one: a "cost awareness" scorer. The setup -- you're running a mixed GPU cluster with on-demand A100s and spot-priced A10Gs. On-demand A100s run $3.67/hour, spot A10Gs run $0.38/hour. You want the EPP to lean toward the cheap instances when the latency hit is acceptable -- specifically, when the cache match gap between the expensive pod and the cheap pod is under 20 percentage points.

// CostScorer scores pods based on GPU instance cost. // Pods running on cheaper instances score higher. type CostScorer struct { weight float64 maxCost float64 // the most expensive instance type ($/hr) } func NewCostScorer(weight float64) *CostScorer { return &CostScorer{ weight: weight, maxCost: 3.67, // A100 on-demand $/hr } } func (s *CostScorer) Name() string { return "cost" } func (s *CostScorer) Weight() float64 { return s.weight } func (s *CostScorer) Score( ctx context.Context, req *InferenceRequest, pod *PodState, ) (float64, error) { // Read the pod's hourly cost from its annotations. // Pods must report this via a label or annotation: // metadata.annotations["llm-d.ai/gpu-cost-per-hour"] costStr, ok := pod.Annotations["llm-d.ai/gpu-cost-per-hour"] if !ok { // No cost annotation; assume maximum cost (neutral score) return 0.5, nil } cost, err := strconv.ParseFloat(costStr, 64) if err != nil { return 0.5, nil // parse error; default to neutral } // Normalize: cheaper = higher score // Score = 1 - (cost / maxCost), clamped to [0, 1] score := 1.0 - (cost / s.maxCost) if score < 0 { score = 0 } if score > 1 { score = 1 } return score, nil } // Score examples with this scorer: // A100 on-demand ($3.67/hr): score = 1 - (3.67/3.67) = 0.00 // A10G spot ($0.38/hr): score = 1 - (0.38/3.67) = 0.90 // A100 spot ($1.50/hr): score = 1 - (1.50/3.67) = 0.59

Plugin Registration #

Custom scorers register at EPP startup. Registration validates that all weights sum to 1.0 and names are unique -- so when you add one, you have to take weight away from the defaults to keep the sum at 1.0.

// RegisterScorers configures the EPP's scoring pipeline. // Called during EPP initialization. func configureScorers(epp *EPP) { epp.RegisterScorers( // Default scorers with adjusted weights NewCacheScorer(0.50), // reduced from 0.60 to make room NewLoadScorer(0.20), // unchanged NewQueueScorer(0.10), // unchanged NewHealthScorer(0.05), // unchanged NewAdapterScorer(0.05), // unchanged // Custom scorer: cost awareness NewCostScorer(0.10), // new: 10% weight for cost ) // Sum: 0.50 + 0.20 + 0.10 + 0.05 + 0.05 + 0.10 = 1.00 }

When Custom Scorers Make Sense vs. Weight Tuning #

Weight tuning (Section 13) changes the relative importance of the five built-in factors, and it's enough whenever your need boils down to "more cache" or "less queue sensitivity." Custom scorers are for when you need a genuinely new signal -- something the five defaults don't capture at all.

How I'd decide:

  • Use weight tuning when the signal you care about is already a factor (cache, load, queue, health, adapter) but at the wrong priority. Example: "Cache matters less for my batch workload" -- reduce w_cache.
  • Use a custom scorer when the signal is not captured by any existing factor. Examples: cost, data residency, GPU architecture preference, request priority levels, client SLA tiers.
  • Avoid custom scorers for signals that are highly correlated with existing factors. If your "priority" signal is really just "give this request a shorter queue wait," increase w_queue instead. Adding a redundant scorer wastes weight budget.

Testing Strategies for Custom Scorers #

Test custom scorers at three levels:

// 1. Unit test: Verify score calculation for known inputs. func TestCostScorer_Score(t *testing.T) { scorer := NewCostScorer(0.10) tests := []struct { name string annotation string wantScore float64 }{ {"A100 on-demand", "3.67", 0.00}, {"A10G spot", "0.38", 0.8965}, {"missing annotation", "", 0.50}, {"invalid value", "notanumber", 0.50}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pod := &PodState{ Annotations: map[string]string{}, } if tt.annotation != "" { pod.Annotations["llm-d.ai/gpu-cost-per-hour"] = tt.annotation } score, err := scorer.Score(context.Background(), &InferenceRequest{}, pod) require.NoError(t, err) assert.InDelta(t, tt.wantScore, score, 0.01) }) } } // 2. Integration test: Verify scorer interacts correctly with pipeline. // Register the scorer, send a mock request, verify composite score. // 3. Simulation test: Replay production traffic logs through the scoring // pipeline with and without the custom scorer. Compare routing // decisions and simulate latency impact. This catches cases where // the scorer inadvertently overrides cache affinity.

Weight budget discipline: every point you give a custom scorer comes out of the default factors. Giving cost 0.10 means cache drops from 0.60 to 0.50 (or queue from 0.10 to 0.00, etc.). Replay real traffic traces through the pipeline before shipping a custom scorer -- one that looks correct in isolation can still wreck routing quality if its weight steals too much from cache affinity.

Key Takeaway

Custom scorers add brand-new routing signals (cost, data residency, SLA tiers) through a Go interface. Their weight has to come out of the existing factors, so replay production traces first -- don't quietly starve cache affinity.

13. Tuning Guide: When to Adjust Which Weight #

The default weights (0.60 / 0.20 / 0.10 / 0.05 / 0.05) are tuned for general-purpose inference with moderate-to-high prefix reuse. Real workloads vary a lot, and matching the weights to yours can buy you real latency and throughput. Here's what I'd actually do, by workload.

Workload-Specific Weight Recommendations #

Workload Pattern Symptom Adjustment Expected Impact
Chatbot (high prefix reuse) p99 TTFT exceeds 500ms while cache hit rate is above 70%: cache-hot pods are overloaded Increase w_queue from 0.10 to 0.20. Decrease w_cache from 0.60 to 0.50. Spreads traffic across pods earlier, reducing queue wait. p99 TTFT typically drops 30-50%. Cache hit rate drops 5-10% but is offset by lower queue latency.
Batch processing (low prefix reuse) Cache hit rate consistently below 15%. Pods unevenly loaded despite no cache affinity signal. Decrease w_cache from 0.60 to 0.30. Increase w_load from 0.20 to 0.40. Increase w_queue from 0.10 to 0.20. Routing behaves more like a traditional load balancer. GPU utilization variance across pods drops from 40-50% to 10-15%. Total throughput increases 15-25% from better utilization.
Multi-tenant LoRA (10+ adapters) Adapter load times (200-800ms per load) dominate TTFT. Adapters thrashing: loading and unloading repeatedly. Increase w_adapter from 0.05 to 0.20. Decrease w_cache from 0.60 to 0.45. Decrease w_load from 0.20 to 0.15. Adapter loads per minute drop 60-80%. Each pod stabilizes on 2-3 adapters instead of thrashing across 10+. TTFT p99 improves because adapter loading overhead is eliminated for most requests.
Code completion (very long prefixes, 8K-32K tokens) Prefill dominates latency. Cache hits provide extreme speedup (10s+ saved per hit). Cache misses are catastrophic to latency. Keep w_cache at 0.60 or increase to 0.70. Decrease w_load from 0.20 to 0.15. Decrease w_queue from 0.10 to 0.05. Higher cache affinity means code sessions stick to their pod more aggressively. p50 TTFT drops 40-60% due to fewer cache misses. p99 may increase slightly due to occasional queue wait on hot pods.
RAG pipeline (shared document prefixes) Many users query the same document set. System prompt + document chunks are shared across requests but user queries differ. Keep w_cache at 0.60. Increase w_queue from 0.10 to 0.15. Decrease w_load from 0.20 to 0.15. Document prefixes cluster on pods efficiently. Queue-based spillover ensures no single "document pod" is overwhelmed. Effective prefill reduction of 70-85% on cached document chunks.
Low-latency API gateway (strict SLOs, p99 < 200ms) SLO violations caused by queue wait, not prefill. Need predictable latency above all else. Increase w_queue from 0.10 to 0.30. Decrease w_cache from 0.60 to 0.40. Decrease w_load from 0.20 to 0.15. Decrease maxQueue from 20 to 8. Aggressive queue avoidance. Requests spread across more pods, accepting higher cache miss rates for predictable latency. p99 TTFT drops 50-70% at the cost of 20-30% higher total GPU compute.

Decision Flowchart for Weight Tuning #

Or work from symptoms -- this flow tells you which weight to touch:

Weight Tuning Decision Flow

Is your cache hit rate below 30%? -> Reduce w_cache to 0.30-0.40 and increase w_load to 0.30-0.40. Cache is not providing value; optimize for load distribution instead.
Is your p99 latency dominated by queue wait time (queue wait > prefill time)? -> Increase w_queue to 0.20-0.30 and reduce maxQueue from 20 to 8-12. Also consider decreasing w_cache by the same amount you added to w_queue.
Are LoRA adapter load times a significant fraction of TTFT (>20% of p50)? -> Increase w_adapter to 0.15-0.20. Reduce w_cache and w_load proportionally. This is most impactful with 5+ distinct adapters and rank-32 or higher.
Are cache-hot pods consistently at >85% GPU utilization while cold pods sit at <30%? -> Increase w_load from 0.20 to 0.30 and decrease w_cache from 0.60 to 0.50. This narrows the utilization gap between pods.
Are pods frequently failing or being replaced (churn > 5% per hour)? -> Increase w_health from 0.05 to 0.10-0.15 and lower the exclusion threshold. This more aggressively avoids pods that are likely to fail mid-request.
Is everything performing well? -> Keep the defaults. The weights (0.60/0.20/0.10/0.05/0.05) hold up fine for most inference workloads. Don't tune speculatively.

The one rule you can't break: the five weights must always sum to 1.0. Raise one, lower others by the same total. Break the invariant and the score range shifts and things get weird. Always check: w_cache + w_load + w_queue + w_health + w_adapter = 1.00.

Key Takeaway

Tune from observed symptoms, not vibes. High-reuse chatbots may want more queue weight, batch workloads more load weight, multi-tenant LoRA more adapter weight. And the weights always have to sum to 1.0.

14. Debugging Routing Decisions #

When requests aren't landing where you expect, you need to see exactly what the EPP decided and why. Here's the tooling for that -- per-request logs up through cluster-wide Prometheus metrics.

Tracing a Single Request Through the EPP #

Every request the EPP touches gets a unique requestID (UUID v4) that shows up on every log line for that request, so tracing one is just filtering the logs by ID. The EPP logs at three points:

  1. Request received: Logged when the ext_proc filter receives the request. Includes the model name, input token count, and any adapter requirements.
  2. Scoring complete: Logged after all pods have been scored. Includes the composite score and per-factor breakdown for the top 3 pods (and the selected pod if it is not in the top 3).
  3. Pod selected: Logged when the routing decision is finalized. Includes the selected pod name, its IP address, and the reason for selection.

EPP Logging Configuration #

Logging is structured JSON with configurable levels. For routing debugging you care about:

  • INFO (default): Logs the final routing decision for each request -- the selected pod, composite score, and cache match ratio. This is sufficient for most operational monitoring.
  • DEBUG: Adds per-factor score breakdowns for all candidate pods, prefix tree lookup results, and timing information. Use this when investigating specific routing anomalies.
  • TRACE: Adds block-level prefix tree walk details, individual pod status report processing, and weight calculation intermediates. Use sparingly -- this generates 10-50x the log volume of INFO.

Set the log level via the EPP_LOG_LEVEL environment variable or the --log-level command-line flag on the EPP deployment.

Key Log Fields #

These are the log fields I actually look at when debugging routing:

2025-06-15T14:23:07.442Z INFO routing/scorer.go:187 request scored requestID="f47ac10b-58cc-4372-a567-0e02b2c3d479" -- unique request identifier model="llama-3.1-70b" -- requested model name inputTokens=2048 -- total input tokens inputBlocks=128 -- token blocks (tokens / block_size) selectedPod="vllm-llama-70b-7xk9d" -- winner pod name selectedPodIP="10.244.3.17" -- winner pod IP compositeScore=0.823 -- final composite score cacheMatchRatio=0.953 -- 95.3% of blocks cached cacheMatchBlocks=122 -- 122 of 128 blocks matched loadScore=0.45 -- 1 - loadRatio queueScore=0.85 -- 1 - (queueDepth/maxQueue) queueDepth=3 -- actual queue depth healthScore=1.0 -- pod health adapterMatch=0 -- no adapter requested candidatePods=8 -- total pods evaluated scoringLatencyUs=412 -- scoring took 412 microseconds

At DEBUG you also get the breakdown for runner-up pods, which is what you need for understanding close calls:

2025-06-15T14:23:07.442Z DEBUG routing/scorer.go:201 candidate scores requestID="f47ac10b-58cc-4372-a567-0e02b2c3d479" candidates=[ {pod: "vllm-llama-70b-7xk9d", score: 0.823, cache: 0.953, load: 0.45, queue: 0.85, health: 1.0} -- SELECTED {pod: "vllm-llama-70b-2mf4p", score: 0.687, cache: 0.781, load: 0.62, queue: 0.90, health: 1.0} -- runner-up {pod: "vllm-llama-70b-9qw1r", score: 0.412, cache: 0.312, load: 0.78, queue: 0.95, health: 1.0} {pod: "vllm-llama-70b-5ht8n", score: 0.385, cache: 0.250, load: 0.82, queue: 1.00, health: 1.0} ... (4 more) ]

Prometheus Metrics #

The EPP exposes Prometheus metrics on its /metrics endpoint (default port 9090). This is your cluster-wide view for routing visibility and alerting.

Metric Name Type Labels Description
epp_routing_decisions_total Counter model, pool Total number of routing decisions made
epp_cache_match_ratio Histogram model, pool Distribution of cache match ratios for selected pods (buckets: 0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 1.0)
epp_composite_score Histogram model, pool Distribution of composite scores for selected pods
epp_scoring_latency_us Histogram pool Time to compute scores for all candidate pods (microseconds)
epp_candidate_pods Gauge pool Number of eligible candidate pods in the pool
epp_queue_depth Gauge pod, pool Current queue depth per pod as reported to the EPP
epp_pod_health_score Gauge pod, pool Current health score per pod
epp_prefix_tree_nodes Gauge pool Total number of nodes in the prefix tree
epp_prefix_tree_lookups_total Counter pool Total prefix tree lookups performed
epp_stale_cache_routing_total Counter pool Requests routed based on stale cache data (pod reported cache miss after EPP expected hit)
epp_adapter_loads_triggered_total Counter adapter, pool Adapter load events triggered by routing decisions (adapter not already on selected pod)

Common Debugging Scenarios #

Scenario A: "Why is my request going to the wrong pod?"

Symptom: a multi-turn conversation that's been happily routing to Pod A suddenly sends a turn to Pod C, which has a cold cache for this conversation.

How I'd chase it down:

  1. Find the request's requestID from the response headers or client logs.
  2. Grep the EPP logs for that requestID at DEBUG level.
  3. Check the candidate scores: was Pod A scored lower than Pod C?
  4. If Pod A has a lower score, check its individual factors. Common causes:
    • Cache staleness: Pod A evicted the conversation's cache due to memory pressure, but the EPP has not yet received the updated cache report. Check epp_stale_cache_routing_total for recent increments.
    • Queue buildup: Pod A's queue hit maxQueue, making it ineligible. Check epp_queue_depth{pod="pod-a"}.
    • Health degradation: Pod A's health score dropped, reducing its composite score. Check epp_pod_health_score{pod="pod-a"}.
  5. If Pod A was not in the candidate list at all, it may have been excluded (health below 0.3) or removed from the InferencePool.

Scenario B: "Why is latency spiking?"

Symptom: p99 TTFT went from 200ms to 2000ms over the last hour.

Checks, in order:

  1. Check epp_cache_match_ratio histogram: has the distribution shifted toward lower values? A drop in cache hit rates means more prefill, which means higher TTFT.
  2. Check epp_queue_depth across all pods: are queues building up? Rising queue depths indicate the cluster is under-provisioned for the current request rate.
  3. Check epp_candidate_pods: has the pool shrunk? Pod failures or scale-down events reduce capacity and increase per-pod load.
  4. Check epp_scoring_latency_us: is the EPP itself slowing down? This can happen if the prefix tree has grown very large (check epp_prefix_tree_nodes). Scoring latency above 5ms is a red flag.
  5. Cross-reference with epp_stale_cache_routing_total: a spike in stale routing indicates the prefix tree is out of sync with actual pod state, causing expected cache hits to become misses.

Scenario C: "Why are adapters thrashing?"

Symptom: epp_adapter_loads_triggered_total is climbing fast. Pods are loading and unloading LoRA adapters multiple times a minute.

Steps:

  1. Check the adapter label on epp_adapter_loads_triggered_total: is thrashing concentrated on one adapter or spread across many?
  2. If concentrated: one adapter's requests are being distributed across too many pods. Increase w_adapter to consolidate requests on fewer pods.
  3. If spread: too many adapters for the pool size. Each pod can realistically serve 3-5 LoRA adapters without thrashing (depending on adapter rank and GPU memory). If you have 20 adapters on 4 pods, thrashing is inevitable.
  4. Check whether cache affinity is fighting adapter affinity: a pod with good cache but the wrong adapter will be selected, triggering an adapter load. If this is frequent, increase w_adapter from 0.05 to 0.15-0.20 (and decrease w_cache by the same amount).

Grafana Dashboard Recommendations #

For ongoing monitoring, these are the Grafana panels I'd build:

  • Panel 1: Cache Hit Rate Distribution -- Heatmap of epp_cache_match_ratio over time. Shows whether the cluster's cache effectiveness is improving or degrading. Alert if p50 cache match drops below 0.40 for more than 10 minutes.
  • Panel 2: Queue Depth per Pod -- Time series of epp_queue_depth with one line per pod. Shows load distribution. Alert if any pod stays above maxQueue * 0.8 for more than 5 minutes.
  • Panel 3: Routing Decision Rate -- Counter rate of epp_routing_decisions_total, broken down by model. Shows request throughput and model popularity.
  • Panel 4: Composite Score Distribution -- Histogram of epp_composite_score over time. A bimodal distribution (cluster of scores near 0.8 and another near 0.3) indicates a split between cache-hot and cache-cold routing -- which is normal. A uniform distribution near 0.3 indicates the cache is not providing value.
  • Panel 5: Stale Cache Rate -- Counter rate of epp_stale_cache_routing_total divided by epp_routing_decisions_total. This is the percentage of routing decisions that were based on stale cache data. Alert if above 5%.
  • Panel 6: Scoring Latency -- Histogram quantiles (p50, p99) of epp_scoring_latency_us. Should stay below 1000us (1ms) at p99. Alert if p99 exceeds 5000us.
  • Panel 7: Pod Health Overview -- Table or heatmap of epp_pod_health_score for all pods. Color-code: green (1.0), yellow (0.5-0.9), red (below 0.3). Provides at-a-glance fleet health.
  • Panel 8: Adapter Load Events -- Counter rate of epp_adapter_loads_triggered_total broken down by adapter name. Spikes indicate adapter thrashing.

Log aggregation tip: ship the EPP's JSON logs to whatever you use (Loki, Elasticsearch, CloudWatch Logs) and save a query for level="DEBUG" AND msg="candidate scores" so scoring breakdowns are one click away. Also inject the requestID into your API response headers via the Gateway -- then client-side debugging can correlate straight to server-side routing logs. Saved me a bunch of time.

Key Takeaway

Debug with JSON logs filtered by requestID, Prometheus metrics for the cluster view, and a few purpose-built Grafana panels. The metrics that matter most: cache match ratio distribution, queue depth per pod, stale cache rate, and scoring latency.

15. Edge Cases and Failure Modes #

No routing algorithm gets to live in a clean room. Real systems hit failures, race conditions, and hostile workload patterns. Here's how the EPP behaves when things get ugly.

Best Pod is Overloaded (Queue Full) #

When the best-cache pod's queue is full (queueDepth >= maxQueue), its queue score drops to 0.0 -- which with default weights only costs 0.10 points. For a cold pod to beat it on load + queue alone, it would need to overcome the 0.60 cache advantage, which is mathematically impossible at cacheMatchRatio = 0.0. So scoring alone can't save you here, and the EPP applies a hard eligibility cutoff instead: pods at or above maxQueue get pulled from the candidate pool outright. Traffic spills to the next-best pod, often a partial match, which pays a proportional prefill cost but dodges the queue wait.

Tie-Breaking: Equal Cache Matches #

When several pods have the same cache match (common with shared system prompts), the other factors break the tie -- load first (0.20), then queue depth (0.10). So among equally-cached pods the EPP just picks the least loaded one. When cache can't differentiate, it quietly turns back into a load balancer.

Pod Crash Mid-Request #

If a pod crashes mid-request, the request fails. The EPP does not auto-retry, because streaming inference isn't idempotent -- you can't pick up a partially streamed response on a different pod. The client has to retry, and the retry gets routed to the pod with the next-best cache. If the crashed pod held unique cached state, that state is gone, and requests that would've used it pay full prefill until the cache gets rebuilt somewhere else.

Cold Start #

The first request for a brand-new prefix always pays full prefill -- there's no cache anywhere in the cluster to match. The EPP routes it on load and queue (all cache scores are 0.0, so the cache term adds nothing for anyone and the remaining weights -- load 0.20, queue 0.10, health 0.05, adapter 0.05 -- effectively renormalize among themselves). Once it completes, the serving pod's cache is populated and future requests for that prefix route there and get the hit. Cold start is unavoidable; what the EPP can do is send the cold request to the least-loaded pod and make sure the cache it creates gets reused.

Cache Eviction Race #

Remember, the prefix tree is only eventually consistent with what's actually on the pods. There's a 1-5 second window (set by the reporting interval) between a pod evicting a prefix (memory pressure, LRU) and the EPP removing it from the tree. In that window the EPP can route a request based on a cache match that no longer exists. The pod discovers the miss and does a full prefill. Annoying, not catastrophic -- the request completes, just slower than expected, and the next status update fixes the tree.

Thundering Herd #

When a pile of requests for the same prefix lands at once (Example 5 walks through this), the EPP has to spread them out instead of dumping everything on the one pod with the best cache. The queue factor and the maxQueue hard cutoff do exactly that: the first requests fill the cache-hot pod's queue, the rest spill elsewhere. Those pods do the prefill, build their own caches, and are ready for the next wave with the same prefix. The herd actually speeds up cache warming across the cluster. In a 4-pod cluster with maxQueue=20, the first 20 requests hit the cache-hot pod and requests 21-50 spread across the other 3, warming their caches in parallel.

Memory Pressure and Cache Eviction #

GPU memory is finite. An A100 80GB SXM has roughly 72GB left for KV cache once model weights are loaded (a 70B model at BF16 needs about 140GB of weights spread across tensor-parallel GPUs; per-GPU KV cache allocation depends on the parallelism setup). When a pod's KV cache fills up, vLLM evicts entries LRU-style -- oldest, least-accessed prefixes go first. The pod reports the eviction on its next status update and the EPP prunes the tree. That's just steady state in a busy cluster: popular prefixes stay cached, unpopular ones fall out, and the prefix tree ends up mirroring the cluster's aggregate working set. The EPP never manages eviction itself -- it watches and adapts.

Operational note: the eviction race window is typically 1-5 seconds, set by podReportIntervalMs (default: 2000ms). For most workloads stale cache routing stays under 1% of decisions. If you've got heavy cache churn (aggressive eviction under memory pressure), drop the interval to 500-1000ms or add GPU memory to slow the evictions. Each reporting cycle is only about 2-8KB of block hash data per pod, so cranking up the frequency costs almost nothing on the network.

Key Takeaway

Edge case handling in one breath: full queues trigger hard cutoffs and spillover, equal caches fall back to load-based tiebreaking, pod crashes don't auto-retry (streaming inference isn't idempotent), and eviction races self-correct on the next reporting interval.

Common Pitfalls to Avoid #

Deploying the EPP is easy; tuning it has sharp edges. These are the mistakes I see (and have made) most often with llm-d's routing layer.

  • Pitfall 1: Setting weights that do not sum to 1.0

    If your five weights (cache, load, queue, health, adapter) don't sum to exactly 1.0, the score range shifts in ways you won't enjoy. Weights summing to 1.2 produce scores up to 1.2, breaking anything that assumes [0, 1]. Weights summing to 0.8 throw away 20% of the dynamic range and make it harder to tell pods apart. Check the sum after every weight change, especially when adding custom scorers.

  • Pitfall 2: Setting maxQueue too low or too high

    maxQueue=5 spills too early -- requests bounce to cold pods before the cache-hot pod is anywhere near saturated, wasting GPU time on prefill that didn't need to happen. maxQueue=100 lets queues grow unchecked, so requests wait behind dozens of others and latency spikes. The default of 20 is fine for most things. Go 8-12 for latency-sensitive APIs, 30-40 only for batch workloads where throughput beats latency.

  • Pitfall 3: Ignoring the pod reporting interval (stale cache data)

    The prefix tree is only as fresh as each pod's last status update. With the default 2-second interval there's a window where the EPP thinks a pod has data it already evicted. High cache churn makes this worse. Watch epp_stale_cache_routing_total, and if stale routing goes past 3-5% of decisions, drop podReportIntervalMs to 500-1000ms.

  • Pitfall 4: Over-tuning weights without production traffic data

    Tuning weights off synthetic benchmarks or gut feel usually makes things worse. Synthetic traffic never looks like the real thing -- real traffic is bursty, prefix reuse is uneven, adapter popularity shifts around. Start with defaults, measure at least 24 hours under real traffic, then move one weight at a time by no more than 0.10 while watching cache hit rate, p99 TTFT, and queue depth distribution.

  • Pitfall 5: Expecting the EPP to retry failed requests

    The EPP won't retry when a pod crashes or errors out. Streaming inference isn't idempotent -- you can't resume a half-streamed response on another pod. Retries belong at the client or gateway level. If you need request-level fault tolerance, have your Gateway retry on connection errors, just know the retry will probably land on a different pod with a cold cache.

  • Pitfall 6: Confusing GPU utilization with queue depth

    One more time: GPU utilization is lagging, queue depth is leading. A pod at 80% utilization with an empty queue beats a pod at 40% with 10 queued requests. Don't crank w_load to "fix" routing when the real problem is queue buildup -- raise w_queue or lower maxQueue instead.

Key Takeaway

The common mistakes split into config errors (weights not summing to 1.0, bad maxQueue) and wrong expectations (assuming retries exist, mixing up GPU utilization and queue depth). Start with defaults, measure under real traffic, tune in small steps.

16. Conclusion and What is Next #

If there's one framing to walk away with: the EPP isn't a load balancer, it's an inference placement optimizer. It understands the economics of LLM computation and puts each request on the pod that can run it with the least compute. The five-factor formula encodes those priorities: cache affinity at 0.60 because a cache hit on a 70B model saves 8.2 seconds of prefill; load at 0.20 to keep hot pods from saturating; queue depth at 0.10 for backpressure and latency predictability; health at 0.05 for graceful degradation; adapter affinity at 0.05 to dodge the 200-800ms cost of unnecessary LoRA loads.

The architectural decisions that make it all work:

  1. A global prefix tree of xxHash64 block fingerprints that gives the EPP visibility into every pod's cache state -- the content-aware routing no HTTP-level proxy can do. Lookups finish in under 50 microseconds for typical inputs.
  2. Continuous scoring instead of binary thresholds, so degradation is smooth and tradeoffs are nuanced. The composite score always lands in [0, 1] and behaves predictably.
  3. Configurable weights, so you can tune for your workload -- cache-heavy chatbots (w_cache=0.60), latency-sensitive API gateways (w_queue=0.30) -- without touching the underlying logic.
  4. Emergent session affinity that falls out of cache-aware routing for free, replacing brittle sticky sessions. Conversations stick to their pod harder as the cached context grows (Example 4).
  5. Gateway API integration through the InferencePool and InferenceModel CRDs -- Kubernetes-native, standard HTTPRoute routing, ext_proc-based pod selection.
  6. A pluggable scoring pipeline, so custom scorers can add new signals (cost, data residency, SLA tiers) without forking the core algorithm.

Routing is one piece of llm-d -- there's also autoscaling, disaggregated prefill/decode, multi-model orchestration, and more. But it's the foundational piece: every other optimization depends on requests landing on the right pod in the first place.

What is Next: Disaggregated Prefill/Decode #

The thing I want to dig into next is disaggregated prefill/decode -- running the prefill and decode phases on different pods, or even different GPU types, each optimized for its phase. Prefill is compute-bound and wants high-FLOPS GPUs (H100 SXM: 989 TFLOPS BF16). Decode is memory-bandwidth-bound and wants fast memory (H100: 3.35 TB/s HBM3). Disaggregation splits the phases across specialized hardware, and the routing layer has to orchestrate the handoff: prefill on a compute-optimized pod, transfer the KV cache to a decode-optimized pod, route subsequent tokens there. Much harder routing problem, but it builds directly on the prefix-matching and cache-awareness stuff in this post. Probably a future writeup.

Key Takeaway

The EPP is an inference placement optimizer, not a load balancer. Five-factor scoring, a global prefix tree, tunable weights, session affinity for free, and Gateway API integration -- that's the foundation everything else in llm-d builds on. Next up: disaggregated prefill/decode routing.

Play with the Routing Algorithm

I built a companion interactive page for this post -- adjust the weights, poke at pod states, and watch the routing decision update live.

Further Reading #

If you want to go deeper on anything I covered:

  • llm-d Source Code (GitHub) The EPP scoring pipeline, prefix tree implementation, and Gateway API integration code.
  • llm-d Documentation Official documentation covering deployment, configuration, tuning, and architecture guides.
  • Kubernetes Gateway API The specification for Gateway, HTTPRoute, and the extension model that llm-d builds on.
  • Gateway API Concepts: API Overview Core concepts including GatewayClass, Gateway, and Routes -- essential background for the InferencePool integration.
  • vLLM Documentation The inference engine that llm-d's EPP is designed to work with -- KV cache management, block size configuration, and LoRA adapter support.