KV 生命周期、回收与驱逐#

前两节已经解释了 waiting queue 怎样塑形 batch,也解释了 prefix reuse 为什么不能被看成简单布尔命中。再往下走,就必须回答一个更物理的问题:这些请求背后的 KV 到底怎样被占用、引用、释放和驱逐。

这一节只处理三件事:

  1. request 到 token,再到物理 KV 的映射是怎样建立起来的;
  2. KV 在 extend、decode 和完成之后怎样继续存活或被释放;
  3. cache 驱逐为什么不是简单的“空间满了就删最旧”。

一张图先看 KV 生命周期#

flowchart TB
    A["Req"] --> B["ReqToTokenPool"]
    B --> C["TokenToKVPoolAllocator / KVCache"]
    C --> D["ForwardBatch / ModelRunner"]
    D --> E["reuse / retain / evict"]

这张图最值得记住的一点是:KV 生命周期不是单独存在的一层,它和请求对象、调度对象、执行对象都绑在一起。

ReqToTokenPool 解决的是“谁占了哪些位置”#

ReqToTokenPool 的角色,很适合先用一句话固定下来:它负责“某个 request 当前占了哪些 token 位置”。

这件事为什么关键?因为对 runtime 来说:

  • 逻辑上看到的是 request 和 token;
  • 物理上管理的是 KV 槽位。

如果没有这层中间映射,scheduler 很难知道某个 request 释放时应该收回哪一段资源,cache 也很难知道自己命中的到底是哪组真实位置。

ReqToTokenPool 的数据形状已经说明它不是普通 map,而是一张预分配表:

self.req_to_token = torch.zeros(
    (size, max_context_len), dtype=torch.int32, device=device
)

这意味着它从一开始就被设计成可批量访问的运行时账本,而不是方便写代码的辅助结构。

ScheduleBatch 把 KV 生命周期带进执行层#

第三章已经见过 Req,但真正让 KV 生命周期进入执行路径的,是 ScheduleBatchForwardBatch

ScheduleBatch 持有:

  • req_to_token_pool
  • token_to_kv_pool_allocator
  • tree_cache
  • out_cache_loc

这意味着一旦 batch 被创建,KV 生命周期就已经不再是“调度器后面的资源问题”,而是 batch 本身的一部分。

一个请求的 KV 生命周期:从 prefill 到 evict#

以一个 prompt 长度 256、生成上限 128 tokens 的请求为例,追踪它的 KV 全程:

阶段 1:prefill(一次性填满 256 个 slots)

Admission 时,scheduler 通过 ReqToTokenPool 为这个请求分配 256 个 slot:

req.kv_allocated_len = 256    # 预分配
req.kv_committed_len = 0      # 还没有通过 RadixCache.insert 写入

prefill 执行后,attention 计算把 Q/K/V 写入这 256 个 slot。完成后,这批 KV 会通过 RadixCache.insert 被登记进 prefix tree:

req.kv_committed_len = 256    # 已提交到 RadixCache,后续请求可以复用

阶段 2:decode(每步增加 1 个 slot)

每生成一个 token,scheduler 额外分配 1 个 slot:

step 1: kv_allocated_len = 257
step 2: kv_allocated_len = 258
...

decode 阶段新增的 KV slot 通常不立刻 commit 到 RadixCache(因为 decode 中间态不是完整前缀),而是在请求完成后才统一 commit(或放弃,取决于后续是否有相同前缀的请求)。

阶段 3:请求完成

请求 finish 时发生两件事:

  1. 本次请求的 token pool 条目被释放ReqToTokenPool 里这个 req 的行被清零,对应的 token 位置不再属于这个请求;
  2. RadixCache 决定是否保留 KV:如果 commit 了的 KV(即 prefill 部分)被 RadixCache 保留在树里,KV 物理 slots 不会立即被释放,等待未来的前缀匹配命中;如果 RadixCache 觉得这段前缀不值得保留,会触发 evict,对应的物理 KV slots 才会真正归还给 TokenToKVPoolAllocator

