KV cache
主线位置:一个请求的一生里「调度 → KV 块分配 / 前缀命中」这一段,以及前向里写 KV、读 KV 的那一步。vLLM 这一段全在 Python(调度器进程)加 CUDA / Triton kernel;SGLang 的编排在 Python,基数树的树核心默认是 Rust(
sglang-radix-tree,进程内 PyO3)。
版本:vLLM v0.32.0、SGLang v0.5.22
每生成一个 token,模型都要读这个请求此前所有 token 的 K 和 V。这些 K、V 留在显存里,就是 KV cache。它随序列变长、随并发变多,通常是权重之外最大的一块显存。服务能撑多少并发,主要看它怎么分、怎么复用、怎么回收。
两个引擎回答这三个问题的方式不同:
- 怎么分:vLLM 把显存切成固定大小的块(默认 16 个 token 一块),每个请求一张块表;SGLang 默认按 token 分槽位(
page_size默认 1),每个请求一行 token 到槽位的映射。 - 怎么复用:vLLM 给每个满块算一个链式哈希,用哈希表查前缀;SGLang 把所有缓存的 token 序列放进一棵基数树,按 token 匹配。
- 怎么回收:vLLM 用一条空闲块队列,同时充当 LRU;SGLang 从树的叶子开始,按最近访问时间驱逐。
本部分四页:
- 分页注意力:块、块池与块表:vLLM v1 的
BlockPool、空闲块队列、引用计数、分配与释放的顺序;worker 侧的块表和slot_mapping;注意力 kernel 只通过块表和slot_mapping两个张量接触这套管理。对照 SGLang 的req_to_token和 token 槽位分配器。 - RadixAttention:SGLang 的基数树:
UnifiedRadixCache的 Python 编排与树核心;匹配会拆节点,插入会释放重复的 KV;锁沿路径加到根;驱逐从可驱逐叶子的最小堆里取。树核心默认 Rust,非 Linux、PyTorch 版本不符等七类情况回退 Python。 - 前缀缓存:块哈希对基数树:哈希怎么算(前一块的哈希、LoRA、多模态、
cache_salt),命中按整块还是按 token,驱逐谁先走;一个按两边源码规则写的 CPU 模拟,看整块命中比 token 级命中少多少。命中率与 TTFT 的实测待 C3-3。 - KV 量化:
--kv-cache-dtype在两边的取值和默认;FP8 的数据存成uint8,每层一个 K scale、一个 V scale 挂在注意力层上;vLLM 的 per-token-head 模式把 fp32 scale 嵌在页里,SGLang 的 FP4 另开 scale 缓冲。效果待 C3-5。
flowchart LR
req["新请求的 token 序列"] --> look{"前缀查找"}
look -->|"vLLM:逐块哈希查表"| vhit["命中的块 ref_cnt+1"]
look -->|"SGLang:沿基数树匹配"| shit["命中的节点加锁"]
vhit --> valloc["从空闲队列头取新块"]
shit --> salloc["分配 token 槽位,不够就驱逐叶子"]
valloc --> fwd["前向:按 slot_mapping 写 KV,按块表或 req_to_token 读 KV"]
salloc --> fwd
fwd --> done["请求结束"]
done -->|"vLLM"| vfree["块倒序回到空闲队列尾,哈希保留"]
done -->|"SGLang"| sins["整段插回树,解锁"]
两个引擎的这部分代码都在调度器所在的进程里。vLLM 打开 Rust 前端(VLLM_USE_RUST_FRONTEND=1)只换掉 API 服务进程,KV 管理仍是 Python;SGLang 的 Rust 树核心见 调度器里的前缀树。
💬 关联讨论
发起讨论加载中...