前置讲 P2 · Transformer 推理的账本:参数、FLOPs、KV cache 与采样 · The Arithmetic of Transformer Inference

AI Inference Infrastructure · 预备知识 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:原课没有对应的一次课。第 1 讲直接给出了”每 token 约 2N 次运算""KV 字节 = 2 × 层数 × KV 头数 × 头维度 × 字节”这些结论,这篇前置讲把它们逐项推导出来,并补上推理引擎里除了 forward 以外的部分:采样、停止条件、分词与流式输出。 依据说明:模型结构来自公开的 config.json 和技术报告(Meta Llama 3 / 3.1 / 3.2、Mistral-7B、Qwen2.5、Mixtral、DeepSeek-V2 / V3)。注意力变体依据 N. Shazeer, Fast Transformer Decoding: One Write-Head is All You Need(2019,MQA)、J. Ainslie et al., GQA(EMNLP 2023)、DeepSeek-AI, DeepSeek-V2(2024,MLA)。FLOPs 的写法参照 J. Kaplan et al., Scaling Laws for Neural Language Models(2020)。采样依据 A. Fan et al.(2018,top-k)、A. Holtzman et al., The Curious Case of Neural Text Degeneration(ICLR 2020,top-p)、M. Nguyen et al.(2024,min-p)、N. Keskar et al.(2019,CTRL 的重复惩罚),以及 FlashInfer 团队 2025 年的博客 Sorting-Free GPU Kernels for LLM Sampling。分词依据 R. Sennrich et al.(ACL 2016,BPE)和 GPT-2 的字节级 BPE。增量反分词的写法参照 vLLM 和 HF TGI 的开源实现。 配套代码 Code:code/prereq2/(numpy + 标准库;6 个脚本 + 2 个共享模块;全部 1 秒左右跑完) 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一篇,你应该能够:

  1. 画出一次 decoder-only Transformer forward 的数据流,说出每一步的张量形状。
  2. 只看一个模型的 config.json,就能算出它的参数量(包括共享嵌入、QKV 偏置、MoE 的总参数和激活参数),并和官方数字对上。
  3. 推导每个 token、每次 prefill 的 FLOPs,说清注意力项什么时候变得重要,“2N”里的 N 应该用哪个数。
  4. 推导每个 token 的 KV cache 字节数,解释 MHA、MQA、GQA、MLA 各省了什么、代价是什么;能复述 MLA 的”吸收”技巧为什么成立。
  5. 做显存预算:权重放不放得下、KV 能放多少 token、最多能同时服务多少个请求;算出 TTFT、TPOT 的硬件下界和每百万 token 的成本下界。
  6. 说出温度、top-k、top-p、min-p、重复惩罚、束搜索各自做什么,以及它们在推理系统里的代价;解释为什么 top-p 可以不排序。
  7. 解释流式输出时为什么不能逐个 token 解码,停止字符串被拆开时怎样处理。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲不展开 Out of scope
第 1 讲 §2–§3:prefill / decode、KV cache、roofline模型怎样训练、为什么这样设计(只讲推理时的账)
前置讲 P1(建议):算术强度、Tensor Core、数值格式注意力内核的实现(第 4 讲)
Transformer 大概是什么:注意力、MLP、残差MoE 的路由和部署(第 8 讲);量化方法(附录讲 A)
概率的基本概念:分布、期望、对数投机解码的采样(第 5 讲,本讲给出它需要的采样基础)

0.3 本讲地图 Map

§1  为什么要会算账
§2  一次 forward 经过什么:逐层的数据流与张量形状
§3  参数量:逐项推导;共享嵌入、偏置、MoE 的总参数与激活参数                  ← 代码 01
§4  FLOPs:每个 token;prefill 的平方项;"2N"里的 N                          ← 代码 01、02
§5  KV cache:每 token 的字节;MHA / MQA / GQA / MLA;MLA 的吸收技巧           ← 代码 03
§6  显存预算、延迟下界与成本计算器                                           ← 代码 02、03、06
§7  采样:温度、top-k、top-p、min-p、重复惩罚、束搜索;不排序的 top-p;可复现性  ← 代码 04
§8  停止条件、分词与流式输出                                                ← 代码 05
§9  与推理系统的联系(只想快速浏览的读者读这一节)
§10–14  小结、常见误区、练习、延伸阅读、下一篇

0.4 记号 Notation

沿用第 1 讲:NN 参数量,LpL_p 提示长度,BB 批大小,dd 隐藏维度,nlayersn_\text{layers} 层数,nhn_h query 头数,nkvn_{kv} KV 头数,dhd_h 每头维度,π\pi 峰值算力,β\beta 显存带宽。本篇新增:

符号含义Llama-3-8B
VV词表大小128,256
dffd_{ff}MLP(SwiGLU)的中间维度14,336
g=nh/nkvg = n_h / n_{kv}GQA 的组大小:几个 query 头共用一个 KV 头4
SS一个 token 能看到的上下文长度(含它自己)—
bb每个数的字节数(BF16 = 2,FP8 = 1)2
dcd_c、drd_rMLA 的潜向量维度、共用的 RoPE key 维度(DeepSeek-V3:512、64)
TT采样温度—

0.5 关于数字 · About the numbers

本篇的数字都是算出来的,不是实测:参数量、FLOPs、KV 字节是从模型结构按公式算的(并且和官方公布的参数量、第 1 讲 tiny_llm 里真的数出来的运算次数核对过);时间和成本是”硬件下界”,用的是第 1 讲的纸上 GPU,多卡按理想张量并行、忽略通信。采样和分词的演示是真的跑出来的,但用的是合成的分布和一小段原创语料。


1. 为什么要会算账 · Why learn the arithmetic

推理系统里几乎每一个决策,背后都是一道算术题:

  • 这个模型要几块卡?——先算权重多少字节、KV 每 token 多少字节。
  • 用户最快能多快拿到第一个字、之后每个字多快?——算 prefill 的 FLOPs、decode 每步要读的字节。
  • 一块卡最多能同时服务多少人?每百万 token 成本多少?——算显存还剩多少给 KV、满批时每步多久。
  • 为什么新模型都用 GQA、DeepSeek 要发明 MLA?——算 KV cache 占多少显存、decode 时读它要多久。

好消息是:这些数全部能从一个模型的 config.json(层数、隐藏维度、头数、中间维度、词表大小)和 GPU 的规格表算出来,不需要跑任何模型。本篇就是这本账的推导。我们会发现,第 1 讲的几个经验公式都只是这本账的”主项”;不同的结构(共享嵌入、MoE、MLA)会让主项变形,长上下文会让”次要项”变成主项。

账本之外,推理引擎每一步还要做几件和 forward 无关、却同样会出错、会花时间的事:从 logits 里采样出下一个 token,检查停止条件,把 token 变回文字流式地发给用户。§7、§8 讲这些。


2. 一次 forward 经过什么 · What a forward pass does

现在的大语言模型几乎都是 decoder-only 的 Transformer(Llama、Qwen、Mistral、DeepSeek……)。一次 forward 处理一段新 token(prefill 时是整个提示,decode 时是 1 个),设这一步有 tt 个新 token:

token id(t 个整数)
  │ embedding:查表,取词嵌入表的 t 行                          → (t, d)
  ▼
