← 返回首页

vLLM 0.31 的 KV Cache:分块分配到底省在哪

vllmkv-cachellm-inference

TL;DR:分块把并发上限从「按最长序列预留」变成「按实际用量分配」(我实测 24 到 157),前缀缓存让共享前缀按整块复用(默认块 16 时,1,000 token 前缀只丢 0.80%);四个真实报错全部定位到了源码出处。

先把三条边界说清楚,免得你照着做却得到别的结果。

第一,本文所有实测数字来自我自己在 CPU 上跑的分配器模拟脚本(脚本在第三节,纯标准库,可一键复现)。它是分配器行为的模拟,不是 GPU 上的真实吞吐压测,我不会把它包装成「vLLM 实测吞吐」。第二,本文是纯技术拆解,正文不含任何商品推荐链接与联盟推广链接,页面底部是站点统一的推广入口。第三,版本号一律取自 GitHub Releases:写作时最新为 v0.31.0,发布于 2026-10-05。检索过程中我看到几篇第三方教程把「当前稳定版」分别写成 v0.20.1 与 v0.25.1,彼此不一致,也和官方 Releases 对不上——版本相关的事,请以官方 Releases 与对应版本文档为准。

一、最大的坑:官方文档自己声明「已不描述当前代码」

绝大多数中文解读 PagedAttention 的文章,源头都是 https://docs.vllm.ai/en/latest/design/paged_attention/ 这一页。而这页顶部现在挂着一条 Warning,大意是:这是一份基于 vLLM 原始论文的历史文档,它描述的已不再是今天 vLLM 的代码;当前 vLLM 使用的是自己的 multi-head query attention kernel,源码在 csrc/attention/attention_kernels.cu。(页面底部标注的日期是 June 24, 2026。)

也就是说,你在二手文章里看到的「v1 kernel / v2 kernel 三段循环」那套叙述,官方已经明确打了历史标记。这不代表「块」这个概念过时了——恰恰相反,同一份文档里对 Block 的定义今天依然成立:key 与 value 分开存放,每个块在一个 head 上存固定数量(BLOCK_SIZE)的 token;当 BLOCK_SIZE=16、HEAD_SIZE=128 时,一个 head 的一个块装 16 × 128 = 2048 个元素。

我把这条放在最前面,是因为它决定你怎么读二手资料:机制可以学,但拿二手文章里的 kernel 细节去对今天的源码,一定对不上。

二、块、块表与「逻辑连续、物理离散」

分块方案里只有三个概念需要记住。

块(block):KV cache 的最小分配单位,固定 token 数。v0.31.0 源码里 CacheConfig.block_size 字段的默认值是 None,未显式指定时回落到类常量 DEFAULT_BLOCK_SIZE = 16(vllm/config/cache.py)。同一个文件里另外两个常用默认值:gpu_memory_utilization = 0.92,enable_prefix_caching = True。

块表:请求到物理块的映射,也就是「逻辑上连续、物理上散落」的那一层。官方前缀缓存文档给出了 v1 的实现骨架(简化版):一个 Block Pool(KVCacheBlock 列表)、一条用双向链表指针直接穿起来的 Free Block Queue(这样把中间元素挪到队尾是 O(1))、一张 cache blocks(哈希到 block id)、一张 request blocks(request id 到已分配 block id)。

启动日志:vLLM 启动时会打印形如 GPU KV cache size: N tokens, Maximum concurrency for M tokens per request: X.XXx 的一行(vllm/v1/core/kv_cache_utils.py)。这是你判断「这台机器到底能跑多少并发」的第一手依据,比任何二手经验值都准。

三、实测:两个分配器对拍

3.1 实验设定(先把边界钉死)

不包含的部分:prefill 峰值激活、CUDA graph 显存、模型权重、真实调度器策略。它回答的只有一个问题——同样的容量,换一种分配方式能多塞多少并发。

3.2 结果:峰值并发 24 到 157

分配方式峰值并发跑完 400 个请求(步)平均排队(步)内部碎片
连续预分配(每请求 4,096)243,524178.9—
分块按需(块 = 16)1571,264180.31.02%

