6.2 自回归推理的 KV Cache 困境:LLM 推理为什么慢


文档摘要

6.2 自回归推理的 KV Cache 困境:LLM 推理为什么慢 一个 7B 模型生成 1000 个 token,要跑 1000 次前向传播;每一次前向都要重新算一遍所有历史 token 的注意力——除非把这些中间结果缓存下来。这就是 KV Cache,但缓存本身又成了新的瓶颈。 6.2.1 自回归:LLM 推理的天然低效 理解 LLM 推理为什么慢,首先要理解它的自回归(Autoregressive) 本质。LLM 生成文本是「逐 token 生成」:每生成一个 token,都要把它作为下一步的输入,再生成下一个。一个 1000 token 的回复要跑 1000 次前向传播。 对比传统 ML(如分类模型):一次前向就出结果。LLM 的「自回归」让它的单次请求成本是传统模型的几百到几千倍。

6.2 自回归推理的 KV Cache 困境:LLM 推理为什么慢

一个 7B 模型生成 1000 个 token,要跑 1000 次前向传播;每一次前向都要重新算一遍所有历史 token 的注意力——除非把这些中间结果缓存下来。这就是 KV Cache,但缓存本身又成了新的瓶颈。

6.2.1 自回归:LLM 推理的天然低效

理解 LLM 推理为什么慢,首先要理解它的自回归(Autoregressive) 本质。LLM 生成文本是「逐 token 生成」:每生成一个 token,都要把它作为下一步的输入,再生成下一个。一个 1000 token 的回复要跑 1000 次前向传播。

生成序列 "Hello world how are you": Step 1: 输入 [Hello] → 预测 "world" Step 2: 输入 [Hello, world] → 预测 "how" Step 3: 输入 [Hello, world, how] → 预测 "are" ...每次都要重新计算所有历史 token

对比传统 ML(如分类模型):一次前向就出结果。LLM 的「自回归」让它的单次请求成本是传统模型的几百到几千倍。

更糟糕的是,朴素的自回归实现有一个明显浪费——每次前向都要重新计算所有历史 token 的 Key/Value。如果生成第 1000 个 token 时还要重算前 999 个 token 的 K/V,这是巨大的算力浪费。

6.2.2 KV Cache:避免重算的缓存

KV Cache(Key-Value Cache) 是解决「重算历史」的标准方案。回顾 Transformer 的注意力机制:

\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

其中 Q、K、V 分别是查询、键、值矩阵。在自回归生成中,已经计算过的历史 token 的 K 和 V 不会变——无论后面生成什么,前面 token 的 K/V 都一样。所以可以把它们缓存下来,下一步只算新 token 的 K/V,追加到缓存。

无 KV Cache:每步重算所有 token 的 K/V → O(n²) 重复计算 有 KV Cache:缓存历史 K/V,每步只算新 token → 节省大量算力
维度 无 KV Cache 有 KV Cache
每步计算量 全部 token 的 K/V 仅新 token 的 K/V
内存 不存历史 存历史 K/V
速度 极慢

💡 判读:KV Cache 是 LLM 推理的「时间换空间」——用显存存历史 K/V,换不用重算的算力。它是 LLM 推理优化最基础也最重要的机制,几乎所有推理引擎(vLLM、TGI、TensorRT-LLM)都默认启用。

6.2.3 KV Cache 随序列长度线性增长

但 KV Cache 带来一个新问题:它随序列长度线性增长,而且是大头。

一个 Transformer 层、一个 token 的 KV Cache 大小:

\text{KV per token per layer} = 2 \times \text{hidden\_dim} \times \text{dtype\_size}