┌─────────────────── 重复 n_layers 层 ───────────────────┐
│ h = RMSNorm(x)                                          │
│ q = h·W_q   k = h·W_k   v = h·W_v                       │  q: (t, n_h·d_h)   k, v: (t, n_kv·d_h)
│ q, k ← RoPE(q, k, 位置)                                 │  按位置旋转,让点积只依赖相对位置
│ 把 k、v 写进 KV cache;取出全部 S 个 token 的 K、V        │
│ o = softmax(q·Kᵀ/√d_h + 因果掩码)·V                     │  每个 query 头和它那一组的 KV 头做注意力
│ x = x + o·W_o                                           │  残差连接   (t, d)
│ h = RMSNorm(x)                                          │
│ x = x + (SiLU(h·W_gate) ⊙ h·W_up)·W_down                │  SwiGLU MLP,中间维度 d_ff
└─────────────────────────────────────────────────────────┘
  │ RMSNorm;只取需要 logits 的位置(生成时是最后一个)
  ▼
logits = h·W_lm_head                                          → (1, V)
  │ 惩罚、温度、截断、采样(§7)
  ▼
下一个 token id

几个组件的作用(只说推理时需要知道的):

  • RMSNorm:把每个 token 的向量除以它的均方根,再乘一个可学习的缩放向量。几乎没有参数,但是一个逐元素 + 归约的算子(P1 §5:memory-bound,要和残差相加融合)。
  • RoPE(旋转位置编码):把 q、k 的每一对维度按”位置 × 频率”的角度旋转。两个旋转后的向量做点积,结果只依赖它们的相对位置。它没有参数,但有两个推理上的后果:KV cache 里存的是旋转后的 k(所以前缀缓存要求位置也相同,第 3 讲);它会破坏 MLA 的”吸收”技巧(§5.4)。
  • 注意力:新 token 的 q 和 cache 里全部 SS 个 token 的 k 做点积,softmax 之后对 v 加权求和。GQA 时几个 query 头共用一个 KV 头(§5)。
  • SwiGLU MLP:两个”上投影”(gate 和 up),gate 过 SiLU 后和 up 逐元素相乘,再”下投影”回 dd 维。三个矩阵,所以参数是 3d dff3 d\, d_{ff}。
  • lm_head:把最后的隐藏向量投影到词表上,得到 VV 个 logits。有的模型让它和词嵌入表共享同一个矩阵(tied embeddings)。

第 1 讲的 tiny_llm.py 就是按这张图实现的(只是位置编码用了可学习的表,而不是 RoPE),可以对照着读它的 forward。


3. 参数量 · Parameter count

3.1 逐项推导

所有参数都在矩阵里(RMSNorm 的缩放向量可以忽略)。按上图逐项数:

部分参数量Llama-3-8B
词嵌入表VdV d5.25 亿
每层注意力d⋅nhdh+2⋅d⋅nkvdh+nhdh⋅dd \cdot n_h d_h + 2 \cdot d \cdot n_{kv} d_h + n_h d_h \cdot d4,194 万
每层 MLP3 d dff3\, d\, d_{ff}1.76 亿
lm_headdVd V(共享时为 0)5.25 亿
RMSNorm(2nlayers+1)d(2 n_\text{layers} + 1) d27 万

代码 01 演示 1 把 Llama-3-8B 逐个矩阵列出来:每层 2.18 亿(注意力 0.42 亿 + MLP 1.76 亿),32 层共 69.8 亿,加上嵌入表和 lm_head 各 5.25 亿,一共 8,030,261,248 个——和官方的 8.03 B 完全一致。同一套公式数第 1 讲 tiny_llm 的参数(它多一张可学习的位置编码表),得到 122,688 个,和它实际数组的元素个数一样。

几个值得注意的比例:MLP 占了约 70%(dffd_{ff} 是 dd 的 3.5 倍,又有三个矩阵);注意力只占约 17%,因为 GQA 让 WkW_k、WvW_v 只有 WqW_q 的 1/4;嵌入表和 lm_head 合起来占 13%。

3.2 公开模型的核对

代码 01 演示 2 用同一套公式算了 9 个公开模型,和官方数字的误差都在 0.5% 以内:

模型注意力账本官方嵌入 + 头注意力MLP / 专家
Llama-3.2-1B(共享嵌入)GQA1.236 B1.23 B21.3%13.6%65.2%
Mistral-7BGQA7.242 B7.24 B3.6%18.5%77.8%
Qwen2.5-7B(QKV 偏置)GQA7.616 B7.61 B14.3%10.8%74.9%
Llama-3-8BGQA8.030 B8.03 B13.1%16.7%70.2%
Llama-3-70BGQA70.554 B70.6 B3.0%17.1%79.9%
Llama-3.1-405BGQA405.853 B405 B1.0%17.7%81.3%
Mixtral-8x7BGQA + MoE46.703 B46.7 B0.6%2.9%96.6%
DeepSeek-V3MLA + MoE671.026 B671 B0.3%1.7%98.0%
  • 小模型里嵌入表很显眼:词表大小(12.8 万)和模型大小无关,Llama-3.2-1B 即使共享了嵌入表,也有 21% 的参数在里面;405B 只有 1%。Mistral-7B 和 Llama-3-8B 的层结构一模一样,参数差的 8 亿几乎全在词表上(3.2 万 vs 12.8 万)。
  • 共享嵌入的模型,词嵌入表和 lm_head 是同一个矩阵,只算一次;Qwen2.5 的 Q、K、V 带偏置,多出几千个参数——账本要照顾到这些细节才对得上。

3.3 MoE:总参数与激活参数

MoE(混合专家,第 8 讲)把每层的一个 MLP 换成很多个”专家”MLP,每个 token 只经过其中 top-k 个。所以有两个参数量:

  • 总参数:所有专家都要常驻显存。显存看总参数。
  • 激活参数:每个 token 实际用到的参数(注意力 + top-k 个专家 + 共享专家 + 嵌入)。计算看激活参数。

代码 01 演示 3:Mixtral-8x7B 总参数 46.7 B、激活 12.9 B(27.6%);DeepSeek-V3 总参数 671.0 B、激活约 37.6 B(官方 37 B,5.6%)。DeepSeek-V3 的 671 B 里 97.4% 是路由专家(58 层 × 256 个专家),MLA 注意力只有 11.4 B。用 FP8 存它要 671 GB,8 块 80 GB 的 H100 都放不下(§6.1);但每个 token 的运算量只相当于一个 37 B 的稠密模型。


4. FLOPs · How much computation

4.1 矩阵乘法的运算量

一个 (m×k)(m \times k) 矩阵乘 (k×n)(k \times n) 矩阵:每个输出元素做 kk 次乘法和 kk 次加法,一共 2mkn2mkn 次运算。所以一个 token(m=1m = 1)经过一个 k×nk \times n 的权重矩阵,就是 2kn2kn 次运算——每个参数一次乘、一次加。把一层里所有矩阵加起来,一个 token 的线性部分就是 2 × 参数量。这就是”每 token 约 2N”的来历。

4.2 每个 token 的 FLOPs

decode 时生成一个 token(它能看到 SS 个 token,含自己)的完整账:

Ftoken  =  2Nmatmul⏟各层的矩阵乘法  +  2dV⏟lm_head  +  nlayers⋅4 nhdh⋅S⏟注意力.F_\text{token} \;=\; \underbrace{2 N_\text{matmul}}_{\text{各层的矩阵乘法}} \;+\; \underbrace{2 d V}_{\text{lm\_head}} \;+\; \underbrace{n_\text{layers} \cdot 4\, n_h d_h \cdot S}_{\text{注意力}}.

