radixattentionsglangkv-cachellm-inferencetutorial

RadixAttention: Tree-Structured KV Cache Guide for 2026

RadixAttention Architecture: Tree-Structured KV Cache Prefix Sharing, Hierarchical Node Splitting, and LRU Memory Eviction

The Problem: The Inefficiency of Linear KV Caching in Multi-Turn and Branching AI

In modern AI agent frameworks, autonomous data pipelines, and interactive workspaces like AiFetchly, generative workloads rarely consist of isolated, single-turn prompts.

Instead, production applications exhibit complex branching access patterns:

  • Multi-Turn Conversational Chains: User turns continuously append new queries to historical conversation turns, re-sending the same system prompt, tool definitions, and chat logs.
  • Tree-of-Thought & Reasoning Search: High-accuracy reasoning workflows generate multiple competing hypothesis branches (e.g., self-consistency sampling, Monte Carlo Tree Search, or parallel tool calls) from the exact same prefix.
  • Few-Shot Examples and Static System Context: Thousands of independent user requests share identical system instructions, API documentation, or few-shot demonstration tables.

Why Standard PagedAttention and Linear Caching Break Down

While PagedAttention (popularized by early vLLM versions) resolved physical GPU memory fragmentation by mapping non-contiguous virtual memory blocks, it historically treated KV cache allocation through a flat, linear lens.

Traditional serving systems employ basic hash-based prefix caching that matches linear token prefixes from left to right. When deployed across complex agentic workloads, this linear paradigm breaks down:

  1. Inability to Branch Dynamically: When an agent explores two alternate reasoning pathways (Branch A vs. Branch B), a linear cache cannot easily track the shared fork point. If the context diverges slightly, subsequent common suffix tokens must be recomputed from scratch.
  2. Coarse Cache Eviction: Linear caches treat sessions as monolithic blocks. When GPU memory pressure spikes, the eviction engine must evict either an entire conversation history or arbitrary recent blocks, prematurely evicting expensive system prompts and shared tool schemas.
  3. Redundant Prefill Overhead: In a 10-step agent loop, the agent re-computes the initial system prompt and tool definitions on every single iteration, wasting up to 80% of GPU compute solely recalculating known Key-Value states.

The Architecture: Radix Trees for KV Cache Organization

To unlock full memory sharing across arbitrary conversational trees, the team behind SGLang introduced RadixAttention.

Rather than storing cached tokens in a flat hash map, RadixAttention organizes the entire GPU Key-Value cache as a Radix Tree (compact prefix trie) where each edge represents a sequence of tokens, and each tree node references physical GPU page blocks.

RadixAttention Tree Structure: Shared Root System Prompts, Branching Multi-Turn Edges, Dynamic Edge Splitting, and Leaf Node Eviction

The Three Operational Mechanics of RadixAttention

  1. Prefix Matching and Zero-Computation Reuse: When a new prompt arrives, the serving engine tokenizes the input and traverses the radix tree from the root. If a matching prefix path is discovered, the engine retrieves the pre-computed KV cache page pointers directly from the tree nodes. The model skips the prefill phase entirely for those tokens, slashing Time-to-First-Token (TTFT) from hundreds of milliseconds down to near-zero latency.
  2. Dynamic Edge Splitting: When a prompt shares an initial prefix with an existing edge but diverges partway through, RadixAttention automatically splits the edge:
    • A new parent node is created containing the common token prefix and its corresponding GPU memory blocks.
    • Two child branch nodes are spawned—one preserving the original branch and one accommodating the new divergent prompt.
    • This structural mutation occurs entirely in CPU metadata without copying or modifying the underlying GPU VRAM pages.
  3. Reference Counting with LRU Eviction: Every node in the radix tree maintains an active reference counter ($\text{ref_count}$):
    • When a request is actively generating tokens, all nodes along its prefix path have $\text{ref_count} > 0$ and are strictly pinned in GPU VRAM.
    • Once a request finishes, the reference counts decrement back to zero, but the memory blocks remain resident in the tree.
    • When GPU memory capacity is reached, an automated Least Recently Used (LRU) eviction walker prunes unreferenced leaf nodes ($\text{ref_count} == 0$), recycling physical GPU pages while preserving highly frequented root nodes (such as enterprise system prompts and tool schemas).

Comparing KV Cache Memory Architectures