同样的池子,峰值并发从 24 抬到 157,跑完同一批负载的步数从 3,524 降到 1,264。代价是 1.02% 的内部碎片——也就是每个请求最后一个块没填满的那点浪费。

顺带一个容易被忽略的数字:分块方案的峰值块占用是 6,247 / 6,250,即 99.95%。池子几乎被打满,这正是后面 4 次抢占的来源。

3.3 块大小怎么影响内部碎片

块大小峰值并发内部碎片率抢占次数
81580.50%6
161571.02%4
321552.18%2
641534.58%7
1281479.08%8

碎片率随块大小近乎线性增长,符合「最后一个块平均浪费 B/2 个 token」的直觉。但抢占次数不是单调的(16 到 32 反而从 4 降到 2,64 又升到 7):我这轮只有 400 个请求、抢占本身是稀有事件,这几次数值不足以支撑「块越大越容易抢占」的结论。这里我只报数字,不下结论。

3.4 一个便宜的止损:watermark

官方文档把 watermark 定义为「接纳等待队列或被抢占请求时保留的空闲块比例」,默认 0.0,也就是关闭。我在模拟里把它从 0 抬起来:

watermark峰值并发抢占次数跑完 400 个请求(步)
0.0015741,264
0.0115601,264
0.0215401,264
0.0515201,265
0.1014501,279

代价是一个并发位,换来 4 次抢占归零,总耗时没变。这是这轮我唯一会主动推荐先试的参数——前提是你在日志里确实看到了抢占。

3.5 前缀缓存只认整块

官方前缀缓存文档里有一句很关键的话:We only cache full blocks.(只缓存完整的块。)所以共享前缀的有效缓存长度等于 floor(前缀长度 / 块大小) × 块大小:

共享前缀(token)块 = 8块 = 16块 = 32块 = 64
777776(丢 0.13%)768(丢 1.16%)768(丢 1.16%)768(丢 1.16%)
1,0001,000(丢 0%)992(丢 0.80%)992(丢 0.80%)960(丢 4.00%)
1,5001,496(丢 0.27%)1,488(丢 0.80%)1,472(丢 1.87%)1,472(丢 1.87%)
3,0003,000(丢 0%)2,992(丢 0.27%)2,976(丢 0.80%)2,944(丢 1.87%)

实践含义:system prompt 在 1,000 token 量级时,用默认块大小 16,每次命中最多丢 8 个 token 的重算,基本可以忽略;但如果为了别的原因把块调到 64,同样的前缀要丢 40 个 token(4.00%)。

同一份文档里还有两条容易被漏掉的信息:

3.6 可复现脚本

下面就是我这轮实际跑的脚本,纯标准库,复制粘贴即可复现上面所有数字。

"""vLLM 风格 KV cache 块分配器模拟(纯标准库,可复现)
对比:连续预分配(pre-allocate max_model_len) vs 分块按需分配(paged)
"""

import random

B = 16                 # 块大小(token),vLLM 默认
POOL_TOKENS = 100_000  # KV cache 总容量(token 槽位)
MAX_LEN = 4096         # max_model_len
N_REQ = 400            # 请求总数
SEED = 20261013


def gen_workload(n=N_REQ, seed=SEED):
    rng = random.Random(seed)
    reqs = []
    for i in range(n):
        # 提示长度 60~1800 长尾;输出长度 20~900 长尾
        p = min(int(rng.lognormvariate(6.0, 0.7)), 1800)
        o = min(int(rng.lognormvariate(4.8, 0.9)), 900)
        reqs.append((max(p, 8), max(o, 8)))
    return reqs