注意力项的来历:每个 query 头和 SS 个 key 各做一次 dhd_h 维的点积(2dhS2 d_h S 次运算),再把 SS 个 value 加权求和(又是 2dhS2 d_h S 次)。它只和 query 头数有关,和 KV 头数无关:GQA 省的是 KV 的存储和读取,不省运算。Kaplan 等人的缩放定律论文给出的前向运算量是 2N+2nlayernctxdattn2N + 2 n_\text{layer} n_\text{ctx} d_\text{attn}:注意力项只有一行,相当于只数了 QK⊤QK^\top;这里把乘 V 的那一半也算上,所以系数是 4。

代码 02 演示 1:注意力项随上下文线性增长,什么时候追上其余全部?

模型“2N”项(含 lm_head)注意力 @ 8K注意力 @ 128K持平的上下文
Llama-3.2-1B2.5 G1.1 G(30%)17.2 G(87%)18.9K
Llama-3-8B15.0 G4.3 G(22%)68.7 G(82%)28.6K
Llama-3-70B139.0 G21.5 G(13%)343.6 G(71%)53.0K
Llama-3.1-405B807.5 G67.6 G(8%)1.1 T(57%)97.8K

上下文几千时注意力只是零头;到了几万、十几万,它就成了主项。不过别忘了 decode 是 memory-bound 的:注意力真正的代价是读 KV,而不是这些运算(第 1 讲、P1 §5.4)。

4.3 prefill:注意力的平方项

prefill 一个长度为 LpL_p 的提示,第 pp 个位置能看到 pp 个 token,所以注意力的总运算量是 nlayers⋅4nhdh⋅(1+2+⋯+Lp)≈nlayers⋅2nhdhLp2n_\text{layers} \cdot 4 n_h d_h \cdot (1 + 2 + \dots + L_p) \approx n_\text{layers} \cdot 2 n_h d_h L_p^2——随提示长度平方增长:

Fprefill  =  2NmatmulLp  +  2dV  +  nlayers⋅4nhdh⋅Lp(Lp+1)2.F_\text{prefill} \;=\; 2 N_\text{matmul} L_p \;+\; 2dV \;+\; n_\text{layers} \cdot 4 n_h d_h \cdot \frac{L_p (L_p + 1)}{2}.

(只为最后一个位置算 logits。)代码 02 演示 2(Llama-3-8B):

提示长度总 FLOPs注意力占比平均每 token ÷ 2N
2,04829.7 T3.7%1.04
8,192131.9 T13.3%1.15
32,768738.9 T38.1%1.62
131,0726.3 P71.1%3.46

128K 的提示,平均每个 token 的运算量是”2N”的 3.5 倍。这就是长上下文的 prefill 越来越贵、第 10 讲的稀疏注意力要专门降低这一项的原因。

这个公式和第 1 讲 tiny_llm 里用 OpCounter 真的数出来的运算次数逐个相等(提示长度 16、64、200)。tiny_llm 的 dd 只有 64,“2N”项很小,200 个 token 时注意力就占了 20%(Llama-3-8B 到 2,048 个 token 也只有 3.7%):模型越小,注意力的相对代价越大。

4.4 “2N”里的 N 到底是哪个数

代码 01 演示 4 比较”2 × 总参数”和实际的 FLOPs(上下文 1,不含注意力项):

模型2 × 总参数2 × 激活参数实际 FLOPs实际 ÷ 2N_总
Llama-3.2-1B(共享嵌入)2.5 G2.5 G2.5 G1.00
Llama-3-8B16.1 G16.1 G15.0 G0.93
Mixtral-8x7B93.4 G25.8 G25.5 G0.27
DeepSeek-V31.3 T75.1 G73.3 G0.055
  • 稠密模型的”2N”略高估:词嵌入表只是查表(取一行),不做矩阵乘法。共享嵌入的 1B 模型正好 1.00:那一张表在 lm_head 里恰好做了一次矩阵乘法。
  • MoE 用总参数就完全错了。准确的说法是:每 token 的 FLOPs ≈ 2 × 参与矩阵乘法的激活参数 + 注意力项。

顺带一提:训练时每个 token 的运算量约为 6N6N(前向 2N2N,反向约 4N4N)。推理只有前向,所以是 2N2N。


5. KV cache · The KV cache

5.1 每个 token 存什么、存多大

KV cache 存的是每个历史 token 在每一层算出的 key 和 value(RoPE 之后的),这样新 token 做注意力时不用重算它们(第 1 讲 §2)。每个 token、所有层加起来:

KV 字节 / token  =  2×nlayers×nkv×dh×b.\text{KV 字节 / token} \;=\; 2 \times n_\text{layers} \times n_{kv} \times d_h \times b.

(2 是 K 和 V 各一份。)Llama-3-8B:2×32×8×128×2=131,0722 \times 32 \times 8 \times 128 \times 2 = 131{,}072 字节 = 128 KiB。代码 03 演示 1:

模型种类KV 头 / query 头每 token KV假如是 MHA节省32K 对话
Llama-3.2-1BGQA8 / 3232 KiB128 KiB4×1.07 GB
Qwen2.5-7BGQA4 / 2856 KiB392 KiB7×1.88 GB
Llama-3-8BGQA8 / 32128 KiB512 KiB4×4.29 GB
Llama-3-70BGQA8 / 64320 KiB2.5 MiB8×10.74 GB
Llama-3.1-405BGQA8 / 128504 KiB7.9 MiB16×16.91 GB
DeepSeek-V3MLA1 个潜向量68.6 KiB4.8 MiB71×2.30 GB

一段 32K token 的对话,Llama-3-8B 要 4.3 GB 的 KV——和模型权重(16 GB)是同一个量级。KV cache 是推理系统的头号资源(第 3、9 讲),所以模型设计者想方设法缩小它。

5.2 MHA、MQA、GQA

  • MHA(multi-head attention,原始的 Transformer):每个 query 头有自己的 K、V 头,nkv=nhn_{kv} = n_h。
  • MQA(multi-query attention,Shazeer 2019):所有 query 头共用一个 K、V 头,nkv=1n_{kv} = 1。Shazeer 的出发点正是推理:decode 受限于读 K、V 的带宽,KV 小 nhn_h 倍,读得就快 nhn_h 倍。代价是质量有所下降、训练也不太稳定。
  • GQA(grouped-query attention,Ainslie 等,2023):折中——把 query 头分成 nkvn_{kv} 组,每组共用一个 KV 头。Llama 3 系列都是 8 个 KV 头。

GQA 不是近似。代码 03 演示 2 在 tiny_llm 上验证:一个 GQA 模型(4 个 query 头、2 个 KV 头),和”把每个 KV 头复制 2 份”得到的 MHA 模型,生成的 40 个 token 完全相同(logits 的差只有 7×10−77 \times 10^{-7},是 float32 的舍入),但 MHA 的 KV cache 大一倍。GQA 只是”不把重复的 K、V 存两遍”。