Feature / MetricTraditional Linear CachePagedAttention (vLLM v1)RadixAttention (SGLang)
Data StructureFlat contiguous bufferVirtual memory page tableRadix Tree (Compact Prefix Trie)
Branching SupportNone (1:1 request mapping)Manual cloning requiredNative multi-branch & tree reuse
Multi-Turn Cache Hit RateLow to moderate (~30%)Moderate (~55%)Exceptional (85% – 95% on agent loops)
Edge SplittingUnsupportedUnsupportedInstant zero-copy metadata split
Eviction GranularityFull sequence evictionLinear block LRUHierarchical leaf-first LRU eviction
TTFT on Follow-up TurnsHigh (Reprocesses past turns)ModerateInstant (< 15ms zero-prefill reuse)

Implementation: Building a Working RadixAttention Tree in Python

Let us implement a complete Python simulation of a RadixAttention cache manager demonstrating prefix matching, dynamic edge splitting, reference counting, and LRU leaf eviction.

Step 1: Define the Radix Tree Node and Page Metadata

import time from typing import List, Dict, Optional, Tuple class RadixNode: def __init__(self, token_slice: List[int], page_ids: List[int], parent: Optional['RadixNode'] = None): self.token_slice: List[int] = token_slice self.page_ids: List[int] = page_ids # Physical GPU page block indices self.parent: Optional['RadixNode'] = parent self.children: Dict[int, 'RadixNode'] = {} # Keyed by the first token of child's slice self.ref_count: int = 0 # Number of active requests reading this node self.last_accessed: float = time.perf_counter() @property def is_leaf(self) -> bool: return len(self.children) == 0 def touch(self): self.last_accessed = time.perf_counter()

Step 2: Implement the RadixAttention Tree Manager

class RadixAttentionManager: def __init__(self, total_gpu_pages: int = 100): self.root = RadixNode(token_slice=[], page_ids=[]) self.total_gpu_pages = total_gpu_pages self.allocated_page_counter = 0 def _allocate_pages(self, count: int) -> List[int]: pages = list(range(self.allocated_page_counter, self.allocated_page_counter + count)) self.allocated_page_counter += count return pages def match_prefix(self, tokens: List[int]) -> Tuple[List['RadixNode'], int]: """Traverses tree to find longest matching prefix. Returns (matched_nodes, matched_token_count).""" curr = self.root matched_nodes = [] token_idx = 0 while token_idx < len(tokens): first_tok = tokens[token_idx] if first_tok not in curr.children: break child = curr.children[first_tok] child_tokens = child.token_slice # Compare token by token along the edge common_len = 0 while (common_len < len(child_tokens) and token_idx + common_len < len(tokens) and child_tokens[common_len] == tokens[token_idx + common_len]): common_len += 1 if common_len == len(child_tokens): # Full edge matched matched_nodes.append(child) token_idx += common_len curr = child else: # Partial match -> edge must split token_idx += common_len break return matched_nodes, token_idx def insert_prompt(self, tokens: List[int]) -> Tuple[List[int], int]: """ Inserts a prompt into the radix tree. Returns: (all_page_ids, reused_token_count) """ matched_nodes, matched_tokens = self.match_prefix(tokens) # Collect existing pages from matched nodes reused_pages = [] curr = self.root for node in matched_nodes: node.touch() reused_pages.extend(node.page_ids) curr = node remaining_tokens = tokens[matched_tokens:] if not remaining_tokens: return reused_pages, matched_tokens # Check if we diverged mid-edge in a child node first_tok = remaining_tokens[0] if first_tok in curr.children: child = curr.children[first_tok] # Find common prefix length common_len = 0 while (common_len < len(child.token_slice) and common_len < len(remaining_tokens) and child.token_slice[common_len] == remaining_tokens[common_len]): common_len += 1 # Split child edge parent_slice = child.token_slice[:common_len] child_slice = child.token_slice[common_len:] parent_pages = child.page_ids[:common_len] child_pages = child.page_ids[common_len:] # Create intermediate split node split_node = RadixNode(parent_slice, parent_pages, parent=curr) split_node.children[child_slice[0]] = child child.token_slice = child_slice child.page_ids = child_pages child.parent = split_node curr.children[parent_slice[0]] = split_node reused_pages.extend(parent_pages) matched_tokens += common_len remaining_tokens = remaining_tokens[common_len:] curr = split_node # Allocate new pages for genuinely new tokens if remaining_tokens: new_pages = self._allocate_pages(len(remaining_tokens)) new_leaf = RadixNode(remaining_tokens, new_pages, parent=curr) curr.children[remaining_tokens[0]] = new_leaf reused_pages.extend(new_pages) return reused_pages, matched_tokens