# ---------------- 分配器 1:连续预分配 ----------------
class ContiguousPool:
    def __init__(self, total):
        self.free = [(0, total)]   # 空闲区间列表(起始, 长度)

    def alloc(self, size):
        for i, (start, length) in enumerate(self.free):
            if length >= size:
                if length == size:
                    self.free.pop(i)
                else:
                    self.free[i] = (start + size, length - size)
                return start
        return None

    def release(self, start, size):
        self.free.append((start, size))
        self.free.sort()
        merged = []
        for s, l in self.free:
            if merged and merged[-1][0] + merged[-1][1] == s:
                merged[-1] = (merged[-1][0], merged[-1][1] + l)
            else:
                merged.append((s, l))
        self.free = merged

    def used(self):
        return sum(l for _, l in self.free)


# ---------------- 分配器 2:分块按需 ----------------
class PagedPool:
    def __init__(self, n_blocks):
        self.n = n_blocks
        self.free = n_blocks

    def take(self, k):
        if self.free >= k:
            self.free -= k
            return True
        return False

    def give(self, k):
        self.free += k


def run_contiguous(reqs, total=POOL_TOKENS, max_len=MAX_LEN):
    pool = ContiguousPool(total)
    waiting = list(reqs)
    running = []          # (start, prompt, out, generated, wait)
    done = 0
    peak = 0
    waits = []
    step = 0
    while (waiting or running) and step < 200000:
        step += 1
        # 尝试接纳(每步最多接纳一个新请求)
        if waiting:
            p, o = waiting[0]
            st = pool.alloc(max_len)
            if st is not None:
                waiting.pop(0)
                running.append([st, p, o, 0, 0])
        # 推进
        for r in running:
            r[3] += 1
            if r[3] >= r[2]:
                pool.release(r[0], max_len)
                waits.append(r[4])
                r[3] = -1
        running = [r for r in running if r[3] != -1]
        done = len(waits)
        for r in running:
            r[4] += 1
        peak = max(peak, len(running))
        if not waiting and not running:
            break
    return {
        "allocator": "contiguous",
        "steps": step,
        "done": done,
        "peak_concurrency": peak,
        "avg_wait_steps": round(sum(waits) / max(len(waits), 1), 1),
        "utilization": round(100 * (1 - pool.used() / total), 1),
    }