对于一个 70B 模型(80 层、hidden_dim 8192、FP16):

  • 单 token 单层 KV = 2 × 8192 × 2 字节 = 32 KB。
  • 单 token 全部 80 层 KV = 32 KB × 80 = 2.56 MB。
  • 一个 4096 token 的请求 KV Cache = 2.56 MB × 4096 ≈ 10 GB
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 280" font-family="sans-serif" font-size="12"> <text x="440" y="22" text-anchor="middle" font-size="15" font-weight="bold">KV Cache 大小 vs 序列长度(70B 模型示意)</text> <!-- 坐标轴 --> <line x1="80" y1="240" x2="820" y2="240" stroke="#475569" stroke-width="1.5"/> <line x1="80" y1="240" x2="80" y2="50" stroke="#475569" stroke-width="1.5"/> <text x="40" y="145" text-anchor="middle" transform="rotate(-90, 40, 145)">KV Cache 大小 (GB)</text> <text x="450" y="270" text-anchor="middle">序列长度 (token)</text> <!-- 网格刻度 --> <g font-size="10" fill="#64748b"> <text x="80" y="258" text-anchor="middle">0</text> <text x="240" y="258" text-anchor="middle">1K</text> <text x="400" y="258" text-anchor="middle">2K</text> <text x="560" y="258" text-anchor="middle">4K</text> <text x="720" y="258" text-anchor="middle">8K</text> <text x="74" y="244" text-anchor="end">0</text> <text x="74" y="180" text-anchor="end">10</text> <text x="74" y="120" text-anchor="end">20</text> <text x="74" y="60" text-anchor="end">40</text> </g> <!-- 柱状图 --> <rect x="180" y="200" width="120" height="40" fill="#dbeafe" stroke="#2563eb"/> <text x="240" y="195" text-anchor="middle">2.5GB</text> <rect x="340" y="160" width="120" height="80" fill="#fef9c3" stroke="#ca8a04"/> <text x="400" y="155" text-anchor="middle">5GB</text> <rect x="500" y="120" width="120" height="120" fill="#fed7aa" stroke="#ea580c"/> <text x="560" y="115" text-anchor="middle">10GB</text> <rect x="660" y="60" width="120" height="180" fill="#fecaca" stroke="#dc2626"/> <text x="720" y="55" text-anchor="middle">20GB</text> <text x="440" y="225" text-anchor="middle" font-size="11" fill="#dc2626" font-style="italic">单次请求 KV Cache 可达 10-20GB,远超模型权重分摊</text> </svg>

KV Cache 的几个关键特点:

  • 每个请求独立:不同请求的 KV Cache 互不共享(除非用 Prefix Caching,见 6.4 节)。
  • 随序列长度线性增长:序列越长,KV Cache 越大。
  • 随 batch 数倍增:一个 batch 内 N 个请求,KV Cache 总量是 N 倍。
  • 随模型规模增:大模型层数多、hidden_dim 大,KV Cache 更夸张。

6.2.4 显存碎片:传统 KV Cache 管理的另一个杀手

KV Cache 不仅总量大,它的增长模式还导致严重的显存碎片

传统推理引擎(如 HuggingFace Transformers 的早期实现)为每个请求预分配最大序列长度的连续显存。例如,设定最大 2048 token,那么每个请求一开始就分配 2048 token 的 KV Cache 空间,无论实际生成多长。

这种预分配导致两类浪费:

  • 内部碎片(Internal Fragmentation):请求只生成 100 token,但占了 2048 的空间,浪费 1948。
  • 外部碎片(External Fragmentation):不同请求的 KV Cache 块大小不一,空闲空间被切碎,无法装下新的请求。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 240" font-family="sans-serif" font-size="12"> <text x="440" y="22" text-anchor="middle" font-size="15" font-weight="bold">传统 KV Cache 管理:预分配导致碎片</text> <!-- 显存块 --> <rect x="40" y="60" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="80" font-size="11" fill="#7f1d1d">请求 A:预分配 2048 token,实际用 100(深色),浪费 1948(浅色)</text> <rect x="40" y="65" width="40" height="40" fill="#dc2626"/> <rect x="80" y="65" width="760" height="40" fill="#fecaca"/> <rect x="40" y="120" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="140" font-size="11" fill="#7f1d1d">请求 B:预分配 2048,实际用 500(深色),浪费 1548</text> <rect x="40" y="125" width="200" height="40" fill="#dc2626"/> <rect x="240" y="125" width="600" height="40" fill="#fecaca"/> <rect x="40" y="180" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="200" font-size="11" fill="#7f1d1d">请求 C:预分配 2048,实际用 2000(深色),浪费 48</text> <rect x="40" y="185" width="780" height="40" fill="#dc2626"/> <rect x="820" y="185" width="20" height="40" fill="#fecaca"/> <text x="440" y="225" text-anchor="middle" font-size="11" fill="#dc2626" font-style="italic">实际利用率 60%-80%,浪费 20%-40% 显存</text> </svg>

研究表明,传统 KV Cache 管理下,显存实际利用率只有 60%-80%——20%-40% 被浪费在碎片上。这等于「花钱买了一张 A100,只用到 0.7 张」。

6.2.5 最大批大小受限:吞吐天花板