但一个按 MHA 训练好的模型,不能直接改成 GQA。演示 2 的第二部分把一个 MHA 模型的 KV 头在组内取平均(这是 GQA 论文的初始化方法),不再训练:GQA 只剩 9/40 个 token 和原模型相同,MQA 只剩 3/40。(tiny_llm 是随机权重,这个数字会夸大真实模型上的损失,只说明”改结构会改变输出”。)GQA 论文的做法是先这样初始化,再用原预训练约 5% 的算力继续训练(uptraining);现在的模型大多从一开始就按 GQA 训练。

GQA 对推理还有一个影响:decode 注意力的算术强度约等于组大小 gg(每读一个 K、V,被 gg 个 query 头各用一次;第 4 讲 §4.1)。Llama-3-8B 是 4,Llama-3-70B 是 8,都远低于屋脊点。

5.3 MLA:只缓存一个潜向量

DeepSeek-V2 提出的 MLA(multi-head latent attention)走得更远:每个 token 每层只缓存一个 dcd_c 维的潜向量 c=xWDKVc = x W_{DKV}(DeepSeek-V3:dc=512d_c = 512),用到时再用”解压”矩阵得到每个头的 key 和 value:

kh=c WUK,h,vh=c WUV,h.k_h = c\, W_{UK,h}, \qquad v_h = c\, W_{UV,h}.

128 个头、每个头的 key 192 维、value 128 维,如果直接存要 128×320=40,960128 \times 320 = 40{,}960 个数;MLA 只存 512 + 64 = 576 个,小 71 倍(那 64 个数是什么,见 §5.4)。

问题是:如果每一步都要把 SS 个历史 token 的潜向量解压成 KhK_h、VhV_h,运算量就爆炸了。吸收(absorb)技巧解决这个问题。第 hh 个头的注意力分数:

qhkh,j⊤=qh(cjWUK,h)⊤=(qhWUK,h⊤)⏟q~h: dc 维cj⊤,q_h k_{h,j}^\top = q_h (c_j W_{UK,h})^\top = \underbrace{(q_h W_{UK,h}^\top)}_{\tilde q_h:\ d_c\ \text{维}} c_j^\top,

输出:

∑jpjvh,j=∑jpj cjWUV,h=(∑jpjcj)WUV,h.\sum_j p_j v_{h,j} = \sum_j p_j\, c_j W_{UV,h} = \Big(\sum_j p_j c_j\Big) W_{UV,h}.

先把 query 变到 dcd_c 维的潜空间,直接和缓存的潜向量做点积;先在潜空间里加权求和,最后再乘一次 WUV,hW_{UV,h}(它还能并进输出投影 WOW_O)。这只是矩阵乘法的结合顺序不同((AB)C=A(BC)(AB)C = A(BC)),结果完全相同。代码 03 演示 3 用 float64 验证:两种算法的输出最大差 6.7×10−166.7 \times 10^{-16}。对 DeepSeek-V3(上下文 4,096、decode 一步、一层),朴素算法光解压就要 137 GFLOP,吸收后只剩注意力本身的约 1.1 GFLOP。

吸收后,MLA 的 decode 就像一个”所有 128 个头共用一份 576 维 KV”的 MQA:每读一个字节的 KV,被 128 个头各用一次。演示 3 算了 decode 注意力的算术强度:

模型decode 注意力的强度(FLOP / KV 字节)
Llama-3-8B(GQA,g=4g = 4)4
Llama-3-70B(GQA,g=8g = 8)8
DeepSeek-V3(MLA)242

MLA 让 decode 注意力第一次接近 compute-bound(H100 的屋脊点 295)。代价是:只有一份潜向量,张量并行没法按头切开它,每块卡都得存完整的 KV(第 8 讲 §5)。

5.4 RoPE 为什么破坏吸收

RoPE 要把 key 乘一个依赖它自己位置 nn 的旋转矩阵 RnR_n:qhRm(cnWUK,hRn)⊤=qhRmRn⊤WUK,h⊤cn⊤q_h R_m (c_n W_{UK,h} R_n)^\top = q_h R_m R_n^\top W_{UK,h}^\top c_n^\top。中间的 RnR_n 随每个历史 token 变化,没法和 WUK,hW_{UK,h} 合成一个固定的矩阵,吸收就失效了。DeepSeek 的办法是”解耦的 RoPE”:内容部分(不旋转)用潜向量 + 吸收;另外为每个 token 缓存一个所有头共用的 64 维 key,只有它做 RoPE。这就是每层 512 + 64 个数里那 64 个的来历。


6. 显存预算、延迟下界与成本 · Memory, latency bounds, and cost

6.1 显存:权重 + KV + 其他

一块卡的显存要放:

  1. 权重:总参数 × 每个数的字节(多卡张量并行时平均切开);
  2. KV cache:剩下的大部分空间,决定能同时服务多少 token;
  3. 其他:激活(每一步的中间结果,用完就释放)、logits、CUDA Graph 和通信的缓冲区、碎片。推理引擎通常只让自己用显存的约 90%(vLLM 的默认值)。

代码 03 演示 4(显存用 90%,理想张量并行;KV 按头切开,KV 头数少于卡数时复制;MLA 的潜向量每块卡存一份):

部署每卡权重KV 容量4K 上下文32K128K
Llama-3-8B · BF16 · 1×H10016.06 GB426,784 token104 个请求133
Llama-3-70B · BF16 · 8×H10017.64 GB1,327,1863244010
Llama-3.1-405B · FP8 · 8×H10050.73 GB659,360160205
DeepSeek-V3 · FP8 · 8×H10083.88 GB放不下权重———
DeepSeek-V3 · FP8 · 8×H20083.88 GB1,224,433298379
  • 上下文越长,能同时服务的请求越少:Llama-3-8B 单卡在 4K 上下文时能放 104 个请求的 KV,128K 时只剩 3 个。显存是批大小的天花板(第 1、3 讲)。
  • “要几块卡”首先是一道显存题:8 块 H100 一共只有 576 GB 可用,放不下 671 GB 的 FP8 DeepSeek-V3;换成每块 141 GB 的 H200 才行。
  • logits 是隐藏的显存炸弹:logits 通常用 FP32 存,词表 128,256 时每个位置 512 KB。decode 批 256 只要最后一个位置,共 131 MB;但如果要返回一个 8,192 token 的提示里每个位置的对数概率(评测、打分常用),就是 4.2 GB。推理引擎通常分块计算或限制这类请求。

6.2 延迟的下界

不管引擎写得多好,都快不过硬件:

TTFT  ≥  FprefillnGPU⋅π,TPOTB=1  ≥  权重字节+这个请求的 KV 字节nGPU⋅β.\text{TTFT} \;\ge\; \frac{F_\text{prefill}}{n_\text{GPU} \cdot \pi}, \qquad \text{TPOT}_{B=1} \;\ge\; \frac{\text{权重字节} + \text{这个请求的 KV 字节}}{n_\text{GPU} \cdot \beta}.

代码 02 演示 3(提示 2,048;decode 上下文 2,048;batch 1;100% 峰值、忽略通信):

模型 · 精度 · 硬件TTFT ≥TPOT ≥单用户 token/s ≤
Llama-3.2-1B · BF16 · 1×H1004.31 ms0.76 ms1,320
Llama-3-8B · BF16 · 1×H10030.02 ms4.87 ms205
Llama-3-8B · FP8 · 1×H10015.01 ms2.44 ms410
Llama-3-70B · BF16 · 4×H10072.26 ms10.58 ms95
Llama-3.1-405B · FP8 · 8×H100105.06 ms15.16 ms66
DeepSeek-V3 · FP8 · 8×H200(只读被选中的专家)11.49 ms0.98 ms1,021

