ML Interview Notes
33 min read16 sections
Part 2 · Memory and the KV cache · 02-04

RadixAttention: the tree (SGLang)

Status
SOURCE PINNED
Primary sources
  • python/sglang/srt/mem_cache/radix_cache.py
  • python/sglang/srt/mem_cache/base_prefix_cache.py
  • python/sglang/srt/managers/schedule_policy.py
Edition pins
vllm a556f3f · sglang 7d89325

Two requests share the first 2,055 tokens of their prompt and then diverge. A hash map can tell you that they share a prefix. A tree tells you where they stopped sharing it, which requests are still standing on each piece, and therefore which bytes are safe to throw away. That difference is the whole chapter.

§1

The problem

Run a customer-support agent on an H100 node. Every request opens with the same 512-token system prompt, then a 1,543-token few-shot block, then a per-user conversation. Enable prefix caching on both engines and the hit rates come out close, because on steady-state traffic both find the same shared prefixes. Then the queue backs up, eviction starts firing, and they stop behaving alike — not because one hashes better, but because one knows the shape of what it is caching and the other knows a flat set of 16-token blocks.

Here is the question that separates them. Your cache holds a 512-token system prompt that 400 live conversations share, and a 2,112-token RAG context that exactly one dead request ever used. You need 300 tokens of space. Which do you evict?

vLLM answers with a doubly-linked free queue ordered by the moment each block's reference count last hit zero. SGLang answers with a structural invariant: the system prompt is an interior node and is not in the candidate set at all while anything below it lives. This chapter is about that tree — what it is, what its four operations cost, what the scheduler does with it that vLLM's cannot, and what it costs you in return.

Scope

The vLLM half of this pair is §2.3, which owns the block-hash chain and the free-queue mechanics; this chapter cites vLLM only at the level of properties. Allocation is §2.2, KV sizing §2.1, HiCache's tiers §2.6.

Which class actually runs

This chapter reads mem_cache/radix_cache.py, because it is the clearest statement of the algorithm. It is not what a stock server instantiates. The selection chain in default_radix_cache_factory falls through to UnifiedRadixCache (python/sglang/srt/mem_cache/registry.py:L143), and that class is a sibling of RadixCache, not a subclass — both extend BasePrefixCache independently (radix_cache.py:L303, unified_radix_cache.py:L148). Plain RadixCache survives as a base for PureSWARadixCache and via create_simulated. The tree walk and the split are the same shape in both, which is why reading the simpler one first is the right order — but when you go to patch something, patch the one on the live path. §12.3 reads the live class directly.

§2

Mental model

A radix tree is a trie in which every edge carries a sequence of symbols rather than a single one: chains of single-child nodes are collapsed into one edge. That is the only difference from a trie, and it is exactly why it fits KV prefixes. A KV cache does not care about individual tokens; it cares about runs of tokens that are always used together. A run with no divergence inside it is one edge. A divergence point is a node.

The sentence to remember: a node boundary is exactly a point where two requests stopped agreeing. Every token inside one edge is used by precisely the same set of requests. That is not true of a 16-token block, whose boundaries fall at arbitrary positions modulo 16.

Three requests against Llama-3-8B ($L=32$, $h_{kv}=8$, $d_h=128$, bf16), which costs $2 \cdot 32 \cdot 8 \cdot 128 \cdot 2 = 131{,}072$ B = 128 KiB of KV per token (§2.1). R1: 512-token system prompt S, 1,543-token few-shot block F, 96-token query. R2: same S and F, different 80-token query. R3: same S, then a 2,112-token RAG context and query of its own.

