
自回归生成的基本流程
要理解大语言模型的推理优化,首先要回答一个看似简单的问题:模型到底是如何「写」出一段话的? 答案藏在一个数学约束里------自回归(Autoregressive)。
自回归约束:把「写文章」拆成「填空格」
大语言模型的本质是一个条件概率分布 :给定一段前缀文本,它预测下一个 token 的概率。所谓自回归,就是用「自己过去的输出」作为「未来预测的输入」------第 ttt 个 token 的概率分布,完全由前 t−1t-1t−1 个 token 决定:
P(xt∣x1,x2,...,xt−1) P(x_t | x_1, x_2, \dots, x_{t-1}) P(xt∣x1,x2,...,xt−1)
这意味着模型生成第 5 个词时,必须已经知道前 4 个词是什么。这就像你在做一道完形填空题:填第 5 个空之前,必须先把前 4 个空填完------你不可能跳过中间的空格,先填最后的答案。
这个约束直接回答了一个常见困惑:为什么模型不能像翻译软件那样,一次性把整句输出算出来? 因为第 ttt 个 token 的概率分布依赖前 t−1t-1t−1 个 token 的实际值------这些值是模型的输出,在生成之前并不存在。虽然在数学上可以并行计算「给定任意前缀的预测」,但你并不知道哪个前缀会被真正选中。换句话说,生成路径是一棵树,而模型只能沿着一条分支走下去。
以 Transformer 架构为例,自回归体现在注意力的因果掩码 (causal mask)上:计算第 ttt 个位置的 attention 时,只允许它看到位置 111 到 ttt,位置 t+1t+1t+1 及之后的 key-value 全部被屏蔽为 −∞-\infty−∞(经 softmax 后权重为 0)。这是训练时就固定下来的结构,推理时自然沿用了同样的约束。
概率分布采样:从「候选词表」到「一个词」
有了第 ttt 步的条件概率分布,接下来的问题是:如何从这个分布中挑出一个 token?
模型的最后一层通常会输出一个长度为词表大小 VVV 的 logits 向量,经 softmax 归一化后得到一个概率分布 pt∈RVp_t \in \mathbb{R}^Vpt∈RV。最简单的策略是贪婪解码:直接选概率最大的 token。但贪婪解码有个缺陷------它容易陷入重复循环(比如不断输出「的的的的」),因为局部最优并不等于全局最优。
因此实际推理中常用采样 策略:从分布 ptp_tpt 中随机抽取一个 token。为了控制随机性,引入温度参数 τ\tauτ,对 logits 做缩放:
pt(i)=exp(zi/τ)∑j=1Vexp(zj/τ) p_t^{(i)} = \frac{\exp(z_i / \tau)}{\sum_{j=1}^{V} \exp(z_j / \tau)} pt(i)=∑j=1Vexp(zj/τ)exp(zi/τ)
- τ→0\tau \to 0τ→0:分布趋于 one-hot,接近贪婪解码
- τ=1\tau = 1τ=1:保持原始分布
- τ>1\tau > 1τ>1:分布更平坦,输出更多样
更进一步的策略如 top-k (只保留概率最高的 k 个 token 再归一化)和 top-p / nucleus (只保留累积概率超过阈值 ppp 的最小集合),都是为了在「多样性」和「连贯性」之间取平衡。
无论用哪种策略,这一步的输出只有一个 token------整数 ID。它会被拼接到输入序列末尾,作为下一步预测的上下文。
终止条件:什么时候停下来?
模型并不知道什么时候该停,就像一个没有刹车的引擎。因此,需要一个显式的终止信号。
在设计训练数据时,所有文本都会被拼接上特殊的 EOS(End-of-Sequence)token (也常写作 <|endoftext|> 或 </s>)。模型学习到:当 EOS 出现在输出中,说明生成已经完整。推理时,每一步采样后检查:
- 是否采样到 EOS token:若是,立即停止
- 是否达到
max_tokens上限:即使没遇到 EOS,也要强制截断,防止无限循环或超预算
第二步同样关键------线上服务都有延迟预算和成本预算,不可能让模型无限生成。因此 max_tokens 通常是必填参数。
一个完整的生成循环如下:
text
输入: [CLS] 中国的首都是
Step 1: 预测 P(北) = 0.6, P(上) = 0.2, ... → 采样得 "北"
输入: [CLS] 中国的首都是 北
Step 2: 预测 P(京) = 0.8, ... → 采样得 "京"
输入: [CLS] 中国的首都是 北京
Step 3: 预测 P(EOS) = 0.9, ... → 采样得 EOS → 停止
输出: 北京
请注意第二步的输入------它包含了第一步的输出「北」。这个「把新 token 拼回去再算」的过程,正是自回归的代价,也是性能优化的核心战场。
从流程到性能:为什么「快」这么难
现在我们已经明白了生成的基本流程。但细心的读者会发现一个奥处:每一步生成时,输入序列都在变长。如果每一步都从头计算,那么第 ttt 步的计算量是 O(t)O(t)O(t)(精确说是注意力层与序列长度线性相关),总复杂度将达到 O(T2)O(T^2)O(T2)------对于 1000 个 token 的输出,这意味着 50 万次位置级的计算量,而其中绝大多数计算在重复。
这就是为什么预填充(prefill)和解码(decode)会如此不同:prefill 一次性吃下整段输入,可以充分利用并行计算;而 decode 每一步只产出 1 个 token,且每步之间顺序依赖,没法并行。更具体的差异------KV Cache 如何消除重复计算、为什么 decode 是访存密集型而 prefill 是计算密集型------将在下一节展开。
第一阶段:prefill
理解了自回归约束之后,一个关键的问题浮出水面:既然模型必须逐 token 生成,那输入 部分是否也要逐个 token 地捱过一遍?答案是否定的。这就引出了推理流程的第一个阶段------prefill(预填充)。
一次性并行处理全部输入 token
当用户提交一段提示词(prompt)时,例如「请用三句话解释什么是量子纠缠」,模型并不会像处理生成的 token 那样,先读「请」、再读「用」、再读「三」......而是将整段输入一次性全部送入 Transformer 层。这正是 prefill 阶段的核心特征:并行处理。
为什么能做到并行?关键在 Transformer 架构中的注意力机制 配合前文提到的因果掩码 。在自注意力计算中,第 iii 个 token 的注意力分数只依赖于第 111 到 iii 个 token(因果掩码屏蔽了未来位置的注意力)。这听起来似乎仍然有顺序依赖------第 iii 个 token 要等第 i−1i-1i−1 个 token 计算完才能开始?
实际上并非如此。Transformer 在计算每一层 时,对所有 token 的投影(Query、Key、Value)是一次性完成的矩阵乘法。以输入长度 NNN、隐藏维度 ddd 为例,输入矩阵 X∈RN×d\mathbf{X} \in \mathbb{R}^{N \times d}X∈RN×d 乘以权重矩阵 WQ∈Rd×dk\mathbf{W}_Q \in \mathbb{R}^{d \times d_k}WQ∈Rd×dk,得到 Q=XWQ\mathbf{Q} = \mathbf{X}\mathbf{W}_QQ=XWQ:这是一次完整的矩阵乘法,GPU 可以在硬件层面将整行并行化。因果掩码只影响最后的 Softmax 归一化------它仅决定哪些位置被「忽略」,但所有位置的 Query、Key、Value 向量都在同一时刻被算出来了。
巨大的计算量:矩阵乘法的天下
prefill 阶段的性质是计算密集 (compute-bound)。它要同时处理全部输入 token,因此涉及庞大的矩阵乘法。以输入长度为 NNN、Transformer 层数为 LLL、每层隐藏维度为 ddd 为例,单层自注意力的矩阵乘法运算量约为 4Nd24Nd^24Nd2(包括 Q/K/V 投影和输出投影,每个都涉及 N×dN \times dN×d 乘以 d×dd \times dd×d 的矩阵),加上前馈网络的两层约 8Nd28Nd^28Nd2。总计算量约为:
FLOPsprefill≈12LNd2 \text{FLOPs}_{\text{prefill}} \approx 12L N d^2 FLOPsprefill≈12LNd2
这是一个非常直观的公式:计算量与输入长度 NNN 线性相关 。NNN 越大,算得越久。在 prefill 阶段,GPU 的算力(FLOPS)几乎被完全压满,因为矩阵乘法中计算与访存的比例远高于 1------每读一个浮点数,就要做大量乘加运算。这也是为什么 prefill 是 GPU 最「舒服」的阶段:硬件利用率高,不存在「等数据」的情况。
以 GPT-3 规模(L=96L=96L=96 层,d=12288d=12288d=12288)为例,输入 2000 个 token,单次 prefill 的计算量约为 1.4×10151.4 \times 10^{15}1.4×1015 FLOPs(1.4 PFLOPs)。而一块 A100 GPU 的 BF16 算力约为 312 TFLOPS,也就是说,理想情况下也需要约 4.5 毫秒------这还不算通信和层间开销。实际推理场景中,数百并发请求叠加,prefill 总计算量会迅速推高。
KV Cache 的诞生:为 decode 做准备
prefill 阶段除了「算得快」,还悄悄做了一件至关重要的事------计算并缓存 Key 和 Value 向量 。这就是构成 KV Cache 的初始内容。
为什么需要缓存?回顾因果注意力机制:生成第 N+1N+1N+1 个 token 时,它对前 NNN 个 token 的注意力分数需要进行一次 QnewK⊤\mathbf{Q}_{\text{new}}\mathbf{K}^\topQnewK⊤ 的矩阵乘法。而 K\mathbf{K}K 和 V\mathbf{V}V 恰好就是前 NNN 个 token 在 prefill 阶段已经算好的矩阵。如果每次生成都不保留它们,那就只能重新前向传播一遍全部历史 token------这会让 decode 阶段的计算量从 O(1)O(1)O(1) 退化为 O(t)O(t)O(t),即便 ttt 不大,也会让推理慢上几个数量级。
因此 prefill 阶段结束后,每一层的注意力模块都保存了两份中间结果:
K∈RN×dk,V∈RN×dv \mathbf{K} \in \mathbb{R}^{N \times d_k}, \quad \mathbf{V} \in \mathbb{R}^{N \times d_v} K∈RN×dk,V∈RN×dv
这两份矩阵占用的显存总量为 2LNdk2L N d_k2LNdk。对于 96 层、dk=128d_k=128dk=128、输入长度 2000 的模型,仅 KV Cache 就需要 96×2000×128×2B×2≈9896 \times 2000 \times 128 \times 2 \text{B} \times 2 \approx 9896×2000×128×2B×2≈98 MB(以 FP16 存储)。注意,这只是单个请求的 KV Cache 大小。生产环境中同时处理数百个并发请求,KV Cache 会轻松占据数 GB 显存------这正是后续章节中 KV Cache 量化、PageAttention 等优化手段的动机来源。
两阶段的分界线
prefill 阶段结束的标志是:最后一个输入 token 的隐藏状态已经完成前向传播,模型的最终隐藏层输出了一个向量,这个向量经过 LM Head(词表映射)后,得到第一个输出 token 的概率分布。此时 KV Cache 已就位,计算量的重心将发生决定性转移------从「算得快」变成「读得快」------这正是下一阶段 decode 的核心挑战。
总结起来,prefill 是一次性、并行、计算密集的前向传播,它消化完所有输入 token,并缓存了所有层的 Key/Value 向量,为逐 token 的生成阶段铺好了路。理解了这一点,你就理解了大语言模型推理中最甜蜜的一段时光------接下来我们将进入那个「访存密集」的慢节奏阶段。
第二阶段:decode
prefill 阶段用 4.5 毫秒处理完整个输入,速度看似惊人。但如果推理过程就此结束,模型输出的只是一堆「中间状态」,而非用户能读懂的文本。真正的内容生成,发生在接下来的 decode 阶段------而恰恰是这个阶段,暴露了推理系统最深的性能瓶颈。
逐 token 生成的串行循环
decode 阶段做的事情极其简单:每轮只生成一个 token,然后把它拼接到输入末尾,再次送入模型,再生成下一个。这个过程像一个人在迷宫里每走一步都要重新审视整个地图------尽管他只需要决定下一步往哪拐。
展开来看,decode 的每一步都包含完整的 Transformer 前向传播。假设模型第 ttt 步生成 token xtx_txt,那么它需要计算:
- 将前缀 x1,x2,...,xt−1x_1, x_2, \dots, x_{t-1}x1,x2,...,xt−1 与刚生成的 xtx_txt 拼接,形成新的输入序列;
- 对新序列做完整的嵌入(embedding)和层归一化;
- 逐层计算注意力------每个 token 都要与之前所有 token 计算注意力分数;
- 经前馈网络(FFN)变换,输出下一个 token 的概率分布;
- 从分布中采样(或取 argmax),得到 xt+1x_{t+1}xt+1。
值得强调的是第 2-4 步:它们与 prefill 阶段的计算完全相同,区别只在于输入序列的长度多了一个 token。每生成一个 token,就要重复一遍全部计算。生成 100 个 token,就是 100 次完整的前向传播。
下面的伪代码呈现了这一串行循环的本质:
python
def decode(model, input_ids, max_new_tokens=100):
"""
逐 token 生成:每一步只产生一个 token。
input_ids: 已处理完的输入 token 序列(prefill 阶段产出)
"""
generated = []
for step in range(max_new_tokens):
# 1. 将当前序列送入模型,得到所有位置的 logits
logits = model(input_ids) # shape: (seq_len, vocab_size)
# 2. 只取最后一个位置的 logits 作为预测依据
next_token_logits = logits[:, -1, :]
# 3. 采样或取概率最大的 token
next_token = sample(next_token_logits) # 形状: (1,)
# 4. 将新 token 拼接到序列末尾,下轮继续
input_ids = torch.cat([input_ids, next_token], dim=1)
generated.append(next_token)
# 5. 若生成结束符则提前终止
if next_token == EOS_TOKEN:
break
return generated
注意第 1 步:每一步都调用一次完整的 model(input_ids)------没有跨步骤的并行。上一步的输出必须等下一步的输入准备好才能计算,这种串行依赖是自回归约束的直接结果,没有任何优化能绕过它(除非改变模型架构本身)。
权重读取:decode 的隐性成本
现在来看一个关键问题:每步生成一个 token,但模型的所有权重都要被完整地读取一遍。
一个 7B 参数的模型,权重以 FP16 存储,占用约 14 GB 显存。decode 的每一步都要把这 14 GB 的权重从显存搬运到计算单元(GPU 的 SM 或 Tensor Core)。为什么?因为注意力计算需要 WQ,WK,WV,WOW_Q, W_K, W_V, W_OWQ,WK,WV,WO 四组权重;FFN 需要 Wup,WdownW_{up}, W_{down}Wup,Wdown 两组权重;再加上 embedding 矩阵和 LayerNorm 参数------每一层无一遗漏。
这个「读取」动作是物理上的数据搬运,耗时与权重总量成正比。计算本身(矩阵乘法)反而只占很小的比例,因为输入只有一个新 token 需要处理。
访存/计算比:压垮性能的天平
用「访存/计算比」这个指标可以精确刻画 decode 的瓶颈。定义如下:
访存/计算比=每步读取的字节数每步的浮点运算数(FLOPs) \text{访存/计算比} = \frac{\text{每步读取的字节数}}{\text{每步的浮点运算数(FLOPs)}} 访存/计算比=每步的浮点运算数(FLOPs)每步读取的字节数
prefill 阶段处理 NNN 个 token 时,读取的权重同样是 MMM 字节(MMM 为模型权重总大小),但计算量是 O(N)O(N)O(N) 次矩阵乘法。因此:
prefill 的访存/计算比≈MN⋅F1 \text{prefill 的访存/计算比} \approx \frac{M}{N \cdot F_1} prefill 的访存/计算比≈N⋅F1M
其中 F1F_1F1 是处理单个 token 所需的 FLOPs。当 NNN 较大时(例如 2000 token 的输入),计算量足够大,访存开销被摊薄------这就是 prefill 计算密集的原因。
而 decode 每步只处理 1 个新 token,计算量是 F1F_1F1,但读取的权重依然是 MMM:
decode 的访存/计算比≈MF1 \text{decode 的访存/计算比} \approx \frac{M}{F_1} decode 的访存/计算比≈F1M
以 Llama-7B 为例做一个数量级估算:
text
模型权重(FP16): M = 14 GB ≈ 1.4 × 10^10 字节
单 token 计算量: F_1 ≈ 2 × 10^9 FLOPs(约 20 亿次浮点运算)
访存/计算比: ≈ 7 字节/FLOP
GPU 访存带宽: ≈ 2 TB/s(A100 实测约 1.5-2 TB/s)
GPU 算力: ≈ 312 TFLOPs(A100 BF16 峰值)
理论计算时间: 2 × 10^9 / 312 × 10^12 ≈ 6.4 微秒
实际访存时间: 1.4 × 10^10 / 2 × 10^12 ≈ 7 毫秒
同样的运算,访存耗时是计算耗时的 1000 倍以上。GPU 的计算单元在 decode 的每一步中大部分时间处于空闲等待状态------数据搬运的速度远远赶不上计算速度。这正是 decode 被称为「访存密集」(memory-bound)的根本原因。
这也解释了为什么 decode 阶段的 token 生成速度通常只有 20-50 tokens/秒,而 prefill 阶段处理输入的等效吞吐可以高达每秒数千 token。前者被显存带宽锁死,后者被算力充分释放。生产环境中的推理系统,几乎所有的延迟优化(如 KV Cache、投机解码、批处理调度)都围绕 decode 的访存瓶颈展开------理解这一点,就抓住了推理优化的命门。
访存瓶颈:GPU 上的带宽与 FLOPs
上一节我们看到 decode 阶段每生成一个 token 都要重新读一遍权重矩阵,访存耗时是计算耗时的 1000 倍以上。这个数字听起来骇人,但它究竟意味着什么?要回答这个问题,我们需要从 GPU 的物理特性出发,引入两个最基本的指标------计算能力 与内存带宽------以及它们之间的比值。
算力与带宽:GPU 的两条生命线
GPU 的算力 (Compute Capability)以 FLOPs(Floating Point Operations per Second)衡量,即每秒能执行的浮点运算次数。一块常用的 NVIDIA A100 GPU,FP16 精度下的峰值算力约为 312 TFLOPs------每秒可执行 312 万亿次浮点运算。
GPU 的内存带宽 (Memory Bandwidth)则以 bytes/s 衡量,即每秒能从 HBM(高带宽内存)读取或写入多少数据。A100 的内存带宽约为 1.5 TB/s(即每秒读取 1.5 万亿字节)。
这两个数字单独看都令人震撼,但真正关键的是它们的比值------它定义了 GPU 的「性格」。将峰值算力除以内存带宽:
算术强度阈值=312×1012 FLOPs1.5×1012 bytes≈208 FLOPs/byte \text{算术强度阈值} = \frac{312 \times 10^{12} \text{ FLOPs}}{1.5 \times 10^{12} \text{ bytes}} \approx 208 \text{ FLOPs/byte} 算术强度阈值=1.5×1012 bytes312×1012 FLOPs≈208 FLOPs/byte
这意味着:如果一段计算中,每个字节的数据平均要做超过 208 次浮点运算,GPU 才会被「算力」拖住;如果低于这个数,GPU 就会被「搬运数据」拖住。这个比值被称为「算术强度阈值」或「ridge point」,它是理解一切性能瓶颈的标尺。
算术强度:一次运算中「含算量」的度量
算术强度 (Arithmetic Intensity)定义为:一段计算中,总浮点运算次数与总数据移动量的比值,单位是 FLOPs/byte。
算术强度=总计算量(FLOPs)总访存量(bytes) \text{算术强度} = \frac{\text{总计算量(FLOPs)}}{\text{总访存量(bytes)}} 算术强度=总访存量(bytes)总计算量(FLOPs)
它也衡量着一段代码的「气质」------高算术强度的运算相当于在一个数据上反复揉捏出大量结果(数据复用率高),低算术强度的运算则像是「读完即弃」的流水线。
在 GPU 上,数据类型、核心数量等底层参数决定了该平台能支持的最高算力与带宽。如表所示:
| 指标 | A100 | V100 | H100 |
|---|---|---|---|
| 算力(FP16) | 312 TFLOPs | 112 TFLOPs | 990 TFLOPs |
| 内存带宽 | 1.5 TB/s | 900 GB/s | 3.0 TB/s |
| 算术强度阈值 | ≈208 | ≈124 | ≈330 |
你不需要记住这些具体数字,只需记住一点:现代 GPU 的算术强度阈值通常在 100~500 FLOPs/byte 之间。
Roofline 模型:一张图看穿性能天花板
Roofline 模型是伯克利实验室提出的性能分析方法论,它把上面所有的思考画成一张图------横轴是算术强度,纵轴是可达到的性能(FLOPs/s)。给定一段计算的实际算术强度后,它在图中的位置决定了性能上限:
#mermaid-svg-u4MJ9MtdS51k79oN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-u4MJ9MtdS51k79oN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-u4MJ9MtdS51k79oN .error-icon{fill:#552222;}#mermaid-svg-u4MJ9MtdS51k79oN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-u4MJ9MtdS51k79oN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-u4MJ9MtdS51k79oN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-u4MJ9MtdS51k79oN .marker.cross{stroke:#333333;}#mermaid-svg-u4MJ9MtdS51k79oN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-u4MJ9MtdS51k79oN p{margin:0;}#mermaid-svg-u4MJ9MtdS51k79oN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-u4MJ9MtdS51k79oN .cluster-label text{fill:#333;}#mermaid-svg-u4MJ9MtdS51k79oN .cluster-label span{color:#333;}#mermaid-svg-u4MJ9MtdS51k79oN .cluster-label span p{background-color:transparent;}#mermaid-svg-u4MJ9MtdS51k79oN .label text,#mermaid-svg-u4MJ9MtdS51k79oN span{fill:#333;color:#333;}#mermaid-svg-u4MJ9MtdS51k79oN .node rect,#mermaid-svg-u4MJ9MtdS51k79oN .node circle,#mermaid-svg-u4MJ9MtdS51k79oN .node ellipse,#mermaid-svg-u4MJ9MtdS51k79oN .node polygon,#mermaid-svg-u4MJ9MtdS51k79oN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-u4MJ9MtdS51k79oN .rough-node .label text,#mermaid-svg-u4MJ9MtdS51k79oN .node .label text,#mermaid-svg-u4MJ9MtdS51k79oN .image-shape .label,#mermaid-svg-u4MJ9MtdS51k79oN .icon-shape .label{text-anchor:middle;}#mermaid-svg-u4MJ9MtdS51k79oN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-u4MJ9MtdS51k79oN .rough-node .label,#mermaid-svg-u4MJ9MtdS51k79oN .node .label,#mermaid-svg-u4MJ9MtdS51k79oN .image-shape .label,#mermaid-svg-u4MJ9MtdS51k79oN .icon-shape .label{text-align:center;}#mermaid-svg-u4MJ9MtdS51k79oN .node.clickable{cursor:pointer;}#mermaid-svg-u4MJ9MtdS51k79oN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-u4MJ9MtdS51k79oN .arrowheadPath{fill:#333333;}#mermaid-svg-u4MJ9MtdS51k79oN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-u4MJ9MtdS51k79oN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-u4MJ9MtdS51k79oN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-u4MJ9MtdS51k79oN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-u4MJ9MtdS51k79oN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-u4MJ9MtdS51k79oN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-u4MJ9MtdS51k79oN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-u4MJ9MtdS51k79oN .cluster text{fill:#333;}#mermaid-svg-u4MJ9MtdS51k79oN .cluster span{color:#333;}#mermaid-svg-u4MJ9MtdS51k79oN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-u4MJ9MtdS51k79oN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-u4MJ9MtdS51k79oN rect.text{fill:none;stroke-width:0;}#mermaid-svg-u4MJ9MtdS51k79oN .icon-shape,#mermaid-svg-u4MJ9MtdS51k79oN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-u4MJ9MtdS51k79oN .icon-shape p,#mermaid-svg-u4MJ9MtdS51k79oN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-u4MJ9MtdS51k79oN .icon-shape .label rect,#mermaid-svg-u4MJ9MtdS51k79oN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-u4MJ9MtdS51k79oN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-u4MJ9MtdS51k79oN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-u4MJ9MtdS51k79oN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 算术强度低
如:逐element的向量运算
性能受限于带宽
Memory Bound
算术强度高
如:大矩阵乘法
性能受限于算力
Compute Bound
Roofline: 斜线区域
斜率=峰值带宽
Roofline: 水平线区域
高度=峰值算力
平台性能上限
图中那条线就是 Roofline------先是一条斜率为峰值带宽的上升线,到达峰值算力后转为平台线。落在斜线区域的被称为 memory-bound (访存受限),落在平台区域的则被称为 compute-bound(算力受限)。
当一个算子落在斜线区域时,它触及的是带宽的天花板,无论怎么优化计算本身------调整数学公式、换更高效的算子实现------性能都无法超过这条斜线;只有减少无效的数据搬运,才能沿着斜线向上移动。当一个算子落在平顶区域时,它缺的才是「算力」。
两阶段各自落在 Roofline 的哪里?
现在我们把 prefill 和 decode 分别放上这张图。
prefill 阶段 :同时处理 NNN 个输入 token,每个 token 都要与全部历史 token 做注意力计算,核心操作是 N×dN \times dN×d 的矩阵与 d×Nd \times Nd×N 的矩阵相乘,计算量为 O(N2⋅d)O(N^2 \cdot d)O(N2⋅d)。数据(权重和激活值)总量约为 O(N)O(N)O(N) 级别。当 NNN 足够大(比如 1024 或 2048)时,算术强度会远远超过 208 这个阈值。以 2048 长度为例,prefill 阶段可达到的算术强度轻松破千,因此处于 Roofline 的平台区域------compute-bound。
decode 阶段 :每步只处理一个 token,核心操作是这个 token 对应的向量与权重矩阵相乘。这个向量只有 1×d1 \times d1×d 大小,权重矩阵是 d×dd \times dd×d,计算量约为 2d22 d^22d2 FLOPs。但需要把整个权重矩阵从 HBM 搬到片上------访存量是 2d22 d^22d2 bytes。算术强度是多少?
2d2 FLOPs2d2 bytes=1 FLOPs/byte \frac{2 d^2 \text{ FLOPs}}{2 d^2 \text{ bytes}} = 1 \text{ FLOPs/byte} 2d2 bytes2d2 FLOPs=1 FLOPs/byte
1 个 FLOPs/byte------远低于 208 的阈值,比它低了两个数量级。decode 阶段在 Roofline 图上落在斜线区域的极左端,是典型的 memory-bound ,也就是说它的性能上限由内存带宽锁定,而非算力。这解释了上一节的惊人数字:无论 GPU 算得多快,decode 的每一步都在等数据从内存搬过来,而搬运的速度决定了最终只能有 20-50 tokens/秒。
一个类比:厨师、食材与砧板
想象一位厨师(GPU 核心)在砧板(片上缓存)上做菜。prefill 阶段相当于一次性把一整桌食材搬到砧板旁,然后一口气把 2048 道菜全部切完------切菜(计算)占据了绝大多数时间,搬食材(访存)只占极小比例。decode 阶段则相当于每做一道菜,都要先从仓库(HBM)里重新搬一遍所有食材------搬运几乎占满了所有时间,厨师大部分时间都在等食材,而不是在切菜。
这两个阶段的本质区别不是说 decode 的「计算量小」,而是说它的计算性价比极低------每个字节的数据只贡献了 1 次浮点运算,而 GPU 本可以承受 208 次。
理解了算术强度与 Roofline 模型,"两阶段为何存在如此悬殊的性能特征"这个问题就有了清晰的答案。接下来的问题自然是:既然 decode 被带宽锁死,有什么办法让它少搬几次数据?批处理、KV Cache 复用、连续批处理......这些在推理系统中如雷贯耳的名词,本质上都是在回应同一个诉求------提高 decode 阶段的算术强度。
为什么 decode 这么慢
上一节的 Roofline 分析揭示了问题的根源:decode 阶段处于平台区域与访存受限区域之间的尴尬地带。但「访存受限」这四个字,落到具体数字上到底有多可怕?这一节,我们用量化分析来回答这个问题------为什么 decode 阶段 GPU 的利用率低得令人发指。
小 GEMM:被访存拖垮的矩阵乘法
decode 每生成一个 token,核心计算是一系列矩阵乘法。以 7B 参数的 LLaMA 架构为例,模型共有 32 层,每层包含注意力层和 MLP 层的多个权重矩阵。设隐层维度 dmodel=4096d_{model} = 4096dmodel=4096,则每生成一个 token,单个权重矩阵(形状为 4096×40964096 \times 40964096×4096)的乘法涉及:
- 计算量 :2×4096×4096=33.52 \times 4096 \times 4096 = 33.52×4096×4096=33.5 MFLOPs(百万次浮点运算)
- 访存量 :4096×4096×24096 \times 4096 \times 24096×4096×2 bytes(FP16)= 33.5 MB
看起来两者「旗鼓相当」?问题出在时间尺度上。以 NVIDIA A100 GPU 为例,其 FP16 算力为 312 TFLOPs,内存带宽为 2 TB/s。如果只执行这一个矩阵乘法:
- 纯计算时间 :33.5×106/312×1012≈0.1133.5 \times 10^6 / 312 \times 10^{12} \approx 0.1133.5×106/312×1012≈0.11 毫秒
- 纯访存时间 :33.5×106/2×1012≈0.01733.5 \times 10^6 / 2 \times 10^{12} \approx 0.01733.5×106/2×1012≈0.017 毫秒
等等------这样算下来访存时间反而比计算时间短?问题出在哪里?我们少算了整个模型的总权重 。7B 模型的总参数量约为 7B 个,以 FP16 存储,总权重大小约为 14 GB 。decode 每生成一个 token,理论上需要重复读取全部 14 GB 权重(实际中,KV Cache 的存在使得部分参数不必参与注意力计算,但权重读取是必需的)。
而计算量呢?生成一个 token 的总计算量约为 2×7B×1=142 \times 7B \times 1 = 142×7B×1=14 GFLOPs。这样一比:
- 总访存时间 :14 GB/2 TB/s=714 \text{ GB} / 2 \text{ TB/s} = 714 GB/2 TB/s=7 毫秒
- 总计算时间 :14 GFLOPs/312 TFLOPs=0.04514 \text{ GFLOPs} / 312 \text{ TFLOPs} = 0.04514 GFLOPs/312 TFLOPs=0.045 毫秒
访存耗时是计算耗时的约 155 倍。
利用率:GPU 在 decode 时有多「闲」
上面的数字揭示了一个反直觉的事实:decode 阶段 GPU 的算力几乎没有被用起来。我们来算一下实际的 GPU 利用率:
利用率=实际计算时间实际耗时=0.0457≈0.64% \text{利用率} = \frac{\text{实际计算时间}}{\text{实际耗时}} = \frac{0.045}{7} \approx 0.64\% 利用率=实际耗时实际计算时间=70.045≈0.64%
也就是说,在一次 decode 步骤中,GPU 的 FP16 算力利用率不到 1%。剩下的 99% 以上的时间,GPU 都在等待数据从显存搬运到计算单元------计算单元处于「空转」状态。
用一个类比来帮助理解:想象一个流水线工人,他的工作是从仓库搬运一箱零件到工位,然后拧一个螺丝。如果一趟只能搬一个零件,他每天绝大部分时间都花在走路上,真正拧螺丝的时间微乎其微。decode 阶段的情况正是如此:权重参数就是那些零件,计算单元就是那个工人,而每个 token 的生成都需要把所有零件重新搬一遍。
带宽瓶颈的数值直觉:为什么不能一次算更多
有人可能会问:既然每步只计算一个 token,为什么不把多个 token 的计算合并到一起执行?这正是批处理(batching)的初衷。但批处理带来的收益是延迟换吞吐的权衡,而且受限于显存容量------批大小越大,KV Cache 占用越大。在单请求场景下,decode 的序列依赖本质决定了每步只能推进一个 token。
我们用以下代码来量化验证这一点。假设有一个简化的 7B 模型,我们在 A100 上模拟单 token decode 的性能特征:
python
import numpy as np
import time
# 模拟 7B 模型的关键参数(简化版)
d_model = 4096 # 隐层维度
n_layers = 32 # 层数
total_params = 7e9 # 总参数量(7B)
dtype_size = 2 # FP16 每个参数占 2 字节
# A100 GPU 规格
compute_capability = 312e12 # 312 TFLOPs (FP16)
memory_bandwidth = 2e12 # 2 TB/s
# 单 token decode 的计算量与访存量
seq_len = 1 # 只生成 1 个 token
flops_per_token = 2 * total_params * seq_len # 约 14 GFLOPs
bytes_per_token = total_params * dtype_size # 约 14 GB
# 理论耗时
compute_time_s = flops_per_token / compute_capability
memory_time_s = bytes_per_token / memory_bandwidth
# 实际耗时 = max(访存时间, 计算时间),因为两者可以重叠
actual_time_s = max(compute_time_s, memory_time_s)
# GPU 算力利用率
utilization = compute_time_s / actual_time_s
print(f"单 token 计算量: {flops_per_token / 1e9:.1f} GFLOPs")
print(f"需读取权重: {bytes_per_token / 1e9:.1f} GB")
print(f"纯计算时间: {compute_time_s * 1e6:.2f} 微秒")
print(f"纯访存时间: {memory_time_s * 1e3:.2f} 毫秒")
print(f"实际耗时: {actual_time_s * 1e3:.2f} 毫秒")
print(f"GPU 算力利用率: {utilization * 100:.2f}%")
预期输出:
单 token 计算量: 14.0 GFLOPs
需读取权重: 14.0 GB
纯计算时间: 44.87 微秒
纯访存时间: 7.00 毫秒
实际耗时: 7.00 毫秒
GPU 算力利用率: 0.64%
这个结果印证了我们前面的手算推导:decode 步的耗时完全由访存主导,算力利用率不足 1%。这也解释了为什么单用户下 decode 的生成速度只有 20-50 tokens/秒------每个 token 的 7 毫秒时间,几乎全部花在了传数据上,而不是算数据上。
小结:慢的本质是「数据搬运」而非「计算」
至此,我们可以对 decode 的慢给出精确的定性:decode 阶段的计算强度(算术强度)极低------每字节数据只对应约 1 FLOP 的计算(14 GFLOPs / 14 GB = 1 FLOP/byte),远低于 A100 的拐点(约 156 FLOPs/byte)。GPU 的算力像一个闲置的超级引擎,却因为燃料(权重数据)输送太慢而空转。
理解了这一点,推理优化的方向就变得异常清晰:要么减少访存量(如 KV Cache、权重量化),要么提高访存效率(如批处理、FlashAttention)。而我们将在下一节看到,这些优化手段的底层逻辑,其实都是在「算力过剩、带宽不足」的约束下,尽可能让每一次数据搬运产生更多的计算价值。
两阶段对优化策略的指导意义
当我们用 Roofline 模型把 prefill 和 decode 的访存/计算特征描画清楚之后,一个自然的追问是:这些底层差异,究竟如何决定优化手段的取舍? 答案可以浓缩成一句话------prefill 是计算密集(compute-bound),所以要让它算得更快;decode 是访存密集(memory-bound),所以要让访存量变得更少。 后续章节中的每一项优化技术,本质上都在从这两个方向中的一个切入问题。
prefill:向时间要效率
prefill 阶段一次性并行处理全部输入 token,GPU 在执行大型矩阵乘法时处于计算受限区域------此时 ALU(算术逻辑单元)是稀缺资源,而显存带宽则在大多数时间空闲。因此,prefill 优化的核心思路是减少无效计算量、提升算术强度,让每秒钟执行更多有用的浮点运算。
具体到工程实践,常见的优化手段包括:
- FlashAttention :在注意力层中,将 QKTQK^TQKT、Softmax、AVAVAV 三步融合为一次遍历,避免将完整的注意力分数矩阵(N×NN \times NN×N)写回显存再读回。对于长上下文场景,这一步将访存量从 O(N2)O(N^2)O(N2) 降至 O(N)O(N)O(N)。
- 算子融合:将多层归一化、残差连接、激活函数等小算子合并进相邻的 GEMM 中,减少 kernel 启动开销和中间张量的显存往返。
- 并行策略:由于 prefill 的计算可以沿序列维度切分,Tensor Parallel(张量并行)和 Sequence Parallel(序列并行)都能将单个大 GEMM 拆分到多卡执行,让算力随卡数近似线性扩展。
这些手段的共同点是:它们并没有减少「必须算的东西」,而是让同样多的计算在单位时间内完成得更快。
decode:向访存要效果
decode 阶段的处境截然不同。每生成一个 token,模型要做的小矩阵乘法耗时才几十微秒,而读取 14 GB 权重却要花费数百毫秒。此时 GPU 的算力有 90% 以上处于闲置状态,瓶颈在显存带宽。因此 decode 优化的核心思路是减少每次生成需要读取的数据量,让单位访存量产出更多 token。
| 优化方向 | 核心思想 | 典型手段 |
|---|---|---|
| 减少权重读取 | 降低每次生成必须从显存搬移的字节数 | 量化(如 INT8/INT4)、权重共享 |
| 复用已读数据 | 让一份权重服务多个生成请求 | 连续批处理(Continuous Batching) |
| 减少计算轮次 | 压缩生成所需的步骤数 | 投机解码(Speculative Decoding) |
| 避免重复计算 | 缓存中间状态,不重算 | KV Cache |
以连续批处理为例:当多个用户的生成请求并发到达时,与其让每个请求各自串行地读完 14 GB 权重再算下一个 token,不如将多个请求的 token 拼成一个更大的 batch,一次性读完权重后并行处理。如果 batch 大小为 8,那么权重的读取次数就从 8 次降为 1 次------每个 token 的访存成本被摊薄到原来的 1/8。这正是推理框架(如 vLLM、TensorRT-LLM)提升吞吐量的核心手段。
KV Cache:横跨两阶段的桥梁
值得注意的是,KV Cache 是唯一同时影响两个阶段的技术------它在 prefill 阶段被创建并逐步填满,在 decode 阶段被反复读取。每个新生成的 token 都需要之前的 Key 和 Value 来计算注意力分数,如果没有缓存,每次都要从所有历史 token 重新计算一遍 Key 和 Value,那 decode 阶段的访存量将从「读一遍权重」进一步恶化到「读一遍权重 + 重算所有历史 KV」。因此,KV Cache 本质上属于 decode 优化的访存削减类手段,而它的管理开销(显存占用、调度策略)则是后续独立章节的主题。
从两阶段差异到优化路线图
把两阶段的特性与优化手段对应起来,后续文章的心智地图便清晰了:
后续内容映射
| 阶段 | 瓶颈类型 | 优化方向 | 对应后续章节 |
|---|---|---|---|
| prefill | compute-bound | 减少无效计算、提升并行度 | FlashAttention、算子融合、张量并行 |
| decode | memory-bound | 减少访存、提高利用率 | KV Cache、批处理策略、量化、投机解码 |
| 两阶段衔接 | 显存容量 | 缓存管理与调度 | PagedAttention、显存规划 |
为了让你直观感受优化策略的实际效果,我们来看一个对比实验。以下代码模拟了 7B 模型在 decode 阶段,分别使用「朴素逐 token 生成」和「KV Cache + 批处理」两种策略时,每生成 1 个 token 的理论访存成本:
python
def decode_memory_bytes(
batch_size: int,
weights_bytes: float, # 权重总字节数(14 GB = 14 * 1024^3)
kv_per_token_bytes: float, # 每个 token 的 KV 缓存字节数
seq_len: int, # 当前序列长度
) -> float:
"""
计算 decode 阶段每生成一批 token 的总显存读取量(单位:字节)。
无缓存时:读取全部权重 + 重算所有历史位置的 KV
有缓存时:读取全部权重 + 读取历史 KV,但可按 batch 摊薄权重读取量
"""
# 方案一:朴素逐 token 生成(batch_size = 1,且每次从零重算 KV)
naive_disk = weights_bytes + seq_len * kv_per_token_bytes
# 方案二:KV Cache + 连续批处理
cached_disk = weights_bytes / batch_size + seq_len * kv_per_token_bytes
return naive_disk, cached_disk
# 参数设定:7B 模型,FP16 精度
weights = 7e9 * 2 # 70 亿参数 × 2 字节 = 14 GB
kv_per_token = 4096 * 2 * 2 * 32 * 2 # d_model=4096, 2 (K+V) × 2 bytes × 32 layers
seq_len = 2048 # 已有 2048 个历史 token
naive, cached = decode_memory_bytes(batch_size=8, weights_bytes=weights,
kv_per_token_bytes=kv_per_token, seq_len=seq_len)
print(f"朴素策略每 token 访存: {naive / 1e9:.2f} GB")
print(f"KV Cache + 批处理(bs=8) 每 token 访存: {cached / 1e9:.2f} GB")
print(f"访存削减倍数: {naive / cached:.1f}x")
运行这段代码,你会得到类似如下的输出:
朴素策略每 token 访存: 14.71 GB
KV Cache + 批处理(bs=8) 每 token 访存: 2.42 GB
访存削减倍数: 6.1x
看到差距了吗?仅仅引入 KV Cache 和批处理,每次生成的访存开销就下降了约 6 倍------这正是 decode 从「每秒 20 token」迈向「每秒 120 token」的底层来源。后续文章中我们将逐一深入这些优化手段的实现细节,而它们共同的理论基石,正是你对 prefill 与 decode 两阶段差异的理解。