一个 8B 的 BF16 模型在 H100 上,每个用户每秒最多约 200 个 token。想更快只有三条路:字节更少(FP8、量化)、带宽更多(多卡张量并行、更新的 GPU)、每读一次权重产出更多 token(投机解码,第 5 讲)。MoE 在 batch 1 时只需读被选中的专家,所以 671 B 的 DeepSeek-V3 的 TPOT 下界和一个 40 B 左右的稠密模型相当;但批一大,不同的 token 选中不同的专家,几乎所有专家都要读(第 8 讲)。

一个请求的时间花在哪里?(代码 02 演示 4,Llama-3-8B、H100、batch 1 的下界)聊天(提示 1,000 / 输出 300)时 decode 占端到端时间的 99%;摘要(8,000 / 200)占 89%;推理模型(500 / 8,000)几乎 100%;只有”32,000 的提示、100 的输出”时 prefill 才过半。prefill 一次处理整个提示,效率高;decode 每步只产出一个 token,却要把权重读一遍。

6.3 成本计算器

把显存、延迟、吞吐放在一起,就得到一个”推理成本计算器”(llm_ledger.serving_estimate):

  1. 显存 → 最大并发 = min(引擎的批上限, KV 容量 ÷ 每个请求的 token 数);
  2. 满批时 decode 一步的时间 → 吞吐上界 = 最大并发 ÷ TPOT;
  3. 成本下界 = 每秒的 GPU 价格 ÷ 每秒输出的 token 数(按每 GPU 小时 3 美元)。

代码 06 演示 1(聊天:提示 1,000、输出 300)节选:

部署最大批TPOT@1TPOT@满批token/s ≤美元 / 百万 token ≥
Llama-3-8B · BF16 · 1×H1002566.16 ms20.51 ms12,4800.067
Llama-3-8B · FP8 · 1×H1002563.33 ms10.51 ms24,3670.034
Llama-3-70B · BF16 · 2×H100626.50 ms26.86 ms2237.460
Llama-3-70B · FP8 · 2×H10025613.50 ms22.47 ms11,3910.146
Llama-3-70B · BF16 · 4×H10025613.50 ms22.47 ms11,3910.293
Llama-3-70B · BF16 · 8×H1002567.00 ms11.49 ms22,2860.299
  • 0.067 美元 / 百万 token 是下界:第 17 讲用完整的模拟器(真实的调度、SLO、prefill 的干扰)算出 Llama-3-8B 在 H100 上的混合负载约 0.11 美元。
  • 70B 的 BF16 权重 141 GB,2 块 H100 放得下权重却几乎放不下 KV,最大批只有 6、成本贵了几十倍;FP8 让 2 块卡就够用。量化常常首先是为了”少用几块卡”。
  • 卡越多,每个用户越快,但每百万 token 未必更便宜:4 块卡 0.293 美元、8 块卡 0.299 美元,吞吐翻倍,钱也翻倍。

负载不同,成本差很多(演示 2、3):长文档问答(16,000 / 500)时 Llama-3-8B 单卡只能同时服务 25 个请求,成本涨到 0.866 美元;推理模型(500 / 8,000)每百万 token 0.285 美元,是聊天的 4.3 倍(每个请求要预留 8,500 个 token 的 KV,批只有 50),一个请求的成本是聊天的约 114 倍——推理模型贵在”说得多”。

代码 06 演示 4 把批大小单独拿出来(Llama-3-8B、BF16、1×H100、聊天):批从 1 增大到 256,总吞吐提高约 77 倍、成本降到 1/77,而每个用户的速度只从 162 降到 49 token/s;显存最多放 328 个请求。decode 受限于带宽时,多服务一个请求几乎是免费的——这就是推理服务”延迟 vs 成本”的基本权衡。


7. 采样 · Sampling

forward 的终点是 VV 个 logits ℓ1,…,ℓV\ell_1, \dots, \ell_V。怎样从它们得到下一个 token,是一个产品问题(输出要多确定、多有创意),也是一个系统问题(每一步、每个请求都要做,批里每个请求的参数还可能不同)。

7.1 从 logits 到分布

  • 贪心(greedy,温度 0):直接取 logit 最大的 token。确定、快,但开放式生成时容易陷入重复。
  • 温度(temperature)TT:pi∝exp⁡(ℓi/T)p_i \propto \exp(\ell_i / T)。T<1T < 1 让分布更尖,T>1T > 1 让分布更平。它不改变哪些 token 有机会,只改变机会的大小。
  • top-k(Fan 等,2018):只在概率最大的 kk 个 token 里采样。不管分布是尖是平都留 kk 个——分布很尖时留多了,很平时又可能留少了。
  • top-p / nucleus(Holtzman 等,2020):按概率从大到小排,保留累积概率刚好达到 pp 的最小集合。候选集合的大小随分布自适应。
  • min-p(Nguyen 等,2024):保留概率 ≥ pmin×p_\text{min} \times 最大概率的 token。按”相对最可能 token 的比例”截断。

代码 04 演示 1 在一个合成的分布上(词表 32,000,Zipf 分布:第 rr 可能的 token 概率 ∝ r−1.5r^{-1.5},模仿真实语言模型”头部几个 token 占大半、尾巴很长”的形状)比较:

参数候选 token 数熵(nats)最可能的概率
贪心101.000
T=1T = 1(原始分布)32,0003.140.384
T=0.7T = 0.732,0001.410.654
T=1.5T = 1.532,0007.300.091
top_k = 50502.300.429
top_p = 0.9542.330.427
T=0.7T = 0.7 + top_p = 0.950.920.712
min_p = 0.0571.430.531
T=1.5T = 1.5 + top_p = 0.910,7056.650.101
T=1.5T = 1.5 + min_p = 0.05202.530.278

最有意思的是最后两行:高温时 top-p 会放进大量长尾 token(温度把尾巴抬高了,要一万多个 token 才凑够 90%),而 min-p 只留 20 个。这正是 min-p 论文的动机:想要高温带来的多样性,又不想让模型从长尾里抽出胡言乱语。

7.2 顺序与默认值

这些操作的先后顺序会改变结果。代码 04 演示 2:同样写着 temperature = 1.5、top_p = 0.9,“先升温、再截断”留下 10,705 个候选,“先在原始分布上截断、再升温”只留 54 个;两个分布的总变差距离 0.54。不同的推理引擎和库在处理顺序、默认值(例如 top-k 默认不限还是 50)、重复惩罚统计哪些 token(只算生成的,还是连提示一起算)上并不完全一致。把一个应用从一个引擎搬到另一个,“同样的参数”可能产生明显不同的输出——评测时要固定引擎和版本。

7.3 重复惩罚

代码 04 演示 3 比较三种写法(token 0 出现过 3 次、logit +2;token 1 出现过 1 次、logit −1):

  • repetition_penalty(CTRL,Keskar 等,2019;HF transformers 的写法):出现过的 token,logit 为正时除以 θ\theta、为负时乘以 θ\theta。θ=1.3\theta = 1.3 时 +2.00 → +1.54、−1.00 → −1.30。它是乘性的,只看”出现过没有”,对 logit 接近 0 的 token 几乎没有作用。
  • frequency_penalty(OpenAI API):logit 减去 α×\alpha \times 出现次数。出现 3 次的 token 0 被扣 1.5。
  • presence_penalty:只要出现过,就减去固定的 α\alpha。