Figure 1 — the radix tree after three requests, with both edge splits drawn. Edge labels are token counts. The dashed edges are the two splits _split_node performed; the original single 2,151-token edge no longer exists. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The tree stores $512 + 1543 + 96 + 80 + 2112 = 4{,}343$ tokens. Storing the three requests independently would take $2151 + 2135 + 2624 = 6{,}910$ tokens. The saving is 2,567 tokens — 321 MiB of HBM and 2,567 tokens of prefill that never runs. At the ~22,000 tok/s effective Llama-3-8B prefill rate derived in §1.4 (148.7 TFLOP per 8,192 tokens at an assumed 400 TFLOP/s achieved on H100 SXM, against §0.4's 989.4 TFLOP/s peak), that is 117 ms of GPU time avoided. Derived, not measured.

§3

First principles: the four operations

Everything the cache does is four operations on that tree, all in python/sglang/srt/mem_cache/radix_cache.py (863 lines at SHA 7d89325).

The node

python/sglang/srt/mem_cache/radix_cache.py:L238-L251 SGLang
class TreeNode:

    counter = 0

    def __init__(self, id: Optional[int] = None, priority: int = 0):
        self.children = defaultdict(TreeNode)
        self.parent: TreeNode = None
        self.key: RadixKey = None
        self.value: Optional[torch.Tensor] = None
        self.lock_ref = 0
        self.last_access_time = time.monotonic()
        self.creation_time = time.monotonic()

        self.hit_count = 0

Three fields carry the design. key is a RadixKey — the token sequence on the edge into this node, not a single token. value is a 1-D int64 tensor of KV slot indices, one per token of key; the KV bytes live in the pools of §2.2 and the tree holds only indices into them. lock_ref keeps in-flight requests' prefixes alive. The two timestamps and hit_count are there so eviction can be pluggable: python/sglang/srt/mem_cache/evict_policy.py:L16-L64 defines seven strategies over exactly those fields plus priority — LRU and MRU on ±last_access_time, FIFO and FILO on ±creation_time, LFU on (hit_count, last_access_time), and SLRU on a hit-count threshold.

RadixKey.child_key (radix_cache.py:L217-L229) keys children by the first page_size tokens of the child's edge, which makes descent an O(1) dict lookup. It also folds extra_key and cache_salt into that key, so identical tokens under different LoRA adapters or salts land in disjoint subtrees — the same idea as vLLM's extra_keys in the block hash, different carrier.

match_prefix — the longest-common-prefix walk

match_prefix (radix_cache.py:L417-L436) page-aligns the key, calls the helper below, and torch.cats the returned per-node index tensors into one. The walk itself is fourteen lines and is the heart of RadixAttention:

python/sglang/srt/mem_cache/radix_cache.py:L679-L703 SGLang
    def _match_prefix_helper(self, node: TreeNode, key: RadixKey):
        access_time = time.monotonic()
        node.last_access_time = access_time

        child_key = key.child_key(self.page_size)

        value = []
        while len(key) > 0 and child_key in node.children.keys():
            child = node.children[child_key]
            child.last_access_time = access_time
            prefix_len = child.key.match(key, page_size=self.page_size)
            if prefix_len < len(child.key):
                new_node = self._split_node(child.key, child, prefix_len)
                value.append(new_node.value)
                node = new_node
                break
            else:
                value.append(child.value)
                node = child
                key = key[prefix_len:]

                if len(key):
                    child_key = key.child_key(self.page_size)

Two things. First, child.last_access_time = access_time fires on every node along the path — one lookup refreshes the recency of the whole matched prefix, including interior nodes whose other branches were not touched. Second, and more important: a read mutates the tree. If the query diverges mid-edge, _split_node runs. Matching is not a pure operation. Hold that for the concurrency discussion.

The per-edge comparison is not a Python loop over tokens. RadixKey.match gallops:

python/sglang/srt/mem_cache/radix_cache.py:L181-L215 SGLang
    def match(self, other: RadixKey, page_size: int = 1) -> int:
        """Logical-unit prefix length shared with ``other``. Result is rounded down to ``page_size``."""
        self._check_compatible(other)
        t0, t1 = self.token_ids, other.token_ids
        assert type(t0) is type(t1), (type(t0), type(t1))
        n = min(len(t0), len(t1))

        # Exponential search for the first diverging token: gallop in doubling
        # windows (one C-level slice compare each), then binary-search the window
        # holding the divergence -- no per-token Python loop on long shared prefixes.
        matched_tokens = n
        lo = 0
        step = 1
        while lo < n:
            hi = lo + step if lo + step < n else n
            if t0[lo:hi] != t1[lo:hi]:
                while hi - lo > 1:
                    mid = (lo + hi) // 2
                    if t0[lo:mid] == t1[lo:mid]:
                        lo = mid
                    else:
                        hi = mid
                matched_tokens = lo
                break
            lo = hi
            step *= 2
        # ...
        matched_tokens = min(matched_tokens, len(self), len(other))
        if page_size == 1:
            return matched_tokens
        return (matched_tokens // page_size) * page_size

For an edge of m tokens, the galloping/binary search uses O(log m) Python-level slice comparisons. Each slice can copy or compare many elements; this is not O(log m) total byte work, and repeated comparisons can cost O(m log m) in an unfavorable case. Compare end-to-end lookup profiles against hashing, including key construction, allocation, and cache warmth, rather than equating one C-level call with constant work.

_split_node — the operation that makes it a radix tree

python/sglang/srt/mem_cache/radix_cache.py:L705-L728 SGLang
    def _split_node(self, key: RadixKey, child: TreeNode, split_len: int):
        # new_node -> child
        # New node inherits child's priority (represents shared prefix)
        new_node = TreeNode(priority=child.priority)
        new_node.hit_count = child.hit_count
        new_node.children = {key[split_len:].child_key(self.page_size): child}
        new_node.parent = child.parent
        new_node.lock_ref = child.lock_ref
        new_node.key = child.key[:split_len]
        new_node.value = child.value[:split_len].clone()
        child.parent = new_node
        child.key = child.key[split_len:]
        child.value = child.value[split_len:].clone()
        new_node.parent.children[key.child_key(self.page_size)] = new_node
        # ...
        return new_node

Twelve lines of pointer surgery plus two tensor clones. new_node.lock_ref = child.lock_ref matters: the new parent inherits the lock count, so a request already standing on the deep node keeps a consistent chain of locks to the root. The clones are of the index tensor (8 B/token), not the KV (131,072 B/token): splitting a 2,151-token edge copies 17 KB, not 269 MiB. No KV is moved, ever — that is what makes the tree affordable.

insert

_insert_helper is _match_prefix_helper with a tail: same walk, same splits, and when the key runs out of matching tree it hangs a fresh node.

python/sglang/srt/mem_cache/radix_cache.py:L758-L791 SGLang
        total_prefix_length = 0
        while len(key) > 0 and child_key in node.children.keys():
            node = node.children[child_key]
            node.last_access_time = access_time
            prefix_len = node.key.match(key, page_size=self.page_size)
            total_prefix_length += prefix_len
            key = key[prefix_len:]
            value = value[prefix_len:]

            if prefix_len < len(node.key):
                new_node = self._split_node(node.key, node, prefix_len)
                new_node.priority = max(new_node.priority, priority)
                self._inc_hit_count(new_node, chunked)
                node = new_node
            else:
                node.priority = max(node.priority, priority)
                self._inc_hit_count(node, chunked)
            if len(key):
                child_key = key.child_key(self.page_size)

        if len(key):
            new_node = TreeNode(priority=priority)
            new_node.parent = node
            new_node.key = key
            new_node.value = value.clone()
            self._inc_hit_count(new_node, chunked)
            node.children[child_key] = new_node
            self.evictable_size_ += len(key)
            self._update_leaf_status(node)
            self._update_leaf_status(new_node)
            # Hash will be computed lazily during event emission
            self.kv_events.record_store(new_node)
            node = new_node
        return total_prefix_length, node

insert returns total_prefix_length, which the caller uses to free the slots it just discovered were duplicates: in cache_finished_req the request's freshly written KV indices below prefix_len go straight back to the allocator (radix_cache.py:L491-L509). Deduplication happens at insert time, not allocation time.

Complexity, stated

Derived complexity. $D$ = divergence points on the matched path, $m$ = tokens on one edge, $L$ = evictable-leaf set size, $k$ = tokens requested by an eviction call.
OperationCostDominant term
match_prefix$O(D \log m)$ slice compares + $D$ dict lookups + one torch.cat over $D$ tensorsthe torch.cat, for large $D$
_split_node$O(1)$ pointer ops + $O(m)$ index-tensor clone (8 B/token)the clone
insertsame walk + $\le 1$ split per hop + $O(n_{new})$ clone of the new tailthe tail clone
evict(k)$O(L)$ heapify + $O(k' \log L)$ pops, $k'$ = nodes evictedthe heapify, rebuilt every call
inc_lock_ref / dec_lock_ref$O(D)$ — walk from node to roottree depth
_update_leaf_status$O(\text{children of node})$fan-out

The row worth staring at is evict: leaves = list(self.evictable_leaves) then heapq.heapify (radix_cache.py:L599-L603) rebuilds the heap from scratch on every call. Under sustained pressure with a wide tree, this is the one operation whose cost grows with the size of the cache rather than the size of the request.

§4

Why a tree beats a hash map

Figure 2 is the same three requests as Figure 1, under a flat block-hash map with $B = 16$.

Figure 2 — the same workload as a flat hash map, block_size=16. 271 map entries with no structure between them; the only ordering that exists is the free queue, which is a list, not a shape. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Both find the shared prefix; the map is not wrong. What it lacks is any representation of the three facts the tree makes free:

1 / multi-turn

The deepest match is automatic

Turn $N{+}1$ is turn $N$'s tokens plus the assistant reply plus the new user message. cache_finished_req inserts origin_input_ids + output_ids, so the reply is in the tree too. The walk descends until the tokens stop agreeing and returns the node it stopped at — one traversal, no length hypothesis. The map probes every block hash from position 0 and breaks on the first miss.

2 / few-shot

Divergence is a first-class object

Eight requests sharing a 2,000-token preamble produce one interior node with eight children. The node is the preamble: lock_ref counts live requests depending on it, children counts cached branches. The map has 125 independent entries and no way to ask "how many requests need this block".

3 / eviction

Leaf-first is structural, not conventional

SGLang's candidate set holds only nodes with no live children, maintained incrementally by _update_leaf_status. vLLM gets the same ordering by convention: free_blocks's docstring requires callers to pass blocks "ordered by their eviction priority", and tail-first comes from reversing the list at the call site. It works — but it is a property every caller must honour, not a property of the structure.

Put a number on case 1. A conversation whose context reaches 4,300 tokens by turn 4: turn 5 adds a 300-token reply and a 120-token user message. Assuming the reply KV was retained, the tree matches 4600 and prefills 120; cold it would prefill 4720. Saving: 4,600 $/$ 22,000 tok/s $\approx$ 209 ms per turn (derived). Both engines get this; the tree gets it in one descent.

Where vLLM's ordering actually differs

Be precise, because it is easy to overstate. vLLM's free queue is a genuine LRU: a matched block is removed by touch and re-appended at the back when released, so queue position tracks last use. And free_blocks segregates hashed from unhashed blocks so never-matchable ones are reused first:

vllm/v1/core/block_pool.py:L727-L743 vLLM
        # Identify blocks with hash (LRU cache) and without it (never match APC)
        blocks_to_evict_last = []
        blocks_to_evict_first = []
        for block in ordered_blocks:
            block.ref_cnt -= 1
            if block.ref_cnt == 0 and not block.is_null:
                if block.block_hash is None or not self.enable_caching:
                    # LIFO reuse of non-cached blocks for better GPU locality.
                    blocks_to_evict_first.append(block)
                else:
                    # FIFO reuse of cached blocks for LRU eviction behavior.
                    blocks_to_evict_last.append(block)

        # Blocks to reuse first are prepended to the front of the free queue.
        self.free_block_queue.prepend_n(blocks_to_evict_first)
        # Blocks to reuse last are appended to the end of the free queue.
        self.free_block_queue.append_n(blocks_to_evict_last)

The genuine difference is what happens if that invariant breaks. vLLM's lookup stops dead at the first missing block — the code says so:

vllm/v1/core/single_type_kv_cache_manager.py:L733-L740 vLLM
        # Phase 1: longest run of cached full blocks from the start. A missing
        # block implies every later block misses too (chained hashes).
        for block_hash in itertools.islice(full_block_hashes, max_length // block_size):
            cached_block = block_pool.get_cached_block(block_hash, kv_cache_group_ids)
            if not cached_block:
                break
            for computed, cached in zip(computed_blocks, cached_block):
                computed.append(cached)

Any resident block whose ancestor was evicted is unreachable: it occupies HBM and can never produce a hit until the ancestor is repopulated. Nothing in BlockPool prevents that state — the free-ordering discipline at the call sites does. In SGLang it is impossible by construction, because _update_leaf_status refuses to put a node in the candidate set while it has a non-evicted child:

python/sglang/srt/mem_cache/radix_cache.py:L821-L834 SGLang
    def _update_leaf_status(self, node: TreeNode):
        if node.evicted or node.lock_ref > 0:
            if node in self.evictable_leaves:
                self.evictable_leaves.remove(node)
            return

        for child in node.children.values():
            if not child.evicted:
                if node in self.evictable_leaves:
                    self.evictable_leaves.remove(node)
                return

        if node not in self.evictable_leaves:
            self.evictable_leaves.add(node)

What the tree costs

Four honest costs:

  • Pointer chasing and Python objects. A TreeNode carries fourteen attributes plus a defaultdict plus a torch tensor. At page_size=1 with many short branches you accumulate a lot of them. Hundreds of bytes per node against 128 KiB per token of KV is negligible in bytes — but every node is GIL-bound work on the scheduler thread.
  • More bookkeeping per token. inc_lock_ref / dec_lock_ref walk to the root on every admission and completion, adjusting evictable_size_, protected_size_ and the leaf set at each hop. vLLM's equivalent is block.ref_cnt += 1 in a loop.
  • Split-on-read. match_prefix mutates. There is no lock-free read path, which is why the structure is confined to the single-threaded scheduler loop.
  • A harder concurrency story. A hash-map lookup is a pure read and can be sharded; a radix walk with edge splitting cannot. radix_cache_cpp exists partly to move this off the interpreter.
§5

Granularity: page_size versus block_size

SGLang's tree granularity is page_size, and the default is 1:

python/sglang/srt/arg_groups/overrides.py:L2378-L2397 SGLang
def _page_size_default(view: Any) -> dict:
    if view.page_size is not None:
        return {}
    # ...
    if is_hip() and envs.SGLANG_AITER_KV_CACHE_LAYOUT.get().lower() == "vectorized_5d":
        logger.info(
            "Setting page_size=64 as default for "
            "SGLANG_AITER_KV_CACHE_LAYOUT=vectorized_5d."
        )
        return {"page_size": 64}
    if not is_musa():
        return {"page_size": 1}
    return {"page_size": 64}

vLLM's is CacheConfig.DEFAULT_BLOCK_SIZE = 16 (vllm/config/cache.py:L59, applied at L285-L286). Both round the match down: SGLang in RadixKey.match's final (matched_tokens // page_size) * page_size and again in page_aligned (radix_cache.py:L150-L154), vLLM by iterating whole blocks.

So on a CUDA default deployment SGLang matches at token granularity and vLLM at 16-token granularity. The loss is at most $B - 1 = 15$ tokens per divergence point. On our workload the shared prefix is 2,055 tokens; vLLM matches 2,048 and re-prefills 7 — $7/22{,}000 = 0.32$ ms. The worst case, 15 tokens, is 0.68 ms.

Verdict

Finer match granularity is a real advantage of the tree and it is not where the win comes from — sub-millisecond per request on Llama-3-8B. The wins are structural: leaf-safe eviction and scheduler coupling.

The granularity that does matter is eviction granularity. SGLang evicts a whole node — a variable-length run whose boundaries are the divergence points, so every token in it has the same set of dependants. vLLM evicts one 16-token block whose boundaries fall wherever position $\bmod\ 16$ says. Evicting node E in Figure 1 releases 2,112 tokens (264 MiB) in one heap pop and one free_segment; the equivalent in vLLM is 132 separate poplefts, and nothing guarantees they are the 132 the tree would have chosen.

§6

Eviction: LRU over leaves

python/sglang/srt/mem_cache/radix_cache.py:L593-L621 SGLang
    def evict(self, params: EvictParams) -> EvictResult:
        if self.disable:
            return EvictResult()

        start_time = time.perf_counter()
        num_tokens = params.num_tokens
        leaves = list(self.evictable_leaves)
        eviction_heap = [
            (self.eviction_strategy.get_priority(node), node) for node in leaves
        ]
        heapq.heapify(eviction_heap)

        num_evicted = 0
        while num_evicted < num_tokens and len(eviction_heap):
            _priority, x = heapq.heappop(eviction_heap)

            # Tree values are page-aligned copies of a kv row: page-exact segment.
            self.token_to_kv_pool_allocator.free_segment(x.value, start_pos=0)
            num_evicted += len(x.value)
            self._delete_leaf(x)

            if len(x.parent.children) == 0 and x.parent.lock_ref == 0:
                new_priority = self.eviction_strategy.get_priority(x.parent)
                heapq.heappush(eviction_heap, (new_priority, x.parent))

            self.kv_events.record_remove(x)

Three properties fall out. The candidate set is only leaves. A parent joins the heap the instant it becomes an unlocked leaf, so eviction cascades up a dead branch within one call. And the ordering key is not hard-coded — self.eviction_strategy.get_priority(node) dispatches to one of seven strategies:

python/sglang/srt/mem_cache/evict_policy.py:L16-L46 SGLang
class LRUStrategy(EvictionStrategy):
    def get_priority(self, node: TreeNode) -> float:
        return node.last_access_time


class LFUStrategy(EvictionStrategy):
    def get_priority(self, node: TreeNode) -> Tuple[int, float]:
        return (node.hit_count, node.last_access_time)


class FIFOStrategy(EvictionStrategy):
    def get_priority(self, node: TreeNode) -> float:
        return node.creation_time

# ... MRUStrategy and FILOStrategy negate the same two fields ...

class PriorityStrategy(EvictionStrategy):
    """Priority-aware eviction: lower priority values evicted first, then LRU within same priority."""

    def get_priority(self, node: TreeNode) -> Tuple[int, float]:
        # Return (priority, last_access_time) so lower priority nodes are evicted first
        return (node.priority, node.last_access_time)

Plus SLRUStrategy, a segmented LRU protecting nodes with hit_count >= 2. Selected by --radix-eviction-policy, default "lru" (python/sglang/srt/server_args.py:L934-L947). vLLM has one policy and no flag: the free queue's order.

Locking

python/sglang/srt/mem_cache/radix_cache.py:L623-L636 SGLang
    def inc_lock_ref(self, node: TreeNode) -> IncLockRefResult:
        if self.disable:
            return IncLockRefResult(delta=0)

        delta = 0
        while node != self.root_node:
            if node.lock_ref == 0:
                self.evictable_size_ -= len(node.key)
                self.protected_size_ += len(node.key)
                delta -= len(node.key)
            node.lock_ref += 1
            self._update_leaf_status(node)
            node = node.parent
        return IncLockRefResult(delta=delta)

One call locks the whole path to the root and moves those tokens from the evictable budget to the protected one, which is what makes the scheduler's admission arithmetic correct: evictable_size() is exactly the memory eviction can still reclaim. The root carries a permanent lock_ref = 1 (radix_cache.py:L360), so the loop always terminates.

Figure 3 — eviction ordering on the Figure 1 tree, after R1 and R3 finished and R2 was re-admitted for turn 2. R2's inc_lock_ref(D) locked D, B and A. Only C and E are candidates. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The payoff: R2's 2,055-token prefix (257 MiB) is protected by a single inc_lock_ref, and the 400-conversation system prompt from the opening question is node A — an interior node with a live child, therefore not a candidate at any price. vLLM protects the same block by reference counting, which also works; what vLLM cannot express is "this block has cached descendants that would become unreachable".

§7

Scheduler coupling: LPM

This is the part with no vLLM counterpart. SGLang's SchedulePolicy holds a reference to the tree and sorts the waiting queue by how much of it each request will hit:

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

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


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

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

vLLM's complete set, for contrast, is two members:

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

    FCFS = "fcfs"
    PRIORITY = "priority"

LPM's sort is trivial once the matches exist:

python/sglang/srt/managers/schedule_policy.py:L380-L391 SGLang
    @staticmethod
    def _sort_by_longest_prefix(
        waiting_queue: List[Req], temporary_deprioritized: Set[int]
    ) -> None:
        """Sorts the waiting queue based on the longest prefix match."""
        waiting_queue.sort(
            key=lambda r: (
                -r.num_matched_prefix_tokens
                if r.rid not in temporary_deprioritized
                else float("inf")
            )
        )

The interesting machinery is temporary_deprioritized. _compute_prefix_matches builds a second, throwaway radix tree over the waiting queue itself and uses it to detect requests that will collide on a prefix that is not cached yet:

python/sglang/srt/managers/schedule_policy.py:L339-L365 SGLang
            # NOTE(sang): This logic is for in-batch prefix caching;
            # If there are more than 1 request that have small matching prefix from
            # existing cache, but all those requests share the same prefix, we prefer
            # to schedule only one of them so that we can increase the cache hit rate.
            # ...
            if len(r.prefix_indices) <= IN_BATCH_PREFIX_CACHING_CHECK_THRESHOLD:
                match_result = self.waiting_queue_radix_tree.match_prefix(
                    MatchPrefixParams(
                        key=RadixKey(
                            token_ids=prefix_ids,
                            extra_key=extra_key,
                            cache_salt=cache_salt,
                        )
                    )
                )
                # ...
                in_batch_matching_prefixes = match_result.device_indices
                if (
                    len(in_batch_matching_prefixes)
                    >= IN_BATCH_PREFIX_CACHING_DEPRIORITIZE_THRESHOLD
                ):
                    temporary_deprioritized.add(r.rid)

Work the example. Sixty-four requests arrive together sharing a cold 2,000-token prefix. Admit them all at once and every one prefills 2,000 tokens, because none has finished so none has inserted anything: $64 \times 2000 = 128{,}000$ tokens. Deprioritize 63, let the first populate the tree, and the other 63 hit — saving 126,000 tokens $/$ 22,000 tok/s $\approx$ 5.7 s of H100 prefill (derived). A scratch tree makes this convenient, but a hash-based shared-prefix index can also detect overlap among requests none of which are in the cache yet; the cheapest way is a scratch radix tree, which is exactly what RadixCache.create_simulated() builds (python/sglang/srt/managers/schedule_policy.py:L235).

The coupling is not free, and the code admits it:

python/sglang/srt/managers/schedule_policy.py:L290-L294 SGLang
    def _determine_active_policy(self, waiting_queue: List[Req]) -> Policy:
        if self.policy == CacheAwarePolicy.LPM and len(waiting_queue) > 128:
            # Turn off the expensive prefix matching and sorting when the #queue is large.
            return CacheAgnosticPolicy.FCFS
        return self.policy

LPM does a full match_prefix for every waiting request on every scheduling round. Past 128 queued requests it disables itself. Note also that the shipped default is schedule_policy = "fcfs" (python/sglang/srt/server_args.py:L843-L858), so LPM is opt-in: --schedule-policy lpm.

§8

Behaviour under preemption

§1.4 established this asymmetry; it belongs in the comparison table, so verify it here. On retraction SGLang calls:

python/sglang/srt/managers/schedule_batch.py:L1941-L1945 SGLang
    # TODO (csy): for preempted requests, we may want to insert into the tree
    release_kv_cache(req, tree_cache, is_insert=False)
    # NOTE(lsyin): we should use the newly evictable memory instantly.
    num_tokens = remaing_req_count * envs.SGLANG_RETRACT_DECODE_STEPS.get()
    evict_from_tree_cache(tree_cache, num_tokens)

The is_insert=False flag skips insertion of the newly computed suffix. It does not mean existing canonical tree nodes or borrowed shared prefix slots are erased. Release and later eviction must respect ownership and locks. The canonical RadixCache excerpt illustrates the algorithm; the configured default may be UnifiedRadixCache, as the variant table explains.

Neither the quoted release flag nor free-queue ordering proves a 372 ms versus 3.7 ms engine gap. Recompute cost depends on the still-indexed contiguous prefix, victim ownership, eviction pressure, and warm-suffix attention. Measure those quantities on matched workloads before comparing engines.

§9

The variants at this SHA

At SHA 7d89325 there are six tree implementations plus the no-op, and the layout is moving fast: python/sglang/srt/mem_cache/README.md:L40-L45 says the per-model variants "are converging onto the Unified Radix Cache".

Radix-cache implementations at SGLang 7d89325. "Default path" means reachable from default_radix_cache_factory in python/sglang/srt/mem_cache/registry.py:L80-L143.
FileClassLinesWhat it is for
radix_cache.pyRadixCache863The canonical implementation, and what this chapter reads. Still live: the scheduler's in-batch dedup tree is a RadixCache.create_simulated().
unified_radix_cache.pyUnifiedRadixCache2887default The same tree generalised over per-component validators (FULL / SWA / MAMBA / C128). Its walk, in unified_cache/unified_tree_core.py, has the same _match_prefix_helper / _split_node shape.
hiradix_cache.pyHiRadixCache2022Hierarchical: each node optionally backed by a host mirror (host_value, host_ref_counter). Tiers are §2.6.
mamba_radix_cache.pyMambaRadixCache1427not constructed Hybrid SSM models. Own TreeNode and an explicit LRUList, because an SSM state is one checkpoint per node, not one KV row per token. The class is never instantiated anywhere under python/sglang/ at this SHA — the live hybrid path is UnifiedRadixCache's MAMBA component (unified_cache/components/mamba_component.py), which mirrors this mechanism. Read it as the clearest statement of the algorithm, not as the code that runs; §7.3 traces the live one.
swa_radix_cache.pySWARadixCache1440Hybrid sliding-window models. Dual LRU: full and SWA layers age out independently.
pure_swa_radix_cache.pyPureSWARadixCache153All-SWA models. Thin RadixCache subclass: caches only [0, evict_floor), frees the window range, no tombstones.
radix_cache_cpp.pyRadixCacheCpp272Experimental C++ tree behind SGLANG_EXPERIMENTAL_CPP_RADIX_TREE. Rejects cache_salt. The escape hatch from the GIL.
chunk_cache.pyChunkCache178Not a tree. The no-reuse implementation used when radix caching is off.

ChunkCache is the control: the same BasePrefixCache interface with every operation stubbed to nothing.

python/sglang/srt/mem_cache/chunk_cache.py:L67-L77 SGLang
    def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
        return MatchResult(
            device_indices=torch.empty((0,), dtype=torch.int64),
            last_device_node=None,
            last_host_node=None,
            best_match_node=None,
        )

    def insert(self, params: InsertParams) -> InsertResult:
        # ChunkCache does not support prefix caching, so insert is a no-op
        return InsertResult(prefix_len=0)

reset, match_prefix, cache_finished_req, cache_unfinished_req, evict, inc_lock_ref, dec_lock_ref are the seven abstract methods of BasePrefixCache (python/sglang/srt/mem_cache/base_prefix_cache.py:L273-L336). Every row of the table above is a different answer to the same seven questions.

§10

Head to head

Prefix-cache design comparison. vLLM a556f3f, SGLang 7d89325. All numbers derived from source reading plus the Llama-3-8B / H100 arithmetic of §1.4; none measured.
PropertyvLLM — hash mapSGLang — radix tree
StructureBlockHashToBlockMap + doubly-linked FreeKVCacheBlockQueueCompressed trie; edges carry token sequences; explicit evictable_leaves set
Match granularityBlock-aligned, default 16 tokenspage_size, default 1 token on CUDA
Granularity costUp to 15 tokens lost per divergence, $\approx$0.68 ms Llama-3-8B prefillNone; more nodes to bookkeep instead
Match semanticsIterate chained block hashes from position 0, break on first missDescend from root, split the edge where the key diverges, return the node
Match cost, 8k prompt512 map lookups$D$ dict lookups + $D\log m$ slice compares; $D$ = divergence points, typically 3–5
Eviction unitOne 16-token blockOne node = one full run between divergence points
Eviction orderingFree-queue position (release time). Leaf-before-parent by caller convention — the list is reversed at free timeHeap over leaves only; interior nodes structurally excluded by _update_leaf_status
Eviction policyLRU, not configurableLRU / LFU / SLRU / FIFO / MRU / FILO / priority via --radix-eviction-policy
In-use protectionblock.ref_cnt, one integer per blocklock_ref on every node from the match point to the root; maintains protected_size_
Scheduler couplingNone. SchedulingPolicy is FCFS | PRIORITYLPM and DFS_WEIGHT sort the waiting queue by tree match; a scratch tree deprioritizes in-batch prefix collisions
On preemptionFreed blocks keep their hashes; victim can re-hit its own prefix ($\approx$3.7 ms)is_insert=False drops the victim's KV from the tree entirely ($\approx$372 ms), with a live TODO (csy)
ConcurrencyLookup is a pure read; touch mutates the queue onlyLookup mutates the tree (edge splitting). Single-threaded scheduler loop only; radix_cache_cpp is the escape hatch
Per-entry overheadKVCacheBlock — id, ref_cnt, hash, two queue pointersTreeNode — 14 attributes, a defaultdict, an int64 index tensor (8 B/token)
§11

Worked trace: R2 arrives

R1 has finished; the tree is root -> [S+F+q1: 2151 tokens], one node. R2 arrives with S + F + q2. Follow it through the real call path.

  1. SchedulePolicy.calc_priority (schedule_policy.py:L237) → _compute_prefix_matchesmatch_prefix_for_req (schedule_policy.py:L138-L197), which wraps the token ids in a RadixKey carrying req.extra_key and req.cache_salt.
  2. RadixCache.match_prefixpage_aligned(1) is a no-op → _match_prefix_helper(root, key).
  3. First hop: R2's first token equals R1's, so the single child is found. child.key.match(key, page_size=1) gallops — the arrays agree for 2,055 tokens (S + F) and diverge at the query. Returns 2055.
  4. prefix_len=2055 < len(child.key)=2151, so _split_node runs. A new node takes the first 2,055 indices; the old node keeps the last 96 and becomes its child. Two .clone()s move $2151 \times 8 = 17$ KB of int64. No KV moves.
  5. The helper breaks and returns ([new_node.value], new_node); match_prefix torch.cats and returns 2,055 device indices. match_prefix_for_req writes req.prefix_indices, req.last_node, req.num_matched_prefix_tokens = 2055.
  6. _sort_by_longest_prefix sorts R2 ahead of anything with a shorter match.
  7. Admission: PrefillAdder._req_inc_lock_ref (schedule_policy.py:L957-L963) walks to the root. 2,055 tokens (257 MiB) move from evictable_size_ to protected_size_.
  8. Only 80 tokens are prefilled. Derived saving against cold: $2055/22000 = 93$ ms.
  9. R2 finishes. release_kv_cache(..., is_insert=True)cache_finished_reqinsert, which walks back down, finds total_prefix_length = 2055, hangs an 80-token node, and preserves the 2,055 already shared slots; duplicate freeing applies only if independently allocated overlapping slots actually exist. dec_lock_ref unwinds the locks; _update_leaf_status adds the new node to evictable_leaves.
§12

Pitfalls and war stories

The tree is disabled and you did not ask for it. disable_radix_cache is forced to True from at least six places in server_args.py (lines 3877, 3927, 4826, 6023, 6114, 8338) as a side effect of other flags. If your hit rate is flat zero, check the resolved server args first. The tell in code is ChunkCache: is_chunk_cache() returns True and every match returns empty.

LPM silently turns itself off. Past 128 waiting requests _determine_active_policy returns FCFS, with no log line. Hit rate degrades exactly at the load where you most wanted the policy. Deliberate — the sort is $O(Q)$ full matches — but invisible from outside.

cache_salt and extra_key partition the tree. Folded into child_key, so identical tokens under different salts never share a node. Deliberate, and an efficient way to drive your hit rate to zero if a client sets a per-request salt. RadixKey._check_compatible raises ValueError: RadixKey operations require matching extra_key if the two get crossed.

Assertion on index-length mismatch. cache_unfinished_req asserts len(new_indices) == len(radix_key) (radix_cache.py:L555-L557). The comment below it names the trap: at page_size > 1 the partial tail page is in req.prefix_indices but not in the tree, and cache_protected_len exists solely so that tail is freed later. Writing a new cache variant, this is the invariant you break first.

Preemption can lose reusable suffix work. Retraction may release newly computed KV without inserting it, while previously indexed shared prefixes remain. Log the surviving prefix length and recomputed token count; do not assume every retraction starts from zero.

Debugging tool. tree.pretty_print() (radix_cache.py:L586-L588) prints edge length, the first ten token ids, and r=lock_ref per node, and asserts each dict key equals the child's recomputed child_key — a cheap structural check.

§13

Hands-on

The tree runs standalone. radix_cache.py has a __main__ block that builds one with no GPU:

shell — SGLang checkout at 7d89325 shell
cd ~/Documents/other_git_repos/sglang
python -m sglang.srt.mem_cache.radix_cache

It inserts [1,2,3], [1,2,3], [1,2,4,5], [1,2,4,5,6,7], [8,9,10,11,12], prints the tree, and matches [1,2,3,13,14] (radix_cache.py:L849-L863). Add inserts and watch _split_node fire — inserting [1,2,4,5] is what splits [1,2,3] into [1,2] -> [3].

Then run the server four ways and compare the cache-hit gauge:

shell shell
# Baseline: FCFS, default page_size=1, LRU
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct --enable-metrics

# Cache-aware admission + segmented LRU
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --schedule-policy lpm --radix-eviction-policy slru --enable-metrics

# Coarsen the tree to vLLM's granularity and see how little changes
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --page-size 16 --enable-metrics

# The control: no tree at all (ChunkCache)
python -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
  --disable-radix-cache --enable-metrics

Drive all four with a few-shot workload where many prompts share a long preamble. The prediction from this chapter: --page-size 16 costs you almost nothing, --schedule-policy lpm helps most when many similar requests arrive together, and --disable-radix-cache is the cliff.

§14

Exercises

  1. Read the file. In python/sglang/srt/mem_cache/radix_cache.py, find every call site of _split_node — there are exactly two. Explain why one lives inside a function whose name begins with _match, and what that implies about calling match_prefix from two threads.
  2. Predict, then verify. Before running the __main__ block, write down how many nodes the final tree has and each edge's token count. Run pretty_print() and check. Now append tree.insert(InsertParams(key=RadixKey(token_ids=array("q", [1, 2, 4, 8])))) and predict again before running.
  3. Derive. Twelve requests share a 3,000-token preamble and each has a distinct 150-token query, Llama-3-8B at 128 KiB/token. Compute (a) tokens resident in the tree, (b) tokens resident without sharing, (c) HBM saved, (d) prefill saved at 22,000 tok/s. Then redo (a) and (d) for vLLM at block_size=16 with a 3,007-token preamble.
  4. Read across engines. Compare RadixCache.evict with vLLM's BlockPool.free_blocks plus get_new_blocks. Name the one guarantee SGLang makes that vLLM does not, and state precisely what would have to go wrong in vLLM to violate it.
  5. Design. The TODO (csy) at schedule_batch.py:L1941 proposes inserting preempted requests into the tree. Sketch what breaks if you simply flip is_insert=False to True. Hint: read the two lines after it.
Answer — 1

The calls are in _match_prefix_helper (L691) and _insert_helper (L768). Splitting inside the match path is necessary so the tree exposes a node boundary exactly where the query diverged, making the returned last_node a real object the caller can inc_lock_ref. So match_prefix mutates: two concurrent matches can race on parent.children and on the cloned value tensors. There is no safe concurrent read path, and the tree must stay on the single scheduler thread.

Answer — 2

After the five inserts: root has two children, [1,2] and [8,9,10,11,12]. [1,2] has children [3] and [4,5]; [4,5] has child [6,7]. Five non-root nodes. Adding [1,2,4,8] matches [1,2] then [4,5] for one token, so [4,5] splits into [4] -> [5], [5] keeps child [6,7], and a new leaf [8] hangs off [4]. Seven non-root nodes: [1,2], [3], [4], [5], [6,7], [8], [8,9,10,11,12].

Answer — 3

For a 3000-token preamble shared by 12 requests with 150-token suffixes, exact-token reuse stores 3000+12*150=4800 tokens instead of 37800, saving 33000 tokens or about 4.03 GiB. At a changed 3007-token preamble, exact-token reuse stores 4807. A 16-token block hash hits 2992 tokens; all 12 private tails have 165 tokens, requiring 176 physical slots each. Allocated slots total 2992+12*176=5104. Thus compare 5104 with 4807 for the changed workload, not merely add 165 to the old 4800. Extra recomputation for eleven warm requests is 11*15=165 tokens.

Answer — 4

SGLang guarantees a node with a live child is never an eviction candidate — _update_leaf_status enforces it on every structural change. vLLM has no structural guard: free_blocks relies on callers passing blocks "ordered by their eviction priority", and child-before-parent comes from reversing a request's block list at the call site. If any caller passed a forward-order list, a parent could reach the front of the free queue before its children; get_new_blocks would evict the parent's hash and find_longest_cache_hit would break there, leaving every resident descendant unreachable — memory held, hit rate zero.

Answer — 5

Inserting the victim puts its KV into the tree as an evictable path rather than freeing it outright. But the next two lines call evict_from_tree_cache, which evicts leaves only, down to a requested token count — so memory the retraction was supposed to release instantly is released only if the eviction heap happens to select those nodes, and only if they are leaves. Worse, the inserted path may sit under interior nodes other live requests have locked, so the eviction cascade cannot reach it in one call. The comment on the following line — "we should use the newly evictable memory instantly" — is the constraint the TODO has to solve.

§15

Key takeaways

  • The tree's boundaries are the workload's boundaries. Every token inside one edge is used by exactly the same set of requests, because a node is a divergence point. A 16-token block boundary carries no such meaning. This, not token-granularity matching, is the structural argument for RadixAttention.
  • Finer granularity is worth less than a millisecond. page_size=1 versus block_size=16 costs at most 15 tokens per divergence — 0.68 ms of Llama-3-8B prefill. Do not buy the tree for this.
  • Leaf-only eviction is an invariant in SGLang and a convention in vLLM. _update_leaf_status makes it impossible to strand a resident block behind an evicted ancestor; vLLM prevents the same state by reversing block lists at every free site and by find_longest_cache_hit's assumption that a missing block implies all later blocks miss.
  • The tree is a scheduler input, not just a cache. LPM sorting and the throwaway in-batch radix tree are the real divergence: they let SGLang decide admission order from cache state. vLLM's SchedulingPolicy has two members and neither reads the cache. In a burst of 64 requests sharing a cold 2,000-token prefix, that ordering is worth ~5.7 s of H100 prefill.
  • Reads mutate. match_prefix calls _split_node. That single fact explains why the tree is confined to one thread, why radix_cache_cpp exists, and why LPM disables itself past 128 queued requests.
  • SGLang is strictly worse under preemption. is_insert=False drops the victim's KV from the tree — ~372 ms of redundant prefill per 8k preemption versus ~3.7 ms for vLLM. It buys instant memory reclaim, and it has an open TODO against it.
§16

Further reading

  • Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs (arXiv:2312.07104) — the paper that introduced RadixAttention. It reports multi-fold end-to-end throughput improvements (up to 6.4× on its own benchmark suite) against the baselines and configurations described there. Published claim, not reproduced here; treat the per-benchmark configuration in the paper as part of the claim.
  • sgl-project/sglang#25371 — the issue specifying the current mem_cache/ layout. Read it before navigating the directory.
  • sgl-project/sglang#20415 — the Unified Radix Cache convergence tracking issue: where all the variants above are heading.
  • python/sglang/srt/mem_cache/README.md and python/sglang/srt/mem_cache/unified_cache/components/README.md — in-tree, and better than most external write-ups.
  • Morrison, PATRICIA — Practical Algorithm To Retrieve Information Coded In Alphanumeric, JACM 1968 — the original compressed-trie paper. The structure is 58 years old; the application is new.
  • §2.3 for the vLLM half of this pair, §2.6 for HiCache's tiers, and §13.1, which reuses the head-to-head table above.

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

Explore the library

Reading preferences

Appearance
18 px