去年做公司内部的模型推理服务,当时用的是标准 推理——每个请求过来,给 KV Cache 开一块显存。用户少的时候好好的,并发一上到 50,显存直接被 KV Cache 吃爆了。一个长文本请求可能占 3GB 显存,其中真正有用的只有 800MB。剩下的 2.2GB 是"预留的"——因为不知道用户会多长的上下文,只能按最大值分配。

然后我在一次技术分享上听人提到 vLLM 的 。听完我就回去把推理引擎从标准实现换成了 vLLM,同样的 24GB 显存,能撑住的并发从 50 变成了接近 200。不是因为 vLLM 的模型更小,是它用了一个我从没想过的思路——把 KV Cache 当成虚拟内存来管理。

KV Cache 是推理的内存黑洞

先搞清楚问题在哪。 每生成一个 token,都要注意()之前所有 token 的 Key 和 Value。如果不缓存这些 KV,每个新 token 都要重新计算之前所有的 ——O(n²) 的复杂度,根本没法用。

所以标准做法是把每一层的 K 和 V 缓存起来——这就是 KV Cache。但是,KV Cache 要预留多少内存是个难题:

这就是我一开始遇到的那个问题——3GB 显存只用了 800MB,剩下都是浪费。

的核心想法:像虚拟内存一样管理 KV Cache

vLLM 的团队问了这样一个问题:如果操作系统能把物理内存按页分块、虚拟地址映射到物理页,我为什么不把 KV Cache 也按页分块?

答案是——可以。而且效果出奇的好。

把 KV Cache 的显存空间切分成固定大小的 Block(类似操作系统的内存页)。每个 Block 可以存固定数量的 token 的 KV 值(比如 16 个 token)。一个请求不需要预先分配一整块连续的显存——它只需要一组 Block 的"逻辑地址",真正用到的时候再通过 Block Table 映射到物理 Block。

# PagedAttention 的核心数据结构——Block Table
class BlockTable:
    # 每个序列的 Block 映射表
    # block_table[req_id] = [block_0, block_5, block_2, ...]
    # 逻辑块号 → 物理块号的映射
    
    def allocate(self, req_id, num_tokens):
        num_blocks = ceil(num_tokens / BLOCK_SIZE)
        for _ in range(num_blocks):
            block = self.free_blocks.pop()  # 从空闲池取一个 block
            self.block_table[req_id].append(block)
    
    def free(self, req_id):
        for block in self.block_table[req_id]:
            self.free_blocks.append(block)  # 归还 block 到空闲池
        del self.block_table[req_id]

效果显著:

具体的 计算怎么改

标准 假设 K 和 V 是在连续内存里的,所以直接做矩阵乘法就行。 的 K 和 V 分散在不同的 Block 里,需要一个自定义的 CUDA 来处理。

# PagedAttention Kernel 的伪代码
def paged_attention_kernel(query, block_table, k_cache, v_cache):
    for block_id in block_table:
        # 从 k_cache 和 v_cache 中取出当前 block 的片段
        k_block = k_cache[block_id * BLOCK_SIZE : (block_id + 1) * BLOCK_SIZE]
        v_block = v_cache[block_id * BLOCK_SIZE : (block_id + 1) * BLOCK_SIZE]
        # 对当前 block 计算 attention
        scores = matmul(query, k_block.T) / sqrt(head_dim)
        attn = softmax(scores)
        output += matmul(attn, v_block)
    return output

内核循环从"遍历所有 token"变成了"遍历 Block Table 中的每个 Block"。计算量没变——因为实际参与计算的 token 数量一样——但显存布局从连续大块变成了分页小块。这个改动的工程难度不在模型上,在 CUDA 上。

vLLM 团队专门为这个写了一个叫 的 CUDA ,利用 SM 共享内存做 Block 级别的分块矩阵乘法。这个 的代码在 vLLM 仓库里只有大约 400 行 C++/CUDA,但性能优化到能和普通 持平甚至略快——因为分页后每个 Block 的 KV Cache 刚好适合 L2 Cache 的大小。

不止是内存, 还改变了调度

传统推理引擎的调度器看到的是"一个请求 = 一块连续显存"。连续显存碎片多了就分配不了新请求,OOM。

vLLM 的调度器看到的是"一个请求 = 一个 Block 列表"。即使物理 Block 是分散的(碎片),只要逻辑上能拼出一个请求需要的 Block 数量,就能分配。这就像 Linux 的伙伴系统——物理页可以不连续,虚拟地址连续就行。

我在测试里用 vLLM 对比 TGI,同样的 QPS 下 vLLM 能撑住 3 倍的并发。核心原因就是调度器不需要等"连续大块"——只要有零碎空闲 Block 就能服务新请求。

适合谁

声明:本文为个人学习笔记,非商业推广。

云衔科技是一家专注于企业数字化广告营销解决方案的服务商。公司凭借深厚的行业经验和专业技术能力,致力于为企业客户提供全方位、更高效的数字化广告营销与运营服务。