预计阅读 8 分钟

Token Attention:细粒度 KV 管理 vs Page 级别

谁该读这一篇? 想搞清 SGLang 如何在 token-level 与 paged KV 分配之间切换的工程师;要做 attention backend / memory pool 改造的开发者。 前置阅读: 01-radix-attention.md、00-prerequisites.md §4。 耗时: 30 分钟 学完能: 1. 把 KV cache 的物理布局画出来(slot / page / block); 2. 对比 token-level(SGLang)和 page-level(vLLM)的代价/收益矩阵; 3. 解释常见 page_size=1 路径与大页 backend 的取舍; 4. 看到 KV 显存使用情况,能判断碎片是不是问题; 5. 在 memory_pool.py 和 allocator/ 里找到对应的实现。


1. 物理布局回顾

KV cache 不是按"请求"组织的,是按"slot / page"组织的——所有请求的 KV 都挤在同一块 GPU 显存里。

GPU 显存 (KV pool):
  ┌────────────────────────────────────────────┐
  │ slot 0 │ slot 1 │ slot 2 │ ... │ slot N    │
  └────────────────────────────────────────────┘
    K[N, num_layers, num_kv_heads, head_dim]
    V[N, num_layers, num_kv_heads, head_dim]

每个 slot 容纳"1 个 token 的 K/V"(page_size=1),或 "page_size 个 token 的 K/V"(page_size>1)。 每个请求有一张"逻辑 token id → 物理 slot id"的索引表(在 SGLang 是 req_to_token 矩阵)。


2. 两种粒度

维度 Page-level(例如 page=16) Token-level(page=1)
一个物理单元包多少 token 16 1
索引表项数 ceil(seq_len / 16) seq_len
分配单元 一次分 16 token 的 page 一次分 1 token 的 slot
内部碎片 最后一页可能留空 slot 无页尾浪费
前缀命中精度 必须 16 倍数 任意 token
Attention kernel 适配 backend 必须支持相应 page size / layout backend 必须支持 page table indirection;cascade 可进一步复用共享 prefix

3. page_size=1 的代价

3.1 索引表更大

seq_len = 4096:
  page=16: 索引表 256 项
  page=1:  索引表 4096 项

每个请求都要把 4096 个 slot id 存下来。但这是 int32 张量、4096 × 4 byte = 16 KB,可以忽略。

3.2 Attention kernel 必须支持非连续

经典 attention 算 $\text{softmax}(QK^T) V$ 期望 K、V 是连续矩阵。 token-level 时 K、V 是按 slot id 散落的,必须用支持 indirection(gather)的 kernel。 FlashInfer 提供这类 kernel:

  • BatchPrefillWithPagedKVCacheWrapper —— 按 page table gather K/V。
  • BatchDecodeWithPagedKVCacheWrapper
  • MultiLevelCascadeAttentionWrapper —— 处理 RadixTree 的共享 prefix。

SGLang 会根据平台、模型与显式 backend 解析 attention backend 和 page size;常见 CUDA 配置可走 FlashInfer,但 Blackwell、ROCm、NPU 和特定模型可能解析到其他 backend 或强制 16 / 64 / 128 等页大小。 因此不要把“默认永远是 FlashInfer + page=1”当成跨平台契约,应以 /server_info 和启动日志为准。

3.3 一些 kernel 的内部最小粒度

FlashInfer 内部 tile size 通常是 16 或 32(warp 大小相关)。 即使外面 page_size=1,内部还是会以 16 token 为单位访问—— SGLang 在分配 slot 时把同请求的 slot 尽量连续(reduce 实际 indirection),所以性能没掉太多。


4. SGLang 也支持 page_size > 1

sglang serve MODEL_PATH --page-size 16

什么时候开:

  • 极长 context(128k+):page=16 让索引表小一截。
  • 特定 attention kernel 要求 page_size 对齐(一些 quantization 后端)。
  • KV cache 主要装"几大请求"而不是"很多小请求"(碎片不显著)。