阶段 4:eviction(由 RadixCache 触发)

当 KV 内存压力增大时,RadixCache 的 eviction policy(LRU 等)会选择叶节点进行驱逐。被驱逐的节点对应的 KV slots 通过 token_to_kv_pool.free() 归还。

驱逐条件:空闲 KV slots < 阈值
驱逐对象:RadixCache 叶节点(无子节点,引用计数为 0)
驱逐操作:radix_cache.evict(num_tokens) → token_to_kv_pool.free(indices)

关键约束:正在被 running batch 引用的节点(引用计数 > 0)不能被驱逐,即使它是叶节点。这防止了”KV 被驱逐但 batch 还在用”的情况。

为什么 KV 生命周期不是”请求结束时统一释放”这么简单#

最容易先入为主的想法是:request 完成后把 KV 释放掉就行。但从 Req 的字段已经可以看出,系统显然不是这么粗糙:

  • kv_committed_len:已经写入 RadixCache 的 token 数
  • kv_allocated_len:已经从 pool 分配出来的 token 数
  • kv_committed_freed:已经从 RadixCache 侧回收的部分
  • kv_overallocated_freed:超出 committed 的临时分配被回收的部分

这说明 KV 在运行时里至少会区分:

  • 已经 committed 的部分(保留在 RadixCache 里,供后续复用);
  • 只是临时分配、还可能回收的部分(decode 过程中多分配的 slots);
  • 已经释放过的部分;
  • 还在被后续路径引用的部分。

所以 KV 生命周期是分阶段的,而不是”请求结束时一把梭”。

RadixCache 决定的是保留和驱逐策略#

RadixCache 的初始化已经说明,它不仅关心匹配,还关心驱逐策略:

  • page_size
  • eviction_policy
  • evictable_leaves

这意味着 cache 驱逐不是某个 allocator 内部的顺手逻辑,而是一条独立策略线。也正因为这样,你会看到:

  • lru
  • lfu
  • fifo
  • priority
  • slru

这些策略不是为了“写得花”,而是因为不同 workload 下,谁更应该被赶走,本来就不是同一个答案。

为什么驱逐不能只看“谁最旧”#

对工程直觉来说,“空间满了就删最旧”很自然,但对 prefix cache 来说这往往不够。原因至少有三类:

  1. 某段前缀虽然旧,但命中价值很高;
  2. 某个 request 虽然结束了,但它的 committed KV 可能还值得被后续请求复用;
  3. 某些资源虽然现在空闲,但很快还会被下一轮 decode 或 session 路径继续引用。

所以驱逐策略真正解决的不是“怎么腾空间”,而是“哪些状态最不值得继续留”。

调试 KV 问题时先看哪里#

如果你看到的现象是:

  • 显存很快打满;
  • cache 命中看起来正常,但资源回收不及时;
  • 某些请求完成以后,后续 batch 仍然很难接新请求;

更稳的顺序通常是:

  1. 先看 ReqToTokenPool 和 request 侧的占用是否已经释放;
  2. 再看 ScheduleBatch / ForwardBatch 当前还持有哪些 KV 位置;
  3. 然后确认 RadixCache 的保留与驱逐策略是否还在保护这些位置;
  4. 最后再判断这是调度压力问题,还是 cache 驱逐问题。

这里最常见的误读,是把“资源还没释放”直接当成 allocator bug。实际上,很多时候是更上层仍然认为这些 KV 还有价值。

小结#

这一节真正要稳定下来的,是 KV 生命周期的三层关系:

  • ReqToTokenPool 记录 request 占了哪些 token 位置;
  • ScheduleBatch / ForwardBatch 把这些位置带进执行路径;
  • RadixCache 和 allocator 决定哪些位置继续保留、哪些位置可以驱逐。

只要这三层关系稳住,后面再看执行模型时,就不会把 KV cache 误看成“模型前向后面顺带的一层优化”。它本来就是运行时主能力的一部分。