系统上,这些惩罚每一步都要按每个请求的历史修改 logits:引擎要为批里每个请求维护一份 token 计数,在 GPU 上做一次”按下标修改”。

7.4 top-p 一定要排序吗?

top-p 的直接写法是:把整个词表按概率排序、算累积和、截断。词表 12.8 万时这要约 220 万次比较,大批时每一步要为几百个请求各排一次;在 GPU 上排序要好几个内核、多遍读写,相当昂贵。

FlashInfer 团队 2025 年的博客给出了一个不排序的办法——拒绝采样:

  1. pivot = 0;
  2. 在 {概率 > pivot 的 token} 里按概率抽一个 jj(逆变换采样:累积和 + 二分查找,不需要排序);
  3. 如果”比 jj 更可能的 token 的总概率”< pp,说明 jj 在 top-p 集合里,接受;否则把 pivot 提到 pjp_j(扔掉所有不比 jj 可能的 token),回到第 2 步。

为什么结果正确:被拒绝的 jj 不在 top-p 集合 SS 里,而 SS 里每个 token 都比它更可能(否则 jj 也会在 SS 里),所以提高 pivot 永远不会扔掉 SS 的成员;“在一个包含 SS 的集合里按概率抽、落进 SS 才接受”,结果恰好是 SS 上按概率的分布。top-k 和 min-p 只是把接受条件换掉。

代码 04 演示 4(词表 1,000,各抽 2 万次):

设置平均轮数最多轮数拒绝采样的总变差直接抽样的总变差(噪声基线)
Zipf 1.5,top_p = 0.91.1040.0080.011
Zipf 1.5,top_p = 0.51.6280.0030.001
Zipf 1.0,top_p = 0.91.1050.0430.043
Zipf 1.5,top_k = 201.1640.0050.004

拒绝采样和排序截断的分布一致(总变差和”直接从正确分布抽同样次数”的噪声是同一个量级),平均只要一两轮:大部分时候第一次抽到的就在集合里,被拒绝时 pivot 一下子跳过一大片不可能的 token。每一轮只是一次并行的”比较 + 求和”,可以在一个 GPU 内核里完成。FlashInfer 还用”双 pivot”给轮数加了上界,避免个别请求拖慢整批;博客报告在 vLLM 的单 H100 配置上,采样的总时间减少了一半以上。

7.5 束搜索

束搜索(beam search)每一步保留累积对数概率最高的 BB 个候选序列:每个候选的每个可能的下一个 token 都打分,在 B×VB \times V 个扩展里取最高的 BB 个。代码 04 演示 5(tiny_llm,生成 20 个 token):

束宽序列的 log pforward 次数复制 KV 的字节同时存在的 KV
1(贪心)−42.7721057 KB
2−40.9541591 KB115 KB
4−40.54811.32 MB229 KB
8−39.961612.80 MB459 KB

束宽越大,找到的序列整体概率越高:贪心每一步选最好的,整体却未必最好。代价是 BB 倍的 forward(在 GPU 上可以拼成一批,但占 BB 个批位)和 BB 份 KV;一个父候选分出几个子候选时,除了第一个可以直接接手,其余都要复制父候选的 KV——第 3 讲的写时复制(copy-on-write)让子候选共享父候选的块,只复制最后一块。开放式的对话生成很少用束搜索(容易产生重复、乏味的文本),它主要用在翻译、语音识别这类”有标准答案”的任务里。

7.6 可复现性

用户给了随机种子,就期望”同样的输入、同样的种子、同样的输出”。代码 04 演示 6:请求 A 分别单独、排第一、排第三地和其他请求拼批。每个请求用自己的随机数生成器(按它的种子创建)时,A 抽到的 token 三次都一样;整批共用一个生成器时,A 抽到什么取决于排在它前面的请求消耗了多少随机数(三次里出现了两种结果)。所以推理引擎要为每个带种子的请求单独维护随机数状态。但这还不够:前置讲 P1 §7.4 说过,批的组成不同会让 logits 本身有微小的数值差异;要做到完全可复现,两件事都要处理(第 17 讲的”批不变性”)。

7.7 和投机解码的关系

第 5 讲的投机解码要做”拒绝采样式”的验证:比较草稿模型和目标模型给同一个 token 的概率。这要求两边用同一套采样处理(温度、top-p……)得到的分布,否则验证出来的分布就不是用户要的那个。这也是为什么有的不排序采样内核(只返回一个 token、不给出完整的截断分布)和投机解码的验证不好配合。


8. 停止条件、分词与流式输出 · Stopping, tokenization, and streaming

8.1 停止条件

一个请求在下面任何一种情况下结束:

  • 生成了结束 token(EOS,或者聊天模板里”一轮结束”的特殊 token);
  • 达到了 max_tokens(最多生成多少个 token),或者达到了模型的上下文上限;
  • 生成的文字里出现了用户指定的停止字符串(例如 “\n\nUser:”)。

前两种按 token 判断,很简单;第三种按字符判断,麻烦就来了(§8.4)。停止条件决定了请求什么时候释放它的 KV cache 和批位——第 2 讲说过,输出长度事先未知,正是推理调度难的根源。

8.2 分词:BPE

模型看到的不是字符,而是 token。现在最常用的分词方法是 BPE(byte-pair encoding,Sennrich 等,2016):

  1. 从最小的单位开始(GPT-2 以后多数模型用字节:256 个基本 token,所以任何文本都能编码);
  2. 统计训练语料里相邻 token 对的出现次数,把最常见的一对合并成一个新 token,记下这条合并规则;
  3. 重复,直到词表达到目标大小(Llama 3 是 128,256 个)。

编码时先做预分词(用正则表达式把文本切成”词”,合并不跨词),再对每个词按学到的先后顺序应用合并规则。

代码 05 演示 1 在一段一千六百多个字符的原创语料(英文、中文、代码、数字混合)上训练 BPE:

样例字符数0 次合并50 次150 次300 次全部 327 次
英文731.000.710.600.510.51
中文213.002.672.141.861.81
代码451.000.690.490.400.40
数字501.000.680.580.500.50

(表里是每个字符平均几个 token。)0 次合并时一个字节一个 token:英文一个字符 1 个,中文一个汉字 3 个(UTF-8 里汉字占 3 个字节)。合并越多,常见的片段(例如学到的 ” the”、“oken”)变成一个 token。我们的语料太小,中文最多只降到 1.81 token / 字;真实的分词器在海量语料上训练,中文每字约 1 个 token 甚至更少。但不同的分词器差别很大,同一段文字在不同模型上的 token 数可以差一倍以上——而 API 按 token 计费,上下文上限也按 token 算。比较两个模型的”每百万 token 价格”时,要先换算到同样的文字。

8.3 半个汉字

字节级 BPE 能编码任何文本,代价是:训练语料里不常见的字没有被合并成整字,它的字节会散落在一个或几个 token 里。代码 05 演示 2:在我们的分词器上,“流式输出时,半个汉字会变成乱码。“(16 个字符、48 个字节)被切成 37 个 token,其中 33 个不是完整的字符(只是一两个字节)。模型处理 token 没有任何问题;问题出在把 token 变回文字、流式地发给用户的时候。