代价:

  • 命中粒度变 16,可能丢一些 token 级匹配机会。
  • RadixCache 内部要"page 对齐",分裂操作变粗。

不要只凭 workload 名称选页大小;先接受当前 backend 的解析结果,仅在命中粒度、索引开销或 kernel 约束明确时覆盖,并对 TTFT、TPOT、吞吐与可用 KV tokens 做 A/B benchmark。


5. MHATokenToKVPool 类(1804+)

源码:memory_pool.py:1804。

class MHATokenToKVPool(KVCache):
    """Token-level KV pool for multi-head attention."""
    def __init__(self, size, dtype, head_num, head_dim, layer_num, device, ...):
        # 显存一次性预分配:
        self.k_buffer = [torch.empty((size, head_num, head_dim), dtype=dtype)
                          for _ in range(layer_num)]
        self.v_buffer = [torch.empty((size, head_num, head_dim), dtype=dtype)
                          for _ in range(layer_num)]
        ...

    def set_kv_buffer(self, layer_id, loc, cache_k, cache_v):
        """把新算的 K/V 写入指定 slot."""
        self.k_buffer[layer_id][loc] = cache_k
        self.v_buffer[layer_id][loc] = cache_v

关键设计:

  • k_buffer / v_buffer 是 List[Tensor],每层一个张量。
  • 每个张量第 0 维是 slot id。
  • "插入"操作就是按 slot id 索引写入。

变体:

  • MLA(DeepSeek 系):MLA 压缩了 KV,用 MLATokenToKVPool。
  • FP4 / FP8 KV:用 MHATokenToKVPoolFP4 等子类。
  • Hybrid linear(如 Mamba 混合架构):用 HybridLinearKVPool。
  • DSA(DeepSeek Sparse Attention):用 DSATokenToKVPool。

每种变体只是写入/读取格式不同,外部 RadixCache 接口完全一致。


6. TokenToKVPoolAllocator:slot 分配(28+)

源码:allocator/token.py:28。抽象接口在 allocator/base.py:27。

职责:维护空闲 slot / page id 张量并完成分配、释放。

class TokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
    def __init__(self, size, dtype, device, kvcache):
        self.size = size
        self.free_pages = ...           # 自由 slot 列表

    def alloc(self, need_size: int) -> torch.Tensor:
        """从 free pool 里拿 need_size 个 slot id."""
        ...

    def free(self, free_index: torch.Tensor):
        """把 slot id 还回 free pool."""
        ...

两种关键使用场景:

  • extend / prefill:一次需要一批新 token slot。
  • decode:每个运行请求通常再需要一个 token slot。

TokenToKVPoolAllocator.alloc() 直接从 free_pages 张量切出任意 slot id; PagedTokenToKVPoolAllocator 才有 page-aware 的 alloc_extend / alloc_decode Triton kernel。


7. 空闲表与分配失败

请求频繁来去会让空闲 id 顺序变化:

free slot 分布: 100, 101, 105, 109, 200-250, 400, ...

token allocator 不要求这 64 个 slot 物理连续:只要 available_size() 足够,单纯的外部“不连续” 不会导致分配失败。启用排序时,释放项先进入 release_pages,需要时由 merge_and_sort_free() 合并排序;attention 通过索引间接访问。

真正的分配压力来自可用 token / page 总量不足,或某个 page-aware / backend 约束不满足。 当前没有 fragmentation_rate Prometheus 指标;联合观察 sglang:kv_available_tokens、 sglang:kv_evictable_tokens、sglang:kv_used_tokens、retraction / eviction 日志和 TPOT。 不要把定期 flush_cache 或重启当 allocator 正常运行的必要条件。


8. PagedAttention vs Token Attention 选型

workload 特征 较大 page page=1
前缀几乎不复用 索引更小,可能匹配某些 backend 的高效布局 命中粒度优势用不上
长共享前缀、尾部边界不齐 会丢掉未满一页的匹配机会 命中精确到 token
backend 强制大页 按 backend 要求配置 不可用或会在参数解析时被覆盖
大量分支共享 prefix 仍可复用完整页 细粒度 radix 分裂更灵活;是否更快取决于 kernel

