Rust in Everything

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 从树的叶子开始,按最近访问时间驱逐。

本部分四页:

  1. 分页注意力:块、块池与块表:vLLM v1 的 BlockPool、空闲块队列、引用计数、分配与释放的顺序;worker 侧的块表和 slot_mapping;注意力 kernel 只通过块表和 slot_mapping 两个张量接触这套管理。对照 SGLang 的 req_to_token 和 token 槽位分配器。
  2. RadixAttention:SGLang 的基数树:UnifiedRadixCache 的 Python 编排与树核心;匹配会拆节点,插入会释放重复的 KV;锁沿路径加到根;驱逐从可驱逐叶子的最小堆里取。树核心默认 Rust,非 Linux、PyTorch 版本不符等七类情况回退 Python。
  3. 前缀缓存:块哈希对基数树:哈希怎么算(前一块的哈希、LoRA、多模态、cache_salt),命中按整块还是按 token,驱逐谁先走;一个按两边源码规则写的 CPU 模拟,看整块命中比 token 级命中少多少。命中率与 TTFT 的实测待 C3-3。
  4. 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 树核心见 调度器里的前缀树。

💬 关联讨论

发起讨论
加载中...