RadixAttention:SGLang 的基数树
主线位置:一个请求的一生里「调度 → 前缀命中 / KV 分配」这一段,以及预填充之后、请求结束时把 KV 交回缓存的两步。全部发生在 SGLang 的调度器进程里:编排是 Python;树核心从 v0.5.21 起默认是 Rust(
sglang-radix-tree,PyO3 扩展,进程内调用),满足不了条件时回退到 Python。vLLM 没有这棵树,它的做法见 前缀缓存。
版本:vLLM v0.32.0、SGLang v0.5.22
结论先说:
- 默认的前缀缓存是
UnifiedRadixCache。它管编排:调用树核心、执行树核心返回的「释放这些 KV」动作、和分配器打交道。树本身的结构、锁、LRU、驱逐遍历在「TreeCore」里。 - 树的键是 token 序列,按 token 匹配(
page_size默认 1)。LoRA、cache_salt不同的请求,第一层子节点的键就不同,互不命中。 - 匹配不是只读的:命中停在某个节点中间时,当场把节点拆成两段。
- 插入发生两次:预填充之后,和请求结束时。树里已有的那段,请求自己算的那份 KV 立即释放。
- 正在用的路径加锁,锁一直加到根。驱逐只动没上锁的叶子,按最近访问时间从旧到新,叶子删完父节点变成叶子再接着排。
谁是默认的缓存
python/sglang/srt/mem_cache/registry.py
def default_radix_cache_factory(ctx: TreeCacheBuildContext) -> BasePrefixCache:
# ...
if ctx.disable_radix_cache:
# ...
return create_unified_radix_cache(ctx)
if get_memory().enable_lmcache:
# ...
if is_pure_swa:
# ...
return PureSWARadixCache(params=params)
# ...
return create_unified_radix_cache(ctx)
除了纯滑动窗口模型、LMCache、FlexKV 几个分支,都落到 UnifiedRadixCache。--disable-radix-cache 默认 False(memory.py);打开它也仍然建一个 UnifiedRadixCache,只是处于禁用状态,匹配永远返回空。早先用来打开它的 SGLANG_ENABLE_UNIFIED_RADIX_TREE 已经标为废弃(environ.py)。
「Unified」指一棵树同时挂几种组件:全注意力的 KV(FULL)、滑动窗口的 KV(SWA)、Mamba 状态(MAMBA)。本页只讲普通模型用到的 FULL。
编排与树核心
python/sglang/srt/mem_cache/unified_radix_cache.py
self._tree_core_backend = select_tree_core_backend(params)
# ...
self.tree_core = create_tree_core(
name=self._tree_core_backend,
params=tree_params,
components=self.components,
)
后端名来自环境变量 SGLANG_UNIFIED_RADIX_TREE_CORE_BACKEND,默认 "rust"(environ.py)。选了 Rust 也要先过 _rust_fallback_reason,以下任何一条成立就改用 Python 的 UnifiedTreeCore,日志里打一行 Using the Python TreeCore: ...:
| 条件 | 出处 |
|---|---|
打开了会话感知缓存(enable_session_radix_cache) | L45 |
| 组件不止 FULL / SWA / MAMBA,或用了自定义组件 | L52、L54 |
| 不是 Linux(macOS 总是回退) | L55 |
| PyTorch 不在 2.11–2.14 | L68、torch_build.py |
| 设备不是 CPU 或 CUDA | L73 |
| 既没有编好的扩展,也没有源码 | L91 |
要从源码编,但 cargo / rustc 不可用 | L105 |
两个实现满足同一个接口 UnifiedTreeCoreInterface。下文的代码引用 Python 版 UnifiedTreeCore,因为它更好读、行为与 Rust 版对齐;Rust 版的对应函数如 match_prefix。Rust 一侧怎么组织节点、怎么跨 PyO3 边界、为什么挪到 Rust,见 调度器里的前缀树。
分工的要点:树核心不直接释放显存。它返回一串「动作」(比如 FreeDeviceKV(槽位)),由 Python 侧的 UnifiedRadixCache 交给分配器执行(_apply_cache_actions)。这样 Rust 核心只碰元数据,不碰 CUDA 张量的生命周期。
键:token 序列,加命名空间
python/sglang/srt/mem_cache/radix_cache.py
def child_key_at(self, offset: int, page_size: int = 1):
t = self.token_ids
# ...
plain = (
t[offset] if page_size == 1 else tuple(t[offset : offset + page_size])
)
if self.cache_salt is not None:
return ((self.extra_key, self.cache_salt), plain)
return plain if self.extra_key is None else (self.extra_key, plain)
每个节点的 children 是一个字典,键是子节点第一个 token(page_size 为 1)或第一页 token 组成的元组。带 extra_key 或 cache_salt 的请求,这个字典键前面多一层命名空间,所以从根下第一层起就走到不同的子树。extra_key 由调用方给出,LoRA 请求会把 lora_id 拼上去(schedule_batch.py)。多模态的处理方式不同,见 前缀缓存。
page_size 大于 1 时,匹配和插入前都先把键截到整页(page_aligned),比较结果也向下取整到整页(match_at)。这时命中粒度和 vLLM 的块一样。
匹配:沿树走,停在节点中间就拆
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py
while key_offset < len(key) and child_key in node.children:
child = node.children[child_key]
# ...
prefix_len = child.key.match_at(key, key_offset, page_size=self.page_size)
full_kv_hit_length += prefix_len
if prefix_len < len(child.key):
node, action = self._split_node(child.key, child, prefix_len)
# ...
break
# ...
node = child
key_offset += prefix_len
# ...
走到某个子节点只匹配了前一部分时,_split_node 把它拆成「已匹配的前段」和「剩下的后段」两个节点,前段成为后段的父节点,KV 槽位也跟着切开。这样匹配结果总能落在一个完整节点的末尾,后面加锁和插入都以节点为单位。
匹配结束后,从命中的最深节点一路往上到根,把每个节点的 last_access_time 改成当前时刻(L1028),这是 LRU 的「访问」。
调度器拿到命中长度后再截一刀:最多复用 输入长度 - 1 个 token,最后一个 token 必须重算才能得到 logits(_compute_max_prefix_len,调用处在 match_kv_cache)。要返回 prompt 的 logprob 时还会截得更短。
插入:预填充后一次,结束时一次
请求在两处把 KV 交给树:
- 每轮预填充算完,
checkpoint_kv_cache把已经算出的部分插进去。这样同时在跑的其他请求马上就能命中这段前缀,不用等它解码完。 - 请求结束,
release_kv_cache把 prompt 加输出整段插进去,然后释放超出部分、解锁。
插入沿树走,和匹配一样会拆节点;走过的节点刷新访问时间。对「树里已经有」的那一段,请求自己那份 KV 是重复的,当场释放:
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py
dup_start = max(0, state.params.prev_prefix_len - state.total_prefix_length)
if dup_start < consumed_from:
dup = value_slice[dup_start:consumed_from]
# ...
if swa_already_freed < dup.numel():
step_actions.append(FreeDeviceKV([dup[swa_already_freed:]]))
这种重复在并发时常见:两个请求同时预填充同一段系统提示,后插的那个发现树里已经有了,就交出自己的槽位。剩下树里没有的部分,挂成一个新叶子(_add_new_node),槽位归树所有。vLLM 在这一点上相反:同一哈希的多份块都留着,不去重。
锁:从节点一路加到根
请求命中某个节点后要持有一把锁,防止它正在用的 KV 被驱逐:
python/sglang/srt/mem_cache/unified_cache/components/full.py
# Lock the device-on segment up to root
delta = 0
while cur is not root:
cd = cur.component_data[ct]
# ...
if cd.lock_ref == 0:
key_len = self.reclaimable_tokens(cur)
self.tree_core.component_evictable_size_[ct] -= key_len
self.tree_core.component_protected_size_[ct] += key_len
delta += key_len
cd.lock_ref += 1
self.tree_core.evictable_device_leaves.discard(cur)
cur = cur.parent
锁是计数,从被锁的节点一直加到根。某个节点第一次被锁,它的 token 数从「可驱逐」挪到「受保护」,调度器靠这两个数估算还能腾出多少显存。解锁(release_component_lock)反过来,计数归零的节点如果是叶子,重新进入可驱逐集合。节点被拆开时,新的父节点继承原节点的锁计数(full.py L148),路径上的计数保持一致。
插入之后,请求的锁从原来命中的节点挪到插入停下的节点(checkpoint_into_tree)。
驱逐:叶子最小堆
分配槽位前,分配器先算缺口,缺多少就请树驱逐多少:
python/sglang/srt/mem_cache/allocator/base.py
shortfall = num_tokens - self.available_size()
if shortfall > 0:
tree_cache.evict_for_alloc(EvictParams(num_tokens=shortfall))
FULL 组件的驱逐遍历先把所有可驱逐叶子(没上锁、没有子节点)按策略的优先级建成最小堆,然后一次弹一个:
python/sglang/srt/mem_cache/unified_cache/components/full.py
def _evict_device_start(self, request_cnt: int) -> None:
# ...
self._evict_device_heap = [
(self.session_ref_eviction_strategy(n), n)
for n in self.tree_core.evictable_device_leaves
]
heapq.heapify(self._evict_device_heap)
def _evict_device_next_node(self, tracker, device_frees, host_frees):
# ...
if (
lv is not None
and lv.parent is not None
and lv.parent in self.tree_core.evictable_device_leaves
):
heapq.heappush(
self._evict_device_heap,
(self.session_ref_eviction_strategy(lv.parent), lv.parent),
)
# ...
while tracker[ct] < self._evict_device_request_cnt and self._evict_device_heap:
_, x = heapq.heappop(self._evict_device_heap)
# ...
return x.id
上一个被删的叶子如果让父节点变成了叶子,父节点进堆接着排。删一个节点就释放它的全部槽位,所以驱逐的粒度是整个叶子:缺 10 个 token、最旧的叶子有 300 个 token,也是 300 个一起走。
优先级由 --radix-eviction-policy 决定,默认 lru(memory.py),可选 lfu、slru、priority、tlru(choices.py)。LRU 的优先级就是节点的 last_access_time(LRUStrategy)。
一个请求和树打交道的全过程
sequenceDiagram participant Sch as 调度器(Python) participant URC as UnifiedRadixCache(Python) participant TC as TreeCore(默认 Rust) participant Alloc as 槽位分配器(Python) Sch->>URC: match_prefix(token 序列) URC->>TC: 沿树匹配,必要时拆节点 TC-->>URC: 命中长度、最深节点 Sch->>URC: lock(节点):锁计数加到根 Sch->>Alloc: 要 输入长度 - 命中 个槽位 Alloc->>URC: 不够:evict_for_alloc(缺口) URC->>TC: 从叶子堆里按 LRU 弹节点 TC-->>URC: FreeDeviceKV 动作 URC->>Alloc: 释放这些槽位 Note over Sch: 预填充 Sch->>URC: checkpoint:插入已算部分,重复的 KV 释放 Note over Sch: 解码直到结束 Sch->>URC: release:插入整段,解锁
拿一个具体的例子:两个请求共用一段 40 个 token 的系统提示,各自再跟 10 个 token,各生成 4 个 token。按上面的规则走一遍(本卷的 CPU 模拟,见 前缀缓存),第二个请求命中 40 个 token,树变成下面这样:
flowchart TB
root(("根")) --> sys["系统提示 40"]
sys --> a1["请求 0 的后缀 10"]
a1 --> a2["请求 0 的输出 4"]
sys --> b1["请求 1 的后缀 10"]
b1 --> b2["请求 1 的输出 4"]
请求 0 预填充后插入了 50 个 token 的一个节点,结束时又挂上 4 个 token 的输出;请求 1 匹配时在第 40 个 token 处把那个 50 的节点拆成 40 和 10。同样的两个请求在 vLLM 里只命中 32 个 token,原因见下一页。
小结
- SGLang 默认的前缀缓存是
UnifiedRadixCache:Python 编排,树核心默认 Rust,七类条件回退 Python。树核心只改元数据,释放显存的动作交回 Python 执行。 - 键是 token 序列,
extra_key(含 LoRA)和cache_salt在第一层就把树分开。page_size默认 1,命中按 token。 - 匹配会拆节点并刷新路径的访问时间;调度器最多复用
输入长度 - 1个 token。 - 插入在预填充后和请求结束时各一次,树里已有的部分当场释放请求自己那份。
- 锁沿路径加到根;驱逐从未上锁叶子的最小堆里按 LRU 弹,粒度是整个叶子。
- 树操作本身的开销(Python 对 Rust)见 调度器里的前缀树;命中率与 TTFT 待实测(C3-3)。