RadixAttention: the tree (SGLang)
python/sglang/srt/mem_cache/radix_cache.pypython/sglang/srt/mem_cache/base_prefix_cache.pypython/sglang/srt/managers/schedule_policy.py
a556f3f · sglang 7d89325Two 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.
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.
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.
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.
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
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.
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
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:
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:
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
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.
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
| Operation | Cost | Dominant term |
|---|---|---|
match_prefix | $O(D \log m)$ slice compares + $D$ dict lookups + one torch.cat over $D$ tensors | the torch.cat, for large $D$ |
_split_node | $O(1)$ pointer ops + $O(m)$ index-tensor clone (8 B/token) | the clone |
insert | same walk + $\le 1$ split per hop + $O(n_{new})$ clone of the new tail | the tail clone |
evict(k) | $O(L)$ heapify + $O(k' \log L)$ pops, $k'$ = nodes evicted | the heapify, rebuilt every call |
inc_lock_ref / dec_lock_ref | $O(D)$ — walk from node to root | tree 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.
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
Both find the shared prefix; the map is not wrong. What it lacks is any representation of the three facts the tree makes free:
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.
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".
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:
# 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:
# 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:
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
TreeNodecarries fourteen attributes plus adefaultdictplus a torch tensor. Atpage_size=1with 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_refwalk to the root on every admission and completion, adjustingevictable_size_,protected_size_and the leaf set at each hop. vLLM's equivalent isblock.ref_cnt += 1in a loop. - Split-on-read.
match_prefixmutates. 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_cppexists partly to move this off the interpreter.
Granularity: page_size versus block_size
SGLang's tree granularity is page_size, and the default is 1:
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.
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.
Eviction: LRU over leaves
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:
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
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
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".
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:
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:
class SchedulingPolicy(Enum):
"""Enum for scheduling policies."""
FCFS = "fcfs"
PRIORITY = "priority"
LPM's sort is trivial once the matches exist:
@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:
# 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:
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.
Behaviour under preemption
§1.4 established this asymmetry; it belongs in the comparison table, so verify it here. On retraction SGLang calls:
# 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.
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".
7d89325. "Default path" means reachable from default_radix_cache_factory in python/sglang/srt/mem_cache/registry.py:L80-L143.| File | Class | Lines | What it is for |
|---|---|---|---|
radix_cache.py | RadixCache | 863 | The canonical implementation, and what this chapter reads. Still live: the scheduler's in-batch dedup tree is a RadixCache.create_simulated(). |
unified_radix_cache.py | UnifiedRadixCache | 2887 | default 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.py | HiRadixCache | 2022 | Hierarchical: each node optionally backed by a host mirror (host_value, host_ref_counter). Tiers are §2.6. |
mamba_radix_cache.py | MambaRadixCache | 1427 | not 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.py | SWARadixCache | 1440 | Hybrid sliding-window models. Dual LRU: full and SWA layers age out independently. |
pure_swa_radix_cache.py | PureSWARadixCache | 153 | All-SWA models. Thin RadixCache subclass: caches only [0, evict_floor), frees the window range, no tombstones. |
radix_cache_cpp.py | RadixCacheCpp | 272 | Experimental C++ tree behind SGLANG_EXPERIMENTAL_CPP_RADIX_TREE. Rejects cache_salt. The escape hatch from the GIL. |
chunk_cache.py | ChunkCache | 178 | Not 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.
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.
Head to head
a556f3f, SGLang 7d89325. All numbers derived from source reading plus the Llama-3-8B / H100 arithmetic of §1.4; none measured.| Property | vLLM — hash map | SGLang — radix tree |
|---|---|---|
| Structure | BlockHashToBlockMap + doubly-linked FreeKVCacheBlockQueue | Compressed trie; edges carry token sequences; explicit evictable_leaves set |
| Match granularity | Block-aligned, default 16 tokens | page_size, default 1 token on CUDA |
| Granularity cost | Up to 15 tokens lost per divergence, $\approx$0.68 ms Llama-3-8B prefill | None; more nodes to bookkeep instead |
| Match semantics | Iterate chained block hashes from position 0, break on first miss | Descend from root, split the edge where the key diverges, return the node |
| Match cost, 8k prompt | 512 map lookups | $D$ dict lookups + $D\log m$ slice compares; $D$ = divergence points, typically 3–5 |
| Eviction unit | One 16-token block | One node = one full run between divergence points |
| Eviction ordering | Free-queue position (release time). Leaf-before-parent by caller convention — the list is reversed at free time | Heap over leaves only; interior nodes structurally excluded by _update_leaf_status |
| Eviction policy | LRU, not configurable | LRU / LFU / SLRU / FIFO / MRU / FILO / priority via --radix-eviction-policy |
| In-use protection | block.ref_cnt, one integer per block | lock_ref on every node from the match point to the root; maintains protected_size_ |
| Scheduler coupling | None. SchedulingPolicy is FCFS | PRIORITY | LPM and DFS_WEIGHT sort the waiting queue by tree match; a scratch tree deprioritizes in-batch prefix collisions |
| On preemption | Freed 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) |
| Concurrency | Lookup is a pure read; touch mutates the queue only | Lookup mutates the tree (edge splitting). Single-threaded scheduler loop only; radix_cache_cpp is the escape hatch |
| Per-entry overhead | KVCacheBlock — id, ref_cnt, hash, two queue pointers | TreeNode — 14 attributes, a defaultdict, an int64 index tensor (8 B/token) |
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.
SchedulePolicy.calc_priority(schedule_policy.py:L237) →_compute_prefix_matches→match_prefix_for_req(schedule_policy.py:L138-L197), which wraps the token ids in aRadixKeycarryingreq.extra_keyandreq.cache_salt.RadixCache.match_prefix→page_aligned(1)is a no-op →_match_prefix_helper(root, key).- 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. prefix_len=2055 < len(child.key)=2151, so_split_noderuns. 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.- The helper
breaks and returns([new_node.value], new_node);match_prefixtorch.cats and returns 2,055 device indices.match_prefix_for_reqwritesreq.prefix_indices,req.last_node,req.num_matched_prefix_tokens = 2055. _sort_by_longest_prefixsorts R2 ahead of anything with a shorter match.- Admission:
PrefillAdder._req_inc_lock_ref(schedule_policy.py:L957-L963) walks to the root. 2,055 tokens (257 MiB) move fromevictable_size_toprotected_size_. - Only 80 tokens are prefilled. Derived saving against cold: $2055/22000 = 93$ ms.
- R2 finishes.
release_kv_cache(..., is_insert=True)→cache_finished_req→insert, which walks back down, findstotal_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_refunwinds the locks;_update_leaf_statusadds the new node toevictable_leaves.
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.
Hands-on
The tree runs standalone. radix_cache.py has a __main__ block that builds one with no GPU:
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:
# 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.
Exercises
- 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 callingmatch_prefixfrom two threads. - Predict, then verify. Before running the
__main__block, write down how many nodes the final tree has and each edge's token count. Runpretty_print()and check. Now appendtree.insert(InsertParams(key=RadixKey(token_ids=array("q", [1, 2, 4, 8]))))and predict again before running. - 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=16with a 3,007-token preamble. - Read across engines. Compare
RadixCache.evictwith vLLM'sBlockPool.free_blocksplusget_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. - Design. The
TODO (csy)atschedule_batch.py:L1941proposes inserting preempted requests into the tree. Sketch what breaks if you simply flipis_insert=FalsetoTrue. 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.
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=1versusblock_size=16costs 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_statusmakes 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 byfind_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
SchedulingPolicyhas 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_prefixcalls_split_node. That single fact explains why the tree is confined to one thread, whyradix_cache_cppexists, and why LPM disables itself past 128 queued requests. - SGLang is strictly worse under preemption.
is_insert=Falsedrops 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 openTODOagainst it.
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.mdandpython/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.