KV Cache 的总占用决定了「一个 batch 能装多少请求」。一张 A100 80GB,扣除模型权重(如 7B 模型占 14GB)后,留给 KV Cache 的空间是 66GB。如果每个请求平均 KV Cache 是 1GB,那最多只能 batch 66 个请求。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="sans-serif" font-size="12"> <text x="360" y="22" text-anchor="middle" font-size="15" font-weight="bold">显存预算与最大 batch 的权衡</text> <!-- A100 80GB 显存分配 --> <rect x="40" y="60" width="640" height="60" rx="4" fill="#1e293b"/> <rect x="40" y="65" width="112" height="50" fill="#fce7f3"/> <text x="96" y="92" text-anchor="middle" font-size="10">模型权重 14GB</text> <rect x="152" y="65" width="32" height="50" fill="#fef9c3"/> <text x="168" y="92" text-anchor="middle" font-size="10">其他 4GB</text> <rect x="184" y="65" width="496" height="50" fill="#dcfce7"/> <text x="432" y="92" text-anchor="middle" font-size="11">KV Cache 预算 62GB</text> <text x="360" y="150" text-anchor="middle" font-size="11">↓ 每请求 KV Cache 大小决定最大 batch</text> <g font-size="11"> <rect x="40" y="170" width="600" height="30" rx="3" fill="#dbeafe" stroke="#2563eb"/> <text x="50" y="190">每请求 0.5GB → 最大 batch ≈ 124(高吞吐)</text> <rect x="40" y="205" width="300" height="30" rx="3" fill="#fed7aa" stroke="#ea580c"/> <text x="50" y="225">每请求 1GB → 最大 batch ≈ 62(中)</text> <rect x="40" y="240" width="60" height="30" rx="3" fill="#fecaca" stroke="#dc2626"/> <text x="100" y="260">每请求 5GB → 最大 batch ≈ 12(低吞吐,长序列场景)</text> </g> </svg>

这就是 LLM 推理吞吐的核心约束:最大 batch 受显存约束,而显存大头是 KV Cache。任何能压缩 KV Cache 或提升其利用率的优化,都能直接放大 batch,提升吞吐。

6.2.6 Prefill 与 Decode:两阶段的不对称性

LLM 推理分为两个阶段,二者特性截然不同:

Prefill 阶段(预填)

处理输入 prompt 的阶段。一次性处理整个 prompt(可能数千 token),计算所有 token 的 K/V 并填充 Cache。

  • 计算密集:要并行处理大量 token,GPU 计算单元满载。
  • 耗时相对长:长 prompt 的 prefill 可能要数百毫秒。
  • 一次性:一个请求只 prefill 一次。

Decode 阶段(解码)

逐个生成输出 token 的阶段。每步用上一步的输出,计算新 token 的 K/V 追加到 Cache。

  • 内存密集:每步只算 1 个 token,计算单元吃不饱,瓶颈在显存带宽。
  • 重复多次:生成 N 个 token 要 decode N 次。
  • 单步快但累积慢:单步几毫秒,累积到 1000 token 就是几秒。
阶段 特性 瓶颈 频次
Prefill 计算密集 GPU 计算 一次
Decode 内存密集 显存带宽 多次(每 token 一次)

💡 判读:Prefill 与 Decode 的不对称性是后续优化的关键线索。Continuous Batching 让多个请求的 Prefill 与 Decode 交错,提升整体 GPU 利用率(6.3 节);Chunked Prefill 把长 Prefill 切分,避免打断 Decode(7.1 节);PD 分离把 Prefill 与 Decode 放在不同节点(7.4 节)——这些都是基于「两阶段不对称」的洞察。

6.2.7 Static Batching:传统批处理的低效

理解了 KV Cache 与两阶段,再看传统批处理为什么低效。

Static Batching(静态批处理) 是最朴素的批处理方式:把一批请求凑齐,一起送入模型,等所有请求都生成完毕才返回。

问题在于:一个 batch 内各请求的生成长度差异极大——请求 A 生成 50 token 就完了,请求 B 要生成 1000 token。Static Batching 必须等最慢的 B 完成才返回,期间 A 已经完成却要陪着 B 占着显存,GPU 在 A 完成后利用率也下降。

Static Batching: 时间步: 0 1 2 ... 50 ... 1000 请求 A: 生成 生成 生成 ... 完成 空等 空等 请求 B: 生成 生成 生成 ... 生成 ... 完成 ↑ ↑ 开始 全部返回

这种「等最慢的」让 GPU 在后半程大量空闲,吞吐低下。研究表明 Static Batching 的 GPU 利用率在 batch 后半程可能只有 10%-30%。

6.2.8 吞吐与延迟的权衡