实际差距取决于 backend、模型、序列长度和复用边界,必须实测。


9. 怎么观察这一切

curl http://localhost:30000/metrics | grep -E "kv_pool|memory_pool|cache_hit"

会看到:

sglang:kv_used_tokens{...}           # 活跃 KV slot 数
sglang:max_total_num_tokens{...}     # 配置后的设备 KV token 上限
sglang:kv_available_tokens{...}      # 当前可用 KV token
sglang:cache_hit_rate{...}           # 命中率

kv_used_tokens / max_total_num_tokens 可作为占用率近似;具体健康区间取决于 workload。 若占用持续接近上限且 kv_evictable_tokens 很少,说明 KV pool 偏紧或活动请求锁定了大量页。


10. 小结

  • SGLang 的 page size 由平台、模型与 backend 解析;常见路径可为 1,也有 16 / 64 / 128 等强制值。
  • Token-level:命中粒度精确,但索引表大、对 attention kernel 要求高。
  • FlashInfer 的 paged + cascade kernel 是 SGLang 选 token-level 的底气。
  • Token allocator 用 GPU tensor free list 支持非连续 slot;page-aware extend 分配等批量路径使用 Triton kernel。
  • 实际性能取决于 workload,前缀复用 + 分支多则 SGLang 优势明显。

11. 自检

  1. page=1 与 page=16 的代价 / 收益矩阵?
答案 **收益**:(a) 命中粒度精确到 1 token,前缀复用率高的业务能 100% 命中;(b) 显存碎片少(不需要为凑整 16 token 浪费尾部);(c) fork 共享精确到 token 级。 **代价**:(a) 索引表项数 16×(4096 token = 4096 项 vs 256 项);(b) Attention kernel 必须支持细粒度 K/V indirection;(c) 某些 backend / layout 只支持更大页。 具体性能不能由页大小单独推出,应对当前 backend 和请求分布实测。
  1. 为什么 token-level 需要 FlashInfer cascade kernel?
答案 token-level 时 K/V 物理布局是按 slot id 散落的(非连续),经典 attention kernel 要求 K/V 是连续矩阵,需要先 gather 拼起来,慢且费显存。 FlashInfer 的 paged attention kernel 内置 page table indirection(按 indices 间接访问 K/V),不需要 gather;cascade kernel 进一步把 shared prefix 的 K/V 只读一次给 N 个 Q 用,token-level 才能高效跑。 Triton 等其他 backend 也可能支持相应布局;谁更快取决于模型、硬件、batch 和序列长度。
  1. free slot 不连续时会不会出现“总量够但分配失败”?怎么观察?
答案 见 §7:token allocator 使用任意 slot id,不要求连续;关注可用 token 总量、retraction / eviction 和端到端 TPOT,而不是不存在的 fragmentation 指标。
  1. MLA 模型在 SGLang 里用哪个 KV pool 子类?为什么?
答案 `MLATokenToKVPool`([`memory_pool.py:4056`](../sglang/python/sglang/srt/mem_cache/memory_pool.py))。 原因:MLA(Multi-head Latent Attention)把 K 和 V 压缩到一个**共享低维 latent 空间**,物理上不再是 `[num_kv_heads, head_dim]` 而是 `[latent_dim]`。普通 MHATokenToKVPool 的张量形状不适配。 DeepSeek-V3 用 `DSATokenToKVPool`(MLA 的 sparse 变体)。所有 MLA 系列都得用 `flashinfer-mla` 或 `flashmla` attention backend 配套。
  1. 什么时候会使用 alloc_extend_kernel?
答案 `PagedTokenToKVPoolAllocator` 的 page-aware `alloc_extend` / `alloc_decode` 使用 Triton kernel,在 GPU 上根据 page table 和 free-page 张量批量生成索引;普通 `TokenToKVPoolAllocator.alloc()` 则直接切分 free slot tensor。 具体路径由 page size、backend 和 KV pool 类型决定。

12. 下一步

上游源码:sglang/python/sglang/srt/mem_cache/。