def run_paged(reqs, total=POOL_TOKENS, b=B, watermark=0.0):
    n_blocks = total // b
    pool = PagedPool(n_blocks)
    reserve = int(watermark * n_blocks)
    waiting = [[p, o, 0] for p, o in reqs]
    running = []          # [prompt, out, stored, blocks, wait]
    preempt_cnt = 0
    peak = 0
    peak_used = 0
    waits = []
    internal_waste = 0
    step = 0
    while (waiting or running) and step < 200000:
        step += 1
        # 接纳:prefill 需要 ceil(prompt/b) 个块
        if waiting:
            p = waiting[0][0]
            nb = -(-p // b)
            if pool.free - nb >= reserve and pool.take(nb):
                r = waiting.pop(0)
                running.append([r[0], r[1], r[0], nb, r[2]])
        # decode 一步
        for r in running:
            r[2] += 1
            if r[2] % b == 1 and r[2] > r[0]:
                if pool.take(1):
                    r[3] += 1
                else:
                    # 无可用块 -> 抢占(v1 默认 RECOMPUTE:释放全部块后重排队)
                    pool.give(r[3])
                    preempt_cnt += 1
                    waiting.insert(0, [r[0], r[1], r[4] + 1])
                    r[2] = -1
        running = [r for r in running if r[2] != -1]
        # 完成
        for r in running:
            if r[2] - r[0] >= r[1]:
                internal_waste += (b - r[2] % b) % b
                pool.give(r[3])
                waits.append(r[4])
                r[2] = -2
        running = [r for r in running if r[2] != -2]
        for r in running:
            r[4] += 1
        peak = max(peak, len(running))
        peak_used = max(peak_used, n_blocks - pool.free)
        if not waiting and not running:
            break
    total_stored = sum(p + o for p, o in reqs[:len(waits)])
    return {
        "allocator": "paged-b%d" % b,
        "steps": step,
        "done": len(waits),
        "peak_concurrency": peak,
        "peak_blocks_used": peak_used,
        "total_blocks": n_blocks,
        "preemptions": preempt_cnt,
        "avg_wait_steps": round(sum(waits) / max(len(waits), 1), 1),
        "internal_waste_tokens": internal_waste,
        "internal_waste_pct": round(100 * internal_waste / max(total_stored, 1), 2),
    }


def block_sweep(reqs):
    rows = []
    for b in (8, 16, 32, 64, 128):
        out = run_paged(reqs, POOL_TOKENS, b)
        rows.append((b, out["peak_concurrency"], out["preemptions"],
                     out["internal_waste_pct"], out["done"], out["steps"]))
    return rows


def prefix_granularity(prefix_len, b):
    full = prefix_len // b
    cached = full * b
    return cached, prefix_len - cached, round(100 * (prefix_len - cached) / prefix_len, 2)


if __name__ == "__main__":
    reqs = gen_workload()
    tot = sum(p + o for p, o in reqs)
    print("workload: n=%d  total_tokens=%d  avg_len=%.1f  max_len=%d"
          % (len(reqs), tot, tot / len(reqs), max(p + o for p, o in reqs)))
    print()
    print("A. 连续预分配:", run_contiguous(reqs))
    print()
    print("B. 分块按需  :", run_paged(reqs))
    print()
    print("C. 块大小扫描 (b, peak_concurrency, preemptions, waste_pct, done, steps)")
    for r in block_sweep(reqs):
        print("   ", r)
    print()
    print("D2. watermark 扫描 (wm, peak_concurrency, preemptions, done, steps)")
    for wm in (0.0, 0.01, 0.02, 0.05, 0.10):
        o = run_paged(reqs, POOL_TOKENS, 16, wm)
        print("    wm=%.2f peak=%d preempt=%d done=%d steps=%d"
              % (wm, o["peak_concurrency"], o["preemptions"], o["done"], o["steps"]))
    print()
    print("D. 前缀缓存粒度损失 (prefix_len, b) -> (cached, lost, lost_pct)")
    for p in (777, 1000, 1500, 3000):
        for b in (8, 16, 32, 64):
            c, l, pct = prefix_granularity(p, b)
            print("    prefix=%4d b=%2d -> cached=%4d lost=%3d lost_pct=%.2f%%" % (p, b, c, l, pct))

四、四个真实报错与根因

报错一:`ValueError: max_num_batched_tokens (X) is smaller than max_model_len (Y)`

出处:v0.31.0 源码 vllm/config/scheduler.py 的 SchedulerConfig.verify_max_model_len()。

触发条件:关闭 chunked prefill,且 max_num_batched_tokens < max_model_len。

原文(拼接后):max_num_batched_tokens (X) is smaller than max_model_len (Y). This effectively limits the maximum sequence length to max_num_batched_tokens and makes vLLM reject longer sequences. Please increase max_num_batched_tokens or decrease max_model_len.

为什么你会撞上:V1 里 chunked prefill 默认开启,这条平时碰不到;但只要你显式关掉它(比如为了复现旧的调度行为),就必须保证 max_num_batched_tokens >= max_model_len。官方 optimization 文档也警告过:chunked prefill 关闭时若 max_num_batched_tokens < max_model_len,vLLM 可能在服务启动阶段直接崩。同一个函数里还有一条相邻检查:max_num_batched_tokens 不能小于 max_num_seqs。

报错二:`ValueError: To serve at least one request with the model's max seq len (...)`

出处:v0.31.0 源码 vllm/v1/core/kv_cache_utils.py。

原文大意:To serve at least one request with the model's max seq len (N), (X GiB KV cache is needed, which is larger than the available KV cache memory (Y GiB). Based on the available memory, the estimated maximum model length is M. Try increasing gpu_memory_utilization (which also controls CPU memory on the CPU backend) or decreasing max_model_len when initializing the engine.

含义:连一条请求按 max_model_len 算都放不下,池子根本不够开张。

怎么改:报错里已经给了 estimated_max_len,照着把 max_model_len 降下来,或提高 gpu_memory_utilization(默认 0.92)。报错末尾直接指向官方的 conserving memory 文档。

报错三:`Sequence group N is preempted by PreemptionMode.RECOMPUTE mode because there is not enough KV cache space`

出处:官方 optimization 文档里给出的真实 warning。完整一行形如:WARNING 05-09 00:49:33 scheduler.py:1057 Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode because there is not enough KV cache space. This can affect the end-to-end performance. Increase gpu_memory_utilization or tensor_parallel_size to provide more KV cache memory. total_cumulative_preemption_cnt=1

语义:块不够用了,调度器挑一个请求抢占,等有空间再重算(V1 默认 RECOMPUTE 而不是 SWAP,因为 V1 架构下重算开销更低)。

怎么改:官方列了四条——加大 gpu_memory_utilization、减小 max_num_seqs 或 max_num_batched_tokens、加大 tensor_parallel_size、加大 pipeline_parallel_size。我的模拟给了第五条更便宜的选项:先把 watermark 抬到 0.01(见 3.4)。

怎么观测:官方提示可以设 disable_log_stats=False 记录累计抢占数,也可以看 vLLM 暴露的 Prometheus 指标。

报错四:`RuntimeError: Cannot re-initialize CUDA in forked subprocess`

出处:官方 conserving memory 文档的 Warning。

根因:在初始化 vLLM 之前就调用了 CUDA 相关函数(文档举的例子是 torch.accelerator.set_device_index),多进程 fork 时 CUDA 无法重新初始化。

怎么改:用 CUDA_VISIBLE_DEVICES 环境变量控制可见设备,不要在初始化 vLLM 之前动 CUDA。

五、调参清单:按症状找参数

症状先看什么可以调什么
启动就报「放不下一条 max_model_len」报错二里的 estimated_max_lenmax_model_len 调小 / gpu_memory_utilization 调大
日志里出现抢占累计抢占计数(disable_log_stats=False)watermark 0.01 → max_num_seqs 调小 → TP / PP 调大
TTFT 高、长 prompt 拖慢别人max_num_batched_tokens官方建议小模型配大卡时大于 8192;禁用 chunked prefill 时该值必须不小于 max_model_len
共享前缀没省到前缀长度除以块大小保持默认块 16;确认 enable_prefix_caching=True(v0.31.0 默认已是 True)
反复重启很慢编译缓存复用 VLLM_CACHE_ROOT(默认 ~/.cache/vllm),或用 --kv-cache-memory 跳过显存 profiling;--enforce-eager 启动最快但牺牲稳态 decode 性能
优化等级-O0 到 -O3官方表格:默认 -O2,-O3 当前等同 -O2,-O0 启动最快、性能最低

六、这次没覆盖的边界

不写清楚范围的文章容易误导人,这里是我明确没做的部分:

常见问题

Q: 块大小要不要改?

A: 默认 16 就别动。我的数据里 8 只把碎片从 1.02% 降到 0.50%,换来更长的块表和更多元数据;64 以上碎片率直接跳到 4.58%。

Q: 前缀缓存是默认开的吗?

A: 在 v0.31.0 里是。CacheConfig.enable_prefix_caching 默认 True。更早的版本需要显式 --enable-prefix-caching,具体以你所用版本的配置类为准。

Q: 为什么我的并发上不去?

A: 先看启动日志那行 GPU KV cache size: N tokens, Maximum concurrency for M tokens per request: X.XXx——那是按 max_model_len 算的理论并发。如果你的真实请求远短于 max_model_len,实际可承载的并发会明显高于它,这正是分块分配在起作用。

Q: 抢占的代价有多大?

A: 我给不出通用数字,它取决于被抢占请求已经生成了多少 token。能确定的是语义:V1 默认 RECOMPUTE,被抢占的请求重新拿到块之后会重算,官方文档也明确说明这会损害端到端时延。

写在最后

如果你只想记住一句话:分块解决的是「按最长序列预留」这个浪费,前缀缓存解决的是「同一段前缀反复算」这个浪费,两者都是可以被量化的——我上面跑出来的 24 到 157 与 1.02% 就是这两件事的量化结果。

延伸阅读,都是本站已经发过的实测向文章:

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页