最后讲清一个核心工程权衡:吞吐(throughput)与延迟(latency)的博弈

  • 吞吐:单位时间完成的请求数(requests/s)或 token 数(tokens/s)。
  • 延迟:单个请求的响应时间,常用 TTFT(Time To First Token,首 token 延迟)与 TPOT(Time Per Output Token,每 token 延迟)。
指标 含义 影响因素
TTFT 首 token 延迟 Prefill 速度
TPOT 每输出 token 延迟 Decode 速度
吞吐 每秒处理 token/请求数 batch 大小、利用率

batch 越大,吞吐越高(GPU 满载),但单请求延迟也越高(资源被瓜分)。batch 越小,延迟越低,但吞吐也低。这是 LLM 推理的核心权衡,不同的业务场景有不同的偏好:

  • 聊天/实时场景:偏好低延迟(小 batch,TTFT/TPOT 优先)。
  • 批量处理/离线场景:偏好高吞吐(大 batch,资源利用率优先)。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="sans-serif" font-size="12"> <text x="360" y="22" text-anchor="middle" font-size="15" font-weight="bold">吞吐 vs 延迟的权衡</text> <line x1="80" y1="240" x2="680" y2="240" stroke="#475569" stroke-width="1.5"/> <line x1="80" y1="240" x2="80" y2="50" stroke="#475569" stroke-width="1.5"/> <!-- 吞吐曲线 --> <path d="M 80 220 Q 200 200 400 100 T 680 60" fill="none" stroke="#16a34a" stroke-width="3"/> <text x="650" y="80" font-size="11" fill="#16a34a">吞吐</text> <!-- 延迟曲线 --> <path d="M 80 220 Q 200 215 400 180 T 680 100" fill="none" stroke="#dc2626" stroke-width="3"/> <text x="650" y="120" font-size="11" fill="#dc2626">延迟</text> <text x="80" y="260" font-size="11" fill="#475569">小 batch</text> <text x="600" y="260" font-size="11" fill="#475569">大 batch</text> <text x="40" y="60" font-size="11" fill="#475569" transform="rotate(-90, 40, 60)">指标值</text> <text x="360" y="270" text-anchor="middle" font-size="11" fill="#475569">→ batch size</text> </svg>

💡 判读:连续批处理(Continuous Batching,6.3 节)与 PagedAttention 的关键价值,就是「在不损失延迟的前提下,把吞吐推到接近理论上限」——通过让请求动态进出 batch、KV Cache 高效复用,让 GPU 始终满载又不让单请求等太久。这是 vLLM 革命性的根本。

6.2.9 小结:三大瓶颈汇总

把本节诊断的 LLM 推理瓶颈汇总成一张表,这是 6.3-6.4 节 vLLM 与 PagedAttention 要逐个击破的目标:

瓶颈 表现 根因
KV Cache 总占用大 单请求 GB 级,限制 batch 序列长度 × 模型规模
显存碎片 利用率仅 60%-80% 预分配最大长度
Static Batching 低效 GPU 后半程空闲 必须等最慢请求
两阶段不对称 Prefill 阻塞 Decode 计算密集 vs 内存密集

这四大瓶颈正是 LLM 推理「慢且贵」的根因。下一节的 PagedAttention 与 Continuous Batching,将分别击破「显存碎片」与「Static Batching 低效」两大瓶颈。

本节小结

  • LLM 自回归生成:每 token 一次前向,朴素实现要重算所有历史,效率极低。
  • KV Cache 缓存历史 token 的 K/V,避免重算,是「时间换空间」的基础优化。
  • KV Cache 随序列长度线性增长,70B 模型 4K 序列的 KV Cache 可达 10GB+,是显存大头。
  • 传统 KV Cache 预分配最大长度,导致内部碎片与外部碎片,实际利用率仅 60%-80%。
  • 最大 batch 受显存约束(主要被 KV Cache 占满),直接决定吞吐天花板。
  • 推理分 Prefill(计算密集、一次)与 Decode(内存密集、多次)两阶段,特性不对称。
  • Static Batching 必须等最慢请求完成,GPU 后半程空闲,吞吐低。
  • 吞吐与延迟存在权衡:聊天偏好低延迟,离线偏好高吞吐。
  • LLM 推理四大瓶颈:KV Cache 总占用大、显存碎片、Static Batching 低效、两阶段不对称——这些是后续优化的目标。

下一节《6.3 PagedAttention 分页显存原理》将讲清 vLLM 如何用 OS 虚拟内存思想革命性地解决这些瓶颈。


发布者: 作者: 灏天文库 转发
评论区 (0)
U