8.4 流式输出:增量反分词

推理服务通常流式返回结果:每生成一个 token,就把对应的文字发给用户。代码 05 演示 3 比较三种写法(要输出的文字:“答案:缓存命中率 93% 🚀,延迟下降了。“,36 个 token):

写法用户看到的文字”�” 数
(a) 每个 token 单独解码,再拼起来��������缓存�������� 93% ����,延��下�����。27
(b) 每步把全部 token 重新解码,发出多出来的部分���缓存��� 93% �,延�下��。10
(c) 增量反分词答案:缓存命中率 93% 🚀,延迟下降了。0
  • (a) 半个汉字、半个 emoji 单独解码就是 ”�“(U+FFFD,“无法解码的字节”),拼起来乱码还在。用 SentencePiece 一类分词器的模型还有另一个问题:前导空格编码在 token 里,单独解码一个 token 会丢掉或多出空格。
  • (b) 最终的完整文字是对的,但流式输出时已经发出去的 ”�” 收不回来;而且每一步都要把全部 token 重新解码,1,000 个 token 的回答一共要解码约 50 万个 token,工作量随长度平方增长。
  • (c) 增量反分词(vLLM、HF TGI 的做法):记住两个位置 prefix_offset(之前的 token 已经定稿)和 read_offset(之前的文字已经发出去)。每来一个 token,只解码 prefix_offset 之后的几个 token;如果新解码出的文字比上次长、而且末尾不是 ”�“(说明没有半个字符),就把多出来的部分发出去,两个位置往前挪;否则先不发,等下一个 token 把字符补全。它从不输出 ”�“,每一步的工作量是常数;代价是这些字符会晚一个 token 才出现在用户屏幕上。

反分词在 CPU 上做,大批时每一步要为几百个请求各做一次。第 16 讲 SGLang 把它放进单独的进程(detokenizer manager),就是为了不让它拖慢调度循环。

8.5 停止字符串被拆开时

代码 05 演示 4:模型输出”答案是 42。\n\nUser: 下一个问题”,停止字符串是 “\n\nUser:“,它被拆在 “\n\nUs” 和 “er: 下一个” 两个片段里:

写法用户看到的文字停下了吗
(a) 只在每个新片段里找停止字符串答案是 42。\n\nUser: 下一个问题否
(b) 在全部文字里找,但片段一到就发出去答案是 42。\n\nUs是
(c) 缓冲区末尾可能是停止字符串的开头时,先扣下不发答案是 42。是

(a) 找不到被拆开的停止字符串,模型会一直生成,把下一轮的 “User:” 也编出来;(b) 能停下来,但 “\n\nUs” 已经泄露给用户了;(c) 只要缓冲区末尾可能是某个停止字符串的开头,就先扣下,确认不是才放出去。

停止字符串、增量反分词都是”按字符”工作的;而模型、KV cache、调度都是”按 token”工作的。这两个世界之间的翻译,是推理引擎里很容易出错、也很容易被忽视的地方。


9. 与推理系统的联系 · Connections to inference systems

有系统背景的读者可以只读这一节。

本篇的账在推理系统里的体现哪一讲
参数量、总参数 vs 激活参数要几块卡;MoE 的显存与计算的矛盾第 6、8 讲
每 token 约 2N + 注意力项批处理的收益;长上下文的代价第 1、2、10 讲
prefill 的平方项长提示的 TTFT;分块 prefill;稀疏注意力第 7、10 讲
每 token 的 KV 字节显存决定批大小;分页、前缀缓存、分层存储第 3、9 讲
GQA 的组大小、MLA 的潜向量decode 注意力的强度;张量并行在 KV 头数处撞墙第 4、8 讲
显存预算、logits最大并发;准入控制;prompt logprobs 的限制第 2、3 讲
TTFT / TPOT 的下界评估一个系统离极限多远;SLO 的可行性第 1、17 讲
成本计算器部署方案的选择;量化”少用几块卡”第 6、17 讲、附录讲 A
采样参数、顺序、默认值跨引擎的结果差异;评测要固定引擎第 17 讲
不排序的采样大批、大词表时的采样开销第 4 讲(FlashInfer)
每个请求的随机数状态可复现性;批不变性第 17 讲
束搜索、并行采样KV 的共享与写时复制第 3 讲
采样的分布投机解码的验证第 5 讲
停止条件输出长度未知 → 迭代级调度第 2 讲
增量反分词、停止字符串CPU 开销;独立的反分词进程第 16 讲

10. 小结 · Summary

  1. 一次 forward:嵌入 → nlayersn_\text{layers} × (RMSNorm、QKV 投影、RoPE、写 KV、注意力、输出投影、残差、RMSNorm、SwiGLU、残差) → RMSNorm → lm_head → 采样。
  2. 参数量能从 config.json 逐项算出,9 个公开模型都和官方数字对得上。MLP 占大头;小模型里嵌入表占比大;MoE 的显存看总参数、计算看激活参数。
  3. 每 token 的 FLOPs = 2 × 参与矩阵乘法的激活参数 + lm_head + 注意力项(随上下文线性增长,Llama-3-8B 约 29K 时追上其余全部);prefill 的注意力项随提示长度平方增长。
  4. 每 token 的 KV = 2 × 层数 × KV 头数 × 头维度 × 字节。GQA 和”复制 KV 头的 MHA”数学上等价,只是不存重复的 K、V;MLA 只缓存潜向量,靠”吸收”在潜空间里做注意力,让 decode 注意力接近 compute-bound。
  5. 显存预算决定最大并发:上下文越长,能同时服务的请求越少;“要几块卡”首先是显存题。TPOT 的下界是字节 ÷ 带宽,TTFT 的下界是 FLOPs ÷ 算力;批越大,每 token 越便宜。
  6. 采样:温度改变形状,top-k / top-p / min-p 截断候选;顺序和默认值会改变结果;top-p 可以用拒绝采样代替排序;每个请求要有自己的随机数状态。
  7. 流式输出要用增量反分词(不输出半个字符),停止字符串要扣下”可能的前缀”——字符和 token 两个世界之间的翻译很容易出错。

11. 常见误区 · Common pitfalls

  • “参数量是一个要背的数字”。它能从 config.json 算出来,而且算的过程会告诉你参数在哪里(MLP 占 70%、嵌入表在小模型里占 20%)。
  • “MoE 模型 671 B,所以每个 token 要 1.3 T 次运算”。计算看激活参数(约 37 B),显存才看总参数。
  • “GQA 减少了注意力的计算量”。它减少的是 KV 的存储和读取;每个 query 头仍然要和每个 key 做点积。
  • “GQA 是 MHA 的近似”。对一个按 GQA 训练的模型,它和”复制 KV 头的 MHA”完全等价;只有把一个按 MHA 训练好的模型直接改成 GQA 才会改变输出。
  • “上下文翻倍,prefill 时间翻倍”。短提示时近似如此;长提示时注意力的平方项占主导,翻倍会接近翻四倍。
  • “temperature = 1.5、top_p = 0.9 在哪个引擎里都一样”。处理顺序、默认值、惩罚的统计范围都可能不同。
  • “设了种子,结果就一定可复现”。还要每个请求有自己的随机数状态,以及批不变的内核。
  • “每来一个 token 就把它解码发出去”。半个字符会变成乱码,前导空格会出错;要用增量反分词。
  • “同一段文字,各家模型的 token 数差不多”。分词器不同,token 数可以差一倍以上,按 token 计价时要先换算。