Step 3: Implement LRU Leaf Eviction

def evict_lru_leaf(self) -> bool: """Finds and evicts the unreferenced leaf node with the oldest timestamp.""" lru_node = None oldest_time = float('inf') # Depth-First Search for unreferenced leaves def dfs(node: RadixNode): nonlocal lru_node, oldest_time if node.is_leaf and node.ref_count == 0 and node != self.root: if node.last_accessed < oldest_time: oldest_time = node.last_accessed lru_node = node for child in node.children.values(): dfs(child) dfs(self.root) if lru_node and lru_node.parent: parent = lru_node.parent first_tok = lru_node.token_slice[0] del parent.children[first_tok] print(f"[Eviction] Pruned leaf node with {len(lru_node.token_slice)} tokens (Pages freed: {len(lru_node.page_ids)})") return True return False

Step 4: Run a Multi-Turn Branching Benchmark

def run_radix_benchmark(): manager = RadixAttentionManager(total_gpu_pages=1000) # 1. System prompt (Shared by all user turns) system_prompt = [101, 2045, 1024, 3056, 4012] # 5 tokens # 2. Turn 1: User asks question A turn_1_prompt = system_prompt + [5001, 5002, 5003] # 8 tokens total pages_1, reused_1 = manager.insert_prompt(turn_1_prompt) print(f"Request 1: Ingested {len(turn_1_prompt)} tokens -> Reused: {reused_1} tokens (Prefill: {len(turn_1_prompt) - reused_1} tokens)") # 3. Turn 2: User follow-up in same conversation turn_2_prompt = turn_1_prompt + [9001, 9002] # Continues conversation 1 pages_2, reused_2 = manager.insert_prompt(turn_2_prompt) print(f"Request 2 (Follow-up): Ingested {len(turn_2_prompt)} tokens -> Reused: {reused_2} tokens (Prefill: {len(turn_2_prompt) - reused_2} tokens)") # 4. Turn 3: Alternate branching query (e.g. Tree of Thought / Alternate user) branch_prompt = turn_1_prompt + [8888, 8889] # Diverges from Turn 1 pages_3, reused_3 = manager.insert_prompt(branch_prompt) print(f"Request 3 (Branch): Ingested {len(branch_prompt)} tokens -> Reused: {reused_3} tokens (Prefill: {len(branch_prompt) - reused_3} tokens)") # 5. Simulate memory pressure eviction manager.evict_lru_leaf() run_radix_benchmark()

Production Serving and Deployment Trade-offs in 2026

When architecting production LLM serving stacks with SGLang and RadixAttention:

  • Prompt Engineering for Maximum Prefix Hit Rates: To maximize radix cache hit ratios, developers must enforce strict prompt canonicalization. Dynamic variables (such as timestamps, session UUIDs, and randomized user greetings) should always be placed at the end of the prompt. Placing a variable timestamp at the top of a prompt alters the root tokens, invalidating the entire subsequent radix tree.
  • Ordered Tool Definitions: When defining Model Context Protocol (MCP) or JSON tool schemas, sort tool definitions deterministically (e.g., alphabetically by tool name). Non-deterministic tool serialization creates spurious branch splits that fragment GPU cache memory.
  • SGLang vs. vLLM in 2026: For simple batch processing of independent single-turn documents, standard vLLM remains straightforward. For multi-turn conversational agents, complex agent loops, code completion infilling, and Tree-of-Thought reasoning engines, SGLang’s RadixAttention consistently delivers 3x to 5x higher serving throughput by eliminating redundant prefill computation.

Conclusion

RadixAttention represents a fundamental evolution in generative AI memory management. By structuring the Key-Value cache as a dynamic radix tree with zero-copy edge splitting and hierarchical LRU eviction, SGLang enables seamless, high-throughput prefix reuse across complex multi-turn chats and agentic workflows—delivering dramatic TTFT reductions and optimal GPU utilization at enterprise scale.