A stateless load balancer assumes its backends are interchangeable. Inference replicas are not. Each holds a different slice of KV cache, and that slice decides whether a request starts generating almost at once or re-reads the whole prompt first. Route a 30k-token conversation to a worker that already has 29k of it cached and you skip almost all of the prefill; route it one worker over and you pay for every token again.
So the router in front of a fleet cannot just pick the least-busy backend: it has to know what every worker is holding. NVIDIA Dynamo’s KV router does that, and the answer is smaller than the machinery around it — a cost function over two numbers, wrapped in a lot of work to produce them cheaply. These are my notes on the machinery.
The pieces
The router is three parts and a feedback loop: an indexer that knows which worker holds which prefix, a load tracker that knows what each worker is working on, and a selector that scores every candidate and picks one. The workers close the loop: each publishes an event whenever it caches or evicts a block.
A request’s path through it is short. The frontend tokenizes the prompt and chops it into fixed-size blocks, using the same block size as the engine, since both sides have to agree on the boundaries. It hashes each block and hands the sequence of hashes to the indexer, which returns, per worker, how many leading blocks that worker already has. The load tracker supplies each worker’s active prefill and decode load, and the selector combines the two into one cost per worker and dispatches to the cheapest. Generation runs, and the publisher beside the chosen worker’s engine broadcasts every cache and eviction back to the indexer.
The publisher is not optional. An engine caches blocks beyond the lifetime of the request that created them, and its eviction is its own business: least-recently-used (LRU) sweeps, memory pressure, preemption. You cannot infer what a worker still holds from the request/response stream alone, so the worker has to say so.
How a prefix gets a name
Every cached block carries three identifiers, and the split between the first two is what the indexer has to work around.
A local block hash is a content hash of the tokens inside one block and nothing else, so it is position-independent: the same tokens anywhere in any sequence hash the same. That is deliberate. It lets the frontend hash a whole prompt’s blocks in parallel, on the request’s critical path.
A sequence hash is the rolling hash of the whole prefix up to and including that block: seq[i] = hash(seq[i-1] || local[i]). It is position- and history-dependent, so it names one exact prefix.
The cheap hash is the ambiguous one. “Predict the next token | Learn from the error | Predict the next token | And repeat” produces the same local hash at block 0 and block 2, and a worker holding one is not holding the other. The indexer’s data structures are all built around that collision.
The third identifier is the worker ID, and events carry a fourth field, the parent hash, so the index can attach a new block under the right prefix. The event vocabulary is two verbs: Store(worker, parent_hash, local_hash, seq_hash) when a block is cached, Remove(worker, seq_hash) when it is evicted. The stream is bursty in both directions: a prefill emits dozens of stores at once, an eviction sweep as many removes.
The index those events build is a prefix tree over local hashes, where each node carries the set of workers holding that block and its sequence hash, which is how events address it. Two blocks with the same local hash collide only if they hang off the same parent, which means they share the same prefix, which means they are the same block. A lookup walks the query’s block hashes down from the root, intersecting worker sets; the last depth at which a worker is still in the set is its overlap.
The cost function
Selection is one number per worker, lowest wins:
adjusted_prefill = max(0, active_prefill_blocks + incoming_prompt_blocks - overlap_credit)
potential_decode = active_decode_blocks + incoming_active_blocks
cost = prefill_load_scale * adjusted_prefill + potential_decode
+ decode_active_request_weight * active_requests
Read left to right, it estimates what routing here would cost the fleet, in blocks. The prefill term is prompt work: what this worker already owes, plus what the new request adds, minus credit for what it already holds. The decode term is resident KV footprint: the blocks the worker’s in-flight requests are sitting on, plus what the new one will occupy. The third term is opt-in (default weight 0) and charges a flat block-equivalent per active request, for decode regimes where step latency tracks batch size more closely than KV footprint.
overlap_credit is where the cache re-enters. It is the worker’s matched block count, weighted by which tier the match sits in: 1.0 for device-local (G1, the GPU’s own memory), 0.75 for host-pinned (G2), 0.25 for disk (G3). A hit you still have to drag back across PCIe is worth less than one already in high-bandwidth memory (HBM).
Turning the device weight up past 1.0 buys time to first token (TTFT) at the cost of inter-token latency (ITL): you are telling the router to chase cache hits onto busier workers. Turning it to 0 degenerates the router into pure load balancing, a useful A/B baseline. The adjusted prefill term is clamped at zero, and a decay knob can shave the device-local credit off a cache-rich worker that has piled up too much prefill backlog.
Two things wrap the scoring. Before it, candidates are filtered by hard rules — allow-lists, exact worker pins, data-parallel rank bounds, taints, and a busy threshold that takes overloaded workers out of the running entirely. After it, if router_temperature is non-zero, the router softmax-samples over the normalized cost logits, which spreads a hot prefix across several workers rather than piling every request onto the single best one. The default is 0: deterministic, lowest cost.
There is also a queue in front of dispatch, off by default. Set a queue threshold and the router holds requests in a priority queue while every eligible worker is above a fraction of its batched-token capacity, releasing them as capacity frees. What it buys: a request that waits is dispatched against fresh load numbers rather than the stale ones it arrived with. Its ordering policy is a tail-versus-mean trade — first-come-first-served optimizes tail TTFT, while weighted-shortest-processing-time orders by (1 + priority) / uncached_tokens, running short uncached prompts first to optimize the average.
Where the cache lives
The overlap credit assumes something is holding those blocks, and in Dynamo that is KVBM, the KV block manager, which treats KV as a four-tier storage system rather than as GPU scratch space:
| Tier | Medium | Latency | Capacity |
|---|---|---|---|
| G1 | GPU HBM | ~ns | smallest |
| G2 | pinned host DRAM | ~µs | medium |
| G3 | local NVMe | ~ms | large |
| G4 | object store | ~100 ms | effectively unbounded |
The ladder is why the router’s tier weights exist: the credits are a rough encoding of what it costs to bring a block back from each tier.
Blocks move down the ladder through an offload pipeline that is more careful than a background copy. A policy stage screens which blocks are worth demoting; a precondition stage holds them until the forward pass that produced them completes; a batcher groups them (flushing at 8 blocks, capping at 64, or after 10 ms) because per-block transfers waste the interconnect; then a transfer executor commits. Everything before commit is cancellable: a block that gets re-used, or a request that dies, leaves the pipeline before any bytes move.
Coming back up, a block is located, pinned so it cannot be evicted mid-flight, staged, and then pulled over NIXL, the NVIDIA Inference Xfer Library that also carries the disaggregated prefill-to-decode handoff. That pull is a remote direct memory access (RDMA) read.
That is where the two halves meet: the router’s credits are an estimate of the block manager’s tiering, and the block manager’s events are what the router’s index is built from. Get the block size or the hash seed out of sync between them and both halves quietly stop working.
Inside the indexer
Dynamo’s team walked the index through six versions, ending at the one they call the Flash Indexer: about 170M combined ops/s replaying Mooncake production traces on a 24-core desktop, against ~4M for the radix tree it replaced and ~385K for the naive versions (author-reported benchmark, single machine). It ships as the default. The path there shows what “just use a hash map” costs at fleet scale, because the index absorbs every store and remove from every worker while answering a lookup on every request: stale events mean bad routing decisions, slow lookups mean added TTFT.
It started as a nested dictionary (worker → local hash → sequence hashes), which is O(W × D) per query for W workers and D blocks of depth, and conflates colliding local hashes. Porting it to Rust behind a single-threaded actor removed the interpreter overhead and made concurrency correct, but serialized every read behind every write. Inverting the index to be keyed by block rather than worker took queries to O(D + W): walk the query once, intersect worker sets, and each worker drops out at most once. Then the radix tree, which fixed two things at once: parent links scope collisions to a shared prefix, and a per-worker side table from sequence hash to node gives events O(1) access.
The concurrency rewrite is the part I’d steal for something else. Reads take read locks and run inline on the caller’s thread — no actor, no queue. Writes are sticky-routed: each worker ID is deterministically pinned to one thread in a pool, so all of a worker’s events are serialized by construction and no two threads ever contend on the same subtree.
The last step swaps the tree for position-indexed maps — a vector of concurrent maps, index[position] → {local_hash → entry} — which makes any position O(1) instead of a pointer chase from the root.
That random access buys jump search: start with the workers matching at position 0, jump ahead by a stride (64 by default), and check the surviving worker count there. If it hasn’t dropped, the whole skipped range is confirmed; if it has, scan back over that stride to find where each lost worker dropped out. On long prompts with heavy prefix sharing that skips most of the depth, O(D/J + W); on short or highly divergent sequences the radix tree is still the better structure, which is why both ship.
The authors note that the bottleneck has moved off the index entirely, onto the network, tokenization and hashing — which is the honest way to say a component is done.
Knobs and sharp edges
A few things I’d want to know before running this rather than reading about it.
Hash agreement is a hard dependency. The frontend and the engine must compute the same block hashes, or the index is keyed on names nothing else uses. With vLLM this means setting PYTHONHASHSEED=0 across every worker process — an unset seed silently produces a router that routes as if the cache were empty. It also explains a wrinkle: because engines may salt their sequence hashes with digests the router cannot reproduce, the router’s predicted cache state is kept in a separate side index rather than written into the primary tree, so one block never ends up filed under two names.
Events are optional, and the fallback is a guess. With KV events disabled, the router predicts cache state from its own routing decisions, aged out by TTL (120 s by default) and pruned when the tree exceeds a size cap. That is a reasonable degraded mode, and it is exactly as accurate as the assumption that the engine kept what the router sent it.
Router state splits into durable and ephemeral. Prefix state can be reconstructed by a new router replica from the event stream and periodic snapshots, so a restarted or scaled-out router converges to a correct picture. Active-load state cannot — a fresh replica starts blind to in-flight work and learns it by serving, or by opting into replica sync. Multiple frontends therefore agree about the cache long before they agree about the load.
Session affinity is off by default and advisory when on. Enabling it binds a session header to the worker that served its first request, with an idle TTL; bindings are gossiped between router replicas but each replica keeps its own timer, and a dropped or reordered event costs affinity, not correctness. If you need strict stickiness, pin the session to one frontend at the ingress instead of asking the routers to agree.
The failure modes worth watching are all in the small agreements between the halves: the hash seed, the block size, and whose job it is to announce an eviction.
More notes as I go.