12. 练习 · Exercises

练习 1(如果 Llama-3-8B 用 MHA)。把 Llama-3-8B 的 KV 头数从 8 改成 32(其余不变)。参数量变成多少?每 token 的 KV 变成多少?在一块 H100 上,4K 上下文时最多能同时服务多少个请求(原来是 104 个)?

参考答案

WkW_k、WvW_v 各从 4096×10244096 \times 1024 变成 4096×40964096 \times 4096,每层多 2×4096×3072≈2,5162 \times 4096 \times 3072 \approx 2{,}516 万,32 层多 8.05 亿,共约 8.84 B(param_breakdown(replace(LLAMA3_8B, n_kv_heads=32)) 给出 8.836 B)。KV 从 128 KiB 变成 512 KiB / token。权重多了约 1.6 GB,KV 容量变成约 (72−17.67) GB/524,288≈10.4(72 - 17.67)\ \text{GB} / 524{,}288 \approx 10.4 万 token,4K 上下文时只能服务约 25 个请求——GQA 让并发提高了约 4 倍。

练习 2(70B 的长提示)。Llama-3-70B prefill 一个 8,192 token 的提示,总 FLOPs 是多少?注意力占多少?在 8 块 H100 上(BF16,100% 峰值,忽略通信),TTFT 的下界是多少?

参考答案

prefill_flops(LLAMA3_70B, 8192):约 1.21 PFLOP,注意力约 7.3%(70B 的”2N”项大,注意力的相对占比比 8B 小)。下界 1.21×1015/(8×989×1012)≈1531.21 \times 10^{15} / (8 \times 989 \times 10^{12}) \approx 153 ms。真实的张量并行还有通信,实际会更慢。

练习 3(Qwen2.5-7B 的长对话)。Qwen2.5-7B 每 token 的 KV 是多少?50 个 32K 上下文的请求要多少 KV?一块 H100 放得下吗?

参考答案

2×28×4×128×2=57,3442 \times 28 \times 4 \times 128 \times 2 = 57{,}344 字节(56 KiB)。50 × 32,768 × 57,344 ≈ 94 GB,单卡放不下。权重约 15.2 GB,剩下的 KV 容量约 99 万 token,32K 上下文最多约 30 个请求。它只有 4 个 KV 头,已经比 Llama-3-8B 省了一半多。

练习 4(DeepSeek-V2 的 KV)。DeepSeek-V2 有 60 层,MLA 的潜向量 512 维、RoPE key 64 维。每 token 的 KV(BF16)是多少?为什么 MLA 必须把 RoPE 的部分单独拿出来?

参考答案

60×(512+64)×2=69,12060 \times (512 + 64) \times 2 = 69{,}120 字节,约 67.5 KiB。RoPE 让 key 乘一个依赖它自己位置的旋转矩阵,这个矩阵随每个历史 token 变化,没法并进固定的解压矩阵,“吸收”就失效了(§5.4)。所以内容部分用潜向量 + 吸收,另存一个所有头共用的小 RoPE key。

练习 5(手算采样)。下一个 token 的概率是 [0.5, 0.2, 0.15, 0.1, 0.05]。(a) top_p = 0.8 保留哪些?min_p = 0.25 呢?top_k = 2 呢?(b) 用 §7.4 的拒绝采样做 top_p = 0.6,第一轮就被接受的概率是多少?平均要几轮?

参考答案

(a) top-p 0.8:累积 0.5、0.7、0.85,保留前三个(0.85 第一次 ≥ 0.8)。min-p 0.25:门槛 0.125,保留 0.5、0.2、0.15。top-k 2:0.5、0.2。(b) top-p 0.6 的集合是 {0.5, 0.2}(比 0.15 更可能的总概率 0.7 ≥ 0.6,所以 0.15 不在集合里),第一轮接受的概率 0.7。被拒绝时:抽到 0.15,下一轮只剩 {0.5, 0.2},必然接受;抽到 0.1 或 0.05,下一轮还可能再被拒绝一次。精确计算平均约 1.33 轮。

练习 6(反分词的工作量)。一个 1,000 个 token 的回答,“每步全部重新解码”一共要解码多少个 token?增量反分词大约多少?批 256 时差多少?

参考答案

1+2+⋯+1000=500,5001 + 2 + \dots + 1000 = 500{,}500 个;增量反分词每步只解码最近两三个 token,约 2,000–3,000 个。批 256 时每一步要为 256 个请求各做一次,前者到回答后半段每步要解码几十万个 token,CPU 很可能跟不上 GPU(第 16 讲)。

练习 7(换价格)。代码 06 的表按每 GPU 小时 3 美元算出 Llama-3-8B FP8 单卡聊天的成本下界是 0.034 美元 / 百万 token。如果 GPU 价格是 2 美元 / 小时呢?如果把最大批从 256 限制到 64(为了 TPOT 的 SLO)呢?

参考答案

成本和价格成正比:0.034×2/3≈0.0230.034 \times 2 / 3 \approx 0.023 美元。批 64 时满批 TPOT 变短、吞吐变小:用代码 06 演示 4 的方法(step_cost 算批 64 的 TPOT,FP8 时权重和 KV 都是 1 字节)可以算出:批 64 时 TPOT 约 5.1 ms,吞吐约 12,500 token/s,是批 256 时的一半左右,成本约 0.066 美元,约翻倍。SLO 越严,能用的批越小,每 token 越贵——这就是第 1、17 讲的 goodput。


13. 延伸阅读 · Further reading

  • C. Chen (kipply). Transformer Inference Arithmetic. 博客,2022。本篇的很多推导在这篇博客里有更简洁的版本。
  • J. Kaplan et al. Scaling Laws for Neural Language Models. 2020。表 1 的参数与 FLOPs 账。
  • N. Shazeer. Fast Transformer Decoding: One Write-Head is All You Need. 2019(MQA)。
  • J. Ainslie et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP 2023。
  • DeepSeek-AI. DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model. 2024(MLA 与解耦的 RoPE);DeepSeek-V3 Technical Report. 2024。
  • A. Holtzman et al. The Curious Case of Neural Text Degeneration. ICLR 2020(top-p)。
  • M. Nguyen et al. Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs. 2024。
  • FlashInfer 团队. Sorting-Free GPU Kernels for LLM Sampling. 博客,2025。
  • R. Sennrich, B. Haddow, A. Birch. Neural Machine Translation of Rare Words with Subword Units. ACL 2016(BPE)。
  • A. Karpathy 的 minbpe 项目:一个可读的 BPE 实现。

14. 下一篇 · Next

前置讲 P3:操作系统基础。本篇反复出现”显存放不下怎么办""哪些请求先跑""缓存满了扔掉谁”——这些问题操作系统在几十年前就遇到过。P3 讲进程调度(FCFS、SJF、SRPT、轮转、多级反馈队列)、虚拟内存与分页(碎片、页表、写时复制、换出)、缓存与淘汰(LRU、LFU、Belady 最优)、前缀树与引用计数,并把每个概念对应到推理系统里的一个设计:PagedAttention 就是分页,RadixAttention 就是”基数树 + LRU”,抢占就是进程调度。

← 返回课程目录