第二部分:瓶颈篇 —— 为什么 LLM 推理如此困难¶
这一部分解释了工程师在将 LLM投入生产时所遭遇的物理和数学“撞墙”期。
第四章:大模型推理核心指标解析¶
在探讨大模型推理的各种优化技术之前,我们必须先建立一套衡量的标准。大模型“一个字一个字往外蹦”的自回归特性,决定了它不能简单地用传统 Web 服务的“响应时间”来评估。本章将介绍大模型推理中最核心的几个性能指标。
- TTFT(Time to First Token,首字延迟):从用户发送请求到模型吐出第一个字的耗时。它对应的是 Prefill 阶段,决定了系统是否“跟手”。
- TBT(Time Between Tokens,吐字间隔):相邻两个 Token 之间的生成耗时。它对应的是 Decode 阶段,决定了流式输出的流畅度。
- TPS(Tokens Per Second,生成速率):每秒钟模型能吐出多少个 Token,与 TBT 互为倒数( \(TPS = 1 / TBT\) ),用于直观衡量生成速度。
- Latency(总延迟):完成整个请求的总耗时。计算公式为 \(Latency = TTFT + (生成Token数 - 1) \times TBT\) 。
- Throughput(吞吐量):系统每秒钟总共能处理多少 Token 或请求。这是衡量服务器并发能力和成本(TCO)的核心指标。
第五章:从零开始:最朴素的 LLM 推理是如何运作的?¶
在讨论先进的优化技术之前,我们必须先分析最基础的 LLM 推理模式。理解了基础的推理方式,我们才能准确把握大模型推理的严峻瓶颈。
第一节:无优化推理流程¶
假设我们要让模型基于提示词(Prompt)“大模型是”生成后续的文本。在没有任何优化的情况下,生成第 1 个词到第 3 个词的过程如下:
- 生成第 1 个词:
- 输入:["大", "模型", "是"]
- 处理:整个句子穿过 80 层 Transformer 大楼。
- 输出:预测出下一个词是“未”。此时我们得到了 ["大", "模型", "是", "未"]。
- 生成第 2 个词:
- 输入:["大", "模型", "是", "未"]
- 处理:把这 4 个字重新从第 1 层楼喂进去,重新走一遍 80 层楼。
- 输出:预测出下一个词是“来”。此时得到 ["大", "模型", "是", "未", "来"]。
- 生成第 3 个词:
- 输入:["大", "模型", "是", "未", "来"]
- 处理:再次把这 5 个字重新从第 1 层楼喂进去,再走一遍 80 层楼。
- 输出:预测出下一个词是“。”。
第二节:瓶颈剖析:计算复杂度爆炸¶
这种按部就班的推理方式在学术上被称为 Naive Inference(朴素推理) 。我们以一个具体的步骤为例来剖析其本质:假设当前句子长度为 \(N\) ,模型隐藏层维度为 \(d\) (即每个词向量的特征维度,代表模型理解词语的精细程度),层数为 \(L\) 。在 Naive 模式下,为了生成下一个词而进行的 单轮 计算中,它在计算、存储和并行度上有着截然不同的表现:
-
计算复杂度(Compute Complexity) :单轮时间复杂度为 \(O(L \cdot (N \cdot d^2 + N^2 \cdot d))\)
- 线性层计算 : \(O(N \cdot d^2)\) (注:每一层都要计算,总共 \(L\) 层)。我们需要对所有 \(N\) 个词进行 QKV 投影、FFN 映射等矩阵乘法。
- 注意力计算 : \(O(N^2 \cdot d)\) (注:每一层都要计算,总共 \(L\) 层)。所有 \(N\) 个词,每一个词的Query都要与它前面词的Key算点乘积。所以是 \(N \times N\) 的矩阵相乘。
-
存储复杂度(Storage Complexity) :单轮空间复杂度为 \(O(L \cdot d^2) + O(N \cdot d + N^2)\)
- 静态权重 : \(O(L \cdot d^2)\) 。模型的参数矩阵必须常驻显存,与输入长度无关。
- 动态激活值 : \(O(N \cdot d + N^2)\) 。为了算这一轮而临时开辟的内存(计算完即释放)。
-
并行化能力(Parallelization) :
- Prefill 阶段 :因为输入的 Prompt 是完整的,所有词的计算都可以 高度并行化 执行,充分利用 GPU 的多核算力。
- Decode 阶段 :由于自回归的特性,当前词必须依赖前一个词的输出,因此步骤之间是 绝对串行 的,无法跨时间步并行化。此外,Naive Inference 每一轮串行都要重新并行算一遍历史词,造成了极大的算力闲置和浪费。
真实案例推演:以 Llama 3 (405B) 为例
为了具体量化 Naive 模式的严重影响,我们用目前最顶级的开源大模型 Llama 3 (405B) 来做一次真实场景的近似计算。
- 模型参数 :隐藏层维度 \(d = 16384\) ,层数 \(L = 126\) 。
- 场景设定 :当前句子长度为 \(N = 1000\) (比如输入了 1000 字的提示词),我们准备生成下一个词。
- 单轮计算量 :根据公式,这一轮的线性层计算量大约为 \(2 \times L \times (11 \times N \times d^2)\) (注:系数 11 源于 Attention 的 4 个线性层与 SwiGLU FFN 的 3 个线性层折算系数。不同模型由于设计不同,该系数通常在 10~12 之间,此处取 11 作近似;乘以 2 是因为每次乘加操作计为 2 次浮点运算)。带入数字: \(2 \times 126 \times 11 \times 1000 \times (16384^2) \approx 744\) 万亿次浮点运算(TFLOPs)。
- 预计耗时 :假设我们使用目前主流的顶级 AI 显卡 NVIDIA H100,其半精度(FP16)的理论峰值算力约为每秒 1000 万亿次(1 PFLOPS)。在理想状态下(算力利用率 100%),仅仅算这 1 个词 的纯计算耗时就高达: \(744 \div 1000 \approx 0.74\) 秒。
这意味着,在 Naive 模式下,哪怕用最顶级的显卡,模型每秒也只能生成一两个字,随着上下文变长还会更慢。因为在长文本场景下,Attention 的 \(N^2\) 复杂度会占据绝对的主导地位。这意味着我们绝对不能每生成一个词,就把前面所有的词都重新计算一遍。如果我们将上下文长度 \(N\) 推到如今模型动辄支持的 100 万,为了生成第 100 万零 1 个词,计算量将急剧增加,生成单个词的耗时将达到约 2.5 小时。这种速度在生产环境中是完全不可接受的。
第三节:引出优化:我们能否“记住”过去的计算?¶
这种低效的运作方式使得大模型推理在实际应用中变得不可行——它根本无法支持长文本的生成,也无法承受高并发的压力。
系统工程师们开始思考:既然前面的词已经算过了,我们能不能把它们的计算结果“缓存”起来,每次只计算新加入的那个词? 这个朴素的思想,直接催生了大模型推理史上最重要的基石技术——KV Cache。
第六章:KV Cache 机制与巨量显存占用¶
为了打破 \(O(N^2)\) 的计算死循环,KV Cache 应运而生。它用空间换时间,大幅优化了大模型推理的效率。
第一节:空间换时间:缓存 K 和 V¶
回到我们在第一章讨论的 Attention 公式: \(\text{Attention}(Q, K, V) = \text{softmax}(\frac{Q K^T}{\sqrt{d_k}})V\) 。
工程师们惊奇地发现:
- 当生成新词时,新词的 Query (Q) 必须由新词自己产生,因为这是当前的意图。
- 但是,前文中所有词的 Key (K) 和 Value (V) 在生成出来之后,就再也不会改变了!
既然 K 和 V 是固定不变的“固定资产”,我们为什么不在第一轮算完它们之后,把它们存在显存里呢? 这就是 KV Cache(键值缓存) 。在后续的推理中,GPU 只需要为新进来的那一个 Token 计算 Q、K、V,然后把新计算出的 K 和 V 拼接到缓存中,再去和缓存里所有的 K 和 V 做注意力计算即可。 计算复杂度直接从 \(O(N^2)\) 降到了 \(O(N)\) 。
第二节:为什么只有 K 和 V?¶
这是一个经常被问到的 Aha Moment:为什么我们不缓存 Q 呢?
因为 Q 代表的是“寻找的意图”,它是消耗品。
- 当模型预测第 4 个词时,我们需要用第 4 个词的 Q 去看前 3 个词。
- 当模型预测第 5 个词时,我们需要用第 5 个词的 Q 去看前 4 个词。 第 4 个词的 Q 在第 4 步用完就作废了,它对第 5 步的预测毫无帮助。因此,Q 不需要缓存,只需要缓存承载特征的 K 和 V。
第三节:巨量显存占用:TB 级的数学题¶
为了更直观地看清 KV Cache 带来的改变,我们先来对比一下在生成第 N 个词时,无优化模式 (Naive) 与 有 KV Cache 模式 在计算和存储复杂度上的差异(假设模型层数为 L,维度为 d):
| 维度 | 无优化模式 (Naive) | 有 KV Cache 模式 |
|---|---|---|
| 计算复杂度 | O(L * (N * d^2 + N^2 * d)) |
O(L * (d^2 + N * d)) |
| 状态存储复杂度 | O(L * d^2) (仅模型权重) |
O(L * d^2 + L * N * d) (权重 + 缓存) |
核心差异剖析:
- 计算复杂度的大幅降低:KV Cache 把每次生成新词的线性层计算量从
O(N)降到了O(1)(只算当前一个新词),注意力计算量从O(N^2)降到了O(N)。 - 显存空间换时间:天下没有免费的午餐。计算量虽然暴降,但代价是显存占用从几乎不随 N 增长,变成了随 N 线性增长(
O(L * N * d))。 - 关于动态激活值:表中的存储复杂度忽略了计算过程中产生的动态激活值(Activations)。因为激活值是瞬时的(Ephemeral),在单次前向传播结束后就会被释放,不会像 KV Cache 那样随着生成长度 N 的增加而在显存中持续累积,因此在分析长期静态存储瓶颈时通常忽略不计。
这就是为什么 KV Cache 虽然降低了计算量,却带来了巨大的显存占用。
因为大模型不仅层数深(比如 126 层),而且每个词的 K 和 V 向量维度都很大。这意味着你必须为每一个用户的每一个 Token,在每一层楼里都存一份 K 和 V。
我们来算一笔账:假设使用我们之前提到的 Llama 3 (405B) 模型(层数 126,隐藏层维度为 16384),在不考虑任何优化(即标准 MHA 模式)的情况下,上下文窗口为 100 万 Token:
- 每个 Token 的大小:在每一层,K 和 V 向量维度均为 16384,每个元素占 2 字节(FP16),即
16384 * 2 * 2 = 64 KB。穿过 126 层,每个 Token 总共需要64 KB * 126 = 8064 KB(约 8 MB)的缓存。 - 总大小:
8064 KB * 1,000,000 \approx 8.26 TB。仅仅这一个请求的 KV Cache 就会消耗高达 8 TB 以上 的显存。
这直接将大模型推理从“计算密集型”(算力不够)推向了“内存密集型”(显存不够)。
这种任由显存随上下文线性增长的“原始”KV Cache 机制显然是不可持续的。为了解决巨大的显存占用问题,业界后来发明了 GQA(分组查询注意力) 、 PagedAttention(分页注意力) 以及 RadixAttention 等技术,我们将在后续章节为你逐一解析。
第七章:最大化 GPU 利用率:批处理的演进¶
解决了单用户推理的算力瓶颈(通过 KV Cache)后,工程师们面临的主要挑战是:如何同时服务成千上万的用户?
第一节:计算密集 vs 内存密集¶
你可能会认为,GPU 最怕的是计算量太大。但对于大模型推理而言,很多时候真正的瓶颈其实是显存带宽。
大模型的权重矩阵(几百 GB)以及随着上下文增长的 KV Cache 都存储在 GPU 的显存(VRAM)中,而计算发生在线程核心(SMC)中。
- 如果一次只处理一个用户:每生成一个 Token,GPU 不仅要把整栋大楼的几百 GB 权重参数从显存搬进核心,还要把历史累积的 KV Cache 也一起搬进去!算完这一个 Token 后,这些数据在核心中就被释放。下一轮循环,再重新全部搬一遍!
- 这种情况下,数据搬运量极大,严重卡住了显存带宽,GPU 的计算核心绝大多数时间都在闲置等数据。算力被极大地浪费了。
第二节:批处理矩阵乘法(BMM)¶
为了不让 GPU 的算力闲置,最直接的办法就是批处理(Batching)。
既然搬运一次模型权重的开销如此巨大,那我们就一次性多处理几个用户的请求! 通过批处理矩阵乘法(BMM),我们把 \(N\) 个用户的输入向量堆叠成一个 3D 张量。GPU 只需要搬运一次权重矩阵,就可以同时为这 \(N\) 个用户进行计算。吞吐量成倍提升。
[!NOTE] 虽然 N 个用户共享同一套模型权重(只需搬运一次),但每个用户的 KV Cache 是完全独立的私有数据。在计算 Attention 时,GPU 必须把这 N 个用户各自的 KV Cache 分别从显存中搬进计算核心,这会导致 KV Cache 的搬运量随着 Batch Size 线性增长。
第三节:填充问题:“静态批处理”的缺陷¶
然而,传统的 静态批处理(Static Batching) 要求一个批次中的所有请求必须同时开始和结束。由于用户的输入和输出长度各不相同,为了凑齐批次,系统必须对短请求填充大量的无效 Token(Padding)。这不仅浪费了宝贵的计算资源,还导致短请求被迫等待长请求,引发了木桶效应。
第八章:核心不对称性:Prefill 与 Decode¶
第一节:Prefill 阶段 —— 计算密集型任务¶
1. 物理过程与计算复杂度
在这一阶段,模型一次性接收用户输入的 \(N\) 个 Token 作为输入。
- 线性层计算:这 \(N\) 个 Token 的向量与模型权重矩阵相乘。这在数学上是标准的矩阵-矩阵乘法(GEMM),计算量与 Token 数 \(N\) 成正比,复杂度为 \(O(L \cdot N \cdot d^2)\) 。
- 注意力计算:由于 Decoder-Only 架构的因果掩码(Causal Mask)限制,每个词只能与它前面的词计算注意力,生成一个下三角的 \(N \times N\) 注意力分数矩阵。计算量与 Token 数的平方成正比,复杂度为 \(O(L \cdot N^2 \cdot d)\) 。
- 单轮计算复杂度:两者相加,总复杂度为 \(O(L \cdot (N \cdot d^2 + N^2 \cdot d))\) 。其中 \(L\) 为模型层数, \(d\) 为隐藏层维度。可以看出,当输入长度 \(N\) 极大时,注意力计算的二次方复杂度会迅速上升。
2. 为什么它是“计算密集型”(Compute-Bound)? 这里涉及到一个核心概念:计算强度(Arithmetic Intensity),即“每从显存读取一个字节的数据,GPU 能进行多少次浮点运算”。
我们可以计算一下 Prefill 阶段的完整计算强度(包含线性层、Attention 以及 KV Cache 的显存写入):
我们以 \(N = 100,000\) (十万上下文)的 Llama 3 405B 为例进行估算:
- 总计算量:
- 线性层: \(2 \times N \times P = 2 \times 10^5 \times 405 \times 10^9 = 8.1 \times 10^{16}\) FLOPs。
- Attention: \(4 \times L \times N^2 \times d = 4 \times 126 \times (10^5)^2 \times 16384 \approx 8.26 \times 10^{16}\) FLOPs。
- 合计:约 \(1.64 \times 10^{17}\) FLOPs(此时 Attention 计算量已与线性层相当)。
- 总显存流量:
- 读取权重:\(810\) GB(FP16 格式下 405B 模型权重)。
- 写入 KV Cache:约 \(51.6\) GB。
- 合计:约 \(861.6\) GB。
最终的总计算强度为: $\(\frac{1.64 \times 10^{17} \text{ FLOPs}}{861.6 \times 10^9 \text{ Bytes}} \approx \mathbf{190,000} \text{ FLOPs/Byte}\)$
这意味着,每从显存读取一个字节的数据,GPU 需要进行约 19 万次浮点运算。而现代顶级 GPU(如 H100)的“算力/带宽”平衡点(拐点)通常在几百 FLOPs/Byte 左右(例如 H100 SXM 在 FP16 下的算力约为 1000 TFLOPS,带宽约 3.3 TB/s,拐点约 \(300\) FLOPs/Byte)。
由于 \(190,000\) 远超拐点 \(300\) ,GPU 必然处于算力受限(Compute-Bound)状态。加载进来的这批权重被这十万个 Token 充分共享复用,GPU 的数千个计算核心(ALU)会被塞满并高速运转。此时,限制推理速度的瓶颈是 GPU 的理论算力峰值(TFLOPS),而不是显存带宽。
第二节:Decode 阶段 —— 内存带宽密集型任务¶
当 Prefill 吐出第一个词后,推理模式发生转变。模型进入自回归的 Decode 阶段,开始逐字生成文本。
1. 物理过程与计算复杂度 在这一阶段的每一步,模型都只接收上一步生成的 1 个新 Token 作为输入。
- 线性层计算:这 1 个 Token 的向量与模型权重矩阵相乘。这在数学上退化为了矩阵-向量乘法(GEMV)。
- 注意力计算:这个新 Token 的 Query 向量,要去和 KV Cache 中缓存的过去所有 \(N\) 个词的 Key 向量做点积。
- 计算复杂度: \(O(L \cdot (d^2 + N \cdot d))\) 。随着上下文长度 \(N\) 的增长,注意力计算的占比会逐渐增加。
2. 为什么它是“内存带宽密集型”(Memory-Bound)? 这是大模型推理中经典的“内存墙”问题。 为了生成这仅仅 1 个词,GPU 必须执行一个看似低效的操作:它必须把常驻在显存里的、高达几百 GB 的完整模型权重,以及随着对话不断增长的 KV Cache,全部从显存(VRAM)搬运到计算核心(SRAM)中进行计算!
- 计算量极小:因为输入只有 1 个 Token,矩阵-向量乘法的计算量非常微薄,GPU 的绝大多数核心都在闲置。
- 搬运量极大:显存带宽(如 H100 的 HBM3 带宽约 3.3 TB/s)达到极限。 我们可以计算一下 Decode 阶段生成单个 Token 的单步计算强度公式:
我们同样以 \(N = 100,000\) (当前已累计十万字上下文)的 Llama 3 405B 为例进行估算:
- 单步计算量:
- 线性层: \(2 \times 1 \times P = 8.1 \times 10^{11}\) FLOPs。
- Attention: \(4 \times L \times 1 \times N \times d = 4 \times 126 \times 1 \times 10^5 \times 16384 \approx 8.25 \times 10^{11}\) FLOPs。
- 合计:约 \(1.635 \times 10^{12}\) FLOPs。
- 单步显存流量:
- 读取权重:\(810\) GB(每一轮生成都必须完整读取一次权重!)。
- 读取 KV Cache:由于需要和过去十万个词做注意力,我们需要从显存中读取这十万个词的 KV Cache,大小约为 \(51.6\) GB。
- 合计:约 \(861.6\) GB。
最终的单步计算强度为: $\(\frac{1.635 \times 10^{12} \text{ FLOPs}}{861.6 \times 10^9 \text{ Bytes}} \approx \mathbf{1.9} \text{ FLOPs/Byte}\)$
此时,计算强度(1.9)极低,远远低于硬件拐点(约 300)。限制推理速度的瓶颈不再是 GPU 能算多快,而是显存能以多快的速度把数据喂给核心。这就是为什么哪怕你换了算力更强的显卡,Decode 阶段的速度(每秒蹦出的字数)可能也提升有限,除非新显卡拥有更高的显存带宽。
第三节:数据视角下的“不对称性”¶
为了让你对这种不对称性有更直观的量化认知,我们来看一组对比(假设上下文长度为 \(N\) ,隐藏层维度为 \(d\) ,层数为 \(L\) ):
| 特性 | Prefill 阶段 (预填充) | Decode 阶段 (单步解码) |
|---|---|---|
| 输入规模 | \(N\) 个 Token (大) | \(1\) 个 Token (极小) |
| 数学算子 | 矩阵-矩阵乘法 (GEMM) | 矩阵-向量乘法 (GEMV) |
| 计算复杂度 | \(O(L \cdot (N \cdot d^2 + N^2 \cdot d))\) | \(O(L \cdot (d^2 + N \cdot d))\) |
| 显存访问 | 加载权重 + 写入 KV Cache | 加载权重 + 读取 KV Cache + 追加 KV Cache |
| 硬件瓶颈 | 算力受限 (Compute-Bound) | 带宽受限 (Memory-Bound) |
| GPU 利用率 | 极高 (适合榨干算力) | 极低 (算力被严重闲置) |
-
工程上的核心矛盾 这种不对称性直接导致了以下工程难题:
-
吞吐量与延迟的冲突:为了提高吞吐量,我们希望加大 Batch Size。这可以摊薄加载模型权重的显存带宽成本,提升计算强度和吞吐量;但对于 Decode 阶段,更大的 Batch Size 意味着要搬运更多用户的私有 KV Cache,这会直接拉长每个用户的吐字间隔(TBT,即延迟)。在极端情况下(如超长上下文),当 KV Cache 的大小逼近甚至超过模型权重时,加大 Batch Size 甚至无法再有效提升吞吐量。
- 调度器的挑战:当一个新用户的 Prefill 请求(重度计算)到达,并被混合到正在进行 Decode(重度访存)的批处理队列中时,由于 Prefill 的计算耗时长,会导致同批次中正在进行 Decode 的用户遭遇严重的卡顿(Straggler Effect),破坏了流式输出的流畅性。
这正是我们在本书第三部分将要看到的:vLLM 的 连续批处理(Continuous Batching) 和 分块预填充(Chunked Prefill) 想要解决的核心矛盾。
在第二部分中,我们分析了大模型推理的两个核心挑战:由 KV Cache 引发的巨大显存占用,以及由 Prefill 和 Decode 机制差异导致的核心不对称性。这些物理和数学规律制约了大模型的并发能力和响应速度。
面对这些挑战,工程师们提出了多种解决方案。在接下来的第三部分中,我们将深入探讨现代高性能推理引擎(如 vLLM 和 SGLang)的优化策略,看看它们是如何通过精妙的算法与架构设计,突破显存瓶颈并优化这种不对称性的。